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
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import { ResourcesModule } from './resources/resources.module';
import { NpsModule } from './nps/nps.module';
import { DoorAccessModule } from './integrations/access-control/door-access.module';
Expand Down Expand Up @@ -151,6 +152,7 @@ import { DoorAccessModule } from './integrations/access-control/door-access.modu
LeadsModule,
CreditsModule,
TeamsModule,
CalendarSyncModule,
ResourcesModule,
NpsModule,
DoorAccessModule,
Expand Down
45 changes: 45 additions & 0 deletions backend/src/calendar-sync/calendar-sync.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 13 additions & 0 deletions backend/src/calendar-sync/calendar-sync.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
95 changes: 95 additions & 0 deletions backend/src/calendar-sync/calendar-sync.service.ts
Original file line number Diff line number Diff line change
@@ -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<CalendarToken>,
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<void> {
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<void> {
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<void> {
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<void> {
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}`);
}
}
}
31 changes: 31 additions & 0 deletions backend/src/calendar-sync/entities/calendar-token.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading