diff --git a/src/ws/dto/interaction-payload.dto.ts b/src/ws/dto/interaction-payload.dto.ts new file mode 100644 index 0000000..76516bc --- /dev/null +++ b/src/ws/dto/interaction-payload.dto.ts @@ -0,0 +1,7 @@ +export class InteractionPayloadDto { + senderUserId: string; + senderName: string; + content: string; + type: string; + timestamp: string; +} diff --git a/src/ws/dto/send-interaction.dto.ts b/src/ws/dto/send-interaction.dto.ts new file mode 100644 index 0000000..90f793a --- /dev/null +++ b/src/ws/dto/send-interaction.dto.ts @@ -0,0 +1,16 @@ +import { IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator'; + +export class SendInteractionDto { + @IsUUID() + @IsNotEmpty() + recipientUserId: string; + + @IsString() + @IsNotEmpty() + @MaxLength(500) + content: string; + + @IsString() + @IsNotEmpty() + type: string; +} diff --git a/src/ws/state/state.gateway.spec.ts b/src/ws/state/state.gateway.spec.ts index 0ae5ac0..bb761bf 100644 --- a/src/ws/state/state.gateway.spec.ts +++ b/src/ws/state/state.gateway.spec.ts @@ -7,6 +7,8 @@ import { JwtVerificationService } from '../../auth/services/jwt-verification.ser import { PrismaService } from '../../database/prisma.service'; import { UserSocketService } from './user-socket.service'; import { WsNotificationService } from './ws-notification.service'; +import { SendInteractionDto } from '../dto/send-interaction.dto'; +import { WsException } from '@nestjs/websockets'; interface MockSocket extends Partial { id: string; @@ -472,4 +474,130 @@ describe('StateGateway', () => { ).rejects.toThrow('Unauthorized'); }); }); + + describe('handleSendInteraction', () => { + it('should send interaction to friend if online', async () => { + const mockClient: MockSocket = { + id: 'client1', + data: { + user: { keycloakSub: 'test-sub', name: 'TestUser' }, + userId: 'user-1', + friends: new Set(['friend-1']), + }, + emit: jest.fn(), + }; + + const data: SendInteractionDto = { + recipientUserId: 'friend-1', + content: 'hello', + type: 'text', + }; + + (mockUserSocketService.isUserOnline as jest.Mock).mockResolvedValue(true); + + await gateway.handleSendInteraction( + mockClient as unknown as AuthenticatedSocket, + data, + ); + + expect(mockWsNotificationService.emitToUser).toHaveBeenCalledWith( + 'friend-1', + 'interaction-received', + expect.objectContaining({ + senderUserId: 'user-1', + senderName: 'TestUser', + content: 'hello', + type: 'text', + }), + ); + }); + + it('should fail if recipient is not a friend', async () => { + const mockClient: MockSocket = { + id: 'client1', + data: { + user: { keycloakSub: 'test-sub' }, + userId: 'user-1', + friends: new Set(['friend-1']), + }, + emit: jest.fn(), + }; + + const data: SendInteractionDto = { + recipientUserId: 'stranger-1', + content: 'hello', + type: 'text', + }; + + await gateway.handleSendInteraction( + mockClient as unknown as AuthenticatedSocket, + data, + ); + + expect(mockClient.emit).toHaveBeenCalledWith( + 'interaction-delivery-failed', + expect.objectContaining({ + reason: 'Recipient is not a friend', + }), + ); + expect(mockWsNotificationService.emitToUser).not.toHaveBeenCalled(); + }); + + it('should fail if recipient is offline', async () => { + const mockClient: MockSocket = { + id: 'client1', + data: { + user: { keycloakSub: 'test-sub' }, + userId: 'user-1', + friends: new Set(['friend-1']), + }, + emit: jest.fn(), + }; + + const data: SendInteractionDto = { + recipientUserId: 'friend-1', + content: 'hello', + type: 'text', + }; + + (mockUserSocketService.isUserOnline as jest.Mock).mockResolvedValue( + false, + ); + + await gateway.handleSendInteraction( + mockClient as unknown as AuthenticatedSocket, + data, + ); + + expect(mockClient.emit).toHaveBeenCalledWith( + 'interaction-delivery-failed', + expect.objectContaining({ + reason: 'Recipient is offline', + }), + ); + expect(mockWsNotificationService.emitToUser).not.toHaveBeenCalled(); + }); + + it('should throw Unauthorized if user not initialized', async () => { + const mockClient: MockSocket = { + id: 'client1', + data: { + // Missing user/userId + }, + }; + + const data: SendInteractionDto = { + recipientUserId: 'friend-1', + content: 'hello', + type: 'text', + }; + + await expect( + gateway.handleSendInteraction( + mockClient as unknown as AuthenticatedSocket, + data, + ), + ).rejects.toThrow(WsException); + }); + }); }); diff --git a/src/ws/state/state.gateway.ts b/src/ws/state/state.gateway.ts index a6c69d4..f678aa9 100644 --- a/src/ws/state/state.gateway.ts +++ b/src/ws/state/state.gateway.ts @@ -18,6 +18,8 @@ import type { AuthenticatedSocket } from '../../types/socket'; import { AuthService } from '../../auth/auth.service'; import { JwtVerificationService } from '../../auth/services/jwt-verification.service'; import { CursorPositionDto } from '../dto/cursor-position.dto'; +import { SendInteractionDto } from '../dto/send-interaction.dto'; +import { InteractionPayloadDto } from '../dto/interaction-payload.dto'; import { PrismaService } from '../../database/prisma.service'; import { UserSocketService } from './user-socket.service'; import { WsNotificationService } from './ws-notification.service'; @@ -336,4 +338,55 @@ export class StateGateway } } } + + @SubscribeMessage(WS_EVENT.CLIENT_SEND_INTERACTION) + async handleSendInteraction( + client: AuthenticatedSocket, + data: SendInteractionDto, + ) { + const user = client.data.user; + const currentUserId = client.data.userId; + + if (!user || !currentUserId) { + throw new WsException('Unauthorized: User not initialized'); + } + + // 1. Verify recipient is a friend + const friends = client.data.friends; + if (!friends || !friends.has(data.recipientUserId)) { + client.emit(WS_EVENT.INTERACTION_DELIVERY_FAILED, { + recipientUserId: data.recipientUserId, + reason: 'Recipient is not a friend', + }); + return; + } + + // 2. Check if recipient is online + const isOnline = await this.userSocketService.isUserOnline( + data.recipientUserId, + ); + if (!isOnline) { + client.emit(WS_EVENT.INTERACTION_DELIVERY_FAILED, { + recipientUserId: data.recipientUserId, + reason: 'Recipient is offline', + }); + return; + } + + // 3. Construct payload + const payload: InteractionPayloadDto = { + senderUserId: currentUserId, + senderName: user.name || user.username || 'Unknown', + content: data.content, + type: data.type, + timestamp: new Date().toISOString(), + }; + + // 4. Send to recipient + await this.wsNotificationService.emitToUser( + data.recipientUserId, + WS_EVENT.INTERACTION_RECEIVED, + payload, + ); + } } diff --git a/src/ws/state/ws-events.ts b/src/ws/state/ws-events.ts index 494f397..2d79a98 100644 --- a/src/ws/state/ws-events.ts +++ b/src/ws/state/ws-events.ts @@ -12,6 +12,9 @@ export const WS_EVENT = { FRIEND_DOLL_UPDATED: 'friend-doll-updated', FRIEND_DOLL_DELETED: 'friend-doll-deleted', FRIEND_ACTIVE_DOLL_CHANGED: 'friend-active-doll-changed', + CLIENT_SEND_INTERACTION: 'client-send-interaction', + INTERACTION_RECEIVED: 'interaction-received', + INTERACTION_DELIVERY_FAILED: 'interaction-delivery-failed', } as const; export const REDIS_CHANNEL = {