socket pager

This commit is contained in:
2025-12-10 09:49:10 +03:30
parent 33b250287c
commit 2e12cdb529
10 changed files with 1294 additions and 5 deletions
@@ -0,0 +1,26 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthenticatedSocket } from '../guards/ws-admin-auth.guard';
/**
* Extract admin ID from authenticated WebSocket client
* Use this decorator in WebSocket handlers to get the adminId from the authenticated admin
*
* @example
* ```typescript
* @SubscribeMessage('some:event')
* handleEvent(@WsAdminId() adminId: string) {
* // adminId is automatically extracted from authenticated admin
* }
* ```
*/
export const WsAdminId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
const client = ctx.switchToWs().getClient<AuthenticatedSocket>();
const adminId = client.adminId;
if (!adminId) {
throw new Error('Admin ID not found. Ensure WsAdminAuthGuard is applied.');
}
return adminId;
});
@@ -0,0 +1,26 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthenticatedSocket } from '../guards/ws-admin-auth.guard';
/**
* Extract restaurant ID from authenticated WebSocket client
* Use this decorator in WebSocket handlers to get the restId from the authenticated admin
*
* @example
* ```typescript
* @SubscribeMessage('join:restaurant')
* handleJoinRestaurant(@WsRestId() restId: string) {
* // restId is automatically extracted from authenticated admin
* }
* ```
*/
export const WsRestId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
const client = ctx.switchToWs().getClient<AuthenticatedSocket>();
const restId = client.restId;
if (!restId) {
throw new Error('Restaurant ID not found. Ensure WsAdminAuthGuard is applied.');
}
return restId;
});
@@ -0,0 +1,104 @@
import { CanActivate, ExecutionContext, Injectable, Logger, Inject } from '@nestjs/common';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { IAdminTokenPayload } from '../../auth/interfaces/IToken-payload';
export interface AuthenticatedSocket extends Socket {
adminId?: string;
restId?: string;
}
@Injectable()
export class WsAdminAuthGuard implements CanActivate {
private readonly logger = new Logger(WsAdminAuthGuard.name);
constructor(
@Inject(JwtService)
private readonly jwtService: JwtService,
@Inject(ConfigService)
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const client: Socket = context.switchToWs().getClient<Socket>();
try {
const token = this.extractToken(client);
if (!token) {
this.logger.warn('No token provided in WebSocket connection', {
socketId: client.id,
auth: client.handshake.auth,
query: client.handshake.query,
});
throw new WsException('Authentication required');
}
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
const payload = await this.jwtService.verifyAsync<IAdminTokenPayload>(token, {
secret,
});
if (!payload.adminId || !payload.restId) {
this.logger.error('Invalid token payload structure', payload);
throw new WsException('Invalid token payload');
}
// Attach admin info to socket
(client as AuthenticatedSocket).adminId = payload.adminId;
(client as AuthenticatedSocket).restId = payload.restId;
this.logger.log(`Admin authenticated via WebSocket: ${payload.adminId}, restId: ${payload.restId}`);
return true;
} catch (error) {
if (error instanceof WsException) {
throw error;
}
this.logger.error('WebSocket authentication error', {
error: error instanceof Error ? error.message : 'Unknown error',
socketId: client.id,
});
throw new WsException('Authentication failed');
}
}
private extractToken(client: Socket): string | undefined {
// Try to get token from handshake auth (recommended for socket.io)
const auth = client.handshake.auth;
const authToken = (auth?.token as string | undefined) || (auth?.authorization as string | undefined);
if (authToken) {
// If it's "Bearer <token>", extract just the token
if (typeof authToken === 'string' && authToken.startsWith('Bearer ')) {
return authToken.substring(7);
}
return authToken;
}
// Try to get from query parameters (fallback)
const queryToken = client.handshake.query?.token || client.handshake.query?.authorization;
if (queryToken) {
if (typeof queryToken === 'string' && queryToken.startsWith('Bearer ')) {
return queryToken.substring(7);
}
return queryToken as string;
}
// Try to get from headers (if available)
const headers = client.handshake.headers;
const authHeader = headers.authorization || headers['authorization'];
if (authHeader && typeof authHeader === 'string') {
const [type, token] = authHeader.split(' ');
if (type?.toLowerCase() === 'bearer' && token) {
return token;
}
}
return undefined;
}
}
+139
View File
@@ -0,0 +1,139 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger, UseGuards } from '@nestjs/common';
import { Pager } from './entities/pager.entity';
import { WsAdminAuthGuard, type AuthenticatedSocket } from './guards/ws-admin-auth.guard';
import { WsRestId } from './decorators/ws-rest-id.decorator';
@WebSocketGateway({
cors: {
origin: true,
credentials: true,
},
namespace: '/pager',
})
export class PagerGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(PagerGateway.name);
handleConnection(client: Socket) {
this.logger.log(`Client connected: ${client.id}`);
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
/**
* Join a room for a specific restaurant (admin/staff)
* Requires admin authentication via JWT token
* Restaurant ID is extracted from the authenticated admin's token
* Room format: `restaurant:${restId}`
*/
@UseGuards(WsAdminAuthGuard)
@SubscribeMessage('join:restaurant')
handleJoinRestaurant(@ConnectedSocket() client: AuthenticatedSocket, @WsRestId() restId: string) {
const room = `restaurant:${restId}`;
void client.join(room);
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
void client.emit('joined', { room, message: 'Successfully joined restaurant room' });
}
/**
* Join a room for a specific cookie/session (public user)
* Room format: `cookie:${cookieId}`
*/
@SubscribeMessage('join:session')
handleJoinSession(@ConnectedSocket() client: Socket, @MessageBody() data: { cookieId: string }) {
if (!data.cookieId) {
void client.emit('error', { message: 'Cookie ID is required' });
return;
}
const room = `cookie:${data.cookieId}`;
void client.join(room);
this.logger.log(`Client ${client.id} joined room: ${room}`);
void client.emit('joined', { room, message: 'Successfully joined session room' });
}
/**
* Leave a room
*/
@SubscribeMessage('leave:room')
handleLeaveRoom(@ConnectedSocket() client: Socket, @MessageBody() data: { room: string }) {
if (data.room) {
void client.leave(data.room);
this.logger.log(`Client ${client.id} left room: ${data.room}`);
void client.emit('left', { room: data.room, message: 'Successfully left room' });
}
}
/**
* Emit pager created event to restaurant room
*/
emitPagerCreated(pager: Pager) {
const room = `restaurant:${pager.restaurant.id}`;
this.server.to(room).emit('pager:created', {
pager: {
id: pager.id,
tableNumber: pager.tableNumber,
message: pager.message,
status: pager.status,
createdAt: pager.createdAt,
user: pager.user
? {
id: pager.user.id,
firstName: pager.user.firstName,
lastName: pager.user.lastName,
}
: null,
},
});
this.logger.log(`Emitted pager:created to room: ${room}`);
}
/**
* Emit pager status updated event to both restaurant and cookie rooms
*/
emitPagerStatusUpdated(pager: Pager) {
const restaurantRoom = `restaurant:${pager.restaurant.id}`;
const cookieRoom = `cookie:${pager.cookieId}`;
const pagerData = {
id: pager.id,
tableNumber: pager.tableNumber,
message: pager.message,
status: pager.status,
updatedAt: pager.updatedAt,
staff: pager.staff
? {
id: pager.staff.id,
firstName: pager.staff.firstName,
lastName: pager.staff.lastName,
}
: null,
};
// Emit to restaurant room (admin/staff)
this.server.to(restaurantRoom).emit('pager:status:updated', {
pager: pagerData,
});
this.logger.log(`Emitted pager:status:updated to room: ${restaurantRoom}`);
// Emit to cookie room (public user)
this.server.to(cookieRoom).emit('pager:status:updated', {
pager: pagerData,
});
this.logger.log(`Emitted pager:status:updated to room: ${cookieRoom}`);
}
}
+3 -1
View File
@@ -5,10 +5,12 @@ import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Pager } from './entities/pager.entity';
import { JwtModule } from '@nestjs/jwt';
import { RolesModule } from '../roles/roles.module';
import { PagerGateway } from './pager.gateway';
@Module({
imports: [MikroOrmModule.forFeature([Pager]), JwtModule, RolesModule],
controllers: [PagerController],
providers: [PagerService],
providers: [PagerService, PagerGateway],
exports: [PagerGateway],
})
export class PagerModule {}
+26 -3
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import { CreatePagerDto } from './dto/create-pager.dto';
import { User } from '../users/entities/user.entity';
import { EntityManager } from '@mikro-orm/postgresql';
@@ -7,10 +7,15 @@ import { Pager } from './entities/pager.entity';
import { PagerStatus } from './interface/pager';
import { ulid } from 'ulid';
import { Admin } from '../admin/entities/admin.entity';
import { PagerGateway } from './pager.gateway';
@Injectable()
export class PagerService {
constructor(private readonly em: EntityManager) {}
constructor(
private readonly em: EntityManager,
@Inject(forwardRef(() => PagerGateway))
private readonly pagerGateway: PagerGateway,
) {}
async create(createPagerDto: CreatePagerDto, params: { restId?: string; userId?: string; slug: string }) {
const { restId, userId, slug } = params;
@@ -47,6 +52,13 @@ export class PagerService {
});
await this.em.persistAndFlush(pager);
// Populate relations before emitting
await this.em.populate(pager, ['restaurant', 'user']);
// Emit socket event for new pager
this.pagerGateway.emitPagerCreated(pager);
return pager;
}
@@ -61,7 +73,11 @@ export class PagerService {
}
async updateStatus(pagerId: string, restId: string, staffId: string, status: PagerStatus) {
const pager = await this.em.findOne(Pager, { id: pagerId, restaurant: { id: restId } });
const pager = await this.em.findOne(
Pager,
{ id: pagerId, restaurant: { id: restId } },
{ populate: ['restaurant'] },
);
if (!pager) {
throw new NotFoundException('Pager not found *');
}
@@ -74,6 +90,13 @@ export class PagerService {
pager.staff = staff;
}
await this.em.persistAndFlush(pager);
// Populate relations before emitting
await this.em.populate(pager, ['restaurant', 'staff', 'user']);
// Emit socket event for status update
this.pagerGateway.emitPagerStatusUpdated(pager);
return pager;
}
}