Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,32 @@ class PupilGameProgressService(
)
}

/**
* Returns the current authenticated pupil's classroom-scoped level.
*
* The lookup is resolved from the authenticated pupil and that pupil's
* active classroom. If the pupil has no classroom context yet or no
* progress row exists for the active classroom, the service falls back to
* level `1`.
*
* @param currentUser authenticated pupil whose level should be resolved
* @return persisted classroom-scoped level, or `1` when no progress row exists yet
*/
@Transactional(readOnly = true)
fun getPupilCurrentLevel(currentUser: CurrentUser): Int {
val pupil = currentUserResolver.requirePupil(currentUser.subject)
val pupilId = requireNotNull(pupil.id) { "Authenticated pupil is missing an id" }

val classroomId = try {
val classroom = currentUserResolver.getClassroomByUser(currentUser)
requireNotNull(classroom.id) { "Active classroom is missing an id" }
} catch (_: ResponseStatusException) {
return 1
}

return pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId)?.currentLevel ?: 1
}

/**
* Maps accumulated XP to a 1-based level.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ import jakarta.validation.constraints.Size
/**
* Request payload for updating the current pupil's avatar.
*
* @property bodyType selected body style key
* @property skinTone selected skin tone key
* @property outfitStyle selected outfit style key
* @property hatId selected hat asset name
* @property outfitId selected outfit asset name
* @property skinColorId selected skin color asset name
*/
class UpdateUserAvatarRequest(
@field:NotBlank
@field:Size(max = 50)
val bodyType: String,
@field:Size(max = 100)
val hatId: String,
@field:NotBlank
@field:Size(max = 50)
val skinTone: String,
@field:Size(max = 100)
val outfitId: String,
@field:NotBlank
@field:Size(max = 50)
val outfitStyle: String,
@field:Size(max = 100)
val skinColorId: String,
)
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
package edu.ntnu.idi.idatt.backend.users.avatar.api

import edu.ntnu.idi.idatt.backend.users.avatar.application.UpdateUserAvatarCommand
import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar
import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAsset

/**
* Converts the persisted avatar entity into the public API response payload.
* Converts the update request payload into an application command.
*
* @return response exposed by the avatar API
* @return command consumed by the service layer
*/
fun UserAvatar.toUserAvatarResponse(): UserAvatarResponse =
UserAvatarResponse(
bodyType = bodyType,
skinTone = skinTone,
outfitStyle = outfitStyle,
fun UpdateUserAvatarRequest.toCommand(): UpdateUserAvatarCommand =
UpdateUserAvatarCommand(
hatId = hatId,
outfitId = outfitId,
skinColorId = skinColorId
)

/**
* Converts the update request payload into an application command.
* Converts one persisted avatar asset into a frontend-ready option response.
*
* @return command consumed by the service layer
* @param currentLevel pupil level used to derive whether the asset is unlocked
* @return response exposed by the avatar assets API
*/
fun UpdateUserAvatarRequest.toCommand(): UpdateUserAvatarCommand =
UpdateUserAvatarCommand(
bodyType = bodyType,
skinTone = skinTone,
outfitStyle = outfitStyle
)
fun AvatarAsset.toAvatarAssetOptionResponse(currentLevel: Int): AvatarAssetOptionResponse =
AvatarAssetOptionResponse(
id = name,
unlocksAtLevel = unlocksAtLevel,
unlocked = unlocksAtLevel <= currentLevel,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package edu.ntnu.idi.idatt.backend.users.avatar.api

/**
* Response payload describing all avatar asset options available to the authenticated pupil.
*
* @property currentLevel pupil's current classroom-scoped level, or the default starting level
* @property hats available hat assets
* @property outfits available outfit assets
* @property skinColors available skin color assets
*/
data class UserAvatarAssetsResponse(
val currentLevel: Int,
val hats: List<AvatarAssetOptionResponse>,
val outfits: List<AvatarAssetOptionResponse>,
val skinColors: List<AvatarAssetOptionResponse>,
)

/**
* Response payload for one selectable avatar asset option.
*
* @property id stable asset name used by the avatar API
* @property unlocksAtLevel minimum level required to equip the asset
* @property unlocked whether the authenticated pupil can currently equip the asset
*/
data class AvatarAssetOptionResponse(
val id: String,
val unlocksAtLevel: Int,
val unlocked: Boolean,
)
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,18 @@ class UserAvatarController(
@GetMapping
fun getUserAvatar(
@AuthenticationPrincipal currentUser: CurrentUser,
): UserAvatarResponse = userAvatarService.getUserAvatar(currentUser.subject).toUserAvatarResponse()
): UserAvatarResponse = userAvatarService.getUserAvatar(currentUser)

/**
* Returns all avatar assets available to the current authenticated pupil.
*
* @param currentUser authenticated principal resolved from the JWT access token
* @return grouped avatar asset catalog including unlock state for the pupil
*/
@GetMapping("/assets")
fun getUserAvatarAssets(
@AuthenticationPrincipal currentUser: CurrentUser,
): UserAvatarAssetsResponse = userAvatarService.getUserAvatarAssets(currentUser)

/**
* Updates the current authenticated pupil's avatar with the submitted values.
Expand All @@ -42,5 +53,5 @@ class UserAvatarController(
fun updateUserAvatar(
@AuthenticationPrincipal currentUser: CurrentUser,
@Valid @RequestBody request: UpdateUserAvatarRequest,
): UserAvatarResponse = userAvatarService.updateUserAvatar(currentUser.subject, request.toCommand()).toUserAvatarResponse()
): UserAvatarResponse = userAvatarService.updateUserAvatar(currentUser, request.toCommand())
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ package edu.ntnu.idi.idatt.backend.users.avatar.api
/**
* Response payload for the authenticated pupil's avatar.
*
* @property bodyType selected body style key
* @property skinTone selected skin tone key
* @property outfitStyle selected outfit style key
* @property hatId selected hat asset name
* @property outfitId selected outfit asset name
* @property skinColorId selected skin color asset name
*/
data class UserAvatarResponse(
val bodyType: String,
val skinTone: String,
val outfitStyle: String,
val hatId: String,
val outfitId: String,
val skinColorId: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ package edu.ntnu.idi.idatt.backend.users.avatar.application
/**
* Full avatar update by pupil.
*
* @property bodyType the selected body type
* @property skinTone the selected skin tone
* @property outfitStyle the selected outfit style
* @property hatId the selected hat asset name
* @property outfitId the selected outfit asset name
* @property skinColorId the selected skin color asset name
*/
class UpdateUserAvatarCommand(
val bodyType: String,
val skinTone: String,
val outfitStyle: String,
)
val hatId: String,
val outfitId: String,
val skinColorId: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ package edu.ntnu.idi.idatt.backend.users.avatar.application
import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver
import edu.ntnu.idi.idatt.backend.iam.domain.User
import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser
import edu.ntnu.idi.idatt.backend.users.avatar.api.UserAvatarAssetsResponse
import edu.ntnu.idi.idatt.backend.users.avatar.api.UserAvatarResponse
import edu.ntnu.idi.idatt.backend.users.avatar.api.toAvatarAssetOptionResponse
import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType
import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar
import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.AvatarAssetRepository
import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository
import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService
import edu.ntnu.idi.idatt.backend.xp.application.XpService
import edu.ntnu.idi.idatt.backend.xp.domain.XpEventCode
import org.springframework.http.HttpStatus
Expand All @@ -13,66 +19,122 @@ import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException

/**
* Handles lookup of the authenticated pupil's current avatar.
* Handles lookup and updates for the authenticated pupil's current avatar.
*
* @property userRepository repository used to resolve the authenticated user
* @property userAvatarRepository repository used to load the persisted avatar row
* @property avatarAssetRepository repository used to resolve avatar asset names and unlock rules
* @property pupilGameProgressService service used to resolve the pupil's current classroom-scoped level
*/
@Service
class UserAvatarService(
private val currentUserResolver: CurrentUserResolver,
private val userAvatarRepository: UserAvatarRepository,
private val avatarAssetRepository: AvatarAssetRepository,
private val pupilGameProgressService: PupilGameProgressService,
private val xpService: XpService,
) {
/**
* Returns the current authenticated pupil's avatar.
*
* @param authenticatedSubject JWT subject containing the persisted user id
* @param currentUser authenticated principal resolved from the JWT access token
* @return current pupil avatar
*/
@Transactional(readOnly = true)
fun getUserAvatar(authenticatedSubject: String): UserAvatar {
val user = currentUserResolver.requirePupil(authenticatedSubject)
fun getUserAvatar(currentUser: CurrentUser): UserAvatarResponse {
val user = currentUserResolver.requirePupil(currentUser.subject)
val userId = requireNotNull(user.id) { "Authenticated user is missing an id" }

return userAvatarRepository.findById(userId)
val userAvatar = userAvatarRepository.findById(userId)
.orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil avatar was not found")
}

return toResponse(userAvatar)
}

/**
* Returns all selectable avatar assets for the authenticated pupil together with
* their unlock requirements and current availability.
*
* @param currentUser authenticated principal resolved from the JWT access token
* @return grouped avatar asset catalog for the current pupil
*/
@Transactional(readOnly = true)
fun getUserAvatarAssets(currentUser: CurrentUser): UserAvatarAssetsResponse {
currentUserResolver.requirePupil(currentUser.subject)
val currentLevel = pupilGameProgressService.getPupilCurrentLevel(currentUser)
val assets = avatarAssetRepository.findAllByOrderByTypeAscUnlocksAtLevelAscNameAsc()

return UserAvatarAssetsResponse(
currentLevel = currentLevel,
hats = assets
.filter { it.type == AvatarAssetType.HAT }
.map { it.toAvatarAssetOptionResponse(currentLevel) },
outfits = assets
.filter { it.type == AvatarAssetType.OUTFIT }
.map { it.toAvatarAssetOptionResponse(currentLevel) },
skinColors = assets
.filter { it.type == AvatarAssetType.SKIN_COLOR }
.map { it.toAvatarAssetOptionResponse(currentLevel) },
)
}

/**
* Updates the current authenticated pupil's avatar with the submitted values.
*
* @param authenticatedSubject JWT subject containing the persisted user id
* @param command full avatar payload to persist
* @param currentUser authenticated principal resolved from the JWT access token
* @param command full avatar payload to persist, expressed as avatar asset names
* @return updated pupil avatar
* @throws ResponseStatusException when an asset name is unknown or the pupil has not unlocked
* the requested asset for their current level
*/
@Transactional
fun updateUserAvatar(
authenticatedSubject: String,
currentUser: CurrentUser,
command: UpdateUserAvatarCommand,
): UserAvatar {
val user = currentUserResolver.requirePupil(authenticatedSubject)
): UserAvatarResponse {
val user = currentUserResolver.requirePupil(currentUser.subject)
val userAvatar = findUserAvatar(user)

val hatId = resolveAssetId(command.hatId, AvatarAssetType.HAT)
val outfitId = resolveAssetId(command.outfitId, AvatarAssetType.OUTFIT)
val skinColorId = resolveAssetId(command.skinColorId, AvatarAssetType.SKIN_COLOR)

val currentLevel = pupilGameProgressService.getPupilCurrentLevel(currentUser)
assertAssetIsUnlocked(hatId, currentLevel)
assertAssetIsUnlocked(outfitId, currentLevel)
assertAssetIsUnlocked(skinColorId, currentLevel)

userAvatar.update(
bodyType = command.bodyType,
skinTone = command.skinTone,
outfitStyle = command.outfitStyle,
hatId = hatId,
outfitId = outfitId,
skinColorId = skinColorId,
)

xpService.awardXpEvent(
CurrentUser(
subject = authenticatedSubject,
portalType = "pupil",
email = null,
authorities = setOf("ROLE_PUPIL"),
),
currentUser,
XpEventCode.UPDATE_AVATAR,
)

return userAvatarRepository.save(userAvatar)
return toResponse(userAvatarRepository.save(userAvatar))
}

/**
* Verifies that the requested avatar asset is unlocked for the supplied pupil level.
*
* @param assetId persistent avatar asset id to verify
* @param currentLevel pupil's current classroom-scoped level
* @throws ResponseStatusException when the asset does not exist or is still locked
*/
private fun assertAssetIsUnlocked(assetId: Long, currentLevel: Int) {
val asset = avatarAssetRepository.findById(assetId)
.orElseThrow {
ResponseStatusException(HttpStatus.BAD_REQUEST, "Avatar asset $assetId was not found")
}

if (asset.unlocksAtLevel > currentLevel) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Current level is not high enough to use the avatar asset")
}
}

private fun findUserAvatar(user: User): UserAvatar {
Expand All @@ -82,4 +144,34 @@ class UserAvatarService(
ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil avatar was not found")
}
}

private fun toResponse(userAvatar: UserAvatar): UserAvatarResponse =
UserAvatarResponse(
hatId = resolveAssetName(userAvatar.hatId, AvatarAssetType.HAT),
outfitId = resolveAssetName(userAvatar.outfitId, AvatarAssetType.OUTFIT),
skinColorId = resolveAssetName(userAvatar.skinColorId, AvatarAssetType.SKIN_COLOR),
)

private fun resolveAssetId(name: String, type: AvatarAssetType): Long =
avatarAssetRepository.findByTypeAndName(type, name)
.map { requireNotNull(it.id) { "Avatar asset is missing an id" } }
.orElseThrow {
ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown avatar asset '$name' for type $type")
}

private fun resolveAssetName(id: Long, type: AvatarAssetType): String {
val asset = avatarAssetRepository.findById(id)
.orElseThrow {
ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Avatar asset $id was not found")
}

if (asset.type != type) {
throw ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Avatar asset $id had unexpected type ${asset.type}",
)
}

return asset.name
}
}
Loading