From cd71e97655e6ec5ad1c727f20657d4c9ae8c3d4b Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Sun, 21 Dec 2025 02:07:48 +0800 Subject: [PATCH] doll active state --- .../migration.sql | 5 + prisma/schema.prisma | 7 ++ src/auth/auth.service.spec.ts | 1 + src/dolls/dolls.service.spec.ts | 4 + src/dolls/dolls.service.ts | 19 ++- src/users/users.controller.spec.ts | 1 + src/users/users.controller.ts | 77 ++++++++++++ src/users/users.entity.ts | 8 ++ src/users/users.service.spec.ts | 118 ++++++++++++++++++ src/users/users.service.ts | 75 +++++++++++ 10 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/20251220154535_add_active_doll_to_user/migration.sql diff --git a/prisma/migrations/20251220154535_add_active_doll_to_user/migration.sql b/prisma/migrations/20251220154535_add_active_doll_to_user/migration.sql new file mode 100644 index 0000000..0f8bd0f --- /dev/null +++ b/prisma/migrations/20251220154535_add_active_doll_to_user/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "active_doll_id" TEXT; + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_active_doll_id_fkey" FOREIGN KEY ("active_doll_id") REFERENCES "dolls"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 073d184..f7a0c1e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,10 @@ model User { /// Timestamp of last login lastLoginAt DateTime? @map("last_login_at") + // The ID of the currently active doll for the user + activeDollId String? @map("active_doll_id") + activeDoll Doll? @relation("ActiveDoll", fields: [activeDollId], references: [id]) + sentFriendRequests FriendRequest[] @relation("SentFriendRequests") receivedFriendRequests FriendRequest[] @relation("ReceivedFriendRequests") userFriendships Friendship[] @relation("UserFriendships") @@ -90,6 +94,9 @@ model Doll { user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Reverse relation for the active doll + activeForUser User[] @relation("ActiveDoll") + @@map("dolls") } diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index dd7278e..ba5eb6f 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -37,6 +37,7 @@ describe('AuthService', () => { createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), lastLoginAt: new Date('2024-01-01'), + activeDollId: null, }; beforeEach(async () => { diff --git a/src/dolls/dolls.service.spec.ts b/src/dolls/dolls.service.spec.ts index 1061b14..2ef48ec 100644 --- a/src/dolls/dolls.service.spec.ts +++ b/src/dolls/dolls.service.spec.ts @@ -34,6 +34,10 @@ describe('DollsService', () => { friendship: { findMany: jest.fn().mockResolvedValue([]), }, + $transaction: jest.fn((callback) => callback(mockPrismaService)), + user: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, }; const mockEventEmitter = { diff --git a/src/dolls/dolls.service.ts b/src/dolls/dolls.service.ts index 4fa1410..687e5c1 100644 --- a/src/dolls/dolls.service.ts +++ b/src/dolls/dolls.service.ts @@ -187,11 +187,20 @@ export class DollsService { } // Soft delete - await this.prisma.doll.update({ - where: { id }, - data: { - deletedAt: new Date(), - }, + await this.prisma.$transaction(async (tx) => { + // 1. Soft delete the doll + await tx.doll.update({ + where: { id }, + data: { + deletedAt: new Date(), + }, + }); + + // 2. Unset if it was active + await tx.user.updateMany({ + where: { id: requestingUserId, activeDollId: id }, + data: { activeDollId: null }, + }); }); const event: DollDeletedEvent = { diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts index bc19d23..385e32a 100644 --- a/src/users/users.controller.spec.ts +++ b/src/users/users.controller.spec.ts @@ -50,6 +50,7 @@ describe('UsersController', () => { createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), lastLoginAt: new Date('2024-01-01'), + activeDollId: null, }; beforeEach(async () => { diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 24dd40e..6c524c5 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -266,4 +266,81 @@ export class UsersController { this.logger.log(`Delete user ${id} (requested by ${authUser.keycloakSub})`); await this.usersService.delete(id, authUser.keycloakSub); } + + /** + * Set the active doll for the current user. + */ + @Put('me/active-doll/:dollId') + @ApiOperation({ + summary: 'Set active doll', + description: + 'Sets the active doll for the authenticated user. The doll must belong to the user.', + }) + @ApiParam({ + name: 'dollId', + description: 'Doll internal UUID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @ApiResponse({ + status: 200, + description: 'Active doll set successfully', + type: UserResponseDto, + }) + @ApiResponse({ + status: 404, + description: 'Doll not found', + }) + @ApiResponse({ + status: 403, + description: 'Doll does not belong to the user', + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async setActiveDoll( + @Param('dollId') dollId: string, + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + this.logger.log( + `Set active doll ${dollId} (requested by ${authUser.keycloakSub})`, + ); + + // First ensure user exists in our system + const user = await this.authService.ensureUserExists(authUser); + + return this.usersService.setActiveDoll( + user.id, + dollId, + authUser.keycloakSub, + ); + } + + /** + * Remove the active doll for the current user. + */ + @Delete('me/active-doll') + @ApiOperation({ + summary: 'Remove active doll', + description: 'Removes the active doll for the authenticated user.', + }) + @ApiResponse({ + status: 200, + description: 'Active doll removed successfully', + type: UserResponseDto, + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async removeActiveDoll( + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + this.logger.log( + `Remove active doll (requested by ${authUser.keycloakSub})`, + ); + + // First ensure user exists in our system + const user = await this.authService.ensureUserExists(authUser); + + return this.usersService.removeActiveDoll(user.id, authUser.keycloakSub); + } } diff --git a/src/users/users.entity.ts b/src/users/users.entity.ts index d225508..38e2218 100644 --- a/src/users/users.entity.ts +++ b/src/users/users.entity.ts @@ -82,4 +82,12 @@ export class UserResponseDto implements PrismaUser { nullable: true, }) lastLoginAt: Date | null; + + @ApiProperty({ + description: 'ID of the currently active doll', + example: '550e8400-e29b-41d4-a716-446655440000', + required: false, + nullable: true, + }) + activeDollId: string | null; } diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 2d12c04..5835ef3 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -20,6 +20,7 @@ describe('UsersService', () => { lastLoginAt: new Date('2024-01-15T10:30:00.000Z'), createdAt: new Date('2024-01-01T00:00:00.000Z'), updatedAt: new Date('2024-01-15T10:30:00.000Z'), + activeDollId: null, }; const mockPrismaService = { @@ -722,4 +723,121 @@ describe('UsersService', () => { ); }); }); + + describe('active doll management', () => { + const dollId = 'doll-123'; + const mockDoll = { + id: dollId, + name: 'Test Doll', + configuration: {}, + userId: mockUser.id, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }; + + describe('setActiveDoll', () => { + it('should set active doll for user', async () => { + const updatedUser = { ...mockUser, activeDollId: dollId }; + + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + // @ts-expect-error - mockPrismaService type definition is incomplete in test file + mockPrismaService.doll = { findUnique: jest.fn() }; + // @ts-expect-error - mockPrismaService type definition is incomplete in test file + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + mockPrismaService.doll.findUnique.mockResolvedValue(mockDoll); + mockPrismaService.user.update.mockResolvedValue(updatedUser); + + const result = await service.setActiveDoll( + mockUser.id, + dollId, + mockUser.keycloakSub, + ); + + expect(result).toEqual(updatedUser); + expect(mockPrismaService.user.update).toHaveBeenCalledWith({ + where: { id: mockUser.id }, + data: { activeDollId: dollId }, + include: { activeDoll: true }, + }); + }); + + it('should throw ForbiddenException if user tries to update another profile', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + + await expect( + service.setActiveDoll(mockUser.id, dollId, 'other-keycloak-sub'), + ).rejects.toThrow(ForbiddenException); + }); + + it('should throw NotFoundException if doll not found', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + // @ts-expect-error - mockPrismaService type definition is incomplete in test file + mockPrismaService.doll = { findUnique: jest.fn() }; + // @ts-expect-error - mockPrismaService type definition is incomplete in test file + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + mockPrismaService.doll.findUnique.mockResolvedValue(null); + + await expect( + service.setActiveDoll(mockUser.id, dollId, mockUser.keycloakSub), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException if doll is soft deleted', async () => { + const deletedDoll = { ...mockDoll, deletedAt: new Date() }; + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + // @ts-expect-error - mockPrismaService type definition is incomplete in test file + mockPrismaService.doll = { findUnique: jest.fn() }; + // @ts-expect-error - mockPrismaService type definition is incomplete in test file + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + mockPrismaService.doll.findUnique.mockResolvedValue(deletedDoll); + + await expect( + service.setActiveDoll(mockUser.id, dollId, mockUser.keycloakSub), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw ForbiddenException if doll belongs to another user', async () => { + const otherUserDoll = { ...mockDoll, userId: 'other-user' }; + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + // @ts-expect-error - mockPrismaService type definition is incomplete in test file + mockPrismaService.doll = { findUnique: jest.fn() }; + // @ts-expect-error - mockPrismaService type definition is incomplete in test file + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + mockPrismaService.doll.findUnique.mockResolvedValue(otherUserDoll); + + await expect( + service.setActiveDoll(mockUser.id, dollId, mockUser.keycloakSub), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('removeActiveDoll', () => { + it('should remove active doll for user', async () => { + const updatedUser = { ...mockUser, activeDollId: null }; + + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.user.update.mockResolvedValue(updatedUser); + + const result = await service.removeActiveDoll( + mockUser.id, + mockUser.keycloakSub, + ); + + expect(result).toEqual(updatedUser); + expect(mockPrismaService.user.update).toHaveBeenCalledWith({ + where: { id: mockUser.id }, + data: { activeDollId: null }, + }); + }); + + it('should throw ForbiddenException if user tries to update another profile', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + + await expect( + service.removeActiveDoll(mockUser.id, 'other-keycloak-sub'), + ).rejects.toThrow(ForbiddenException); + }); + }); + }); }); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index bb19281..781f14a 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -385,4 +385,79 @@ export class UsersService { return users; } + + /** + * Sets the active doll for a user. + * + * @param userId - The user's internal ID + * @param dollId - The doll's internal ID + * @param requestingUserKeycloakSub - The Keycloak sub of the requesting user + * @throws NotFoundException if the user or doll is not found + * @throws ForbiddenException if the doll does not belong to the user + */ + async setActiveDoll( + userId: string, + dollId: string, + requestingUserKeycloakSub: string, + ): Promise { + const user = await this.findOne(userId); + + // Verify the user is updating their own profile + if (user.keycloakSub !== requestingUserKeycloakSub) { + throw new ForbiddenException('You can only update your own profile'); + } + + // Verify the doll exists and belongs to the user + const doll = await this.prisma.doll.findUnique({ + where: { id: dollId }, + }); + + if (!doll || doll.deletedAt) { + throw new NotFoundException(`Doll with ID ${dollId} not found`); + } + + if (doll.userId !== userId) { + throw new ForbiddenException('You can only activate your own dolls'); + } + + // Update the active doll + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data: { activeDollId: dollId }, + include: { activeDoll: true }, + }); + + this.logger.log(`User ${userId} activated doll ${dollId}`); + + return updatedUser; + } + + /** + * Removes the active doll for a user. + * + * @param userId - The user's internal ID + * @param requestingUserKeycloakSub - The Keycloak sub of the requesting user + * @throws NotFoundException if the user is not found + */ + async removeActiveDoll( + userId: string, + requestingUserKeycloakSub: string, + ): Promise { + const user = await this.findOne(userId); + + // Verify the user is updating their own profile + if (user.keycloakSub !== requestingUserKeycloakSub) { + throw new ForbiddenException('You can only update your own profile'); + } + + // Remove the active doll + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data: { activeDollId: null }, + }); + + this.logger.log(`User ${userId} deactivated their doll`); + + return updatedUser; + } }