From d38b9a9481341fa400682d82628eddbf3e1a1c7e Mon Sep 17 00:00:00 2001 From: nanaf6203-bit Date: Mon, 29 Jun 2026 09:55:41 +0000 Subject: [PATCH] feat(calendar-sync): add Google Calendar OAuth and booking sync service --- backend/package.json | 3 +- backend/src/app.module.ts | 2 + .../calendar-sync/calendar-sync.controller.ts | 45 +++++++++ .../src/calendar-sync/calendar-sync.module.ts | 13 +++ .../calendar-sync/calendar-sync.service.ts | 95 +++++++++++++++++++ .../entities/calendar-token.entity.ts | 31 ++++++ 6 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 backend/src/calendar-sync/calendar-sync.controller.ts create mode 100644 backend/src/calendar-sync/calendar-sync.module.ts create mode 100644 backend/src/calendar-sync/calendar-sync.service.ts create mode 100644 backend/src/calendar-sync/entities/calendar-token.entity.ts diff --git a/backend/package.json b/backend/package.json index 2bfb6cc..d489fe8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -87,7 +87,8 @@ "sharp": "^0.34.5", "socket.io": "^4.8.3", "swagger-ui-express": "^5.0.1", - "typeorm": "^0.3.27" + "typeorm": "^0.3.27", + "googleapis": "^144.0.0" }, "devDependencies": { "@nestjs/schematics": "^10.0.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 57c4631..d94f672 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -46,6 +46,7 @@ import { MaintenanceModule } from './maintenance/maintenance.module'; import { LeadsModule } from './leads/leads.module'; import { CreditsModule } from './credits/credits.module'; import { TeamsModule } from './teams/teams.module'; +import { CalendarSyncModule } from './calendar-sync/calendar-sync.module'; @Module({ imports: [ @@ -148,6 +149,7 @@ import { TeamsModule } from './teams/teams.module'; LeadsModule, CreditsModule, TeamsModule, + CalendarSyncModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/calendar-sync/calendar-sync.controller.ts b/backend/src/calendar-sync/calendar-sync.controller.ts new file mode 100644 index 0000000..9fdc2c1 --- /dev/null +++ b/backend/src/calendar-sync/calendar-sync.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Delete, Query, Res, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { ConfigService } from '@nestjs/config'; +import { CalendarSyncService } from './calendar-sync.service'; +import { GetCurrentUser } from '../auth/decorators/getCurrentUser.decorator'; +import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard'; + +@ApiTags('Calendar Sync') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('calendar-sync') +export class CalendarSyncController { + constructor( + private readonly service: CalendarSyncService, + private readonly config: ConfigService, + ) {} + + @Get('auth/google') + getAuthUrl() { + return { url: this.service.getAuthUrl() }; + } + + @Get('auth/google/callback') + async handleCallback( + @Query('code') code: string, + @GetCurrentUser('id') userId: string, + @Res() res: Response, + ) { + await this.service.handleCallback(code, userId); + const frontendUrl = this.config.get('FRONTEND_URL') || 'http://localhost:3000'; + res.redirect(`${frontendUrl}/settings?calendarConnected=true`); + } + + @Delete('disconnect') + async disconnect(@GetCurrentUser('id') userId: string) { + await this.service.disconnect(userId); + return { message: 'Calendar disconnected' }; + } + + @Get('status') + async getStatus(@GetCurrentUser('id') userId: string) { + return this.service.getStatus(userId); + } +} diff --git a/backend/src/calendar-sync/calendar-sync.module.ts b/backend/src/calendar-sync/calendar-sync.module.ts new file mode 100644 index 0000000..a2304f3 --- /dev/null +++ b/backend/src/calendar-sync/calendar-sync.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CalendarToken } from './entities/calendar-token.entity'; +import { CalendarSyncService } from './calendar-sync.service'; +import { CalendarSyncController } from './calendar-sync.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([CalendarToken])], + controllers: [CalendarSyncController], + providers: [CalendarSyncService], + exports: [CalendarSyncService], +}) +export class CalendarSyncModule {} diff --git a/backend/src/calendar-sync/calendar-sync.service.ts b/backend/src/calendar-sync/calendar-sync.service.ts new file mode 100644 index 0000000..4f71266 --- /dev/null +++ b/backend/src/calendar-sync/calendar-sync.service.ts @@ -0,0 +1,95 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { google } from 'googleapis'; +import { CalendarToken } from './entities/calendar-token.entity'; +import { Booking } from '../bookings/entities/booking.entity'; + +@Injectable() +export class CalendarSyncService { + private readonly logger = new Logger(CalendarSyncService.name); + + constructor( + @InjectRepository(CalendarToken) private tokenRepo: Repository, + private config: ConfigService, + ) {} + + private getOAuth2Client() { + return new google.auth.OAuth2( + this.config.get('GOOGLE_CLIENT_ID'), + this.config.get('GOOGLE_CLIENT_SECRET'), + this.config.get('GOOGLE_REDIRECT_URI'), + ); + } + + getAuthUrl(): string { + const client = this.getOAuth2Client(); + return client.generateAuthUrl({ + access_type: 'offline', + scope: ['https://www.googleapis.com/auth/calendar.events'], + prompt: 'consent', + }); + } + + async handleCallback(code: string, userId: string): Promise { + const client = this.getOAuth2Client(); + const { tokens } = await client.getToken(code); + await this.tokenRepo.upsert( + { + userId, + accessToken: tokens.access_token!, + refreshToken: tokens.refresh_token ?? '', + expiresAt: new Date(tokens.expiry_date ?? Date.now() + 3600_000), + calendarId: 'primary', + }, + ['userId'], + ); + } + + async disconnect(userId: string): Promise { + await this.tokenRepo.delete({ userId }); + } + + async getStatus(userId: string) { + const token = await this.tokenRepo.findOne({ where: { userId } }); + return { connected: !!token, provider: token?.provider ?? null }; + } + + async createEvent(userId: string, booking: Booking): Promise { + const token = await this.tokenRepo.findOne({ where: { userId } }); + if (!token) return; + try { + const client = this.getOAuth2Client(); + client.setCredentials({ access_token: token.accessToken, refresh_token: token.refreshToken }); + const calendar = google.calendar({ version: 'v3', auth: client }); + const event = await calendar.events.insert({ + calendarId: token.calendarId ?? 'primary', + requestBody: { + summary: `Booking: ${(booking as any).workspace?.name ?? 'Workspace'}`, + start: { date: booking.startDate }, + end: { date: booking.endDate }, + description: `ManageHub booking #${booking.id}`, + }, + }); + // Store event ID on booking for later deletion + (booking as any).googleCalendarEventId = event.data.id; + } catch (err) { + this.logger.warn(`Calendar event creation failed for user ${userId}: ${err}`); + } + } + + async deleteEvent(userId: string, booking: Booking): Promise { + const token = await this.tokenRepo.findOne({ where: { userId } }); + const eventId = (booking as any).googleCalendarEventId; + if (!token || !eventId) return; + try { + const client = this.getOAuth2Client(); + client.setCredentials({ access_token: token.accessToken, refresh_token: token.refreshToken }); + const calendar = google.calendar({ version: 'v3', auth: client }); + await calendar.events.delete({ calendarId: token.calendarId ?? 'primary', eventId }); + } catch (err) { + this.logger.warn(`Calendar event deletion failed for user ${userId}: ${err}`); + } + } +} diff --git a/backend/src/calendar-sync/entities/calendar-token.entity.ts b/backend/src/calendar-sync/entities/calendar-token.entity.ts new file mode 100644 index 0000000..da40bd3 --- /dev/null +++ b/backend/src/calendar-sync/entities/calendar-token.entity.ts @@ -0,0 +1,31 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('calendar_tokens') +export class CalendarToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', unique: true }) + userId: string; + + @Column({ default: 'google' }) + provider: string; + + @Column({ type: 'text' }) + accessToken: string; + + @Column({ type: 'text' }) + refreshToken: string; + + @Column({ type: 'timestamptz' }) + expiresAt: Date; + + @Column({ nullable: true }) + calendarId: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +}