interaction system
This commit is contained in:
7
src/ws/dto/interaction-payload.dto.ts
Normal file
7
src/ws/dto/interaction-payload.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export class InteractionPayloadDto {
|
||||||
|
senderUserId: string;
|
||||||
|
senderName: string;
|
||||||
|
content: string;
|
||||||
|
type: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
16
src/ws/dto/send-interaction.dto.ts
Normal file
16
src/ws/dto/send-interaction.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { JwtVerificationService } from '../../auth/services/jwt-verification.ser
|
|||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from '../../database/prisma.service';
|
||||||
import { UserSocketService } from './user-socket.service';
|
import { UserSocketService } from './user-socket.service';
|
||||||
import { WsNotificationService } from './ws-notification.service';
|
import { WsNotificationService } from './ws-notification.service';
|
||||||
|
import { SendInteractionDto } from '../dto/send-interaction.dto';
|
||||||
|
import { WsException } from '@nestjs/websockets';
|
||||||
|
|
||||||
interface MockSocket extends Partial<AuthenticatedSocket> {
|
interface MockSocket extends Partial<AuthenticatedSocket> {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -472,4 +474,130 @@ describe('StateGateway', () => {
|
|||||||
).rejects.toThrow('Unauthorized');
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import type { AuthenticatedSocket } from '../../types/socket';
|
|||||||
import { AuthService } from '../../auth/auth.service';
|
import { AuthService } from '../../auth/auth.service';
|
||||||
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
||||||
import { CursorPositionDto } from '../dto/cursor-position.dto';
|
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 { PrismaService } from '../../database/prisma.service';
|
||||||
import { UserSocketService } from './user-socket.service';
|
import { UserSocketService } from './user-socket.service';
|
||||||
import { WsNotificationService } from './ws-notification.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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export const WS_EVENT = {
|
|||||||
FRIEND_DOLL_UPDATED: 'friend-doll-updated',
|
FRIEND_DOLL_UPDATED: 'friend-doll-updated',
|
||||||
FRIEND_DOLL_DELETED: 'friend-doll-deleted',
|
FRIEND_DOLL_DELETED: 'friend-doll-deleted',
|
||||||
FRIEND_ACTIVE_DOLL_CHANGED: 'friend-active-doll-changed',
|
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;
|
} as const;
|
||||||
|
|
||||||
export const REDIS_CHANNEL = {
|
export const REDIS_CHANNEL = {
|
||||||
|
|||||||
Reference in New Issue
Block a user