diff --git a/src/modules/bookings/bookings.controller.ts b/src/modules/bookings/bookings.controller.ts index ec9dc55..98a7880 100644 --- a/src/modules/bookings/bookings.controller.ts +++ b/src/modules/bookings/bookings.controller.ts @@ -67,6 +67,36 @@ export class BookingsController { return this.service.update(id, payload, user); } + @Post(':id/confirm') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + confirm( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.confirm(id, user); + } + + @Post(':id/complete') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + complete( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.complete(id, user); + } + + @Post(':id/cancel') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER) + cancel( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.cancel(id, user); + } + @Delete(':id') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) diff --git a/src/modules/bookings/bookings.service.ts b/src/modules/bookings/bookings.service.ts index 4d18e26..dfd7b46 100644 --- a/src/modules/bookings/bookings.service.ts +++ b/src/modules/bookings/bookings.service.ts @@ -152,6 +152,7 @@ export class BookingsService { ): Promise { const row = await this.findOne(id); this.assertCanManageBooking(row, actor); + this.assertBookingIsMutable(row); if (dto.scheduledAt !== undefined) { const scheduledAt = this.parseScheduledAt(dto.scheduledAt); @@ -160,7 +161,7 @@ export class BookingsService { } if (dto.status !== undefined) { - row.status = dto.status; + this.applyStatusTransition(row, dto.status, actor); } if (dto.note !== undefined) { @@ -170,6 +171,27 @@ export class BookingsService { return this.repo.save(row); } + async confirm(id: string, actor?: AuthUser): Promise { + const row = await this.findOne(id); + this.assertCanManageBooking(row, actor); + this.applyStatusTransition(row, BookingStatus.CONFIRMED, actor); + return this.repo.save(row); + } + + async complete(id: string, actor?: AuthUser): Promise { + const row = await this.findOne(id); + this.assertCanManageBooking(row, actor); + this.applyStatusTransition(row, BookingStatus.COMPLETED, actor); + return this.repo.save(row); + } + + async cancel(id: string, actor?: AuthUser): Promise { + const row = await this.findOne(id); + this.assertCanCancelBooking(row, actor); + this.applyStatusTransition(row, BookingStatus.CANCELLED, actor); + return this.repo.save(row); + } + async remove(id: string): Promise { const result = await this.repo.delete(id); if (!result.affected) { @@ -195,6 +217,67 @@ export class BookingsService { } } + private assertBookingIsMutable(row: BookingEntity): void { + if ( + row.status === BookingStatus.CANCELLED || + row.status === BookingStatus.COMPLETED + ) { + throw new BadRequestException( + 'Cancelled or completed bookings cannot be edited', + ); + } + } + + private applyStatusTransition( + row: BookingEntity, + nextStatus: BookingStatus, + actor?: AuthUser, + ): void { + if (row.status === nextStatus) { + return; + } + + // همه قوانین تغییر وضعیت رزرو در همین متد نگه داشته می‌شود تا مسیرهای مختلف + // (PATCH، confirm، complete، cancel) رفتار یکسان داشته باشند. + if ( + row.status === BookingStatus.CANCELLED || + row.status === BookingStatus.COMPLETED + ) { + throw new BadRequestException( + 'Cancelled or completed bookings cannot change status', + ); + } + + if (nextStatus === BookingStatus.CANCELLED) { + // مشتری فقط رزرو خودش را لغو می‌کند؛ صاحب کسب‌وکار فقط رزرو کسب‌وکار خودش را. + this.assertCanCancelBooking(row, actor); + row.status = BookingStatus.CANCELLED; + return; + } + + if (actor?.role === UserRole.CUSTOMER) { + throw new ForbiddenException('Customers can only cancel bookings'); + } + + if (nextStatus === BookingStatus.CONFIRMED) { + if (row.status !== BookingStatus.PENDING) { + throw new BadRequestException('Only pending bookings can be confirmed'); + } + row.status = BookingStatus.CONFIRMED; + return; + } + + if (nextStatus === BookingStatus.COMPLETED) { + if (row.status !== BookingStatus.CONFIRMED) { + throw new BadRequestException('Only confirmed bookings can be completed'); + } + row.status = BookingStatus.COMPLETED; + return; + } + + throw new BadRequestException('Unsupported booking status transition'); + } + private assertCanViewBooking(row: BookingEntity, actor?: AuthUser): void { if (!actor || actor.role === UserRole.ADMIN) { return; @@ -214,6 +297,25 @@ export class BookingsService { throw new ForbiddenException('You cannot access this booking'); } + private assertCanCancelBooking(row: BookingEntity, actor?: AuthUser): void { + if (!actor || actor.role === UserRole.ADMIN) { + return; + } + + if (actor.role === UserRole.CUSTOMER && row.customerId === actor.id) { + return; + } + + if ( + actor.role === UserRole.BUSINESS_OWNER && + row.business?.ownerId === actor.id + ) { + return; + } + + throw new ForbiddenException('You cannot cancel this booking'); + } + private assertCanManageBooking(row: BookingEntity, actor?: AuthUser): void { if (!actor || actor.role === UserRole.ADMIN) { return;