doll active state
This commit is contained in:
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user