Skip to content
39 changes: 26 additions & 13 deletions backend/src/bookings/bookings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
Expand All @@ -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';
Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions backend/src/bookings/bookings.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -31,8 +34,10 @@ import { PaystackProvider } from '../payments/providers/paystack.provider';
CreatePublicDayPassProvider,
ConfirmBookingProvider,
CancelBookingProvider,
CancelRecurringBookingProvider,
CompleteBookingProvider,
FindBookingsProvider,
CreateRecurringBookingProvider,
PaystackProvider,
],
exports: [BookingsService],
Expand Down
19 changes: 12 additions & 7 deletions backend/src/bookings/bookings.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,15 +23,19 @@ 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,
) {}

create(dto: CreateBookingDto, userId: string) {
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);
}
Expand All @@ -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);
}
Expand All @@ -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) {
Expand Down
52 changes: 52 additions & 0 deletions backend/src/bookings/dto/create-recurring-booking.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 1 addition & 4 deletions backend/src/bookings/entities/booking.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import {
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
OneToOne,
CreateDateColumn,
UpdateDateColumn,
Index,
Expand All @@ -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;
Expand Down Expand Up @@ -46,7 +45,6 @@ export class Booking {
@Column({ type: 'date' })
endDate: string;

// Total amount in kobo
@Column({ type: 'bigint' })
totalAmount: number;

Expand All @@ -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;

Expand Down
39 changes: 39 additions & 0 deletions backend/src/bookings/entities/recurring-rule.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<Booking>,
) {}

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 };
}
}
Loading
Loading