doll active state

This commit is contained in:
2025-12-21 02:07:48 +08:00
parent 5bed1fc92e
commit cd71e97655
10 changed files with 310 additions and 5 deletions

View File

@@ -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;

View File

@@ -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")
}

View File

@@ -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 () => {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 () => {

View File

@@ -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<User> {
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<User> {
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);
}
}

View File

@@ -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;
}

View File

@@ -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);
});
});
});
});

View File

@@ -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<User> {
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<User> {
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;
}
}