Skip to content
Open
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
20 changes: 20 additions & 0 deletions backend/src/bookings/bookings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string> {
return this.bookingsService.exportAllUpcoming(userId);
}

@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
async findOne(
Expand All @@ -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<string> {
return this.bookingsService.exportSingleBooking(id, userId);
}

@Patch(':id/confirm')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN, UserRole.STAFF)
Expand Down
10 changes: 8 additions & 2 deletions backend/src/bookings/bookings.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,19 +13,23 @@ 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';


@Module({
imports: [
TypeOrmModule.forFeature([Booking, User, Payment]),
TypeOrmModule.forFeature([Booking, User, Payment, UserCredit, UserCreditTransaction]),
WorkspacesModule,
WaitlistModule,
WaitlistModule,
ConfigModule,
],
controllers: [BookingsController],
providers: [
Expand All @@ -38,6 +43,7 @@ import { PaystackProvider } from '../payments/providers/paystack.provider';
CompleteBookingProvider,
FindBookingsProvider,
CreateRecurringBookingProvider,
CalendarExportProvider,
PaystackProvider,
],
exports: [BookingsService],
Expand Down
10 changes: 10 additions & 0 deletions backend/src/bookings/bookings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -64,6 +66,14 @@ export class BookingsService {
return this.findBookingsProvider.findById(bookingId, userId, userRole);
}

exportSingleBooking(bookingId: string, userId: string): Promise<string> {
return this.calendarExportProvider.exportSingleBooking(bookingId, userId);
}

exportAllUpcoming(userId: string): Promise<string> {
return this.calendarExportProvider.exportAllUpcoming(userId);
}

calculatePrice(
hourlyRateKobo: number,
planType: PlanType,
Expand Down
5 changes: 5 additions & 0 deletions backend/src/bookings/dto/create-booking.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,9 @@ export class CreateBookingDto {
@IsOptional()
@IsString()
notes?: string;

@ApiPropertyOptional({ enum: ['paystack', 'credits'] })
@IsOptional()
@IsString()
paymentMethod?: string;
}
20 changes: 20 additions & 0 deletions backend/src/bookings/pricing/pricing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
93 changes: 93 additions & 0 deletions backend/src/bookings/providers/calendar-export.provider.ts
Original file line number Diff line number Diff line change
@@ -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<Booking>,
private readonly configService: ConfigService,
) {}

async exportSingleBooking(bookingId: string, userId: string): Promise<string> {
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<string> {
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<string>('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');
}
}
49 changes: 49 additions & 0 deletions backend/src/bookings/providers/create-booking.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -21,6 +25,10 @@ export class CreateBookingProvider {
private readonly bookingsRepository: Repository<Booking>,
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
@InjectRepository(UserCredit)
private readonly userCreditsRepository: Repository<UserCredit>,
@InjectRepository(UserCreditTransaction)
private readonly creditTransactionsRepository: Repository<UserCreditTransaction>,
private readonly pricingService: PricingService,
private readonly dataSource: DataSource,
private readonly emailService: EmailService,
Expand Down Expand Up @@ -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 } })
Expand Down
Loading