From 61b2ed20cbfdfe25ba175b30179a87eee820bbc5 Mon Sep 17 00:00:00 2001 From: xeeenab Date: Sat, 27 Jun 2026 11:38:20 +0100 Subject: [PATCH] feat: add calendar sync with iCal export endpoints --- backend/src/bookings/bookings.controller.ts | 20 ++++ backend/src/bookings/bookings.module.ts | 10 +- backend/src/bookings/bookings.service.ts | 10 ++ .../src/bookings/dto/create-booking.dto.ts | 5 + .../src/bookings/pricing/pricing.service.ts | 20 ++++ .../providers/calendar-export.provider.ts | 93 +++++++++++++++++++ .../providers/create-booking.provider.ts | 49 ++++++++++ 7 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 backend/src/bookings/providers/calendar-export.provider.ts diff --git a/backend/src/bookings/bookings.controller.ts b/backend/src/bookings/bookings.controller.ts index 00c52ffd..60083ec5 100644 --- a/backend/src/bookings/bookings.controller.ts +++ b/backend/src/bookings/bookings.controller.ts @@ -11,6 +11,7 @@ import { UseGuards, HttpCode, HttpStatus, + Header, } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; @@ -107,6 +108,14 @@ export class BookingsController { }; } + @Get('calendar.ics') + @Header('Content-Type', 'text/calendar; charset=utf-8') + @Header('Content-Disposition', 'attachment; filename=booking.ics') + @ApiOperation({ summary: 'Export all upcoming confirmed bookings as iCal' }) + async exportAllCalendar(@GetCurrentUser('id') userId: string): Promise { + return this.bookingsService.exportAllUpcoming(userId); + } + @Get(':id') @ApiOperation({ summary: 'Get booking by ID' }) async findOne( @@ -118,6 +127,17 @@ export class BookingsController { return { message: 'Booking retrieved successfully', data: booking }; } + @Get(':id/calendar.ics') + @Header('Content-Type', 'text/calendar; charset=utf-8') + @Header('Content-Disposition', 'attachment; filename=booking.ics') + @ApiOperation({ summary: 'Export a single booking as iCal' }) + async exportSingleCalendar( + @Param('id', ParseUUIDPipe) id: string, + @GetCurrentUser('id') userId: string, + ): Promise { + return this.bookingsService.exportSingleBooking(id, userId); + } + @Patch(':id/confirm') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN, UserRole.STAFF) diff --git a/backend/src/bookings/bookings.module.ts b/backend/src/bookings/bookings.module.ts index 686fb76d..3c48f3d7 100644 --- a/backend/src/bookings/bookings.module.ts +++ b/backend/src/bookings/bookings.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; import { Booking } from './entities/booking.entity'; import { RecurringRule } from './entities/recurring-rule.entity'; import { BookingsService } from './bookings.service'; @@ -12,9 +13,12 @@ import { CancelRecurringBookingProvider } from './providers/cancel-recurring-boo import { CompleteBookingProvider } from './providers/complete-booking.provider'; import { FindBookingsProvider } from './providers/find-bookings.provider'; import { CreateRecurringBookingProvider } from './providers/create-recurring-booking.provider'; +import { CalendarExportProvider } from './providers/calendar-export.provider'; import { PricingService } from './pricing/pricing.service'; import { WorkspacesModule } from '../workspaces/workspaces.module'; import { User } from '../users/entities/user.entity'; +import { UserCredit } from '../credits/entities/user-credit.entity'; +import { UserCreditTransaction } from '../credits/entities/credit-transaction.entity'; import { WaitlistModule } from '../waitlist/waitlist.module'; import { Payment } from '../payments/entities/payment.entity'; import { PaystackProvider } from '../payments/providers/paystack.provider'; @@ -22,9 +26,10 @@ import { PaystackProvider } from '../payments/providers/paystack.provider'; @Module({ imports: [ - TypeOrmModule.forFeature([Booking, User, Payment]), + TypeOrmModule.forFeature([Booking, User, Payment, UserCredit, UserCreditTransaction]), WorkspacesModule, - WaitlistModule, + WaitlistModule, + ConfigModule, ], controllers: [BookingsController], providers: [ @@ -38,6 +43,7 @@ import { PaystackProvider } from '../payments/providers/paystack.provider'; CompleteBookingProvider, FindBookingsProvider, CreateRecurringBookingProvider, + CalendarExportProvider, PaystackProvider, ], exports: [BookingsService], diff --git a/backend/src/bookings/bookings.service.ts b/backend/src/bookings/bookings.service.ts index 11ac9274..214e11b1 100644 --- a/backend/src/bookings/bookings.service.ts +++ b/backend/src/bookings/bookings.service.ts @@ -11,6 +11,7 @@ import { CancelRecurringBookingProvider } from './providers/cancel-recurring-boo import { CompleteBookingProvider } from './providers/complete-booking.provider'; import { FindBookingsProvider } from './providers/find-bookings.provider'; import { CreateRecurringBookingProvider } from './providers/create-recurring-booking.provider'; +import { CalendarExportProvider } from './providers/calendar-export.provider'; import { UserRole } from '../users/enums/userRoles.enum'; import { Booking } from './entities/booking.entity'; import { PricingService } from './pricing/pricing.service'; @@ -28,6 +29,7 @@ export class BookingsService { private readonly findBookingsProvider: FindBookingsProvider, private readonly createRecurringBookingProvider: CreateRecurringBookingProvider, private readonly pricingService: PricingService, + private readonly calendarExportProvider: CalendarExportProvider, ) {} create(dto: CreateBookingDto, userId: string) { @@ -64,6 +66,14 @@ export class BookingsService { return this.findBookingsProvider.findById(bookingId, userId, userRole); } + exportSingleBooking(bookingId: string, userId: string): Promise { + return this.calendarExportProvider.exportSingleBooking(bookingId, userId); + } + + exportAllUpcoming(userId: string): Promise { + return this.calendarExportProvider.exportAllUpcoming(userId); + } + calculatePrice( hourlyRateKobo: number, planType: PlanType, diff --git a/backend/src/bookings/dto/create-booking.dto.ts b/backend/src/bookings/dto/create-booking.dto.ts index 9df0de48..e48fce2d 100644 --- a/backend/src/bookings/dto/create-booking.dto.ts +++ b/backend/src/bookings/dto/create-booking.dto.ts @@ -36,4 +36,9 @@ export class CreateBookingDto { @IsOptional() @IsString() notes?: string; + + @ApiPropertyOptional({ enum: ['paystack', 'credits'] }) + @IsOptional() + @IsString() + paymentMethod?: string; } diff --git a/backend/src/bookings/pricing/pricing.service.ts b/backend/src/bookings/pricing/pricing.service.ts index 25cfe8ba..1a4689c1 100644 --- a/backend/src/bookings/pricing/pricing.service.ts +++ b/backend/src/bookings/pricing/pricing.service.ts @@ -55,4 +55,24 @@ export class PricingService { discountPct: PLAN_DISCOUNT[planType] * 100, }; } + + calculateHours( + planType: PlanType, + seatCount: number, + startDate: string, + endDate: string, + ): number { + let days: number; + + if (planType === PlanType.DAILY) { + const start = new Date(startDate); + const end = new Date(endDate); + const diffMs = end.getTime() - start.getTime(); + days = Math.max(1, Math.ceil(diffMs / (1000 * 60 * 60 * 24))); + } else { + days = PLAN_DAYS[planType]; + } + + return PLAN_WORKING_HOURS * days * seatCount; + } } diff --git a/backend/src/bookings/providers/calendar-export.provider.ts b/backend/src/bookings/providers/calendar-export.provider.ts new file mode 100644 index 00000000..52de992f --- /dev/null +++ b/backend/src/bookings/providers/calendar-export.provider.ts @@ -0,0 +1,93 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThanOrEqual } from 'typeorm'; +import { Booking } from '../entities/booking.entity'; +import { BookingStatus } from '../enums/booking-status.enum'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class CalendarExportProvider { + constructor( + @InjectRepository(Booking) + private readonly bookingsRepository: Repository, + private readonly configService: ConfigService, + ) {} + + async exportSingleBooking(bookingId: string, userId: string): Promise { + const booking = await this.bookingsRepository.findOne({ + where: { id: bookingId }, + relations: ['workspace'], + }); + + if (!booking) { + throw new NotFoundException(`Booking "${bookingId}" not found`); + } + + if (booking.userId !== userId) { + throw new ForbiddenException('You can only export your own bookings'); + } + + return this.buildCalendar([booking]); + } + + async exportAllUpcoming(userId: string): Promise { + const now = new Date().toISOString().split('T')[0]; + const bookings = await this.bookingsRepository.find({ + where: { + userId, + status: BookingStatus.CONFIRMED, + startDate: MoreThanOrEqual(now), + }, + relations: ['workspace'], + order: { startDate: 'ASC' }, + }); + + return this.buildCalendar(bookings); + } + + private buildCalendar(bookings: Booking[]): string { + const lines: string[] = []; + lines.push('BEGIN:VCALENDAR'); + lines.push('VERSION:2.0'); + lines.push('PRODID:-//ManageHub//Bookings//EN'); + lines.push('CALSCALE:GREGORIAN'); + lines.push('METHOD:PUBLISH'); + + const hubAddress = + this.configService.get('HUB_ADDRESS') || 'ManageHub Coworking Space'; + + for (const booking of bookings) { + const uid = booking.id; + const dtStart = this.formatDate(booking.startDate); + const dtEnd = this.formatDate(booking.endDate); + const summary = booking.workspace?.name || 'Workspace Booking'; + const location = booking.workspace?.name + ? `${booking.workspace.name}, ${hubAddress}` + : hubAddress; + + lines.push('BEGIN:VEVENT'); + lines.push(`UID:${uid}`); + lines.push(`DTSTART;VALUE=DATE:${dtStart}`); + lines.push(`DTEND;VALUE=DATE:${dtEnd}`); + lines.push(`SUMMARY:${this.escapeText(summary)}`); + lines.push(`LOCATION:${this.escapeText(location)}`); + lines.push(`DTSTAMP:${this.formatDate(new Date().toISOString().split('T')[0])}T000000Z`); + lines.push('END:VEVENT'); + } + + lines.push('END:VCALENDAR'); + return lines.join('\r\n'); + } + + private formatDate(dateStr: string): string { + return dateStr.replace(/-/g, ''); + } + + private escapeText(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n'); + } +} diff --git a/backend/src/bookings/providers/create-booking.provider.ts b/backend/src/bookings/providers/create-booking.provider.ts index 39b7eae3..c827df58 100644 --- a/backend/src/bookings/providers/create-booking.provider.ts +++ b/backend/src/bookings/providers/create-booking.provider.ts @@ -12,6 +12,10 @@ import { BookingStatus } from '../enums/booking-status.enum'; import { PricingService } from '../pricing/pricing.service'; import { Workspace } from '../../workspaces/entities/workspace.entity'; import { User } from '../../users/entities/user.entity'; +import { UserCredit } from '../../credits/entities/user-credit.entity'; +import { UserCreditTransaction } from '../../credits/entities/credit-transaction.entity'; +import { CreditTransactionType } from '../../credits/enums/credit-transaction-type.enum'; +import { MembershipStatus } from '../../users/enums/membership-status.enum'; import { EmailService } from '../../email/email.service'; @Injectable() @@ -21,6 +25,10 @@ export class CreateBookingProvider { private readonly bookingsRepository: Repository, @InjectRepository(User) private readonly usersRepository: Repository, + @InjectRepository(UserCredit) + private readonly userCreditsRepository: Repository, + @InjectRepository(UserCreditTransaction) + private readonly creditTransactionsRepository: Repository, private readonly pricingService: PricingService, private readonly dataSource: DataSource, private readonly emailService: EmailService, @@ -84,6 +92,47 @@ export class CreateBookingProvider { const saved = await manager.save(booking); + // Handle credit payment + if (dto.paymentMethod === 'credits') { + const requiredHours = this.pricingService.calculateHours( + dto.planType, + dto.seatCount, + dto.startDate, + dto.endDate, + ); + + const userCredit = await manager + .createQueryBuilder(UserCredit, 'uc') + .setLock('pessimistic_write') + .where('uc.userId = :userId', { userId }) + .getOne(); + + if (!userCredit || Number(userCredit.remainingHours) < requiredHours) { + throw new BadRequestException('Insufficient credit hours'); + } + + userCredit.remainingHours = Number(userCredit.remainingHours) - requiredHours; + await manager.save(userCredit); + + const transaction = manager.create(UserCreditTransaction, { + userCreditId: userCredit.id, + type: CreditTransactionType.SPEND, + hours: requiredHours, + description: `Booking ${saved.id}`, + }); + await manager.save(transaction); + + saved.status = BookingStatus.CONFIRMED; + await manager.save(saved); + + const user = await manager.findOne(User, { where: { id: userId } }); + if (user && !user.memberSince) { + user.memberSince = new Date(); + user.membershipStatus = MembershipStatus.ACTIVE; + await manager.save(user); + } + } + // Fire-and-forget booking created email this.usersRepository .findOne({ where: { id: userId } })