refactored ws module

This commit is contained in:
2026-01-10 13:48:22 +08:00
parent d1f2f9089e
commit 7e7e21c0e6
11 changed files with 475 additions and 330 deletions

View File

@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { WsNotificationService } from '../ws/state/ws-notification.service';
import { UserSocketService } from '../ws/state/user-socket.service';
import { WS_EVENT } from '../ws/state/ws-events';
import type {
DollCreatedEvent,
DollUpdatedEvent,
DollDeletedEvent,
} from './events/doll.events';
import { DollEvents } from './events/doll.events';
@Injectable()
export class DollsNotificationService {
private readonly logger = new Logger(DollsNotificationService.name);
constructor(
private readonly wsNotificationService: WsNotificationService,
private readonly userSocketService: UserSocketService,
) {}
@OnEvent(DollEvents.DOLL_CREATED)
async handleDollCreated(payload: DollCreatedEvent) {
const { userId, doll } = payload;
await this.wsNotificationService.emitToFriends(
[userId],
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;
await this.wsNotificationService.emitToFriends(
[userId],
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;
await this.wsNotificationService.emitToFriends(
[userId],
WS_EVENT.FRIEND_DOLL_DELETED,
{
friendId: userId,
dollId,
},
);
}
}

View File

@@ -1,13 +1,15 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { DollsService } from './dolls.service'; import { DollsService } from './dolls.service';
import { DollsController } from './dolls.controller'; import { DollsController } from './dolls.controller';
import { DollsNotificationService } from './dolls-notification.service';
import { DatabaseModule } from '../database/database.module'; import { DatabaseModule } from '../database/database.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { WsModule } from '../ws/ws.module';
@Module({ @Module({
imports: [DatabaseModule, AuthModule], imports: [DatabaseModule, AuthModule, forwardRef(() => WsModule)],
controllers: [DollsController], controllers: [DollsController],
providers: [DollsService], providers: [DollsService, DollsNotificationService],
exports: [DollsService], exports: [DollsService],
}) })
export class DollsModule {} export class DollsModule {}

View File

@@ -0,0 +1,127 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { WsNotificationService } from '../ws/state/ws-notification.service';
import { UserSocketService } from '../ws/state/user-socket.service';
import { WS_EVENT } from '../ws/state/ws-events';
import type {
FriendRequestReceivedEvent,
FriendRequestAcceptedEvent,
FriendRequestDeniedEvent,
UnfriendedEvent,
} from './events/friend.events';
import { FriendEvents } from './events/friend.events';
@Injectable()
export class FriendsNotificationService {
private readonly logger = new Logger(FriendsNotificationService.name);
constructor(
private readonly wsNotificationService: WsNotificationService,
private readonly userSocketService: UserSocketService,
) {}
@OnEvent(FriendEvents.REQUEST_RECEIVED)
async handleFriendRequestReceived(payload: FriendRequestReceivedEvent) {
const { userId, friendRequest } = payload;
await this.wsNotificationService.emitToUser(
userId,
WS_EVENT.FRIEND_REQUEST_RECEIVED,
{
id: friendRequest.id,
sender: {
id: friendRequest.sender.id,
name: friendRequest.sender.name,
username: friendRequest.sender.username,
picture: friendRequest.sender.picture,
},
createdAt: friendRequest.createdAt,
},
);
this.logger.debug(`Emitted friend request notification to user ${userId}`);
}
@OnEvent(FriendEvents.REQUEST_ACCEPTED)
async handleFriendRequestAccepted(payload: FriendRequestAcceptedEvent) {
const { userId, friendRequest } = payload;
// Update cache for the sender
await this.wsNotificationService.updateFriendsCache(
friendRequest.senderId,
friendRequest.receiverId,
'add',
);
// Update cache for the receiver
await this.wsNotificationService.updateFriendsCache(
friendRequest.receiverId,
friendRequest.senderId,
'add',
);
// Emit to sender
await this.wsNotificationService.emitToUser(
userId,
WS_EVENT.FRIEND_REQUEST_ACCEPTED,
{
id: friendRequest.id,
friend: {
id: friendRequest.receiver.id,
name: friendRequest.receiver.name,
username: friendRequest.receiver.username,
picture: friendRequest.receiver.picture,
},
acceptedAt: friendRequest.updatedAt,
},
);
this.logger.debug(
`Emitted friend request accepted notification to user ${userId}`,
);
}
@OnEvent(FriendEvents.REQUEST_DENIED)
async handleFriendRequestDenied(payload: FriendRequestDeniedEvent) {
const { userId, friendRequest } = payload;
await this.wsNotificationService.emitToUser(
userId,
WS_EVENT.FRIEND_REQUEST_DENIED,
{
id: friendRequest.id,
denier: {
id: friendRequest.receiver.id,
name: friendRequest.receiver.name,
username: friendRequest.receiver.username,
picture: friendRequest.receiver.picture,
},
deniedAt: friendRequest.updatedAt,
},
);
this.logger.debug(
`Emitted friend request denied notification to user ${userId}`,
);
}
@OnEvent(FriendEvents.UNFRIENDED)
async handleUnfriended(payload: UnfriendedEvent) {
const { userId, friendId } = payload;
// Update cache for the user receiving the notification
await this.wsNotificationService.updateFriendsCache(
userId,
friendId,
'delete',
);
// Update cache for the user initiating the unfriend
await this.wsNotificationService.updateFriendsCache(
friendId,
userId,
'delete',
);
// Emit to the user
await this.wsNotificationService.emitToUser(userId, WS_EVENT.UNFRIENDED, {
friendId,
});
this.logger.debug(`Emitted unfriended notification to user ${userId}`);
}
}

View File

@@ -1,6 +1,7 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { FriendsController } from './friends.controller'; import { FriendsController } from './friends.controller';
import { FriendsService } from './friends.service'; import { FriendsService } from './friends.service';
import { FriendsNotificationService } from './friends-notification.service';
import { DatabaseModule } from '../database/database.module'; import { DatabaseModule } from '../database/database.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
@@ -9,12 +10,12 @@ import { WsModule } from '../ws/ws.module';
@Module({ @Module({
imports: [ imports: [
DatabaseModule, DatabaseModule,
AuthModule, forwardRef(() => AuthModule),
UsersModule, forwardRef(() => UsersModule),
forwardRef(() => WsModule), forwardRef(() => WsModule),
], ],
controllers: [FriendsController], controllers: [FriendsController],
providers: [FriendsService], providers: [FriendsService, FriendsNotificationService],
exports: [FriendsService], exports: [FriendsService],
}) })
export class FriendsModule {} export class FriendsModule {}

View File

@@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../database/prisma.service';
import { WsNotificationService } from '../ws/state/ws-notification.service';
import { UserSocketService } from '../ws/state/user-socket.service';
import { WS_EVENT } from '../ws/state/ws-events';
import type { UserActiveDollChangedEvent } from './events/user.events';
import { UserEvents } from './events/user.events';
@Injectable()
export class UsersNotificationService {
private readonly logger = new Logger(UsersNotificationService.name);
constructor(
private readonly prisma: PrismaService,
private readonly wsNotificationService: WsNotificationService,
private readonly userSocketService: UserSocketService,
) {}
@OnEvent(UserEvents.ACTIVE_DOLL_CHANGED)
async handleActiveDollChanged(payload: UserActiveDollChangedEvent) {
const { userId, dollId, doll } = payload;
// Publish update to all instances via Redis
await this.wsNotificationService.publishActiveDollUpdate(userId, dollId);
// Broadcast to friends
const friends = await this.prisma.friendship.findMany({
where: { userId },
select: { friendId: true },
});
const friendIds = friends.map((f) => f.friendId);
this.logger.log(
`Broadcasting friend-active-doll-changed for user ${userId}, doll: ${doll ? doll.id : 'null'} to ${friendIds.length} friends`,
);
await this.wsNotificationService.emitToFriends(
friendIds,
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,
},
);
}
}

View File

@@ -1,7 +1,9 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { UsersNotificationService } from './users-notification.service';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { WsModule } from '../ws/ws.module';
/** /**
* Users Module * Users Module
@@ -13,8 +15,8 @@ import { AuthModule } from '../auth/auth.module';
* to access user data and perform synchronization. * to access user data and perform synchronization.
*/ */
@Module({ @Module({
imports: [forwardRef(() => AuthModule)], imports: [forwardRef(() => AuthModule), forwardRef(() => WsModule)],
providers: [UsersService], providers: [UsersService, UsersNotificationService],
controllers: [UsersController], controllers: [UsersController],
exports: [UsersService], exports: [UsersService],
}) })

View File

@@ -6,6 +6,7 @@ import { AuthService } from '../../auth/auth.service';
import { JwtVerificationService } from '../../auth/services/jwt-verification.service'; import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
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';
interface MockSocket extends Partial<AuthenticatedSocket> { interface MockSocket extends Partial<AuthenticatedSocket> {
id: string; id: string;
@@ -42,6 +43,14 @@ describe('StateGateway', () => {
let mockUserSocketService: Partial<UserSocketService>; let mockUserSocketService: Partial<UserSocketService>;
let mockRedisClient: { publish: jest.Mock }; let mockRedisClient: { publish: jest.Mock };
let mockRedisSubscriber: { subscribe: jest.Mock; on: jest.Mock }; let mockRedisSubscriber: { subscribe: jest.Mock; on: jest.Mock };
let mockWsNotificationService: {
setIo: jest.Mock;
emitToUser: jest.Mock;
emitToFriends: jest.Mock;
emitToSocket: jest.Mock;
updateActiveDollCache: jest.Mock;
publishActiveDollUpdate: jest.Mock;
};
beforeEach(async () => { beforeEach(async () => {
mockServer = { mockServer = {
@@ -97,6 +106,15 @@ describe('StateGateway', () => {
on: jest.fn(), on: jest.fn(),
}; };
mockWsNotificationService = {
setIo: jest.fn(),
emitToUser: jest.fn(),
emitToFriends: jest.fn(),
emitToSocket: jest.fn(),
updateActiveDollCache: jest.fn(),
publishActiveDollUpdate: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
StateGateway, StateGateway,
@@ -107,6 +125,7 @@ describe('StateGateway', () => {
}, },
{ provide: PrismaService, useValue: mockPrismaService }, { provide: PrismaService, useValue: mockPrismaService },
{ provide: UserSocketService, useValue: mockUserSocketService }, { provide: UserSocketService, useValue: mockUserSocketService },
{ provide: WsNotificationService, useValue: mockWsNotificationService },
{ provide: 'REDIS_CLIENT', useValue: mockRedisClient }, { provide: 'REDIS_CLIENT', useValue: mockRedisClient },
{ provide: 'REDIS_SUBSCRIBER_CLIENT', useValue: mockRedisSubscriber }, { provide: 'REDIS_SUBSCRIBER_CLIENT', useValue: mockRedisSubscriber },
], ],
@@ -142,6 +161,7 @@ describe('StateGateway', () => {
it('should subscribe to redis channel', () => { it('should subscribe to redis channel', () => {
expect(mockRedisSubscriber.subscribe).toHaveBeenCalledWith( expect(mockRedisSubscriber.subscribe).toHaveBeenCalledWith(
'active-doll-update', 'active-doll-update',
'friend-cache-update',
expect.any(Function), expect.any(Function),
); );
}); });
@@ -348,7 +368,11 @@ describe('StateGateway', () => {
expect(mockUserSocketService.removeSocket).toHaveBeenCalledWith( expect(mockUserSocketService.removeSocket).toHaveBeenCalledWith(
'user-id', 'user-id',
); );
expect(mockServer.to).toHaveBeenCalledWith('friend-socket-id'); expect(mockWsNotificationService.emitToSocket).toHaveBeenCalledWith(
'friend-socket-id',
expect.any(String),
expect.any(Object),
);
}); });
}); });
@@ -379,14 +403,15 @@ describe('StateGateway', () => {
data, data,
); );
// Verify that the message was emitted to the friend // Verify that message was emitted via WsNotificationService
expect(mockServer.to).toHaveBeenCalledWith('friend-socket-id'); expect(mockWsNotificationService.emitToSocket).toHaveBeenCalledWith(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 'friend-socket-id',
const emitMock = mockServer.to().emit as jest.Mock; 'friend-cursor-position',
expect(emitMock).toHaveBeenCalledWith('friend-cursor-position', { {
userId: 'user-1', userId: 'user-1',
position: data, position: data,
}); },
);
}); });
it('should NOT emit if user has no active doll', async () => { it('should NOT emit if user has no active doll', async () => {

View File

@@ -8,7 +8,6 @@ import {
WebSocketServer, WebSocketServer,
WsException, WsException,
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import { OnEvent } from '@nestjs/event-emitter';
import Redis from 'ioredis'; import Redis from 'ioredis';
import type { Server } from 'socket.io'; import type { Server } from 'socket.io';
import { import {
@@ -21,45 +20,8 @@ import { JwtVerificationService } from '../../auth/services/jwt-verification.ser
import { CursorPositionDto } from '../dto/cursor-position.dto'; import { CursorPositionDto } from '../dto/cursor-position.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 { FriendEvents } from '../../friends/events/friend.events'; import { WS_EVENT, REDIS_CHANNEL } from './ws-events';
import type {
FriendRequestReceivedEvent,
FriendRequestAcceptedEvent,
FriendRequestDeniedEvent,
UnfriendedEvent,
} from '../../friends/events/friend.events';
import { DollEvents } from '../../dolls/events/doll.events';
import type {
DollCreatedEvent,
DollUpdatedEvent,
DollDeletedEvent,
} from '../../dolls/events/doll.events';
import { UserEvents } from '../../users/events/user.events';
import type { UserActiveDollChangedEvent } from '../../users/events/user.events';
const WS_EVENT = {
CLIENT_INITIALIZE: 'client-initialize',
INITIALIZED: 'initialized',
CURSOR_REPORT_POSITION: 'cursor-report-position',
FRIEND_REQUEST_RECEIVED: 'friend-request-received',
FRIEND_REQUEST_ACCEPTED: 'friend-request-accepted',
FRIEND_REQUEST_DENIED: 'friend-request-denied',
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',
FRIEND_ACTIVE_DOLL_CHANGED: 'friend-active-doll-changed',
} as const;
const REDIS_CHANNEL = {
ACTIVE_DOLL_UPDATE: 'active-doll-update',
} as const;
@WebSocketGateway({ @WebSocketGateway({
cors: { cors: {
@@ -80,6 +42,7 @@ export class StateGateway
private readonly jwtVerificationService: JwtVerificationService, private readonly jwtVerificationService: JwtVerificationService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly userSocketService: UserSocketService, private readonly userSocketService: UserSocketService,
private readonly wsNotificationService: WsNotificationService,
@Inject(REDIS_CLIENT) private readonly redisClient: Redis | null, @Inject(REDIS_CLIENT) private readonly redisClient: Redis | null,
@Inject(REDIS_SUBSCRIBER_CLIENT) @Inject(REDIS_SUBSCRIBER_CLIENT)
private readonly redisSubscriber: Redis | null, private readonly redisSubscriber: Redis | null,
@@ -87,29 +50,28 @@ export class StateGateway
// Setup Redis subscription for cross-instance communication // Setup Redis subscription for cross-instance communication
if (this.redisSubscriber) { if (this.redisSubscriber) {
this.redisSubscriber this.redisSubscriber
.subscribe(REDIS_CHANNEL.ACTIVE_DOLL_UPDATE, (err) => { .subscribe(
REDIS_CHANNEL.ACTIVE_DOLL_UPDATE,
REDIS_CHANNEL.FRIEND_CACHE_UPDATE,
(err) => {
if (err) { if (err) {
this.logger.error( this.logger.error(`Failed to subscribe to Redis channels`, err);
`Failed to subscribe to ${REDIS_CHANNEL.ACTIVE_DOLL_UPDATE}`,
err,
);
} else { } else {
this.logger.log( this.logger.log(`Subscribed to Redis channels`);
`Subscribed to ${REDIS_CHANNEL.ACTIVE_DOLL_UPDATE} channel`,
);
} }
}) },
)
.catch((err) => { .catch((err) => {
this.logger.error( this.logger.error(`Error subscribing to Redis channels`, err);
`Error subscribing to ${REDIS_CHANNEL.ACTIVE_DOLL_UPDATE}`,
err,
);
}); });
this.redisSubscriber.on('message', (channel, message) => { this.redisSubscriber.on('message', (channel, message) => {
if (channel === REDIS_CHANNEL.ACTIVE_DOLL_UPDATE) { if (channel === REDIS_CHANNEL.ACTIVE_DOLL_UPDATE) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
this.handleActiveDollUpdateMessage(message); this.handleActiveDollUpdateMessage(message);
} else if (channel === REDIS_CHANNEL.FRIEND_CACHE_UPDATE) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.handleFriendCacheUpdateMessage(message);
} }
}); });
} }
@@ -117,6 +79,7 @@ export class StateGateway
afterInit() { afterInit() {
this.logger.log('Initialized'); this.logger.log('Initialized');
this.wsNotificationService.setIo(this.io);
} }
private async handleActiveDollUpdateMessage(message: string) { private async handleActiveDollUpdateMessage(message: string) {
@@ -126,29 +89,27 @@ export class StateGateway
dollId: string | null; dollId: string | null;
}; };
const { userId, dollId } = data; const { userId, dollId } = data;
await this.wsNotificationService.updateActiveDollCache(userId, dollId);
// Check if the user is connected to THIS instance
// Note: We need a local way to check if we hold the socket connection.
// io.sockets.sockets is a Map of all connected sockets on this server instance.
// We first get the socket ID from the shared store (UserSocketService)
// to see which socket ID belongs to the user.
const socketId = await this.userSocketService.getSocket(userId);
if (socketId) {
// Now check if we actually have this socket locally
const localSocket = this.io.sockets.sockets.get(socketId);
if (localSocket) {
// We own this connection! Update the local state.
const authSocket = localSocket as AuthenticatedSocket;
authSocket.data.activeDollId = dollId;
this.logger.debug(
`Updated activeDollId locally for user ${userId} to ${dollId}`,
);
}
}
} catch (error) { } catch (error) {
this.logger.error('Error handling redis message', error); this.logger.error('Error handling active doll update message', error);
}
}
private async handleFriendCacheUpdateMessage(message: string) {
try {
const data = JSON.parse(message) as {
userId: string;
friendId: string;
action: 'add' | 'delete';
};
const { userId, friendId, action } = data;
await this.wsNotificationService.updateFriendsCacheLocal(
userId,
friendId,
action,
);
} catch (error) {
this.logger.error('Error handling friend cache update message', error);
} }
} }
@@ -302,9 +263,13 @@ export class StateGateway
await this.userSocketService.getFriendsSockets(friendIds); await this.userSocketService.getFriendsSockets(friendIds);
for (const { socketId } of friendSockets) { for (const { socketId } of friendSockets) {
this.io.to(socketId).emit(WS_EVENT.FRIEND_DISCONNECTED, { this.wsNotificationService.emitToSocket(
socketId,
WS_EVENT.FRIEND_DISCONNECTED,
{
userId: userId, userId: userId,
}); },
);
} }
} }
} }
@@ -363,238 +328,12 @@ export class StateGateway
userId: currentUserId, userId: currentUserId,
position: data, position: data,
}; };
this.io.to(socketId).emit(WS_EVENT.FRIEND_CURSOR_POSITION, payload); this.wsNotificationService.emitToSocket(
}
}
}
@OnEvent(FriendEvents.REQUEST_RECEIVED)
async handleFriendRequestReceived(payload: FriendRequestReceivedEvent) {
const { userId, friendRequest } = payload;
const socketId = await this.userSocketService.getSocket(userId);
if (socketId) {
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_RECEIVED, {
id: friendRequest.id,
sender: {
id: friendRequest.sender.id,
name: friendRequest.sender.name,
username: friendRequest.sender.username,
picture: friendRequest.sender.picture,
},
createdAt: friendRequest.createdAt,
});
this.logger.debug(
`Emitted friend request notification to user ${userId}`,
);
}
}
@OnEvent(FriendEvents.REQUEST_ACCEPTED)
async handleFriendRequestAccepted(payload: FriendRequestAcceptedEvent) {
const { userId, friendRequest } = payload;
const socketId = await this.userSocketService.getSocket(userId);
// 1. Update cache for the user who sent the request (userId / friendRequest.senderId)
if (socketId) {
const senderSocket = this.io.sockets.sockets.get(
socketId, socketId,
) as AuthenticatedSocket; WS_EVENT.FRIEND_CURSOR_POSITION,
if (senderSocket && senderSocket.data.friends) { payload,
senderSocket.data.friends.add(friendRequest.receiverId);
}
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_ACCEPTED, {
id: friendRequest.id,
friend: {
id: friendRequest.receiver.id,
name: friendRequest.receiver.name,
username: friendRequest.receiver.username,
picture: friendRequest.receiver.picture,
},
acceptedAt: friendRequest.updatedAt,
});
this.logger.debug(
`Emitted friend request accepted notification to user ${userId}`,
); );
} }
// 2. Update cache for the user who accepted the request (friendRequest.receiverId)
const receiverSocketId = await this.userSocketService.getSocket(
friendRequest.receiverId,
);
if (receiverSocketId) {
const receiverSocket = this.io.sockets.sockets.get(
receiverSocketId,
) as AuthenticatedSocket;
if (receiverSocket && receiverSocket.data.friends) {
receiverSocket.data.friends.add(friendRequest.senderId);
}
}
}
@OnEvent(FriendEvents.REQUEST_DENIED)
async handleFriendRequestDenied(payload: FriendRequestDeniedEvent) {
const { userId, friendRequest } = payload;
const socketId = await this.userSocketService.getSocket(userId);
if (socketId) {
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_DENIED, {
id: friendRequest.id,
denier: {
id: friendRequest.receiver.id,
name: friendRequest.receiver.name,
username: friendRequest.receiver.username,
picture: friendRequest.receiver.picture,
},
deniedAt: friendRequest.updatedAt,
});
this.logger.debug(
`Emitted friend request denied notification to user ${userId}`,
);
}
}
@OnEvent(FriendEvents.UNFRIENDED)
async handleUnfriended(payload: UnfriendedEvent) {
const { userId, friendId } = payload;
const socketId = await this.userSocketService.getSocket(userId);
// 1. Update cache for the user receiving the notification (userId)
if (socketId) {
const socket = this.io.sockets.sockets.get(
socketId,
) as AuthenticatedSocket;
if (socket && socket.data.friends) {
socket.data.friends.delete(friendId);
}
this.io.to(socketId).emit(WS_EVENT.UNFRIENDED, {
friendId,
});
this.logger.debug(`Emitted unfriended notification to user ${userId}`);
}
// 2. Update cache for the user initiating the unfriend (friendId)
const initiatorSocketId = await this.userSocketService.getSocket(friendId);
if (initiatorSocketId) {
const initiatorSocket = this.io.sockets.sockets.get(
initiatorSocketId,
) as AuthenticatedSocket;
if (initiatorSocket && initiatorSocket.data.friends) {
initiatorSocket.data.friends.delete(userId);
}
}
}
@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,
});
}
}
@OnEvent(UserEvents.ACTIVE_DOLL_CHANGED)
async handleActiveDollChanged(payload: UserActiveDollChangedEvent) {
const { userId, dollId, doll } = payload;
// 1. Publish update to all instances via Redis so they can update local socket state
if (this.redisClient) {
await this.redisClient.publish(
REDIS_CHANNEL.ACTIVE_DOLL_UPDATE,
JSON.stringify({ userId, dollId }),
);
} else {
// Fallback for single instance (no redis) - update locally directly
// This mimics what handleActiveDollUpdateMessage does
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,
});
} }
} }
} }

20
src/ws/state/ws-events.ts Normal file
View File

@@ -0,0 +1,20 @@
export const WS_EVENT = {
CLIENT_INITIALIZE: 'client-initialize',
INITIALIZED: 'initialized',
CURSOR_REPORT_POSITION: 'cursor-report-position',
FRIEND_REQUEST_RECEIVED: 'friend-request-received',
FRIEND_REQUEST_ACCEPTED: 'friend-request-accepted',
FRIEND_REQUEST_DENIED: 'friend-request-denied',
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',
FRIEND_ACTIVE_DOLL_CHANGED: 'friend-active-doll-changed',
} as const;
export const REDIS_CHANNEL = {
ACTIVE_DOLL_UPDATE: 'active-doll-update',
FRIEND_CACHE_UPDATE: 'friend-cache-update',
} as const;

View File

@@ -0,0 +1,101 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import Redis from 'ioredis';
import { Server } from 'socket.io';
import { UserSocketService } from './user-socket.service';
import type { AuthenticatedSocket } from '../../types/socket';
import { REDIS_CLIENT } from '../../database/redis.module';
import { REDIS_CHANNEL } from './ws-events';
@Injectable()
export class WsNotificationService {
private readonly logger = new Logger(WsNotificationService.name);
private io: Server | null = null;
constructor(
private readonly userSocketService: UserSocketService,
@Inject(REDIS_CLIENT) private readonly redisClient: Redis | null,
) {}
setIo(io: Server) {
this.io = io;
}
async emitToUser(userId: string, event: string, payload: any) {
if (!this.io) return;
const socketId = await this.userSocketService.getSocket(userId);
if (socketId) {
this.io.to(socketId).emit(event, payload);
}
}
async emitToFriends(userIds: string[], event: string, payload: any) {
if (!this.io) return;
const friendSockets =
await this.userSocketService.getFriendsSockets(userIds);
for (const { socketId } of friendSockets) {
this.io.to(socketId).emit(event, payload);
}
}
emitToSocket(socketId: string, event: string, payload: any) {
if (!this.io) return;
this.io.to(socketId).emit(event, payload);
}
async updateFriendsCache(
userId: string,
friendId: string,
action: 'add' | 'delete',
) {
if (this.redisClient) {
await this.redisClient.publish(
REDIS_CHANNEL.FRIEND_CACHE_UPDATE,
JSON.stringify({ userId, friendId, action }),
);
} else {
// Fallback: update locally
await this.updateFriendsCacheLocal(userId, friendId, action);
}
}
async updateFriendsCacheLocal(
userId: string,
friendId: string,
action: 'add' | 'delete',
) {
if (!this.io) return;
const socketId = await this.userSocketService.getSocket(userId);
if (socketId) {
const socket = this.io.sockets.sockets.get(
socketId,
) as AuthenticatedSocket;
if (socket?.data?.friends) {
if (action === 'add') socket.data.friends.add(friendId);
else socket.data.friends.delete(friendId);
}
}
}
async updateActiveDollCache(userId: string, dollId: string | null) {
if (!this.io) return;
const socketId = await this.userSocketService.getSocket(userId);
if (socketId) {
const socket = this.io.sockets.sockets.get(
socketId,
) as AuthenticatedSocket;
if (socket) socket.data.activeDollId = dollId;
}
}
async publishActiveDollUpdate(userId: string, dollId: string | null) {
if (this.redisClient) {
await this.redisClient.publish(
REDIS_CHANNEL.ACTIVE_DOLL_UPDATE,
JSON.stringify({ userId, dollId }),
);
} else {
// Fallback: update locally
await this.updateActiveDollCache(userId, dollId);
}
}
}

View File

@@ -1,5 +1,6 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { StateGateway } from './state/state.gateway'; import { StateGateway } from './state/state.gateway';
import { WsNotificationService } from './state/ws-notification.service';
import { UserSocketService } from './state/user-socket.service'; import { UserSocketService } from './state/user-socket.service';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { FriendsModule } from '../friends/friends.module'; import { FriendsModule } from '../friends/friends.module';
@@ -7,7 +8,7 @@ import { RedisModule } from '../database/redis.module';
@Module({ @Module({
imports: [AuthModule, RedisModule, forwardRef(() => FriendsModule)], imports: [AuthModule, RedisModule, forwardRef(() => FriendsModule)],
providers: [StateGateway, UserSocketService], providers: [StateGateway, WsNotificationService, UserSocketService],
exports: [StateGateway], exports: [StateGateway, WsNotificationService, UserSocketService],
}) })
export class WsModule {} export class WsModule {}