This commit is contained in:
2026-02-08 08:50:08 +03:30
commit 8af4936ed0
323 changed files with 92747 additions and 0 deletions
@@ -0,0 +1,101 @@
import { Controller, Get, Post, Body, UseGuards, Res, Req, Patch, Param } from '@nestjs/common';
import { PagerService } from '../providers/pager.service';
import { CreatePagerDto } from '../dto/create-pager.dto';
import { OptionalAuthGuard } from '../../auth/guards/optinalAuth.guard';
import { RestId } from 'src/common/decorators/rest-id.decorator';
import { UserId } from 'src/common/decorators/user-id.decorator';
import { RestSlug } from 'src/common/decorators/rest-slug.decorator';
import { ApiTags, ApiOperation, ApiBody, ApiHeader, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
import { AdminId } from 'src/common/decorators/admin-id.decorator';
import { UpdatePagerStatusDto } from '../dto/update-pager.dto';
import { PagerMessage } from 'src/common/enums/message.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum';
import { API_HEADER_SLUG } from 'src/common/constants';
@ApiTags('pager')
@Controller()
export class PagerController {
constructor(private readonly pagerService: PagerService) { }
@UseGuards(OptionalAuthGuard)
@Post('public/pager')
@ApiOperation({ summary: 'Create a new pager request' })
@ApiHeader({ name: 'X-Slug', required: true, description: 'Restaurant slug identifier' })
@ApiHeader({ name: 'Authorization', required: false, description: 'Bearer token (optional authentication)' })
@ApiBody({ type: CreatePagerDto })
async create(
@Body() createPagerDto: CreatePagerDto,
@Req() request: FastifyRequest,
@Res() reply: FastifyReply,
@RestSlug() slug: string,
@RestId() restId?: string,
@UserId() userId?: string,
) {
// Convert empty strings to undefined for cleaner validation
const normalizedRestId = restId || undefined;
const normalizedUserId = userId || undefined;
const userCookieId = request.cookies['pagerId'] || undefined;
const pager = await this.pagerService.create(createPagerDto, {
restId: normalizedRestId,
userId: normalizedUserId,
userCookieId,
slug,
});
reply.setCookie('pagerId', pager.cookieId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 1000 * 60 * 60 * 24 * 1, // 1 days
path: '/',
});
return reply.code(200).send({
success: true,
message: PagerMessage.CREATED_SUCCESS,
data: null,
});
}
@UseGuards(OptionalAuthGuard)
@Get('public/pager')
@ApiOperation({ summary: 'Get all pager requests for the current session' })
@ApiHeader(API_HEADER_SLUG)
@ApiHeader({ name: 'Authorization', required: false, description: 'Bearer token (optional authentication)' })
async findAll(@Req() request: FastifyRequest) {
const cookieId = request.cookies['pagerId'] || undefined;
if (!cookieId) {
return [];
}
return this.pagerService.findAll(cookieId);
}
/******************** Admin Routes **********************/
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_PAGER)
@ApiBearerAuth()
@Get('admin/pager')
@ApiOperation({ summary: 'Get all pager requests for the restaurant' })
async findAllAdmin(@RestId() restId: string) {
return this.pagerService.findAllByRestaurantId(restId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_PAGER)
@ApiBearerAuth()
@Patch('admin/pager/:pagerId/status')
@ApiParam({ name: 'pagerId', required: true, type: String })
@ApiOperation({ summary: 'Update the status of a pager request' })
@ApiBody({ type: UpdatePagerStatusDto })
async updateStatus(
@Param('pagerId') pagerId: string,
@RestId() restId: string,
@AdminId() adminId: string,
@Body() dto: UpdatePagerStatusDto,
) {
return this.pagerService.updateStatus(pagerId, restId, adminId, dto.status);
}
}
@@ -0,0 +1,26 @@
import type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common';
import type { 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 type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common';
import type { 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;
});
+15
View File
@@ -0,0 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreatePagerDto {
@IsString()
@IsNotEmpty()
@ApiProperty({ description: 'Table number' })
tableNumber!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({ description: 'Message' })
message?: string;
}
@@ -0,0 +1,208 @@
import type { PagerStatus } from '../interface/pager';
/**
* Socket connection configuration for admin pager module
* Used by frontend to establish WebSocket connection
*/
export interface PagerSocketConnectionConfig {
/**
* WebSocket server URL
* Example: 'http://localhost:3000' or 'https://api.example.com'
*/
serverUrl: string;
/**
* Socket namespace (default: '/pager')
* The gateway is configured to use '/pager' namespace
*/
namespace?: string;
/**
* JWT authentication token for admin
* Token should contain adminId and restId in payload
* Can be passed via auth.token, auth.authorization, query.token, or headers.authorization
* Example: { auth: { token: 'your-jwt-token' } }
*/
token: string;
/**
* Optional: Additional connection options
*/
options?: {
/**
* Enable auto-reconnection
* @default true
*/
autoConnect?: boolean;
/**
* Connection timeout in milliseconds
* @default 5000
*/
timeout?: number;
/**
* Enable transports (websocket, polling)
* @default ['websocket', 'polling']
*/
transports?: ('websocket' | 'polling')[];
};
}
/**
* Socket event names for pager module
*/
export enum PagerSocketEvents {
// Client to Server Events
JOIN_RESTAURANT = 'join:restaurant',
LEAVE_ROOM = 'leave:room',
GET_PAGERS = 'get:pagers',
UPDATE_PAGER_STATUS = 'update:pager:status',
// Server to Client Events
JOINED = 'joined',
LEFT = 'left',
PAGER_CREATED = 'pager:created',
PAGER_STATUS_UPDATED = 'pager:status:updated',
PAGERS_LIST = 'pagers:list',
PAGER_STATUS_UPDATED_RESPONSE = 'pager:status:updated:response',
ERROR = 'error',
CONNECT = 'connect',
DISCONNECT = 'disconnect',
}
/**
* Payload for joining restaurant room
* Note: restId is automatically extracted from JWT token, no need to send it
*/
export type JoinRestaurantPayload = Record<string, never>;
/**
* Payload for leaving a room
*/
export interface LeaveRoomPayload {
room: string;
}
/**
* Payload for getting pagers list
* Note: restId is automatically extracted from JWT token, no need to send it
*/
export type GetPagersPayload = Record<string, never>;
/**
* Payload for updating pager status
* Note: restId and adminId are automatically extracted from JWT token
*/
export interface UpdatePagerStatusPayload {
pagerId: string;
status: PagerStatus;
}
/**
* Response when successfully joined a room
*/
export interface JoinedResponse {
room: string;
message: string;
}
/**
* Response when successfully left a room
*/
export interface LeftResponse {
room: string;
message: string;
}
/**
* User information in pager response
*/
export interface PagerUserInfo {
id: string;
firstName: string;
lastName?: string;
}
/**
* Staff information in pager response
*/
export interface PagerStaffInfo {
id: string;
firstName: string;
lastName?: string;
}
/**
* Pager data structure emitted by socket events
*/
export interface PagerSocketData {
id: string;
tableNumber: string;
message?: string;
status: PagerStatus;
cookieId?: string;
createdAt?: Date | string;
updatedAt?: Date | string;
user?: PagerUserInfo | null;
staff?: PagerStaffInfo | null;
}
/**
* Payload for pager:created event
*/
export interface PagerCreatedPayload {
pager: PagerSocketData;
}
/**
* Payload for pager:status:updated event
*/
export interface PagerStatusUpdatedPayload {
pager: PagerSocketData;
}
/**
* Payload for pagers:list event
*/
export interface PagersListPayload {
pagers: PagerSocketData[];
}
/**
* Payload for pager:status:updated:response event (response to update request)
*/
export interface PagerStatusUpdatedResponsePayload {
pager: PagerSocketData;
}
/**
* Error response from socket
*/
export interface SocketErrorResponse {
message: string;
code?: string;
details?: unknown;
}
/**
* Complete socket event map for type safety
*/
export interface PagerSocketEventMap {
// Client emits
[PagerSocketEvents.JOIN_RESTAURANT]: (payload?: JoinRestaurantPayload) => void;
[PagerSocketEvents.LEAVE_ROOM]: (payload: LeaveRoomPayload) => void;
[PagerSocketEvents.GET_PAGERS]: (payload?: GetPagersPayload) => void;
[PagerSocketEvents.UPDATE_PAGER_STATUS]: (payload: UpdatePagerStatusPayload) => void;
// Server emits
[PagerSocketEvents.JOINED]: (response: JoinedResponse) => void;
[PagerSocketEvents.LEFT]: (response: LeftResponse) => void;
[PagerSocketEvents.PAGER_CREATED]: (payload: PagerCreatedPayload) => void;
[PagerSocketEvents.PAGER_STATUS_UPDATED]: (payload: PagerStatusUpdatedPayload) => void;
[PagerSocketEvents.PAGERS_LIST]: (payload: PagersListPayload) => void;
[PagerSocketEvents.PAGER_STATUS_UPDATED_RESPONSE]: (payload: PagerStatusUpdatedResponsePayload) => void;
[PagerSocketEvents.ERROR]: (error: SocketErrorResponse) => void;
[PagerSocketEvents.CONNECT]: () => void;
[PagerSocketEvents.DISCONNECT]: (reason: string) => void;
}
+10
View File
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty } from 'class-validator';
import { PagerStatus } from '../interface/pager';
export class UpdatePagerStatusDto {
@ApiProperty({ description: 'Status of the pager', enum: PagerStatus })
@IsEnum(PagerStatus)
@IsNotEmpty()
status!: PagerStatus;
}
@@ -0,0 +1,30 @@
import { Entity, ManyToOne, Property, Enum } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { User } from '../../users/entities/user.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { PagerStatus } from '../interface/pager';
import { Admin } from 'src/modules/admin/entities/admin.entity';
@Entity({ tableName: 'pagers' })
export class Pager extends BaseEntity {
@Property({ type: 'string' })
cookieId!: string;
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property({ type: 'string' })
tableNumber!: string;
@ManyToOne(() => User, { nullable: true })
user?: User;
@ManyToOne(() => Admin, { nullable: true })
staff?: Admin;
@Property({ type: 'string', nullable: true })
message?: string;
@Enum(() => PagerStatus)
status: PagerStatus = PagerStatus.Pending;
}
+6
View File
@@ -0,0 +1,6 @@
export class PagerCreatedEvent {
constructor(
public readonly restaurantId: string,
public readonly tableNumber: string,
) {}
}
@@ -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;
}
}
+6
View File
@@ -0,0 +1,6 @@
export enum PagerStatus {
Pending = 'pending',
Acknowledged = 'acknowledged',
Rejected = 'rejected',
Completed = 'completed',
}
@@ -0,0 +1,64 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
import { PagerCreatedEvent } from '../events/pager.events';
import { Permission } from 'src/common/enums/permission.enum';
import { NotifTitleEnum } from 'src/modules/notifications/interfaces/notification.interface';
import { NotificationService } from 'src/modules/notifications/services/notification.service';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class PagerListeners {
private readonly logger = new Logger(PagerListeners.name);
private readonly smsPatternPagerCreated: string
constructor(
private readonly adminService: AdminRepository,
private readonly notificationService: NotificationService,
private readonly configService: ConfigService,
) {
this.smsPatternPagerCreated = this.configService.get<string>('SMS_PATTERN_PAGER_CREATED') ?? '123';
}
@OnEvent(PagerCreatedEvent.name)
async handlePagerCreated(event: PagerCreatedEvent) {
try {
this.logger.log(`Pager created event received: ${event.restaurantId}`);
// get admnin os restuaraant that have pager permissuins
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.MANAGE_PAGER);
const recipients = admins.map(admin => ({
adminId: admin.id,
}));
await this.notificationService.sendNotification({
restaurantId: event.restaurantId,
message: {
title: NotifTitleEnum.PAGER_CREATED,
content: `میز شماره ${event.tableNumber} پیج جدیدی انجام داد`,
sms: {
templateId: this.smsPatternPagerCreated,
parameters: {
tableNumber: event.tableNumber.toString(),
},
},
pushNotif: {
title: ` پیج جدید`,
content: `میز شماره ${event.tableNumber} پیج جدیدی انجام داد`,
icon: `/assets/images/logo.png`,
action: {
type: NotifTitleEnum.PAGER_CREATED,
url: `/restaurants/${event.restaurantId}/pagers`,
},
},
},
recipients,
metadata: {
priority: 1,
},
});
} catch (error) {
this.logger.error(
`Failed to send notification for pager created event: ${event.restaurantId}`,
error instanceof Error ? error.stack : String(error),
);
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { PagerService } from './providers/pager.service';
import { PagerController } from './controllers/pager.controller';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Pager } from './entities/pager.entity';
import { JwtModule } from '@nestjs/jwt';
import { RolesModule } from '../roles/roles.module';
import { PagerListeners } from './listeners/notification.listeners';
import { AdminModule } from '../admin/admin.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [MikroOrmModule.forFeature([Pager]), JwtModule, RolesModule, AdminModule, NotificationsModule],
controllers: [PagerController],
providers: [PagerService, PagerListeners],
})
export class PagerModule {}
@@ -0,0 +1,115 @@
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';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { Pager } from '../entities/pager.entity';
import { PagerStatus } from '../interface/pager';
import { ulid } from 'ulid';
import { Admin } from '../../admin/entities/admin.entity';
import { PagerCreatedEvent } from '../events/pager.events';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class PagerService {
constructor(
private readonly em: EntityManager,
// @Inject(forwardRef(() => PagerGateway))
// private readonly pagerGateway: PagerGateway,
private readonly eventEmitter: EventEmitter2,
) {}
async create(
createPagerDto: CreatePagerDto,
params: { restId?: string; userId?: string; slug: string; userCookieId?: string },
) {
const { restId, userId, slug, userCookieId } = params;
let restaurant: Restaurant | null = null;
let user: User | null = null;
if (!restId && !userId && !slug) {
throw new BadRequestException('Invalid parameters');
}
if (restId) {
restaurant = await this.em.findOne(Restaurant, { id: restId });
if (!restaurant) {
throw new NotFoundException('Restaurant not found');
}
}
if (userId) {
user = await this.em.findOne(User, { id: userId });
if (!user) {
throw new NotFoundException('User not found');
}
}
if (!restaurant && slug) {
restaurant = await this.em.findOne(Restaurant, { slug });
if (!restaurant) {
throw new NotFoundException('Restaurant not found');
}
}
const cookieId = userCookieId || ulid().toString();
// const existingPager = await this.em.findOne(Pager, {
// restaurant: { id: restaurant!.id },
// status: PagerStatus.Pending,
// cookieId,
// });
// if (existingPager) {
// throw new BadRequestException('Pager already exists');
// }
const pager = this.em.create(Pager, {
...createPagerDto,
restaurant: restaurant!,
user,
cookieId: ulid().toString(),
status: PagerStatus.Pending,
});
await this.em.persistAndFlush(pager);
// Populate relations before emitting
await this.em.populate(pager, ['restaurant']);
// Emit socket event for new pager
this.eventEmitter.emit(PagerCreatedEvent.name, new PagerCreatedEvent(pager.restaurant.id, pager.tableNumber));
return pager;
}
async findAll(cookieId: string) {
const pagers = await this.em.find(Pager, { cookieId }, { populate: ['user'] });
return pagers;
}
async findAllByRestaurantId(restId: string) {
const pagers = await this.em.find(Pager, { restaurant: { id: restId } }, { populate: ['user'] });
return pagers;
}
async updateStatus(pagerId: string, restId: string, staffId: string, status: PagerStatus) {
const pager = await this.em.findOne(
Pager,
{ id: pagerId, restaurant: { id: restId } },
{ populate: ['restaurant'] },
);
if (!pager) {
throw new NotFoundException('Pager not found *');
}
const staff = await this.em.findOne(Admin, { id: staffId });
if (!staff) {
throw new NotFoundException('Staff not found');
}
pager.status = status;
if (status === PagerStatus.Acknowledged) {
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;
}
}