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
|
/// Timestamp of last login
|
||||||
lastLoginAt DateTime? @map("last_login_at")
|
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")
|
sentFriendRequests FriendRequest[] @relation("SentFriendRequests")
|
||||||
receivedFriendRequests FriendRequest[] @relation("ReceivedFriendRequests")
|
receivedFriendRequests FriendRequest[] @relation("ReceivedFriendRequests")
|
||||||
userFriendships Friendship[] @relation("UserFriendships")
|
userFriendships Friendship[] @relation("UserFriendships")
|
||||||
@@ -90,6 +94,9 @@ model Doll {
|
|||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Reverse relation for the active doll
|
||||||
|
activeForUser User[] @relation("ActiveDoll")
|
||||||
|
|
||||||
@@map("dolls")
|
@@map("dolls")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe('AuthService', () => {
|
|||||||
createdAt: new Date('2024-01-01'),
|
createdAt: new Date('2024-01-01'),
|
||||||
updatedAt: new Date('2024-01-01'),
|
updatedAt: new Date('2024-01-01'),
|
||||||
lastLoginAt: new Date('2024-01-01'),
|
lastLoginAt: new Date('2024-01-01'),
|
||||||
|
activeDollId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ describe('DollsService', () => {
|
|||||||
friendship: {
|
friendship: {
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
|
$transaction: jest.fn((callback) => callback(mockPrismaService)),
|
||||||
|
user: {
|
||||||
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockEventEmitter = {
|
const mockEventEmitter = {
|
||||||
|
|||||||
@@ -187,13 +187,22 @@ export class DollsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Soft delete
|
// Soft delete
|
||||||
await this.prisma.doll.update({
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
// 1. Soft delete the doll
|
||||||
|
await tx.doll.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
deletedAt: new Date(),
|
deletedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2. Unset if it was active
|
||||||
|
await tx.user.updateMany({
|
||||||
|
where: { id: requestingUserId, activeDollId: id },
|
||||||
|
data: { activeDollId: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const event: DollDeletedEvent = {
|
const event: DollDeletedEvent = {
|
||||||
userId: requestingUserId,
|
userId: requestingUserId,
|
||||||
dollId: id,
|
dollId: id,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ describe('UsersController', () => {
|
|||||||
createdAt: new Date('2024-01-01'),
|
createdAt: new Date('2024-01-01'),
|
||||||
updatedAt: new Date('2024-01-01'),
|
updatedAt: new Date('2024-01-01'),
|
||||||
lastLoginAt: new Date('2024-01-01'),
|
lastLoginAt: new Date('2024-01-01'),
|
||||||
|
activeDollId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -266,4 +266,81 @@ export class UsersController {
|
|||||||
this.logger.log(`Delete user ${id} (requested by ${authUser.keycloakSub})`);
|
this.logger.log(`Delete user ${id} (requested by ${authUser.keycloakSub})`);
|
||||||
await this.usersService.delete(id, 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,
|
nullable: true,
|
||||||
})
|
})
|
||||||
lastLoginAt: Date | null;
|
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'),
|
lastLoginAt: new Date('2024-01-15T10:30:00.000Z'),
|
||||||
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||||
updatedAt: new Date('2024-01-15T10:30:00.000Z'),
|
updatedAt: new Date('2024-01-15T10:30:00.000Z'),
|
||||||
|
activeDollId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockPrismaService = {
|
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;
|
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