This commit is contained in:
2025-12-10 23:39:42 +03:30
parent 2d0c7dabee
commit 2be360639e
7 changed files with 692 additions and 2 deletions
@@ -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<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;
}
}
@@ -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')
@@ -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',
});
}
}
}
@@ -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,
@@ -12,8 +12,8 @@ export class SmsAdaptorService {
private readonly configService: ConfigService,
private readonly smsService: SmsService,
) {
this.smsPatternOrderCreated = this.configService.getOrThrow<string>('SMS_PATTERN_ORDER_CREATED');
this.smsPatternPaymentSuccess = this.configService.getOrThrow<string>('SMS_PATTERN_PAYMENT_SUCCESS');
this.smsPatternOrderCreated = this.configService.get<string>('SMS_PATTERN_ORDER_CREATED') ?? '1';
this.smsPatternPaymentSuccess = this.configService.get<string>('SMS_PATTERN_PAYMENT_SUCCESS') ?? '2';
}
async sendNotifySms(params: ISmsNotifyPayload) {