diff --git a/backend/src/bookings/bookings.controller.ts b/backend/src/bookings/bookings.controller.ts index dd166109..00c52ffd 100644 --- a/backend/src/bookings/bookings.controller.ts +++ b/backend/src/bookings/bookings.controller.ts @@ -3,6 +3,7 @@ import { Get, Post, Patch, + Delete, Body, Param, Query, @@ -11,14 +12,10 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; -import { - ApiTags, - ApiBearerAuth, - ApiOperation, - ApiQuery, -} from '@nestjs/swagger'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; import { CreateBookingDto } from './dto/create-booking.dto'; +import { CreateRecurringBookingDto } from './dto/create-recurring-booking.dto'; import { CreatePublicBookingDto } from './dto/create-public-booking.dto'; import { BookingQueryDto } from './dto/booking-query.dto'; import { RolesGuard } from '../auth/guard/roles.guard'; @@ -45,18 +42,34 @@ export class BookingsController { @Post() @ApiOperation({ summary: 'Create a booking' }) - async create( - @Body() dto: CreateBookingDto, - @GetCurrentUser('id') userId: string, - ) { + async create(@Body() dto: CreateBookingDto, @GetCurrentUser('id') userId: string) { const booking = await this.bookingsService.create(dto, userId); return { message: 'Booking created successfully', data: booking }; } + @Post('recurring') + @ApiOperation({ summary: 'Create a recurring booking series' }) + async createRecurring( + @Body() dto: CreateRecurringBookingDto, + @GetCurrentUser('id') userId: string, + ) { + const result = await this.bookingsService.createRecurring(dto, userId); + return { message: 'Recurring bookings created successfully', data: result }; + } + + @Delete('recurring/:groupId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Cancel all future bookings in a recurring series' }) + async cancelRecurring( + @Param('groupId', ParseUUIDPipe) groupId: string, + @GetCurrentUser('id') userId: string, + ) { + const result = await this.bookingsService.cancelRecurringGroup(groupId, userId); + return { message: 'Recurring bookings cancelled successfully', data: result }; + } + @Get() - @ApiOperation({ - summary: 'List bookings (own for users, all for admin/staff)', - }) + @ApiOperation({ summary: 'List bookings (own for users, all for admin/staff)' }) async findAll( @Query() query: BookingQueryDto, @GetCurrentUser('id') userId: string, diff --git a/backend/src/bookings/bookings.module.ts b/backend/src/bookings/bookings.module.ts index 7bbe63be..686fb76d 100644 --- a/backend/src/bookings/bookings.module.ts +++ b/backend/src/bookings/bookings.module.ts @@ -1,14 +1,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Booking } from './entities/booking.entity'; +import { RecurringRule } from './entities/recurring-rule.entity'; import { BookingsService } from './bookings.service'; import { BookingsController } from './bookings.controller'; import { CreateBookingProvider } from './providers/create-booking.provider'; import { CreatePublicDayPassProvider } from './providers/create-public-day-pass.provider'; import { ConfirmBookingProvider } from './providers/confirm-booking.provider'; import { CancelBookingProvider } from './providers/cancel-booking.provider'; +import { CancelRecurringBookingProvider } from './providers/cancel-recurring-booking.provider'; import { CompleteBookingProvider } from './providers/complete-booking.provider'; import { FindBookingsProvider } from './providers/find-bookings.provider'; +import { CreateRecurringBookingProvider } from './providers/create-recurring-booking.provider'; import { PricingService } from './pricing/pricing.service'; import { WorkspacesModule } from '../workspaces/workspaces.module'; import { User } from '../users/entities/user.entity'; @@ -31,8 +34,10 @@ import { PaystackProvider } from '../payments/providers/paystack.provider'; CreatePublicDayPassProvider, ConfirmBookingProvider, CancelBookingProvider, + CancelRecurringBookingProvider, CompleteBookingProvider, FindBookingsProvider, + CreateRecurringBookingProvider, PaystackProvider, ], exports: [BookingsService], diff --git a/backend/src/bookings/bookings.service.ts b/backend/src/bookings/bookings.service.ts index dab2d233..11ac9274 100644 --- a/backend/src/bookings/bookings.service.ts +++ b/backend/src/bookings/bookings.service.ts @@ -1,13 +1,16 @@ import { Injectable } from '@nestjs/common'; import { CreateBookingDto } from './dto/create-booking.dto'; +import { CreateRecurringBookingDto } from './dto/create-recurring-booking.dto'; import { CreatePublicBookingDto } from './dto/create-public-booking.dto'; import { BookingQueryDto } from './dto/booking-query.dto'; import { CreateBookingProvider } from './providers/create-booking.provider'; import { CreatePublicDayPassProvider } from './providers/create-public-day-pass.provider'; import { ConfirmBookingProvider } from './providers/confirm-booking.provider'; import { CancelBookingProvider } from './providers/cancel-booking.provider'; +import { CancelRecurringBookingProvider } from './providers/cancel-recurring-booking.provider'; import { CompleteBookingProvider } from './providers/complete-booking.provider'; import { FindBookingsProvider } from './providers/find-bookings.provider'; +import { CreateRecurringBookingProvider } from './providers/create-recurring-booking.provider'; import { UserRole } from '../users/enums/userRoles.enum'; import { Booking } from './entities/booking.entity'; import { PricingService } from './pricing/pricing.service'; @@ -20,8 +23,10 @@ export class BookingsService { private readonly createPublicDayPassProvider: CreatePublicDayPassProvider, private readonly confirmBookingProvider: ConfirmBookingProvider, private readonly cancelBookingProvider: CancelBookingProvider, + private readonly cancelRecurringBookingProvider: CancelRecurringBookingProvider, private readonly completeBookingProvider: CompleteBookingProvider, private readonly findBookingsProvider: FindBookingsProvider, + private readonly createRecurringBookingProvider: CreateRecurringBookingProvider, private readonly pricingService: PricingService, ) {} @@ -29,6 +34,8 @@ export class BookingsService { return this.createBookingProvider.create(dto, userId); } + createRecurring(dto: CreateRecurringBookingDto, userId: string) { + return this.createRecurringBookingProvider.create(dto, userId); publicDayPass(dto: CreatePublicBookingDto) { return this.createPublicDayPassProvider.create(dto); } @@ -41,6 +48,10 @@ export class BookingsService { return this.cancelBookingProvider.cancel(bookingId, userId, userRole); } + cancelRecurringGroup(groupId: string, userId: string) { + return this.cancelRecurringBookingProvider.cancelGroup(groupId, userId); + } + complete(bookingId: string) { return this.completeBookingProvider.complete(bookingId); } @@ -60,13 +71,7 @@ export class BookingsService { startDate: string, endDate: string, ) { - return this.pricingService.calculateAmount( - hourlyRateKobo, - planType, - seatCount, - startDate, - endDate, - ); + return this.pricingService.calculateAmount(hourlyRateKobo, planType, seatCount, startDate, endDate); } getPlanSummary(planType: PlanType) { diff --git a/backend/src/bookings/dto/create-recurring-booking.dto.ts b/backend/src/bookings/dto/create-recurring-booking.dto.ts new file mode 100644 index 00000000..7252335c --- /dev/null +++ b/backend/src/bookings/dto/create-recurring-booking.dto.ts @@ -0,0 +1,52 @@ +import { + IsEnum, + IsInt, + IsOptional, + IsDateString, + IsArray, + Min, + Max, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { CreateBookingDto } from './create-booking.dto'; +import { RecurringFrequency } from '../entities/recurring-rule.entity'; + +export class RecurringRuleDto { + @ApiProperty({ enum: RecurringFrequency }) + @IsEnum(RecurringFrequency) + frequency: RecurringFrequency; + + @ApiProperty({ example: 1 }) + @IsInt() + @Min(1) + interval: number; + + @ApiPropertyOptional({ example: [1, 3], description: '0=Sun … 6=Sat' }) + @IsOptional() + @IsArray() + @IsInt({ each: true }) + @Min(0, { each: true }) + @Max(6, { each: true }) + daysOfWeek?: number[]; + + @ApiPropertyOptional({ example: '2026-12-31' }) + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional({ example: 10 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(52) + maxOccurrences?: number; +} + +export class CreateRecurringBookingDto extends CreateBookingDto { + @ApiProperty({ type: RecurringRuleDto }) + @ValidateNested() + @Type(() => RecurringRuleDto) + recurringRule: RecurringRuleDto; +} diff --git a/backend/src/bookings/entities/booking.entity.ts b/backend/src/bookings/entities/booking.entity.ts index 8256af65..a4a6b06b 100644 --- a/backend/src/bookings/entities/booking.entity.ts +++ b/backend/src/bookings/entities/booking.entity.ts @@ -3,8 +3,6 @@ import { PrimaryGeneratedColumn, Column, ManyToOne, - OneToMany, - OneToOne, CreateDateColumn, UpdateDateColumn, Index, @@ -19,6 +17,7 @@ import { BookingStatus } from '../enums/booking-status.enum'; @Index(['userId']) @Index(['workspaceId']) @Index(['status']) +@Index(['recurringGroupId']) export class Booking { @PrimaryGeneratedColumn('uuid') id: string; @@ -46,7 +45,6 @@ export class Booking { @Column({ type: 'date' }) endDate: string; - // Total amount in kobo @Column({ type: 'bigint' }) totalAmount: number; @@ -59,7 +57,6 @@ export class Booking { @Column({ type: 'text', nullable: true }) notes: string; - // Populated for MONTHLY/QUARTERLY/YEARLY after Soroban escrow is created @Column({ nullable: true }) sorobanEscrowId: string; diff --git a/backend/src/bookings/entities/recurring-rule.entity.ts b/backend/src/bookings/entities/recurring-rule.entity.ts new file mode 100644 index 00000000..a7921637 --- /dev/null +++ b/backend/src/bookings/entities/recurring-rule.entity.ts @@ -0,0 +1,39 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +export enum RecurringFrequency { + DAILY = 'daily', + WEEKLY = 'weekly', + MONTHLY = 'monthly', +} + +@Entity('recurring_rules') +export class RecurringRule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: RecurringFrequency }) + frequency: RecurringFrequency; + + @Column({ type: 'int', default: 1 }) + interval: number; + + @Column({ type: 'simple-array', nullable: true }) + daysOfWeek: number[]; + + @Column({ type: 'date', nullable: true }) + endDate: string; + + @Column({ type: 'int', nullable: true }) + maxOccurrences: number; + + @Column('uuid') + parentBookingId: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/bookings/providers/cancel-recurring-booking.provider.ts b/backend/src/bookings/providers/cancel-recurring-booking.provider.ts new file mode 100644 index 00000000..3331fa2f --- /dev/null +++ b/backend/src/bookings/providers/cancel-recurring-booking.provider.ts @@ -0,0 +1,36 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MoreThan, Repository } from 'typeorm'; +import { Booking } from '../entities/booking.entity'; +import { BookingStatus } from '../enums/booking-status.enum'; + +@Injectable() +export class CancelRecurringBookingProvider { + constructor( + @InjectRepository(Booking) + private readonly bookingsRepo: Repository, + ) {} + + async cancelGroup(groupId: string, userId: string): Promise<{ cancelled: number }> { + const today = new Date().toISOString().split('T')[0]; + + const future = await this.bookingsRepo.find({ + where: { + recurringGroupId: groupId, + status: BookingStatus.PENDING, + }, + }); + + const toCancel = future.filter((b) => b.startDate > today); + + if (!toCancel.length) { + throw new NotFoundException('No future bookings found for this group'); + } + + await this.bookingsRepo.save( + toCancel.map((b) => ({ ...b, status: BookingStatus.CANCELLED })), + ); + + return { cancelled: toCancel.length }; + } +} diff --git a/backend/src/bookings/providers/create-recurring-booking.provider.ts b/backend/src/bookings/providers/create-recurring-booking.provider.ts new file mode 100644 index 00000000..7e5d2ea8 --- /dev/null +++ b/backend/src/bookings/providers/create-recurring-booking.provider.ts @@ -0,0 +1,165 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; +import { Booking } from '../entities/booking.entity'; +import { RecurringRule, RecurringFrequency } from '../entities/recurring-rule.entity'; +import { CreateRecurringBookingDto } from '../dto/create-recurring-booking.dto'; +import { BookingStatus } from '../enums/booking-status.enum'; +import { PricingService } from '../pricing/pricing.service'; +import { Workspace } from '../../workspaces/entities/workspace.entity'; + +const MAX_INSTANCES = 52; + +function addDays(date: Date, days: number): Date { + const d = new Date(date); + d.setDate(d.getDate() + days); + return d; +} + +function toDateStr(date: Date): string { + return date.toISOString().split('T')[0]; +} + +function generateOccurrences( + baseStart: Date, + baseEnd: Date, + rule: CreateRecurringBookingDto['recurringRule'], +): Array<{ startDate: string; endDate: string }> { + const duration = baseEnd.getTime() - baseStart.getTime(); + const occurrences: Array<{ startDate: string; endDate: string }> = []; + const cutoff = rule.endDate ? new Date(rule.endDate) : null; + const max = Math.min(rule.maxOccurrences ?? MAX_INSTANCES, MAX_INSTANCES); + + let cursor = new Date(baseStart); + + while (occurrences.length < max) { + if (cutoff && cursor > cutoff) break; + + if (rule.frequency === RecurringFrequency.WEEKLY && rule.daysOfWeek?.length) { + if (rule.daysOfWeek.includes(cursor.getDay())) { + const end = new Date(cursor.getTime() + duration); + occurrences.push({ startDate: toDateStr(cursor), endDate: toDateStr(end) }); + } + cursor = addDays(cursor, 1); + } else { + const end = new Date(cursor.getTime() + duration); + occurrences.push({ startDate: toDateStr(cursor), endDate: toDateStr(end) }); + + if (rule.frequency === RecurringFrequency.DAILY) { + cursor = addDays(cursor, rule.interval); + } else if (rule.frequency === RecurringFrequency.WEEKLY) { + cursor = addDays(cursor, rule.interval * 7); + } else { + cursor = new Date(cursor); + cursor.setMonth(cursor.getMonth() + rule.interval); + } + } + + // Safety: weekly day-walk should stop after 2 years + if ( + rule.frequency === RecurringFrequency.WEEKLY && + rule.daysOfWeek?.length && + cursor > addDays(baseStart, 730) + ) break; + } + + return occurrences; +} + +@Injectable() +export class CreateRecurringBookingProvider { + constructor( + @InjectRepository(Booking) + private readonly bookingsRepo: Repository, + @InjectRepository(RecurringRule) + private readonly rulesRepo: Repository, + private readonly pricingService: PricingService, + private readonly dataSource: DataSource, + ) {} + + async create(dto: CreateRecurringBookingDto, userId: string) { + const { recurringRule, ...bookingBase } = dto; + + if (!recurringRule.endDate && !recurringRule.maxOccurrences) { + throw new BadRequestException('Provide endDate or maxOccurrences for the recurring rule'); + } + + const baseStart = new Date(bookingBase.startDate); + const baseEnd = new Date(bookingBase.endDate); + + if (baseEnd <= baseStart) { + throw new BadRequestException('endDate must be after startDate'); + } + + const occurrences = generateOccurrences(baseStart, baseEnd, recurringRule); + if (!occurrences.length) { + throw new BadRequestException('No valid occurrences generated from the given rule'); + } + + return this.dataSource.transaction(async (manager) => { + const workspace = await manager + .createQueryBuilder(Workspace, 'w') + .setLock('pessimistic_write') + .where('w.id = :id', { id: bookingBase.workspaceId }) + .getOne(); + + if (!workspace) throw new NotFoundException(`Workspace "${bookingBase.workspaceId}" not found`); + if (!workspace.isActive) throw new BadRequestException('Workspace is not active'); + + const recurringGroupId = uuidv4(); + const saved: Booking[] = []; + const skipped: string[] = []; + + for (const occ of occurrences) { + const overlap = await manager + .createQueryBuilder(Booking, 'b') + .select('COALESCE(SUM(b.seatCount), 0)', 'booked') + .where('b.workspaceId = :workspaceId', { workspaceId: bookingBase.workspaceId }) + .andWhere('b.status IN (:...statuses)', { statuses: [BookingStatus.PENDING, BookingStatus.CONFIRMED] }) + .andWhere('b.startDate <= :endDate', { endDate: occ.endDate }) + .andWhere('b.endDate >= :startDate', { startDate: occ.startDate }) + .getRawOne<{ booked: string }>(); + + const alreadyBooked = Number(overlap?.booked ?? 0); + if (alreadyBooked + bookingBase.seatCount > workspace.totalSeats) { + skipped.push(occ.startDate); + continue; + } + + const totalAmount = this.pricingService.calculateAmount( + Number(workspace.hourlyRate), + bookingBase.planType, + bookingBase.seatCount, + occ.startDate, + occ.endDate, + ); + + const booking = manager.create(Booking, { + ...bookingBase, + userId, + startDate: occ.startDate, + endDate: occ.endDate, + totalAmount, + status: BookingStatus.PENDING, + isRecurring: true, + recurringGroupId, + }); + + saved.push(await manager.save(booking)); + } + + if (!saved.length) { + throw new ConflictException('No seats available for any of the requested recurring dates'); + } + + const rule = manager.create(RecurringRule, { + ...recurringRule, + parentBookingId: saved[0].id, + }); + await manager.save(rule); + + return { recurringGroupId, created: saved.length, skipped, bookings: saved }; + }); + } +}