diff --git a/package-lock.json b/package-lock.json index 818720a..cb27486 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@as-integrations/fastify": "^3.1.0", "@aws-sdk/client-s3": "^3.922.0", "@aws-sdk/s3-request-presigner": "^3.922.0", + "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^9.3.0", "@fastify/static": "^8.3.0", "@keyv/redis": "^5.1.3", @@ -2236,6 +2237,39 @@ "node": ">=14" } }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/cookie/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@fastify/cors": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.1.0.tgz", diff --git a/package.json b/package.json index c6a5bed..241e33f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@as-integrations/fastify": "^3.1.0", "@aws-sdk/client-s3": "^3.922.0", "@aws-sdk/s3-request-presigner": "^3.922.0", + "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^9.3.0", "@fastify/static": "^8.3.0", "@keyv/redis": "^5.1.3", diff --git a/src/app.module.ts b/src/app.module.ts index 7066ba0..87d56b6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,7 @@ import { CouponModule } from './modules/coupons/coupon.module'; import { ReviewModule } from './modules/review/review.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { PagerModule } from './modules/pager/pager.module'; @Module({ imports: [ @@ -48,6 +49,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; ReviewModule, NotificationsModule, EventEmitterModule.forRoot(), + PagerModule, ], controllers: [], providers: [], diff --git a/src/common/decorators/rest-slug.decorator.ts b/src/common/decorators/rest-slug.decorator.ts new file mode 100644 index 0000000..16296d0 --- /dev/null +++ b/src/common/decorators/rest-slug.decorator.ts @@ -0,0 +1,7 @@ +import { createParamDecorator, type ExecutionContext } from '@nestjs/common'; +import type { Request } from 'express'; + +export const RestSlug = createParamDecorator((data: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest(); + return request.slug || ''; +}); diff --git a/src/main.ts b/src/main.ts index d79d2a5..77c1815 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config'; import { Logger, ValidationPipe } from '@nestjs/common'; import { getSwaggerConfig } from './config/swagger.config'; import multipart from '@fastify/multipart'; +import fastifyCookie from '@fastify/cookie'; // 👈 Import the Fastify application type import { type NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify'; @@ -22,6 +23,8 @@ async function bootstrap() { { logger: ['error', 'warn', 'log', 'debug', 'verbose'] }, ); + await app.register(fastifyCookie); + await app.register(multipart); app.useGlobalPipes(new ValidationPipe()); diff --git a/src/modules/auth/guards/auth.guard.ts b/src/modules/auth/guards/auth.guard.ts index de0b9fd..c600f6d 100644 --- a/src/modules/auth/guards/auth.guard.ts +++ b/src/modules/auth/guards/auth.guard.ts @@ -65,7 +65,8 @@ export class AuthGuard implements CanActivate { return type === 'Bearer' ? token : undefined; } private extractSlugFromHeader(request: Request): string | undefined { - const slug = request.headers['X-Slug'] as string; + // Fastify normalizes headers to lowercase, so check both cases + const slug = (request.headers['x-slug'] || request.headers['X-Slug']) as string; return slug; } } diff --git a/src/modules/auth/guards/optinalAuth.guard.ts b/src/modules/auth/guards/optinalAuth.guard.ts new file mode 100644 index 0000000..2cb712b --- /dev/null +++ b/src/modules/auth/guards/optinalAuth.guard.ts @@ -0,0 +1,94 @@ +import { CanActivate, ExecutionContext, Injectable, Inject, Logger } from '@nestjs/common'; +import { Request } from 'express'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { ITokenPayload } from '../interfaces/IToken-payload'; + +export interface UserOptionalAuthRequest extends Request { + userId?: string; + restId?: string; + slug?: string; +} + +@Injectable() +export class OptionalAuthGuard implements CanActivate { + private readonly logger = new Logger(OptionalAuthGuard.name); + + constructor( + @Inject(JwtService) + private readonly jwtService: JwtService, + @Inject(ConfigService) + private readonly configService: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + const slug = this.extractSlugFromHeader(request); + + // If no token is provided, allow the request without authentication + if (!slug) { + console.log('No slug provided'); + this.logger.warn('No slug provided'); + return false; + } + if (!token) { + request['slug'] = slug; + return true; + } + + // Try to verify the token if it exists + try { + const secret = this.configService.getOrThrow('JWT_SECRET'); + const payload = await this.jwtService.verifyAsync(token, { + secret, + }); + + // Validate payload structure + if (!payload.userId || !payload.restId) { + this.logger.warn('Invalid token payload structure', payload); + // Allow request but don't set user info + request['slug'] = slug; + return true; + } + + // Validate slug if provided + if (slug && payload.slug !== slug) { + this.logger.warn('Token slug does not match header slug', { + tokenSlug: payload.slug, + headerSlug: slug, + }); + // Allow request but don't set user info + request['slug'] = slug; + return true; + } + + // Token is valid, set user info + request['userId'] = payload.userId; + request['restId'] = payload.restId; + request['slug'] = slug; + return true; + } catch (err) { + // Token is invalid or expired, but allow the request anyway + this.logger.debug('Token verification failed in OptionalAuthGuard', { + error: err instanceof Error ? err.message : 'Unknown error', + }); + // Set restId from slug + + request['slug'] = slug; + + return true; + } + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } + + private extractSlugFromHeader(request: Request): string | undefined { + // Fastify normalizes headers to lowercase, so check both cases + const slug = (request.headers['x-slug'] || request.headers['X-Slug']) as string; + return slug; + } +} diff --git a/src/modules/pager/dto/create-pager.dto.ts b/src/modules/pager/dto/create-pager.dto.ts new file mode 100644 index 0000000..af5d45f --- /dev/null +++ b/src/modules/pager/dto/create-pager.dto.ts @@ -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; +} diff --git a/src/modules/pager/dto/update-pager.dto.ts b/src/modules/pager/dto/update-pager.dto.ts new file mode 100644 index 0000000..e66b14b --- /dev/null +++ b/src/modules/pager/dto/update-pager.dto.ts @@ -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; +} diff --git a/src/modules/pager/entities/pager.entity.ts b/src/modules/pager/entities/pager.entity.ts new file mode 100644 index 0000000..3c29bb2 --- /dev/null +++ b/src/modules/pager/entities/pager.entity.ts @@ -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; +} diff --git a/src/modules/pager/interface/pager.ts b/src/modules/pager/interface/pager.ts new file mode 100644 index 0000000..afe6fdc --- /dev/null +++ b/src/modules/pager/interface/pager.ts @@ -0,0 +1,6 @@ +export enum PagerStatus { + Pending = 'pending', + Acknowledged = 'acknowledged', + Rejected = 'rejected', + Completed = 'completed', +} diff --git a/src/modules/pager/pager.controller.ts b/src/modules/pager/pager.controller.ts new file mode 100644 index 0000000..bd10515 --- /dev/null +++ b/src/modules/pager/pager.controller.ts @@ -0,0 +1,89 @@ +import { Controller, Get, Post, Body, UseGuards, Res, Req, Patch, Param } from '@nestjs/common'; +import { PagerService } from './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'; + +@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, + @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 pager = await this.pagerService.create(createPagerDto, { + restId: normalizedRestId, + userId: normalizedUserId, + 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: 'Pager request created successfully', + data: null, + }); + } + + @UseGuards(OptionalAuthGuard) + @Get('public/pager') + @ApiOperation({ summary: 'Get all pager requests for the current session' }) + @ApiHeader({ name: 'X-Slug', required: true, description: 'Restaurant slug identifier' }) + @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); + } + + @UseGuards(AdminAuthGuard) + @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) + @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); + } +} diff --git a/src/modules/pager/pager.module.ts b/src/modules/pager/pager.module.ts new file mode 100644 index 0000000..d8666cc --- /dev/null +++ b/src/modules/pager/pager.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { PagerService } from './pager.service'; +import { PagerController } from './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'; + +@Module({ + imports: [MikroOrmModule.forFeature([Pager]), JwtModule, RolesModule], + controllers: [PagerController], + providers: [PagerService], +}) +export class PagerModule {} diff --git a/src/modules/pager/pager.service.ts b/src/modules/pager/pager.service.ts new file mode 100644 index 0000000..d070e07 --- /dev/null +++ b/src/modules/pager/pager.service.ts @@ -0,0 +1,79 @@ +import { BadRequestException, Injectable, NotFoundException } 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'; + +@Injectable() +export class PagerService { + constructor(private readonly em: EntityManager) {} + + async create(createPagerDto: CreatePagerDto, params: { restId?: string; userId?: string; slug: string }) { + const { restId, userId, slug } = 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 pager = this.em.create(Pager, { + ...createPagerDto, + restaurant: restaurant!, + user, + cookieId: ulid().toString(), + status: PagerStatus.Pending, + }); + + await this.em.persistAndFlush(pager); + 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 } }); + 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); + return pager; + } +}