From 6c63f2d803e0e0117ac01db69ec6c7d0860b98bc Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Tue, 23 Dec 2025 09:16:27 +0800 Subject: [PATCH] doll active state <-> doll stream toggle --- src/dolls/dolls.service.spec.ts | 12 ++++- src/friends/dto/friend-response.dto.ts | 6 +++ src/friends/friends.controller.ts | 48 +++++++++++++++----- src/friends/friends.service.ts | 6 ++- src/types/socket.d.ts | 1 + src/users/events/user.events.ts | 11 +++++ src/users/users.service.ts | 19 +++++++- src/ws/state/state.gateway.ts | 61 ++++++++++++++++++++++++++ 8 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 src/users/events/user.events.ts diff --git a/src/dolls/dolls.service.spec.ts b/src/dolls/dolls.service.spec.ts index 2ef48ec..541522e 100644 --- a/src/dolls/dolls.service.spec.ts +++ b/src/dolls/dolls.service.spec.ts @@ -34,7 +34,10 @@ describe('DollsService', () => { friendship: { findMany: jest.fn().mockResolvedValue([]), }, - $transaction: jest.fn((callback) => callback(mockPrismaService)), + $transaction: jest.fn((callback) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return callback(mockPrismaService); + }), user: { updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, @@ -72,6 +75,13 @@ describe('DollsService', () => { const createDto = { name: 'New Doll' }; const userId = 'user-1'; + // Mock the transaction callback to return the doll + jest + .spyOn(prismaService, '$transaction') + .mockImplementation(async (callback) => { + return callback(prismaService); + }); + await service.create(userId, createDto); expect(prismaService.doll.create).toHaveBeenCalledWith({ diff --git a/src/friends/dto/friend-response.dto.ts b/src/friends/dto/friend-response.dto.ts index 9c5bff5..aa718ff 100644 --- a/src/friends/dto/friend-response.dto.ts +++ b/src/friends/dto/friend-response.dto.ts @@ -26,6 +26,12 @@ export class UserBasicDto { required: false, }) picture?: string; + + @ApiProperty({ + description: "User's active doll", + required: false, + }) + activeDoll?: any; } export class FriendRequestResponseDto { diff --git a/src/friends/friends.controller.ts b/src/friends/friends.controller.ts index 03002fc..5aef304 100644 --- a/src/friends/friends.controller.ts +++ b/src/friends/friends.controller.ts @@ -42,6 +42,20 @@ type FriendRequestWithRelations = FriendRequest & { }; import { UsersService } from '../users/users.service'; +type FriendWithDoll = { + id: string; + name: string; + username: string | null; + picture: string | null; + activeDoll?: { + id: string; + name: string; + configuration: any; + createdAt: Date; + updatedAt: Date; + } | null; +}; + @ApiTags('friends') @Controller('friends') @UseGuards(JwtAuthGuard) @@ -299,16 +313,30 @@ export class FriendsController { const friendships = await this.friendsService.getFriends(user.id); - return friendships.map((friendship) => ({ - id: friendship.id, - friend: { - id: friendship.friend.id, - name: friendship.friend.name, - username: friendship.friend.username ?? undefined, - picture: friendship.friend.picture ?? undefined, - }, - createdAt: friendship.createdAt, - })); + return friendships.map((friendship) => { + // Need to cast to any because TS doesn't know about the included relation in the service method + const friend = friendship.friend as unknown as FriendWithDoll; + + return { + id: friendship.id, + friend: { + id: friend.id, + name: friend.name, + username: friend.username ?? undefined, + picture: friend.picture ?? undefined, + activeDoll: friend.activeDoll + ? { + id: friend.activeDoll.id, + name: friend.activeDoll.name, + configuration: friend.activeDoll.configuration as unknown, + createdAt: friend.activeDoll.createdAt, + updatedAt: friend.activeDoll.updatedAt, + } + : undefined, + }, + createdAt: friendship.createdAt, + }; + }); } @Delete(':friendId') diff --git a/src/friends/friends.service.ts b/src/friends/friends.service.ts index 73c5e7f..20d2e3b 100644 --- a/src/friends/friends.service.ts +++ b/src/friends/friends.service.ts @@ -275,7 +275,11 @@ export class FriendsService { return this.prisma.friendship.findMany({ where: { userId }, include: { - friend: true, + friend: { + include: { + activeDoll: true, + }, + }, }, orderBy: { createdAt: 'desc', diff --git a/src/types/socket.d.ts b/src/types/socket.d.ts index ffb8e9f..aa96222 100644 --- a/src/types/socket.d.ts +++ b/src/types/socket.d.ts @@ -9,6 +9,7 @@ export type AuthenticatedSocket = BaseSocket< { user?: AuthenticatedUser; userId?: string; + activeDollId?: string | null; friends?: Set; // Set of friend user IDs } >; diff --git a/src/users/events/user.events.ts b/src/users/events/user.events.ts new file mode 100644 index 0000000..4615135 --- /dev/null +++ b/src/users/events/user.events.ts @@ -0,0 +1,11 @@ +import { Doll } from '@prisma/client'; + +export const UserEvents = { + ACTIVE_DOLL_CHANGED: 'user.active-doll.changed', +} as const; + +export interface UserActiveDollChangedEvent { + userId: string; + dollId: string | null; + doll: Doll | null; +} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 781f14a..ae6689c 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -4,10 +4,12 @@ import { ForbiddenException, Logger, } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { PrismaService } from '../database/prisma.service'; import { User, Prisma } from '@prisma/client'; import type { UpdateUserDto } from './dto/update-user.dto'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/client'; +import { UserEvents } from './events/user.events'; /** * Interface for creating a user from Keycloak token @@ -56,7 +58,10 @@ export interface CreateUserFromTokenDto { export class UsersService { private readonly logger = new Logger(UsersService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly eventEmitter: EventEmitter2, + ) {} /** * Finds a user or creates one if they don't exist. @@ -429,6 +434,12 @@ export class UsersService { this.logger.log(`User ${userId} activated doll ${dollId}`); + this.eventEmitter.emit(UserEvents.ACTIVE_DOLL_CHANGED, { + userId, + dollId, + doll, + }); + return updatedUser; } @@ -458,6 +469,12 @@ export class UsersService { this.logger.log(`User ${userId} deactivated their doll`); + this.eventEmitter.emit(UserEvents.ACTIVE_DOLL_CHANGED, { + userId, + dollId: null, + doll: null, + }); + return updatedUser; } } diff --git a/src/ws/state/state.gateway.ts b/src/ws/state/state.gateway.ts index a1cc3e2..0479919 100644 --- a/src/ws/state/state.gateway.ts +++ b/src/ws/state/state.gateway.ts @@ -33,6 +33,9 @@ import type { DollDeletedEvent, } from '../../dolls/events/doll.events'; +import { UserEvents } from '../../users/events/user.events'; +import type { UserActiveDollChangedEvent } from '../../users/events/user.events'; + const WS_EVENT = { CURSOR_REPORT_POSITION: 'cursor-report-position', FRIEND_REQUEST_RECEIVED: 'friend-request-received', @@ -44,6 +47,7 @@ const WS_EVENT = { FRIEND_DOLL_CREATED: 'friend-doll-created', FRIEND_DOLL_UPDATED: 'friend-doll-updated', FRIEND_DOLL_DELETED: 'friend-doll-deleted', + FRIEND_ACTIVE_DOLL_CHANGED: 'friend-active-doll-changed', } as const; @WebSocketGateway({ @@ -108,6 +112,13 @@ export class StateGateway await this.userSocketService.setSocket(user.id, client.id); client.data.userId = user.id; + // Sync active doll state to socket + const userWithDoll = await this.prisma.user.findUnique({ + where: { id: user.id }, + select: { activeDollId: true }, + }); + client.data.activeDollId = userWithDoll?.activeDollId || null; + // Initialize friends cache using Prisma directly const friends = await this.prisma.friendship.findMany({ where: { userId: user.id }, @@ -183,6 +194,11 @@ export class StateGateway const currentUserId = client.data.userId; + // Do not broadcast cursor position if user has no active doll + if (!client.data.activeDollId) { + return; + } + if (!currentUserId) { this.logger.warn(`Could not find user ID for client ${client.id}`); return; @@ -387,4 +403,49 @@ export class StateGateway }); } } + + @OnEvent(UserEvents.ACTIVE_DOLL_CHANGED) + async handleActiveDollChanged(payload: UserActiveDollChangedEvent) { + const { userId, dollId, doll } = payload; + + // 1. Update the user's socket data to reflect the change + const socketId = await this.userSocketService.getSocket(userId); + if (socketId) { + const userSocket = this.io.sockets.sockets.get( + socketId, + ) as AuthenticatedSocket; + if (userSocket) { + userSocket.data.activeDollId = dollId; + } + } + + // 2. Broadcast to friends + const friends = await this.prisma.friendship.findMany({ + where: { userId }, + select: { friendId: true }, + }); + const friendIds = friends.map((f) => f.friendId); + + const friendSockets = + await this.userSocketService.getFriendsSockets(friendIds); + + this.logger.log( + `Broadcasting friend-active-doll-changed for user ${userId}, doll: ${doll ? doll.id : 'null'} to ${friendSockets.length} friends`, + ); + + for (const { socketId } of friendSockets) { + this.io.to(socketId).emit(WS_EVENT.FRIEND_ACTIVE_DOLL_CHANGED, { + friendId: userId, + doll: doll + ? { + id: doll.id, + name: doll.name, + configuration: doll.configuration, + createdAt: doll.createdAt, + updatedAt: doll.updatedAt, + } + : null, + }); + } + } }