Dolls with friends

This commit is contained in:
2025-12-20 02:52:08 +08:00
parent 94b87550a9
commit 710d2ba75f
8 changed files with 339 additions and 61 deletions

View File

@@ -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')

View File

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

View File

@@ -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<Doll> {
async getFriendIds(userId: string): Promise<string[]> {
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<Doll> {
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<Doll[]> {
async listByOwner(
ownerId: string,
requestingUserId: string,
): Promise<Doll[]> {
// 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<Doll> {
async findOne(id: string, requestingUserId: string): Promise<Doll> {
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<Doll> {
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<void> {
// Check existence and ownership
await this.findOne(id, userId);
async remove(id: string, requestingUserId: string): Promise<void> {
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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