diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressService.kt index 0349c946..0881d792 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressService.kt @@ -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. * diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UpdateUserAvatarRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UpdateUserAvatarRequest.kt index 39de74d8..58e2f01c 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UpdateUserAvatarRequest.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UpdateUserAvatarRequest.kt @@ -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, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt index 19d4e5e6..48ef0e48 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt @@ -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 - ) \ No newline at end of file +fun AvatarAsset.toAvatarAssetOptionResponse(currentLevel: Int): AvatarAssetOptionResponse = + AvatarAssetOptionResponse( + id = name, + unlocksAtLevel = unlocksAtLevel, + unlocked = unlocksAtLevel <= currentLevel, + ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarAssetsResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarAssetsResponse.kt new file mode 100644 index 00000000..50b96958 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarAssetsResponse.kt @@ -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, + val outfits: List, + val skinColors: List, +) + +/** + * 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, +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt index 52bcaf23..cc9858eb 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt @@ -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. @@ -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()) } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarResponse.kt index 93a190e4..42675719 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarResponse.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarResponse.kt @@ -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, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UpdateUserAvatarCommand.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UpdateUserAvatarCommand.kt index 5ebb0f79..afd059f2 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UpdateUserAvatarCommand.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UpdateUserAvatarCommand.kt @@ -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, -) \ No newline at end of file + val hatId: String, + val outfitId: String, + val skinColorId: String, +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt index cbeb19d5..ac94270c 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt @@ -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 @@ -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 { @@ -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 + } } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAsset.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAsset.kt new file mode 100644 index 00000000..830b7530 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAsset.kt @@ -0,0 +1,33 @@ +package edu.ntnu.idi.idatt.backend.users.avatar.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +/** + * Unlockable avatar asset that can be equipped by pupils. + * + * @property id persistent asset id + * @property name asset key or display-safe identifier + * @property type asset category + * @property unlocksAtLevel minimum level required to unlock the asset + */ +@Entity +@Table(name = "avatar_asset") +class AvatarAsset( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + @Column(name = "name", nullable = false, length = 100) + var name: String, + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + var type: AvatarAssetType, + @Column(name = "unlocks_at_level", nullable = false) + var unlocksAtLevel: Int = 0, +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAssetType.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAssetType.kt new file mode 100644 index 00000000..6e11f100 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAssetType.kt @@ -0,0 +1,10 @@ +package edu.ntnu.idi.idatt.backend.users.avatar.domain + +/** + * Supported avatar asset categories. + */ +enum class AvatarAssetType { + HAT, + OUTFIT, + SKIN_COLOR, +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/UserAvatar.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/UserAvatar.kt index 352fca71..3736398b 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/UserAvatar.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/UserAvatar.kt @@ -15,12 +15,12 @@ import jakarta.persistence.Table * * @property userId shared primary key matching the owning pupil user id * @property user owning pupil user account - * @property bodyType selected body style key - * @property skinTone selected skin tone key - * @property outfitStyle selected outfit style key + * @property hatId selected hat asset id + * @property outfitId selected outfit asset id + * @property skinColorId selected skin color asset id */ @Entity -@Table(name = "avatars") +@Table(name = "user_avatar") class UserAvatar( @Id @Column(name = "user_id") @@ -29,34 +29,34 @@ class UserAvatar( @OneToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "user_id", nullable = false) var user: User, - @Column(name = "body_type", nullable = false, length = 50) - var bodyType: String = DEFAULT_BODY_TYPE, - @Column(name = "skin_tone", nullable = false) - var skinTone: String = DEFAULT_SKIN_TONE, - @Column(name = "outfit_style", nullable = false, length = 50) - var outfitStyle: String = DEFAULT_OUTFIT_STYLE, + @Column(name = "hat_id", nullable = false) + var hatId: Long = DEFAULT_HAT_ID, + @Column(name = "outfit_id", nullable = false) + var outfitId: Long = DEFAULT_OUTFIT_ID, + @Column(name = "skin_color_id", nullable = false) + var skinColorId: Long = DEFAULT_SKIN_COLOR_ID, ) { /** * Applies a full avatar update to the entity * - * @param skinTone what skin tone is chosen - * @param bodyType what body type is chosen - * @param outfitStyle what outfit style is chosen + * @param hatId selected hat asset id + * @param outfitId selected outfit asset id + * @param skinColorId selected skin color asset id */ fun update( - bodyType: String, - skinTone: String, - outfitStyle: String, + hatId: Long, + outfitId: Long, + skinColorId: Long, ) { - this.bodyType = bodyType - this.skinTone = skinTone - this.outfitStyle = outfitStyle + this.hatId = hatId + this.outfitId = outfitId + this.skinColorId = skinColorId } companion object { - const val DEFAULT_BODY_TYPE = "detective-default" - const val DEFAULT_SKIN_TONE = "light" - const val DEFAULT_OUTFIT_STYLE = "starter" + const val DEFAULT_HAT_ID = 1L + const val DEFAULT_OUTFIT_ID = 2L + const val DEFAULT_SKIN_COLOR_ID = 3L /** * Builds the default avatar for a newly created pupil. @@ -67,9 +67,9 @@ class UserAvatar( fun defaultsFor(user: User): UserAvatar = UserAvatar( user = user, - bodyType = DEFAULT_BODY_TYPE, - skinTone = DEFAULT_SKIN_TONE, - outfitStyle = DEFAULT_OUTFIT_STYLE, + hatId = DEFAULT_HAT_ID, + outfitId = DEFAULT_OUTFIT_ID, + skinColorId = DEFAULT_SKIN_COLOR_ID, ) } } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt new file mode 100644 index 00000000..d412ec8b --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt @@ -0,0 +1,15 @@ +package edu.ntnu.idi.idatt.backend.users.avatar.infrastructure + +import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAsset +import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional + +/** + * Repository for avatar asset persistence. + */ +interface AvatarAssetRepository : JpaRepository { + fun findByTypeAndName(type: AvatarAssetType, name: String): Optional + + fun findAllByOrderByTypeAscUnlocksAtLevelAscNameAsc(): List +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt index ae7cfcfc..82f7d8c6 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt @@ -6,4 +6,4 @@ import org.springframework.data.jpa.repository.JpaRepository /** * Repository for current pupil avatar persistence. */ -interface UserAvatarRepository : JpaRepository +interface UserAvatarRepository : JpaRepository \ No newline at end of file diff --git a/src/main/resources/db/migration/V23__split_avatars_into_user_avatar_and_avatar_asset.sql b/src/main/resources/db/migration/V23__split_avatars_into_user_avatar_and_avatar_asset.sql new file mode 100644 index 00000000..b3b9e949 --- /dev/null +++ b/src/main/resources/db/migration/V23__split_avatars_into_user_avatar_and_avatar_asset.sql @@ -0,0 +1,69 @@ +CREATE TABLE avatar_asset ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + type VARCHAR(20) NOT NULL, + unlocks_at_level INT NOT NULL DEFAULT 0, + CONSTRAINT pk_avatar_asset PRIMARY KEY (id), + CONSTRAINT uq_avatar_asset_type_name UNIQUE (type, name), + CONSTRAINT chk_avatar_asset_type CHECK (type IN ('HAT', 'OUTFIT', 'SKIN_COLOR')) +); + +INSERT INTO avatar_asset (id, name, type, unlocks_at_level) +VALUES + (1, 'detective-default', 'HAT', 0), + (2, 'starter', 'OUTFIT', 0), + (3, 'light', 'SKIN_COLOR', 0), + (4, 'wizard-hat', 'HAT', 3), + (5, 'standard2', 'OUTFIT', 0), + (6, 'dark', 'SKIN_COLOR', 0), + (7, 'eilif', 'SKIN_COLOR', 4); + +INSERT INTO avatar_asset (name, type, unlocks_at_level) +SELECT DISTINCT body_type, 'HAT', 0 +FROM avatars +WHERE body_type IS NOT NULL + AND body_type NOT IN ('detective-default', 'wizard-hat'); + +INSERT INTO avatar_asset (name, type, unlocks_at_level) +SELECT DISTINCT outfit_style, 'OUTFIT', 0 +FROM avatars +WHERE outfit_style IS NOT NULL + AND outfit_style NOT IN ('starter', 'standard2'); + +INSERT INTO avatar_asset (name, type, unlocks_at_level) +SELECT DISTINCT skin_tone, 'SKIN_COLOR', 0 +FROM avatars +WHERE skin_tone IS NOT NULL + AND skin_tone NOT IN ('light', 'dark'); + +CREATE TABLE user_avatar ( + user_id BIGINT NOT NULL, + hat_id BIGINT NOT NULL, + outfit_id BIGINT NOT NULL, + skin_color_id BIGINT NOT NULL, + CONSTRAINT pk_user_avatar PRIMARY KEY (user_id), + CONSTRAINT fk_user_avatar_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_user_avatar_hat + FOREIGN KEY (hat_id) REFERENCES avatar_asset (id), + CONSTRAINT fk_user_avatar_outfit + FOREIGN KEY (outfit_id) REFERENCES avatar_asset (id), + CONSTRAINT fk_user_avatar_skin_color + FOREIGN KEY (skin_color_id) REFERENCES avatar_asset (id) +); + +INSERT INTO user_avatar (user_id, hat_id, outfit_id, skin_color_id) +SELECT + legacy.user_id, + hat_asset.id, + outfit_asset.id, + skin_asset.id +FROM avatars legacy +JOIN avatar_asset hat_asset + ON hat_asset.type = 'HAT' AND hat_asset.name = legacy.body_type +JOIN avatar_asset outfit_asset + ON outfit_asset.type = 'OUTFIT' AND outfit_asset.name = legacy.outfit_style +JOIN avatar_asset skin_asset + ON skin_asset.type = 'SKIN_COLOR' AND skin_asset.name = legacy.skin_tone; + +DROP TABLE avatars; diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt index 92dea24c..113adfed 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt @@ -6,6 +6,7 @@ import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository import jakarta.servlet.http.Cookie @@ -235,9 +236,9 @@ class AuthControllerIntegrationTests { assertEquals(64, persistedToken.tokenHash.length) val persistedAvatar = userAvatarRepository.findById(requireNotNull(persistedUser.id)) .orElseThrow { AssertionError("Expected default avatar row for pupil") } - assertEquals("detective-default", persistedAvatar.bodyType) - assertEquals("light", persistedAvatar.skinTone) - assertEquals("starter", persistedAvatar.outfitStyle) + assertEquals(UserAvatar.DEFAULT_HAT_ID, persistedAvatar.hatId) + assertEquals(UserAvatar.DEFAULT_OUTFIT_ID, persistedAvatar.outfitId) + assertEquals(UserAvatar.DEFAULT_SKIN_COLOR_ID, persistedAvatar.skinColorId) } @Test diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt index de710375..6de01774 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt @@ -7,7 +7,9 @@ import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +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.users.profile.infrastructure.PupilProfileRepository import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository @@ -38,6 +40,9 @@ class UserAvatarControllerIntegrationTests { @Autowired private lateinit var userAvatarRepository: UserAvatarRepository + @Autowired + private lateinit var avatarAssetRepository: AvatarAssetRepository + @Autowired private lateinit var pupilProfileRepository: PupilProfileRepository @@ -59,6 +64,7 @@ class UserAvatarControllerIntegrationTests { pupilProfileRepository.deleteAll() teacherProfileRepository.deleteAll() userRepository.deleteAll() + resetAvatarAssetUnlockLevels() } @Test @@ -67,9 +73,9 @@ class UserAvatarControllerIntegrationTests { userAvatarRepository.save( UserAvatar( user = pupil, - bodyType = "detective-default", - skinTone = "light", - outfitStyle = "starter", + hatId = 1L, + outfitId = 2L, + skinColorId = 3L, ), ) val accessToken = pupilAccessToken("ada", "secret123") @@ -82,9 +88,9 @@ class UserAvatarControllerIntegrationTests { }.andReturn().response.contentAsString, ) - assertEquals("detective-default", body.get("bodyType").asText()) - assertEquals("light", body.get("skinTone").asText()) - assertEquals("starter", body.get("outfitStyle").asText()) + assertEquals("detective-default", body.get("hatId").asText()) + assertEquals("starter", body.get("outfitId").asText()) + assertEquals("light", body.get("skinColorId").asText()) } @Test @@ -124,9 +130,9 @@ class UserAvatarControllerIntegrationTests { val persistedAvatar = userAvatarRepository.findById(requireNotNull(persistedUser.id)) .orElseThrow { AssertionError("Expected default avatar row for pupil") } - assertEquals(UserAvatar.DEFAULT_BODY_TYPE, persistedAvatar.bodyType) - assertEquals(UserAvatar.DEFAULT_SKIN_TONE, persistedAvatar.skinTone) - assertEquals(UserAvatar.DEFAULT_OUTFIT_STYLE, persistedAvatar.outfitStyle) + assertEquals(UserAvatar.DEFAULT_HAT_ID, persistedAvatar.hatId) + assertEquals(UserAvatar.DEFAULT_OUTFIT_ID, persistedAvatar.outfitId) + assertEquals(UserAvatar.DEFAULT_SKIN_COLOR_ID, persistedAvatar.skinColorId) val avatarBody = readJson( mockMvc.get("/api/v1/users/me/avatar") { @@ -136,13 +142,60 @@ class UserAvatarControllerIntegrationTests { }.andReturn().response.contentAsString, ) - assertEquals(UserAvatar.DEFAULT_BODY_TYPE, avatarBody.get("bodyType").asText()) - assertEquals(UserAvatar.DEFAULT_SKIN_TONE, avatarBody.get("skinTone").asText()) - assertEquals(UserAvatar.DEFAULT_OUTFIT_STYLE, avatarBody.get("outfitStyle").asText()) + assertEquals("detective-default", avatarBody.get("hatId").asText()) + assertEquals("starter", avatarBody.get("outfitId").asText()) + assertEquals("light", avatarBody.get("skinColorId").asText()) + } + + @Test + fun `authenticated pupil gets avatar assets with unlock state`() { + val wizardHat = avatarAssetRepository.findByTypeAndName(AvatarAssetType.HAT, "wizard-hat") + .orElseThrow { AssertionError("Expected wizard-hat avatar asset") } + wizardHat.unlocksAtLevel = 5 + avatarAssetRepository.save(wizardHat) + + val pupil = userRepository.save(activePupil("asset-pupil")) + userAvatarRepository.save(UserAvatar.defaultsFor(pupil)) + val accessToken = pupilAccessToken("asset-pupil", "secret123") + + val body = readJson( + mockMvc.get("/api/v1/users/me/avatar/assets") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + }.andReturn().response.contentAsString, + ) + + assertEquals(1, body.get("currentLevel").asInt()) + assertEquals(true, body.get("hats").isArray) + assertEquals(true, body.get("outfits").isArray) + assertEquals(true, body.get("skinColors").isArray) + + val wizardHatNode = body.get("hats").firstOrNull { it.get("id").asText() == "wizard-hat" } + ?: throw AssertionError("Expected wizard-hat option in hats response") + + assertEquals(5, wizardHatNode.get("unlocksAtLevel").asInt()) + assertEquals(false, wizardHatNode.get("unlocked").asBoolean()) } private fun readJson(content: String): JsonNode = jsonMapper.readTree(content) + private fun resetAvatarAssetUnlockLevels() { + listOf( + AvatarAssetType.HAT to "detective-default", + AvatarAssetType.HAT to "wizard-hat", + AvatarAssetType.OUTFIT to "starter", + AvatarAssetType.OUTFIT to "standard2", + AvatarAssetType.SKIN_COLOR to "light", + AvatarAssetType.SKIN_COLOR to "dark", + ).forEach { (type, name) -> + val asset = avatarAssetRepository.findByTypeAndName(type, name) + .orElseThrow { AssertionError("Expected avatar asset $name for type $type") } + asset.unlocksAtLevel = 0 + avatarAssetRepository.save(asset) + } + } + private fun pupilAccessToken(username: String, password: String): String = readJson( mockMvc.post("/api/v1/auth/pupil/login") { diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt index 68a2a4b3..b6967226 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt @@ -4,12 +4,16 @@ 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.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus +import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser +import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService +import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAsset +import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType import edu.ntnu.idi.idatt.backend.xp.application.XpService import edu.ntnu.idi.idatt.backend.users.avatar.application.UserAvatarService 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 org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Test import org.mockito.Mockito import org.springframework.http.HttpStatus @@ -21,38 +25,65 @@ class UserAvatarServiceTests { fun `get user avatar returns persisted avatar for authenticated pupil`() { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) + val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) + val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, xpService) + val service = UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val user = pupilUser(42L) val avatar = UserAvatar( user = user, - bodyType = "detective-default", - skinTone = "light", - outfitStyle = "starter", + hatId = 1L, + outfitId = 2L, + skinColorId = 3L, ).apply { userId = 42L } Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(user) Mockito.`when`(userAvatarRepository.findById(42L)).thenReturn(Optional.of(avatar)) + Mockito.`when`(avatarAssetRepository.findById(1L)).thenReturn( + Optional.of(AvatarAsset(id = 1L, name = "detective-default", type = AvatarAssetType.HAT)), + ) + Mockito.`when`(avatarAssetRepository.findById(2L)).thenReturn( + Optional.of(AvatarAsset(id = 2L, name = "starter", type = AvatarAssetType.OUTFIT)), + ) + Mockito.`when`(avatarAssetRepository.findById(3L)).thenReturn( + Optional.of(AvatarAsset(id = 3L, name = "light", type = AvatarAssetType.SKIN_COLOR)), + ) - val resolvedAvatar = service.getUserAvatar("42") + val resolvedAvatar = service.getUserAvatar(currentPupilUser("42")) - assertSame(avatar, resolvedAvatar) + assertEquals("detective-default", resolvedAvatar.hatId) + assertEquals("starter", resolvedAvatar.outfitId) + assertEquals("light", resolvedAvatar.skinColorId) } @Test fun `get user avatar rejects malformed authenticated subject`() { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) + val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) + val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, xpService) + val service = UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid authenticated user subject") Mockito.`when`(currentUserResolver.requirePupil("not-a-number")).thenThrow(exception) val thrown = org.junit.jupiter.api.assertThrows { - service.getUserAvatar("not-a-number") + service.getUserAvatar(currentPupilUser("not-a-number")) } assertEquals(HttpStatus.UNAUTHORIZED, thrown.statusCode) @@ -62,14 +93,22 @@ class UserAvatarServiceTests { fun `get user avatar rejects unknown authenticated user`() { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) + val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) + val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, xpService) + val service = UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated user was not found") Mockito.`when`(currentUserResolver.requirePupil("42")).thenThrow(exception) val thrown = org.junit.jupiter.api.assertThrows { - service.getUserAvatar("42") + service.getUserAvatar(currentPupilUser("42")) } assertEquals(HttpStatus.UNAUTHORIZED, thrown.statusCode) @@ -79,14 +118,22 @@ class UserAvatarServiceTests { fun `get user avatar rejects teacher user`() { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) + val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) + val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, xpService) + val service = UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val exception = ResponseStatusException(HttpStatus.FORBIDDEN, "Only pupils are allowed") Mockito.`when`(currentUserResolver.requirePupil("7")).thenThrow(exception) val thrown = org.junit.jupiter.api.assertThrows { - service.getUserAvatar("7") + service.getUserAvatar(currentPupilUser("7")) } assertEquals(HttpStatus.FORBIDDEN, thrown.statusCode) @@ -96,20 +143,36 @@ class UserAvatarServiceTests { fun `get user avatar rejects pupil without avatar row`() { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) + val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) + val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, xpService) + val service = UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val user = pupilUser(42L) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(user) Mockito.`when`(userAvatarRepository.findById(42L)).thenReturn(Optional.empty()) val exception = org.junit.jupiter.api.assertThrows { - service.getUserAvatar("42") + service.getUserAvatar(currentPupilUser("42")) } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) } + private fun currentPupilUser(subject: String) = + CurrentUser( + subject = subject, + portalType = "pupil", + email = null, + authorities = setOf("ROLE_PUPIL"), + ) + private fun pupilUser(id: Long) = User( id = id,