mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
512f16e288
Emit a notification:all-read event instead of notification:cleared when marking all notifications as read, and handle this event on the client by updating the read status rather than emptying the notification list. Closes #587
101 lines
4.1 KiB
TypeScript
101 lines
4.1 KiB
TypeScript
import { Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
|
import { Permission } from '@bookorbit/types';
|
|
import type { NotificationItem } from '@bookorbit/types';
|
|
import { Server, Socket } from 'socket.io';
|
|
|
|
import type { RequestUser } from '../../common/types/request-user';
|
|
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
|
import { UserService } from '../user/user.service';
|
|
import { NotificationRepository } from './notification.repository';
|
|
|
|
@WebSocketGateway({ namespace: '/notifications', cors: { credentials: true } })
|
|
export class NotificationGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
|
@WebSocketServer() server: Server;
|
|
private readonly logger = new Logger(NotificationGateway.name);
|
|
private readonly clientOrigin: string;
|
|
|
|
constructor(
|
|
private readonly jwtService: JwtService,
|
|
private readonly userService: UserService,
|
|
private readonly notificationRepo: NotificationRepository,
|
|
config: ConfigService,
|
|
) {
|
|
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
|
|
}
|
|
|
|
afterInit(server: Server): void {
|
|
if (!server.engine?.opts) return;
|
|
server.engine.opts.cors = {
|
|
...(server.engine.opts.cors ?? {}),
|
|
origin: this.clientOrigin,
|
|
credentials: true,
|
|
};
|
|
}
|
|
|
|
async handleConnection(client: Socket): Promise<void> {
|
|
const event = 'notification.socket';
|
|
try {
|
|
const token = client.handshake.auth?.token as string | undefined;
|
|
if (!token) throw new Error('No token provided');
|
|
|
|
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
|
|
const user = await this.userService.findByIdWithPermissions(payload.sub);
|
|
if (!user || !user.active) throw new Error('User not found or inactive');
|
|
if (user.tokenVersion !== payload.ver) throw new Error('Token revoked');
|
|
|
|
this.assertHasAccess(user);
|
|
|
|
await client.join(`user:${user.id}`);
|
|
this.logger.debug(`[${event}] [start] userId=${user.id} socketId=${client.id} - websocket connected`);
|
|
|
|
const unreadCount = await this.notificationRepo.countUnread(user.id);
|
|
client.emit('notification:unread-count', { count: unreadCount });
|
|
} catch (err) {
|
|
const errorClass = err instanceof Error ? err.name : 'Error';
|
|
const message = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
|
this.logger.warn(`[${event}] [fail] socketId=${client.id} errorClass=${errorClass} error="${message}" - websocket rejected`);
|
|
client.disconnect();
|
|
}
|
|
}
|
|
|
|
handleDisconnect(client: Socket): void {
|
|
this.logger.debug(`[notification.socket] [end] socketId=${client.id} - websocket disconnected`);
|
|
}
|
|
|
|
emitNew(userId: number, notification: NotificationItem): void {
|
|
this.server?.to(`user:${userId}`).emit('notification:new', notification);
|
|
}
|
|
|
|
emitCountUpdate(userId: number, count: number): void {
|
|
this.server?.to(`user:${userId}`).emit('notification:unread-count', { count });
|
|
}
|
|
|
|
emitRead(userId: number, notificationId: number): void {
|
|
this.server?.to(`user:${userId}`).emit('notification:read', { id: notificationId });
|
|
}
|
|
|
|
emitDismissed(userId: number, notificationId: number): void {
|
|
this.server?.to(`user:${userId}`).emit('notification:dismissed', { id: notificationId });
|
|
}
|
|
|
|
emitAllRead(userId: number): void {
|
|
this.server?.to(`user:${userId}`).emit('notification:all-read', {});
|
|
}
|
|
|
|
emitCleared(userId: number): void {
|
|
this.server?.to(`user:${userId}`).emit('notification:cleared', {});
|
|
}
|
|
|
|
private assertHasAccess(user: RequestUser): void {
|
|
if (user.permissions.includes(Permission.DemoRestricted)) {
|
|
throw new Error('Demo-restricted account cannot access notifications');
|
|
}
|
|
if (user.isSuperuser) return;
|
|
if (user.permissions.includes(Permission.NotificationAccess)) return;
|
|
throw new Error('Missing permission: notification_access');
|
|
}
|
|
}
|