diff --git a/src/modules/notifications/decorators/ws-rest-id.decorator.ts b/src/modules/notifications/decorators/ws-rest-id.decorator.ts new file mode 100644 index 0000000..29f8bc7 --- /dev/null +++ b/src/modules/notifications/decorators/ws-rest-id.decorator.ts @@ -0,0 +1,25 @@ +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('get:notifications') + * handleGetNotifications(@WsRestId() restId: string) { + * // restId is automatically extracted from authenticated admin + * } + * ``` + */ +export const WsRestId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => { + const client = ctx.switchToWs().getClient(); + const restId = client.restId; + + if (!restId) { + throw new Error('Restaurant ID not found. Ensure WsAdminAuthGuard is applied.'); + } + + return restId; +}); diff --git a/src/modules/notifications/guards/ws-admin-auth.guard.ts b/src/modules/notifications/guards/ws-admin-auth.guard.ts new file mode 100644 index 0000000..2e15939 --- /dev/null +++ b/src/modules/notifications/guards/ws-admin-auth.guard.ts @@ -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 { + const client: Socket = context.switchToWs().getClient(); + + 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('JWT_SECRET'); + const payload = await this.jwtService.verifyAsync(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 ", 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; + } +} diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts index 245d9e1..1d44d0f 100644 --- a/src/modules/notifications/notifications.controller.ts +++ b/src/modules/notifications/notifications.controller.ts @@ -30,6 +30,15 @@ export class NotificationsController { ); } + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Get('admin/notifications') + @ApiOperation({ summary: 'Get Admin restaurant notifications' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getAdminNotifications(@RestId() restaurantId: string, @Query('limit') limit?: number) { + return await this.notificationService.findByRestaurant(restaurantId, limit ? parseInt(limit.toString(), 30) : 50); + } + @UseGuards(AdminAuthGuard) @ApiBearerAuth() @Delete('admin/notification/:id') @@ -39,6 +48,7 @@ export class NotificationsController { await this.notificationService.removeNotification(id, restaurantId); return { message: 'Notification deleted successfully' }; } + // @UseGuards(AuthGuard) // @ApiBearerAuth() // @Get('public/notifications/:id') diff --git a/src/modules/notifications/notifications.gateway.ts b/src/modules/notifications/notifications.gateway.ts new file mode 100644 index 0000000..c40175e --- /dev/null +++ b/src/modules/notifications/notifications.gateway.ts @@ -0,0 +1,69 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayConnection, + OnGatewayDisconnect, + MessageBody, + ConnectedSocket, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { Logger, UseGuards } from '@nestjs/common'; +import { WsAdminAuthGuard, type AuthenticatedSocket } from './guards/ws-admin-auth.guard'; +import { WsRestId } from './decorators/ws-rest-id.decorator'; +import { NotificationService } from './services/notification.service'; + +@WebSocketGateway({ + cors: { + origin: true, + credentials: true, + }, + namespace: '/notifications', +}) +export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server!: Server; + + private readonly logger = new Logger(NotificationsGateway.name); + + constructor(private readonly notificationService: NotificationService) {} + + handleConnection(client: Socket) { + this.logger.log(`Client connected: ${client.id}`); + } + + handleDisconnect(client: Socket) { + this.logger.log(`Client disconnected: ${client.id}`); + } + + /** + * Get admin restaurant notifications + * Requires admin authentication via JWT token + * Restaurant ID is extracted from the authenticated admin's token + * This is a WebSocket substitution for GET /admin/notifications + */ + @UseGuards(WsAdminAuthGuard) + @SubscribeMessage('get:notifications') + async handleGetNotifications( + @ConnectedSocket() client: AuthenticatedSocket, + @WsRestId() restId: string, + @MessageBody() data?: { limit?: number }, + ) { + try { + const limit = data?.limit ? parseInt(data.limit.toString(), 10) : 50; + const notifications = await this.notificationService.findByRestaurant(restId, limit); + + void client.emit('notifications:list', { notifications }); + this.logger.log(`Admin ${client.adminId} requested notifications list for restaurant ${restId}`); + } catch (error) { + this.logger.error( + `Error getting notifications list: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + void client.emit('error', { + message: 'Failed to get notifications list', + code: 'GET_NOTIFICATIONS_ERROR', + details: error instanceof Error ? error.message : 'Unknown error', + }); + } + } +} diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts index e213e65..14b956d 100644 --- a/src/modules/notifications/notifications.module.ts +++ b/src/modules/notifications/notifications.module.ts @@ -19,6 +19,8 @@ import { AuthModule } from '../auth/auth.module'; import { ConfigService } from '@nestjs/config'; import { NotificationQueueNameEnum } from './constants/queue'; import { UtilsModule } from '../utils/utils.module'; +import { NotificationsGateway } from './notifications.gateway'; +import { WsAdminAuthGuard } from './guards/ws-admin-auth.guard'; @Module({ imports: [ @@ -48,6 +50,8 @@ import { UtilsModule } from '../utils/utils.module'; PushProcessor, SmsProcessor, NotificationListeners, + NotificationsGateway, + WsAdminAuthGuard, ], exports: [ NotificationService, diff --git a/src/modules/notifications/services/sms.adaptor.ts b/src/modules/notifications/services/sms.adaptor.ts index 88c5588..c5829b7 100644 --- a/src/modules/notifications/services/sms.adaptor.ts +++ b/src/modules/notifications/services/sms.adaptor.ts @@ -12,8 +12,8 @@ export class SmsAdaptorService { private readonly configService: ConfigService, private readonly smsService: SmsService, ) { - this.smsPatternOrderCreated = this.configService.getOrThrow('SMS_PATTERN_ORDER_CREATED'); - this.smsPatternPaymentSuccess = this.configService.getOrThrow('SMS_PATTERN_PAYMENT_SUCCESS'); + this.smsPatternOrderCreated = this.configService.get('SMS_PATTERN_ORDER_CREATED') ?? '1'; + this.smsPatternPaymentSuccess = this.configService.get('SMS_PATTERN_PAYMENT_SUCCESS') ?? '2'; } async sendNotifySms(params: ISmsNotifyPayload) { diff --git a/test-notifications-socket.html b/test-notifications-socket.html new file mode 100644 index 0000000..ecdf2c7 --- /dev/null +++ b/test-notifications-socket.html @@ -0,0 +1,478 @@ + + + + + + Notifications Socket.IO Test + + + + +
+
+

🔔 Notifications Socket.IO Test Client

+

Test real-time admin notifications

+
+ +
+ +
+

Connection

+
+ + +
+
+ + + Enter JWT token for admin authentication. Restaurant ID will be extracted from token. +
+
+ + +
+
Status: Disconnected
+
+ + +
+

Get Notifications

+
+ + + Number of notifications to retrieve (default: 50) +
+
+ +
+
+ + +
+

Notifications List

+
+
+ No notifications loaded yet. Click "Get Notifications" to fetch. +
+
+
+ + +
+

Events Log

+
+
+
Ready to connect...
+
INFO
+
Enter server URL and admin token, then click Connect to start
+
+
+
+
+
+ + + +