doll active state <-> doll stream toggle

This commit is contained in:
2025-12-23 09:16:27 +08:00
parent cd71e97655
commit 6c63f2d803
8 changed files with 151 additions and 13 deletions

View File

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

View File

@@ -26,6 +26,12 @@ export class UserBasicDto {
required: false,
})
picture?: string;
@ApiProperty({
description: "User's active doll",
required: false,
})
activeDoll?: any;
}
export class FriendRequestResponseDto {

View File

@@ -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) => ({
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: friendship.friend.id,
name: friendship.friend.name,
username: friendship.friend.username ?? undefined,
picture: friendship.friend.picture ?? undefined,
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')

View File

@@ -275,7 +275,11 @@ export class FriendsService {
return this.prisma.friendship.findMany({
where: { userId },
include: {
friend: true,
friend: {
include: {
activeDoll: true,
},
},
},
orderBy: {
createdAt: 'desc',

View File

@@ -9,6 +9,7 @@ export type AuthenticatedSocket = BaseSocket<
{
user?: AuthenticatedUser;
userId?: string;
activeDollId?: string | null;
friends?: Set<string>; // Set of friend user IDs
}
>;

View File

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

View File

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

View File

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