Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/src/bookings/bookings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { ApiErrorDto } from '../common/dto/api-error.dto';
import { Booking } from './entities/booking.entity';
import { CsvExportService } from '../common/csv-export/csv-export.service';
import { ExportBookingsProvider } from './providers/export-bookings.provider';
import { CreateRecurringBookingDto } from '../modules/bookings/recurring-booking';

@ApiTags('bookings')
@ApiBearerAuth('bearer')
Expand Down Expand Up @@ -64,6 +65,17 @@ export class BookingsController {
return { message: 'Booking created successfully', data: booking };
}

@Post('recurring')
@ApiOperation({ summary: 'Create recurring bookings' })
@ApiOkResponse({ description: 'Recurring bookings created', type: [Booking] })
async createRecurring(
@Body() dto: CreateRecurringBookingDto,
@GetCurrentUser('id') userId: string,
) {
const bookings = await this.bookingsService.createRecurring(dto, userId);
return { message: 'Recurring bookings created successfully', data: bookings };
}

@Get()
@ApiOperation({
summary: 'List bookings (own for users, all for admin/staff)',
Expand Down
2 changes: 2 additions & 0 deletions backend/src/bookings/bookings.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CacheInvalidationProvider } from '../common/providers/cache-invalidatio
import { ExportBookingsProvider } from './providers/export-bookings.provider';
import { CsvExportService } from '../common/csv-export/csv-export.service';
import { BookingReminderScheduler } from './providers/booking-reminder.provider';
import { RecurringBookingService } from '../modules/bookings/recurring-booking';

@Module({
imports: [TypeOrmModule.forFeature([Booking, User]), WorkspacesModule],
Expand All @@ -31,6 +32,7 @@ import { BookingReminderScheduler } from './providers/booking-reminder.provider'
ExportBookingsProvider,
CsvExportService,
BookingReminderScheduler,
RecurringBookingService,
],
exports: [BookingsService],
})
Expand Down
9 changes: 9 additions & 0 deletions backend/src/bookings/bookings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { UserRole } from '../users/enums/userRoles.enum';
import { Booking } from './entities/booking.entity';
import { PricingService } from './pricing/pricing.service';
import { PlanType } from './enums/plan-type.enum';
import {
CreateRecurringBookingDto,
RecurringBookingService,
} from '../modules/bookings/recurring-booking';

@Injectable()
export class BookingsService {
Expand All @@ -23,13 +27,18 @@ export class BookingsService {
private readonly findBookingsProvider: FindBookingsProvider,
private readonly pricingService: PricingService,
private readonly exportBookingsProvider: ExportBookingsProvider,
private readonly recurringBookingService: RecurringBookingService,
) {}

//create booking id
create(dto: CreateBookingDto, userId: string) {
return this.createBookingProvider.create(dto, userId);
}

createRecurring(dto: CreateRecurringBookingDto, userId: string) {
return this.recurringBookingService.create(dto, userId);
}

/**
* Confirm a booking, optionally reusing the caller's open transaction
* (BE-12) so the Paystack webhook doesn't accidentally nest a
Expand Down
74 changes: 74 additions & 0 deletions backend/src/modules/bookings/recurring-booking.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import { Injectable } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEnum, IsInt, IsOptional, Min } from 'class-validator';
import { CreateBookingDto } from '../../bookings/dto/create-booking.dto';
import { CreateBookingProvider } from '../../bookings/providers/create-booking.provider';

export type RecurrencePattern = 'daily' | 'weekly' | 'monthly' | 'yearly';

export class CreateRecurringBookingDto extends CreateBookingDto {
@ApiProperty({ enum: ['daily', 'weekly', 'monthly', 'yearly'] })
@IsEnum(['daily', 'weekly', 'monthly', 'yearly'])
pattern: RecurrencePattern;

@ApiProperty({ example: 1, minimum: 1 })
@IsInt()
@Min(1)
interval: number;

@ApiPropertyOptional({ example: 12, minimum: 1 })
@IsOptional()
@IsInt()
@Min(1)
maxOccurrences?: number;

@ApiPropertyOptional({ example: '2026-12-31' })
@IsOptional()
@IsDateString()
recurrenceEndDate?: string;
}

export interface RecurringBooking {
bookingId: string;
pattern: RecurrencePattern;
Expand All @@ -10,7 +38,40 @@ export interface RecurringBooking {
interval: number;
}

@Injectable()
export class RecurringBookingService {
constructor(private readonly createBookingProvider: CreateBookingProvider) {}

async create(
dto: CreateRecurringBookingDto,
userId: string,
) {
const duration = this.dateDifferenceInDays(dto.startDate, dto.endDate);
const occurrences = this.generateOccurrences({
bookingId: dto.workspaceId,
pattern: dto.pattern,
startDate: new Date(dto.startDate),
endDate: dto.recurrenceEndDate
? new Date(dto.recurrenceEndDate)
: undefined,
maxOccurrences: dto.maxOccurrences,
interval: dto.interval,
});

return Promise.all(
occurrences.map((startDate) => {
const start = this.formatDate(startDate);
const end = new Date(startDate);
end.setUTCDate(end.getUTCDate() + duration);

return this.createBookingProvider.create(
{ ...dto, startDate: start, endDate: this.formatDate(end) },
userId,
);
}),
);
}

generateOccurrences(config: RecurringBooking): Date[] {
const occurrences: Date[] = [];
let current = new Date(config.startDate);
Expand All @@ -32,10 +93,23 @@ export class RecurringBookingService {
case 'monthly':
current.setMonth(current.getMonth() + config.interval);
break;
case 'yearly':
current.setFullYear(current.getFullYear() + config.interval);
break;
}
count++;
}

return occurrences;
}

private dateDifferenceInDays(startDate: string, endDate: string): number {
return Math.round(
(Date.parse(endDate) - Date.parse(startDate)) / (24 * 60 * 60 * 1000),
);
}

private formatDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
}