From 710d2ba75f453b088c56e27b5157bc4a20bb0ab2 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Sat, 20 Dec 2025 02:52:08 +0800 Subject: [PATCH] Dolls with friends --- src/dolls/dolls.controller.ts | 33 ++++++- src/dolls/dolls.service.spec.ts | 85 +++++++++++++++-- src/dolls/dolls.service.ts | 136 ++++++++++++++++++++++------ src/dolls/events/doll.events.ts | 22 +++++ src/ws/redis-io.adapter.ts | 2 + src/ws/state/state.gateway.spec.ts | 28 ++++-- src/ws/state/state.gateway.ts | 83 +++++++++++++++-- src/ws/state/user-socket.service.ts | 11 ++- 8 files changed, 339 insertions(+), 61 deletions(-) create mode 100644 src/dolls/events/doll.events.ts diff --git a/src/dolls/dolls.controller.ts b/src/dolls/dolls.controller.ts index 79f70f2..196f8e3 100644 --- a/src/dolls/dolls.controller.ts +++ b/src/dolls/dolls.controller.ts @@ -55,19 +55,42 @@ export class DollsController { return this.dollsService.create(user.id, createDollDto); } - @Get() + @Get('me') @ApiOperation({ - summary: 'Get all dolls', + summary: 'Get my dolls', description: 'Retrieves all dolls belonging to the authenticated user.', }) @ApiResponse({ status: 200, - description: 'Return all dolls.', + description: 'Return list of dolls owned by the user.', }) @ApiUnauthorizedResponse({ description: 'Unauthorized' }) - async findAll(@CurrentUser() authUser: AuthenticatedUser) { + async listMyDolls(@CurrentUser() authUser: AuthenticatedUser) { const user = await this.authService.ensureUserExists(authUser); - return this.dollsService.findAll(user.id); + return this.dollsService.listByOwner(user.id, user.id); + } + + @Get('user/:userId') + @ApiOperation({ + summary: "Get a user's dolls", + description: + 'Retrieves dolls belonging to a specific user. Requires being friends with that user.', + }) + @ApiResponse({ + status: 200, + description: 'Return list of dolls owned by the specified user.', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - Not friends with user', + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + async listUserDolls( + @CurrentUser() authUser: AuthenticatedUser, + @Param('userId') userId: string, + ) { + const user = await this.authService.ensureUserExists(authUser); + return this.dollsService.listByOwner(userId, user.id); } @Get(':id') diff --git a/src/dolls/dolls.service.spec.ts b/src/dolls/dolls.service.spec.ts index 09aa432..1061b14 100644 --- a/src/dolls/dolls.service.spec.ts +++ b/src/dolls/dolls.service.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { DollsService } from './dolls.service'; import { PrismaService } from '../database/prisma.service'; import { NotFoundException, ForbiddenException } from '@nestjs/common'; @@ -30,6 +31,13 @@ describe('DollsService', () => { findFirst: jest.fn().mockResolvedValue(mockDoll), update: jest.fn().mockResolvedValue(mockDoll), }, + friendship: { + findMany: jest.fn().mockResolvedValue([]), + }, + }; + + const mockEventEmitter = { + emit: jest.fn(), }; beforeEach(async () => { @@ -40,6 +48,10 @@ describe('DollsService', () => { provide: PrismaService, useValue: mockPrismaService, }, + { + provide: EventEmitter2, + useValue: mockEventEmitter, + }, ], }).compile(); @@ -73,14 +85,14 @@ describe('DollsService', () => { }); }); - describe('findAll', () => { - it('should return an array of dolls', async () => { + describe('listByOwner', () => { + it('should return own dolls without friendship check', async () => { const userId = 'user-1'; - await service.findAll(userId); + await service.listByOwner(userId, userId); expect(prismaService.doll.findMany).toHaveBeenCalledWith({ where: { - userId, + userId: userId, deletedAt: null, }, orderBy: { @@ -88,6 +100,42 @@ describe('DollsService', () => { }, }); }); + + it("should return friend's dolls if friends", async () => { + const ownerId = 'friend-1'; + const requestingUserId = 'user-1'; + + // Mock friendship + jest + .spyOn(prismaService.friendship, 'findMany') + .mockResolvedValueOnce([{ friendId: ownerId } as any]); + + await service.listByOwner(ownerId, requestingUserId); + + expect(prismaService.doll.findMany).toHaveBeenCalledWith({ + where: { + userId: ownerId, + deletedAt: null, + }, + orderBy: { + createdAt: 'asc', + }, + }); + }); + + it('should throw ForbiddenException if not friends', async () => { + const ownerId = 'stranger-1'; + const requestingUserId = 'user-1'; + + // Mock empty friendship (default) + jest + .spyOn(prismaService.friendship, 'findMany') + .mockResolvedValueOnce([]); + + await expect( + service.listByOwner(ownerId, requestingUserId), + ).rejects.toThrow(ForbiddenException); + }); }); describe('findOne', () => { @@ -107,13 +155,11 @@ describe('DollsService', () => { ); }); - it('should throw ForbiddenException if doll belongs to another user', async () => { - jest - .spyOn(prismaService.doll, 'findFirst') - .mockResolvedValueOnce({ ...mockDoll, userId: 'user-2' }); + it('should throw NotFoundException if doll not accessible', async () => { + jest.spyOn(prismaService.doll, 'findFirst').mockResolvedValueOnce(null); await expect(service.findOne('doll-1', 'user-1')).rejects.toThrow( - ForbiddenException, + NotFoundException, ); }); }); @@ -125,6 +171,17 @@ describe('DollsService', () => { expect(prismaService.doll.update).toHaveBeenCalled(); }); + + it('should throw ForbiddenException if not owner', async () => { + jest + .spyOn(prismaService.doll, 'findFirst') + .mockResolvedValueOnce({ ...mockDoll, userId: 'user-2' }); + + const updateDto = { name: 'Updated Doll' }; + await expect( + service.update('doll-1', 'user-1', updateDto), + ).rejects.toThrow(ForbiddenException); + }); }); describe('remove', () => { @@ -138,5 +195,15 @@ describe('DollsService', () => { }, }); }); + + it('should throw ForbiddenException if not owner', async () => { + jest + .spyOn(prismaService.doll, 'findFirst') + .mockResolvedValueOnce({ ...mockDoll, userId: 'user-2' }); + + await expect(service.remove('doll-1', 'user-1')).rejects.toThrow( + ForbiddenException, + ); + }); }); }); diff --git a/src/dolls/dolls.service.ts b/src/dolls/dolls.service.ts index ec86684..4fa1410 100644 --- a/src/dolls/dolls.service.ts +++ b/src/dolls/dolls.service.ts @@ -4,18 +4,39 @@ import { ForbiddenException, Logger, } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { PrismaService } from '../database/prisma.service'; import { CreateDollDto, DollConfigurationDto } from './dto/create-doll.dto'; import { UpdateDollDto } from './dto/update-doll.dto'; import { Doll, Prisma } from '@prisma/client'; +import { + DollEvents, + DollCreatedEvent, + DollUpdatedEvent, + DollDeletedEvent, +} from './events/doll.events'; @Injectable() export class DollsService { private readonly logger = new Logger(DollsService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly eventEmitter: EventEmitter2, + ) {} - async create(userId: string, createDollDto: CreateDollDto): Promise { + async getFriendIds(userId: string): Promise { + const friendships = await this.prisma.friendship.findMany({ + where: { userId }, + select: { friendId: true }, + }); + return friendships.map((f) => f.friendId); + } + + async create( + requestingUserId: string, + createDollDto: CreateDollDto, + ): Promise { const defaultConfiguration: DollConfigurationDto = { colorScheme: { outline: '#000000', @@ -34,19 +55,50 @@ export class DollsService { }, }; - return this.prisma.doll.create({ - data: { - name: createDollDto.name, - configuration: configuration as unknown as Prisma.InputJsonValue, - userId, - }, - }); + return this.prisma.doll + .create({ + data: { + name: createDollDto.name, + configuration: configuration as unknown as Prisma.InputJsonValue, + userId: requestingUserId, + }, + }) + .then((doll) => { + const event: DollCreatedEvent = { + userId: requestingUserId, + doll, + }; + this.eventEmitter.emit(DollEvents.DOLL_CREATED, event); + return doll; + }); } - async findAll(userId: string): Promise { + async listByOwner( + ownerId: string, + requestingUserId: string, + ): Promise { + // If requesting own dolls, no need to check friendship + if (ownerId === requestingUserId) { + return this.prisma.doll.findMany({ + where: { + userId: ownerId, + deletedAt: null, + }, + orderBy: { + createdAt: 'asc', + }, + }); + } + + // If requesting someone else's dolls, check friendship + const friendIds = await this.getFriendIds(requestingUserId); + if (!friendIds.includes(ownerId)) { + throw new ForbiddenException('You are not friends with this user'); + } + return this.prisma.doll.findMany({ where: { - userId, + userId: ownerId, deletedAt: null, }, orderBy: { @@ -55,20 +107,22 @@ export class DollsService { }); } - async findOne(id: string, userId: string): Promise { + async findOne(id: string, requestingUserId: string): Promise { + const friendIds = await this.getFriendIds(requestingUserId); + const accessibleUserIds = [requestingUserId, ...friendIds]; + const doll = await this.prisma.doll.findFirst({ where: { id, + userId: { in: accessibleUserIds }, deletedAt: null, }, }); if (!doll) { - throw new NotFoundException(`Doll with ID ${id} not found`); - } - - if (doll.userId !== userId) { - throw new ForbiddenException('You do not have access to this doll'); + throw new NotFoundException( + `Doll with ID ${id} not found or access denied`, + ); } return doll; @@ -76,10 +130,15 @@ export class DollsService { async update( id: string, - userId: string, + requestingUserId: string, updateDollDto: UpdateDollDto, ): Promise { - const doll = await this.findOne(id, userId); + const doll = await this.findOne(id, requestingUserId); + + // Only owner can update + if (doll.userId !== requestingUserId) { + throw new ForbiddenException('You can only update your own dolls'); + } let configuration = doll.configuration as unknown as DollConfigurationDto; @@ -101,18 +160,31 @@ export class DollsService { }; } - return this.prisma.doll.update({ - where: { id }, - data: { - name: updateDollDto.name, - configuration: configuration as unknown as Prisma.InputJsonValue, - }, - }); + return this.prisma.doll + .update({ + where: { id }, + data: { + name: updateDollDto.name, + configuration: configuration as unknown as Prisma.InputJsonValue, + }, + }) + .then((doll) => { + const event: DollUpdatedEvent = { + userId: requestingUserId, + doll, + }; + this.eventEmitter.emit(DollEvents.DOLL_UPDATED, event); + return doll; + }); } - async remove(id: string, userId: string): Promise { - // Check existence and ownership - await this.findOne(id, userId); + async remove(id: string, requestingUserId: string): Promise { + const doll = await this.findOne(id, requestingUserId); + + // Only owner can delete + if (doll.userId !== requestingUserId) { + throw new ForbiddenException('You can only delete your own dolls'); + } // Soft delete await this.prisma.doll.update({ @@ -121,5 +193,11 @@ export class DollsService { deletedAt: new Date(), }, }); + + const event: DollDeletedEvent = { + userId: requestingUserId, + dollId: id, + }; + this.eventEmitter.emit(DollEvents.DOLL_DELETED, event); } } diff --git a/src/dolls/events/doll.events.ts b/src/dolls/events/doll.events.ts new file mode 100644 index 0000000..4eb00f7 --- /dev/null +++ b/src/dolls/events/doll.events.ts @@ -0,0 +1,22 @@ +import { Doll } from '@prisma/client'; + +export const DollEvents = { + DOLL_CREATED: 'doll.created', + DOLL_UPDATED: 'doll.updated', + DOLL_DELETED: 'doll.deleted', +} as const; + +export interface DollCreatedEvent { + userId: string; + doll: Doll; +} + +export interface DollUpdatedEvent { + userId: string; + doll: Doll; +} + +export interface DollDeletedEvent { + userId: string; + dollId: string; +} diff --git a/src/ws/redis-io.adapter.ts b/src/ws/redis-io.adapter.ts index 421ba15..53d2b8f 100644 --- a/src/ws/redis-io.adapter.ts +++ b/src/ws/redis-io.adapter.ts @@ -72,8 +72,10 @@ export class RedisIoAdapter extends IoAdapter { } createIOServer(port: number, options?: ServerOptions): any { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const server = super.createIOServer(port, options); if (this.adapterConstructor) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access server.adapter(this.adapterConstructor); } return server; diff --git a/src/ws/state/state.gateway.spec.ts b/src/ws/state/state.gateway.spec.ts index 6d58e19..a3f1535 100644 --- a/src/ws/state/state.gateway.spec.ts +++ b/src/ws/state/state.gateway.spec.ts @@ -190,7 +190,9 @@ describe('StateGateway', () => { data: { user: { keycloakSub: 'test-sub' } }, }; - await gateway.handleDisconnect(mockClient as unknown as AuthenticatedSocket); + await gateway.handleDisconnect( + mockClient as unknown as AuthenticatedSocket, + ); expect(mockLoggerLog).toHaveBeenCalledWith( `Client id: ${mockClient.id} disconnected (user: test-sub)`, @@ -203,7 +205,9 @@ describe('StateGateway', () => { data: {}, }; - await gateway.handleDisconnect(mockClient as unknown as AuthenticatedSocket); + await gateway.handleDisconnect( + mockClient as unknown as AuthenticatedSocket, + ); expect(mockLoggerLog).toHaveBeenCalledWith( `Client id: ${mockClient.id} disconnected (user: unknown)`, @@ -213,22 +217,28 @@ describe('StateGateway', () => { it('should remove socket if it matches', async () => { const mockClient: MockSocket = { id: 'client1', - data: { + data: { user: { keycloakSub: 'test-sub' }, userId: 'user-id', friends: new Set(['friend-1']), }, }; - (mockUserSocketService.getSocket as jest.Mock).mockResolvedValue('client1'); + (mockUserSocketService.getSocket as jest.Mock).mockResolvedValue( + 'client1', + ); (mockUserSocketService.getFriendsSockets as jest.Mock).mockResolvedValue([ - { userId: 'friend-1', socketId: 'friend-socket-id' } + { userId: 'friend-1', socketId: 'friend-socket-id' }, ]); - await gateway.handleDisconnect(mockClient as unknown as AuthenticatedSocket); + await gateway.handleDisconnect( + mockClient as unknown as AuthenticatedSocket, + ); expect(mockUserSocketService.getSocket).toHaveBeenCalledWith('user-id'); - expect(mockUserSocketService.removeSocket).toHaveBeenCalledWith('user-id'); + expect(mockUserSocketService.removeSocket).toHaveBeenCalledWith( + 'user-id', + ); expect(mockServer.to).toHaveBeenCalledWith('friend-socket-id'); }); }); @@ -277,7 +287,9 @@ describe('StateGateway', () => { }; // Mock getFriendsSockets to return empty array - (mockUserSocketService.getFriendsSockets as jest.Mock).mockResolvedValue([]); + (mockUserSocketService.getFriendsSockets as jest.Mock).mockResolvedValue( + [], + ); const data: CursorPositionDto = { x: 100, y: 200 }; diff --git a/src/ws/state/state.gateway.ts b/src/ws/state/state.gateway.ts index fc2970d..a1cc3e2 100644 --- a/src/ws/state/state.gateway.ts +++ b/src/ws/state/state.gateway.ts @@ -26,6 +26,13 @@ import type { UnfriendedEvent, } from '../../friends/events/friend.events'; +import { DollEvents } from '../../dolls/events/doll.events'; +import type { + DollCreatedEvent, + DollUpdatedEvent, + DollDeletedEvent, +} from '../../dolls/events/doll.events'; + const WS_EVENT = { CURSOR_REPORT_POSITION: 'cursor-report-position', FRIEND_REQUEST_RECEIVED: 'friend-request-received', @@ -34,6 +41,9 @@ const WS_EVENT = { UNFRIENDED: 'unfriended', FRIEND_CURSOR_POSITION: 'friend-cursor-position', FRIEND_DISCONNECTED: 'friend-disconnected', + FRIEND_DOLL_CREATED: 'friend-doll-created', + FRIEND_DOLL_UPDATED: 'friend-doll-updated', + FRIEND_DOLL_DELETED: 'friend-doll-deleted', } as const; @WebSocketGateway({ @@ -135,8 +145,9 @@ export class StateGateway const friends = client.data.friends; if (friends) { const friendIds = Array.from(friends); - const friendSockets = await this.userSocketService.getFriendsSockets(friendIds); - + const friendSockets = + await this.userSocketService.getFriendsSockets(friendIds); + for (const { socketId } of friendSockets) { this.io.to(socketId).emit(WS_EVENT.FRIEND_DISCONNECTED, { userId: userId, @@ -188,16 +199,15 @@ export class StateGateway const friends = client.data.friends; if (friends) { const friendIds = Array.from(friends); - const friendSockets = await this.userSocketService.getFriendsSockets(friendIds); + const friendSockets = + await this.userSocketService.getFriendsSockets(friendIds); for (const { socketId } of friendSockets) { const payload = { userId: currentUserId, position: data, }; - this.io - .to(socketId) - .emit(WS_EVENT.FRIEND_CURSOR_POSITION, payload); + this.io.to(socketId).emit(WS_EVENT.FRIEND_CURSOR_POSITION, payload); } } } @@ -254,7 +264,9 @@ export class StateGateway } // 2. Update cache for the user who accepted the request (friendRequest.receiverId) - const receiverSocketId = await this.userSocketService.getSocket(friendRequest.receiverId); + const receiverSocketId = await this.userSocketService.getSocket( + friendRequest.receiverId, + ); if (receiverSocketId) { const receiverSocket = this.io.sockets.sockets.get( receiverSocketId, @@ -318,4 +330,61 @@ export class StateGateway } } } + + @OnEvent(DollEvents.DOLL_CREATED) + async handleDollCreated(payload: DollCreatedEvent) { + const { userId, doll } = payload; + const friendSockets = await this.userSocketService.getFriendsSockets([ + userId, + ]); + + for (const { socketId } of friendSockets) { + this.io.to(socketId).emit(WS_EVENT.FRIEND_DOLL_CREATED, { + friendId: userId, + doll: { + id: doll.id, + name: doll.name, + configuration: doll.configuration, + createdAt: doll.createdAt, + updatedAt: doll.updatedAt, + }, + }); + } + } + + @OnEvent(DollEvents.DOLL_UPDATED) + async handleDollUpdated(payload: DollUpdatedEvent) { + const { userId, doll } = payload; + const friendSockets = await this.userSocketService.getFriendsSockets([ + userId, + ]); + + for (const { socketId } of friendSockets) { + this.io.to(socketId).emit(WS_EVENT.FRIEND_DOLL_UPDATED, { + friendId: userId, + doll: { + id: doll.id, + name: doll.name, + configuration: doll.configuration, + createdAt: doll.createdAt, + updatedAt: doll.updatedAt, + }, + }); + } + } + + @OnEvent(DollEvents.DOLL_DELETED) + async handleDollDeleted(payload: DollDeletedEvent) { + const { userId, dollId } = payload; + const friendSockets = await this.userSocketService.getFriendsSockets([ + userId, + ]); + + for (const { socketId } of friendSockets) { + this.io.to(socketId).emit(WS_EVENT.FRIEND_DOLL_DELETED, { + friendId: userId, + dollId, + }); + } + } } diff --git a/src/ws/state/user-socket.service.ts b/src/ws/state/user-socket.service.ts index bee9bdc..020ab3c 100644 --- a/src/ws/state/user-socket.service.ts +++ b/src/ws/state/user-socket.service.ts @@ -71,7 +71,9 @@ export class UserSocketService { return !!socketId; } - async getFriendsSockets(friendIds: string[]): Promise<{ userId: string; socketId: string }[]> { + async getFriendsSockets( + friendIds: string[], + ): Promise<{ userId: string; socketId: string }[]> { if (friendIds.length === 0) { return []; } @@ -84,7 +86,7 @@ export class UserSocketService { const results = await pipeline.exec(); const sockets: { userId: string; socketId: string }[] = []; - + if (results) { results.forEach((result, index) => { const [err, socketId] = result; @@ -95,7 +97,10 @@ export class UserSocketService { } return sockets; } catch (error) { - this.logger.error('Failed to batch get friend sockets from Redis', error); + this.logger.error( + 'Failed to batch get friend sockets from Redis', + error, + ); // Fallback to local implementation } }