From bfbf650ca5b309558554b5ede6a99a3d7d8d4227 Mon Sep 17 00:00:00 2001 From: Yannick Lagger Date: Fri, 10 Apr 2026 21:42:59 +0200 Subject: [PATCH 1/7] sync appointment with google calendar --- GOOGLE_CALENDAR_SETUP.md | 124 +++++++++ README.md | 37 ++- package-lock.json | 167 +++++++++++- package.json | 1 + ...5101486000-addGoogleCalendarIntegration.ts | 21 ++ src/i18n/de/translation.json | 7 + src/i18n/fr/translation.json | 7 + .../services/google-calendar.service.ts | 240 ++++++++++++++++++ src/shared/shared.module.ts | 8 +- .../appointment/appointment.entity.ts | 3 + .../appointment/appointment.facade.ts | 43 ++++ src/training/controller/school.controller.ts | 62 ++++- src/training/school/domain/school-config.ts | 28 +- src/training/school/school.facade.ts | 130 +++++++++- src/training/school/school.module.ts | 4 +- src/training/training.module.ts | 4 + 16 files changed, 865 insertions(+), 21 deletions(-) create mode 100644 GOOGLE_CALENDAR_SETUP.md create mode 100644 src/db/migrations/1775101486000-addGoogleCalendarIntegration.ts create mode 100644 src/shared/services/google-calendar.service.ts diff --git a/GOOGLE_CALENDAR_SETUP.md b/GOOGLE_CALENDAR_SETUP.md new file mode 100644 index 0000000..ea9a744 --- /dev/null +++ b/GOOGLE_CALENDAR_SETUP.md @@ -0,0 +1,124 @@ +# Google Calendar Integration Setup + +This document describes how to set up the Google Calendar integration for Flightbook. + +## Google Cloud Console Setup + +### 1. Create OAuth 2.0 Credentials + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Enable the **Google Calendar API**: + - Navigate to "APIs & Services" → "Library" + - Search for "Google Calendar API" + - Click "Enable" + +4. Create OAuth 2.0 credentials: + - Go to "APIs & Services" → "Credentials" + - Click "Create Credentials" → "OAuth 2.0 Client ID" + - Choose "Web application" as application type + - Configure the OAuth consent screen if prompted + +5. Add authorized redirect URIs: + - Add your backend callback URL: `https://api.flightbook.com/schools/google-calendar/callback` + - Note: We use a single redirect URI for all schools. The school ID is passed via the OAuth `state` parameter + +6. Copy the **Client ID** and **Client Secret** + +## Backend Environment Variables + +Add the following environment variables to your `.env` file: + +```bash +# Google OAuth Credentials (from Google Cloud Console) +GOOGLE_CLIENT_ID=your-client-id-here.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-client-secret-here + +# Backend API URL (where OAuth callbacks are received) +API_URL=https://api.flightbook.com + +# Frontend URL (where users are redirected after OAuth) +INSTRUCTOR_APP_URL=https://instructor-app.flightbook.com +``` + +### Variable Descriptions + +- **GOOGLE_CLIENT_ID**: Your Google OAuth 2.0 client ID (public, can be shared with frontend) +- **GOOGLE_CLIENT_SECRET**: Your Google OAuth 2.0 client secret (keep secure, backend only) +- **API_URL**: The base URL of your backend API +- **INSTRUCTOR_APP_URL**: The URL of your Instructor-App frontend + +## Frontend Environment Variables + +Add the following to your frontend environment configuration: + +**File**: `/Flightbook-InstructorApp/src/environments/environment.ts` + +```typescript +export const environment = { + production: false, + baseUrl: 'http://localhost:8282', + googleClientId: 'your-client-id-here.apps.googleusercontent.com' +}; +``` + +**File**: `/Flightbook-InstructorApp/src/environments/environment.prod.ts` + +```typescript +export const environment = { + production: true, + baseUrl: 'https://api.flightbook.com', + googleClientId: 'your-client-id-here.apps.googleusercontent.com' +}; +``` + +## OAuth Flow + +1. User clicks "Connect Google Calendar" in school settings +2. Frontend redirects to Google OAuth URL with backend callback +3. User authenticates and grants calendar permissions +4. Google redirects to backend: `GET /schools/:id/google-calendar/callback?code=AUTH_CODE` +5. Backend exchanges code for tokens and stores them encrypted +6. Backend redirects to frontend: `https://instructor-app.com/settings?google_calendar=success` +7. Frontend detects success and updates UI + +## Database Migration + +Run the migration to add the `google_calendar_event_id` column: + +```bash +npm run migration:run +``` + +## Testing + +After setup, test the integration: + +1. Log in to Instructor-App as a school admin +2. Navigate to School Settings +3. Click "Connect Google Calendar" +4. Authenticate with Google +5. Verify connection status shows "Connected" +6. Create an appointment and check it appears in Google Calendar + +## Security Notes + +- OAuth tokens are stored encrypted in the database +- `GOOGLE_CLIENT_SECRET` must never be exposed to the frontend +- Each school authenticates with their own Google account +- Tokens are automatically refreshed before expiry + +## Troubleshooting + +### "Redirect URI mismatch" error +- Ensure the redirect URI in Google Cloud Console exactly matches: `{API_URL}/schools/google-calendar/callback` +- Check for trailing slashes + +### "Invalid client" error +- Verify `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are correct +- Ensure the OAuth consent screen is configured + +### Appointments not syncing +- Check backend logs for errors +- Verify the school has a valid Google Calendar connection +- Ensure tokens haven't expired (they auto-refresh) diff --git a/README.md b/README.md index b22068e..09f2686 100644 --- a/README.md +++ b/README.md @@ -48,14 +48,18 @@ AWS_SECRET_ACCESS_KEY=flightbook_dev_secret JWT_SECRET=mySecret TOKEN_EXPIRATION=1000h -FIREBASE_CREDENTIAL_JSON= - # Origin ORIGIN_INSTRUCTOR=localhsot:4200 # Mobile App URL MOBILE_APP_URL=http://localhost:8100 +# Instructor App URL +INSTRUCTOR_APP_URL=http://localhost:4200 + +# Backend API URL +API_URL=http://localhost:8282 + # Email EMAIL_HOST: EMAIL_USER: @@ -78,6 +82,12 @@ MAP_URL= MAP_ATTRIBUTIONS= MAP_CROSS_ORIGIN= +# Google OAuth Credentials (from Google Cloud Console) +GOOGLE_CLIENT_ID=your-client-id-here.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-client-secret-here + +FIREBASE_CREDENTIAL_JSON= + # Version Check (Mobile App) ANDROID_MINIMAL_VERSION_BUILD= ANDROID_LATEST_BUILD= @@ -99,6 +109,29 @@ npm run start:dev MIGRATION_NAME=appointmentType npm run migration:generate ``` +## Google Cloud Console Setup + +### Create OAuth 2.0 Credentials + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Enable the **Google Calendar API**: + - Navigate to "APIs & Services" → "Library" + - Search for "Google Calendar API" + - Click "Enable" + +4. Create OAuth 2.0 credentials: + - Go to "APIs & Services" → "Credentials" + - Click "Create Credentials" → "OAuth 2.0 Client ID" + - Choose "Web application" as application type + - Configure the OAuth consent screen if prompted + +5. Add authorized redirect URIs: + - Add your backend callback URL: `{API_URL}/schools/google-calendar/callback` + - Note: We use a single redirect URI for all schools. The school ID is passed via the OAuth `state` parameter + +6. Copy the **Client ID** and **Client Secret** + ## Security If you discover security related issues, please email yannick.lagger@flightbook.ch instead of using the issue tracker. diff --git a/package-lock.json b/package-lock.json index 76fa67c..7181800 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "csv-parse": "^6.1.0", "firebase-admin": "^13.7.0", "google-auth-library": "^10.5.0", + "googleapis": "^144.0.0", "helmet": "^8.1.0", "jwks-rsa": "^3.2.0", "moment": "^2.30.1", @@ -9196,7 +9197,6 @@ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -9217,7 +9217,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -9567,6 +9566,155 @@ "node": ">=14" } }, + "node_modules/googleapis": { + "version": "144.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-144.0.0.tgz", + "integrity": "sha512-ELcWOXtJxjPX4vsKMh+7V+jZvgPwYMlEhQFiu2sa9Qmt5veX8nwXPksOWGGN6Zk4xCiLygUyaz7xGtcMO+Onxw==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis-common/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis-common/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis-common/node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/googleapis/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis/node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -10068,7 +10216,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -11959,7 +12106,6 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", - "optional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -14426,8 +14572,7 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/ts-api-utils": { "version": "2.1.0", @@ -15064,6 +15209,12 @@ "punycode": "^2.1.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -15179,8 +15330,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "optional": true + "license": "BSD-2-Clause" }, "node_modules/webpack": { "version": "5.104.1", @@ -15365,7 +15515,6 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", - "optional": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" diff --git a/package.json b/package.json index 17cfa2d..6c12692 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "csv-parse": "^6.1.0", "firebase-admin": "^13.7.0", "google-auth-library": "^10.5.0", + "googleapis": "^144.0.0", "helmet": "^8.1.0", "jwks-rsa": "^3.2.0", "moment": "^2.30.1", diff --git a/src/db/migrations/1775101486000-addGoogleCalendarIntegration.ts b/src/db/migrations/1775101486000-addGoogleCalendarIntegration.ts new file mode 100644 index 0000000..6948738 --- /dev/null +++ b/src/db/migrations/1775101486000-addGoogleCalendarIntegration.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddGoogleCalendarIntegration1775101486000 implements MigrationInterface { + name = 'AddGoogleCalendarIntegration1775101486000' + + public async up(queryRunner: QueryRunner): Promise { + // Add google_calendar_event_id column to appointment table + await queryRunner.query(` + ALTER TABLE "appointment" + ADD COLUMN "google_calendar_event_id" character varying + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove google_calendar_event_id column from appointment table + await queryRunner.query(` + ALTER TABLE "appointment" + DROP COLUMN "google_calendar_event_id" + `); + } +} diff --git a/src/i18n/de/translation.json b/src/i18n/de/translation.json index 0d90d7b..d506967 100644 --- a/src/i18n/de/translation.json +++ b/src/i18n/de/translation.json @@ -30,5 +30,12 @@ "forceUpdate": "Diese Version wird nicht mehr unterstützt. Bitte aktualisiere die App, um fortzufahren.", "optionalUpdate": "Eine neue Version der App ist verfügbar. Ein Update wird empfohlen.", "upToDate": "Deine App ist auf dem neuesten Stand." + }, + "appointment": { + "stateValue": { + "ANNOUNCED": "Angesagt", + "CONFIRMED": "Bestätigt", + "CANCELED": "Abgesagt" + } } } \ No newline at end of file diff --git a/src/i18n/fr/translation.json b/src/i18n/fr/translation.json index 75f20d7..e93ebf8 100644 --- a/src/i18n/fr/translation.json +++ b/src/i18n/fr/translation.json @@ -30,5 +30,12 @@ "forceUpdate": "Cette version n'est plus supportée. Veuillez mettre à jour l'application pour continuer.", "optionalUpdate": "Une nouvelle version de l’application est disponible. Une mise à jour est recommandée.", "upToDate": "Votre application est à jour." + }, + "appointment": { + "stateValue": { + "ANNOUNCED": "Annoncé", + "CONFIRMED": "Confirmé", + "CANCELED": "Annulé" + } } } \ No newline at end of file diff --git a/src/shared/services/google-calendar.service.ts b/src/shared/services/google-calendar.service.ts new file mode 100644 index 0000000..226eb9f --- /dev/null +++ b/src/shared/services/google-calendar.service.ts @@ -0,0 +1,240 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { google, Auth } from 'googleapis'; +import { School } from '../../training/school/domain/school.entity'; +import { Appointment } from '../../training/appointment/appointment.entity'; +import { I18nContext } from 'nestjs-i18n'; + +export interface OAuthTokens { + accessToken: string; + refreshToken: string; + tokenExpiry: Date; +} + +@Injectable() +export class GoogleCalendarService { + private readonly logger = new Logger(GoogleCalendarService.name); + private readonly clientId: string; + private readonly clientSecret: string; + private readonly apiUrl: string; + + constructor( + private configService: ConfigService + ) { + this.clientId = this.configService.get('GOOGLE_CLIENT_ID'); + this.clientSecret = this.configService.get('GOOGLE_CLIENT_SECRET'); + this.apiUrl = this.configService.get('API_URL'); + } + + getAuthUrl(schoolId: number): string { + const oauth2Client = new google.auth.OAuth2( + this.clientId, + this.clientSecret, + `${this.apiUrl}/schools/google-calendar/callback` + ); + + const authUrl = oauth2Client.generateAuthUrl({ + access_type: 'offline', + scope: ['https://www.googleapis.com/auth/calendar'], + state: JSON.stringify({ schoolId }), + prompt: 'consent' + }); + + return authUrl; + } + + async exchangeCodeForTokens(code: string): Promise { + const oauth2Client = new google.auth.OAuth2( + this.clientId, + this.clientSecret, + `${this.apiUrl}/schools/google-calendar/callback` + ); + + try { + const { tokens } = await oauth2Client.getToken(code); + + if (!tokens.access_token || !tokens.refresh_token) { + throw new Error('Failed to obtain access or refresh token'); + } + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + tokenExpiry: new Date(tokens.expiry_date || Date.now() + 3600000) + }; + } catch (error) { + this.logger.error(`Failed to exchange code for tokens:`, error); + throw error; + } + } + + async refreshAccessToken(refreshToken: string): Promise { + const oauth2Client = new google.auth.OAuth2( + this.clientId, + this.clientSecret + ); + + oauth2Client.setCredentials({ + refresh_token: refreshToken + }); + + try { + const { credentials } = await oauth2Client.refreshAccessToken(); + + return { + accessToken: credentials.access_token, + refreshToken: refreshToken, + tokenExpiry: new Date(credentials.expiry_date || Date.now() + 3600000) + }; + } catch (error) { + this.logger.error(`Failed to refresh access token:`, error); + throw error; + } + } + + async createEvent(appointment: Appointment, school: School): Promise { + if (!school.configuration?.googleCalendar) { + this.logger.debug(`Google Calendar not configured for school ${school.id}, skipping event creation`); + return null; + } + + try { + const authClient = await this.getAuthClient(school); + const calendar = google.calendar({ version: 'v3', auth: authClient }); + + const event = this.formatAppointmentAsEvent(appointment); + + const response = await calendar.events.insert({ + calendarId: school.configuration.googleCalendar.calendarId, + requestBody: event + }); + + this.logger.debug(`Created Google Calendar event ${response.data.id} for appointment ${appointment.id}`); + return response.data.id; + } catch (error) { + this.logger.error(`Failed to create Google Calendar event for appointment ${appointment.id}:`, error); + return null; + } + } + + async updateEvent(appointment: Appointment, school: School): Promise { + if (!school.configuration?.googleCalendar || !appointment.googleCalendarEventId) { + this.logger.debug(`Google Calendar not configured or no event ID for appointment ${appointment.id}, skipping update`); + return; + } + + try { + const authClient = await this.getAuthClient(school); + const calendar = google.calendar({ version: 'v3', auth: authClient }); + + const event = this.formatAppointmentAsEvent(appointment); + + await calendar.events.update({ + calendarId: school.configuration.googleCalendar.calendarId, + eventId: appointment.googleCalendarEventId, + requestBody: event + }); + + this.logger.debug(`Updated Google Calendar event ${appointment.googleCalendarEventId} for appointment ${appointment.id}`); + } catch (error) { + this.logger.error(`Failed to update Google Calendar event for appointment ${appointment.id}:`, error); + } + } + + async deleteEvent(appointment: Appointment, school: School): Promise { + if (!school.configuration?.googleCalendar || !appointment.googleCalendarEventId) { + this.logger.debug(`Google Calendar not configured or no event ID for appointment ${appointment.id}, skipping deletion`); + return; + } + + try { + const authClient = await this.getAuthClient(school); + const calendar = google.calendar({ version: 'v3', auth: authClient }); + + await calendar.events.delete({ + calendarId: school.configuration.googleCalendar.calendarId, + eventId: appointment.googleCalendarEventId + }); + + this.logger.debug(`Deleted Google Calendar event ${appointment.googleCalendarEventId} for appointment ${appointment.id}`); + } catch (error) { + this.logger.error(`Failed to delete Google Calendar event for appointment ${appointment.id}:`, error); + } + } + + async getAvailableCalendars(school: School): Promise> { + if (!school.configuration?.googleCalendar) { + throw new Error('Google Calendar not configured for this school'); + } + + try { + const authClient = await this.getAuthClient(school); + const calendar = google.calendar({ version: 'v3', auth: authClient }); + + const response = await calendar.calendarList.list(); + + return response.data.items?.map(cal => ({ + id: cal.id || '', + summary: cal.summary || '', + primary: cal.primary || false + })) || []; + } catch (error) { + this.logger.error(`Failed to fetch calendars for school ${school.id}:`, error); + throw error; + } + } + + + private async getAuthClient(school: School): Promise { + if (!school.configuration?.googleCalendar) { + throw new Error('Google Calendar not configured for this school'); + } + + const oauth2Client = new google.auth.OAuth2( + this.clientId, + this.clientSecret + ); + + oauth2Client.setCredentials({ + access_token: school.configuration.googleCalendar.accessToken, + refresh_token: school.configuration.googleCalendar.refreshToken + }); + + return oauth2Client; + } + + private formatAppointmentAsEvent(appointment: Appointment): any { + const i18n = I18nContext.current(); + + const startDateTime = new Date(appointment.scheduling); + const endDateTime = new Date(startDateTime.getTime() + 2 * 60 * 60 * 1000); + + const translatedState = i18n.t(`translation.appointment.stateValue.${appointment.state as any}`, { lang: appointment.school.language }); + + return { + summary: appointment.type? `${appointment.type.name} / ${translatedState}` : `Flightbook Appointment / ${translatedState}`, + location: appointment.meetingPoint || '', + description: appointment.description || '', + start: { + dateTime: startDateTime.toISOString(), + timeZone: 'Europe/Zurich' + }, + end: { + dateTime: endDateTime.toISOString(), + timeZone: 'Europe/Zurich' + } + }; + } + + isTokenExpired(tokenExpiry: Date): boolean { + if (!tokenExpiry) { + return true; + } + + const expiryDate = new Date(tokenExpiry); + const now = new Date(); + const bufferTime = 5 * 60 * 1000; + + return expiryDate.getTime() - now.getTime() < bufferTime; + } +} diff --git a/src/shared/shared.module.ts b/src/shared/shared.module.ts index 0b039f2..5280286 100644 --- a/src/shared/shared.module.ts +++ b/src/shared/shared.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { NotificationsService } from './services/notifications.service'; import { UserModule } from '../user/user.module'; +import { GoogleCalendarService } from './services/google-calendar.service'; @Module({ - imports: [UserModule], - providers: [NotificationsService], - exports: [NotificationsService], + imports: [UserModule, ConfigModule], + providers: [NotificationsService, GoogleCalendarService], + exports: [NotificationsService, GoogleCalendarService], }) export class SharedModule {} diff --git a/src/training/appointment/appointment.entity.ts b/src/training/appointment/appointment.entity.ts index e83523e..0319659 100644 --- a/src/training/appointment/appointment.entity.ts +++ b/src/training/appointment/appointment.entity.ts @@ -77,6 +77,9 @@ export class Appointment { @JoinColumn([{ name: "appointment_type_id", referencedColumnName: "id" }]) type: AppointmentType | null; + @Column("character varying", { name: "google_calendar_event_id", nullable: true }) + googleCalendarEventId: string | null; + findSubscription(email: string) { return this.subscriptions.find((subscription: Subscription) => subscription.user.email === email); } diff --git a/src/training/appointment/appointment.facade.ts b/src/training/appointment/appointment.facade.ts index 4f4aae9..f628d74 100644 --- a/src/training/appointment/appointment.facade.ts +++ b/src/training/appointment/appointment.facade.ts @@ -18,6 +18,8 @@ import { PagerEntityDto } from '../../interface/pager-entity-dto'; import { EmailService } from '../../email/email.service'; import { SchoolDto } from '../school/interface/school-dto'; import { NotificationsService } from '../../shared/services/notifications.service'; +import { GoogleCalendarService } from '../../shared/services/google-calendar.service'; +import { SchoolFacade } from '../school/school.facade'; import { StudentRepository } from '../student/student.repository'; import { SubscriptionDto } from '../subscription/interface/subscription-dto'; import { Student } from '../student/student.entity'; @@ -43,6 +45,8 @@ export class AppointmentFacade { private userRepository: UserRepository, private emailService: EmailService, private notificationsService: NotificationsService, + private googleCalendarService: GoogleCalendarService, + private schoolFacade: SchoolFacade, private flightRepository: FlightRepository ) { } @@ -70,6 +74,13 @@ export class AppointmentFacade { this.emailService.sendNewAppointment(students, appointment); this.notificationsService.sendNewAppointment(students, appointment); + // Sync to Google Calendar + try { + await this.createOrUpdateAppointmentInGoogleCalendar(appointmentResp); + } catch (error) { + Logger.error('Failed to sync appointment to Google Calendar', error); + } + return await this.generateWaitingList(AppointmentMapper.toAppointmentDto(appointmentResp), schoolId); } @@ -131,6 +142,14 @@ export class AppointmentFacade { } const appointmentResp: Appointment = await this.appointmentRepository.save(appointment); + + // Sync to Google Calendar + try { + await this.createOrUpdateAppointmentInGoogleCalendar(appointmentResp); + } catch (error) { + Logger.error('Failed to sync appointment to Google Calendar', error); + } + return await this.generateWaitingList(AppointmentMapper.toAppointmentDto(appointmentResp), schoolId); } @@ -323,4 +342,28 @@ export class AppointmentFacade { }); return appointmentDtoOrList; } + + private async createOrUpdateAppointmentInGoogleCalendar(appointment: Appointment): Promise { + if (!appointment.school.configuration?.googleCalendar) { + return; + } + + // Check if token needs refresh + if (this.googleCalendarService.isTokenExpired(appointment.school.configuration.googleCalendar.tokenExpiry)) { + await this.schoolFacade.refreshGoogleCalendarToken(appointment.school.id); + // Reload school after token refresh + appointment.school = await this.schoolRepository.getSchoolById(appointment.school.id); + } + + // If appointment already has a Google Calendar event ID, update it; otherwise create new + if (appointment.googleCalendarEventId) { + await this.googleCalendarService.updateEvent(appointment, appointment.school); + } else { + const eventId = await this.googleCalendarService.createEvent(appointment, appointment.school); + if (eventId) { + appointment.googleCalendarEventId = eventId; + await this.appointmentRepository.save(appointment); + } + } + } } diff --git a/src/training/controller/school.controller.ts b/src/training/controller/school.controller.ts index 9e55f36..356818e 100644 --- a/src/training/controller/school.controller.ts +++ b/src/training/controller/school.controller.ts @@ -1,4 +1,5 @@ -import { Body, Controller, Delete, Get, Headers, HttpCode, Param, Post, Put, Query, Request, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Headers, HttpCode, Param, Post, Put, Query, Request, Res, UseGuards } from '@nestjs/common'; +import { Response } from 'express'; import { SchoolDto } from '../school/interface/school-dto'; import { SchoolFacade } from '../school/school.facade'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; @@ -14,6 +15,8 @@ import { EnrollmentDto } from '../enrollment/interface/enrollment-dto'; import { FlightDto } from '../../flight/interface/flight-dto'; import { TandemSchoolDataDto } from '../../flight/interface/tandem-school-data-dto'; import { SchoolConfig } from '../school/domain/school-config'; +import { GoogleCalendarService } from '../../shared/services/google-calendar.service'; +import { ConfigService } from '@nestjs/config'; @Controller('schools') @ApiTags('School') @@ -23,7 +26,9 @@ export class SchoolController { constructor( private schoolFacade: SchoolFacade, private enrollmentFacade: EnrollmentFacade, - private tandemPilotFacade: TandemPilotFacade + private tandemPilotFacade: TandemPilotFacade, + private googleCalendarService: GoogleCalendarService, + private configService: ConfigService ) { } @UseGuards(CompositeAuthGuard) @@ -84,4 +89,57 @@ export class SchoolController { validateTandemPilotFlight(@Request() req, @Param('id') schoolId: number, @Param('tandemPilotId') tandemPilotId: number, @Param('flightId') flightId: number, @Body() tandemSchoolDataDto: TandemSchoolDataDto): Promise { return this.tandemPilotFacade.validateTandemPilotFlight(tandemPilotId, flightId, schoolId, req.user.userId, tandemSchoolDataDto); } + + // Google Calendar endpoints + @Get('google-calendar/callback') + async googleCalendarCallback( + @Query('code') code: string, + @Query('state') state: string, + @Query('error') error: string, + @Res() res: Response + ) { + const instructorAppUrl = this.configService.get('INSTRUCTOR_APP_URL'); + + if (error) { + return res.redirect(`${instructorAppUrl}/settings?google_calendar=error&message=${error}`); + } + + if (!code || !state) { + return res.redirect(`${instructorAppUrl}/settings?google_calendar=error&message=no_code_or_state`); + } + + try { + const { schoolId } = JSON.parse(state); + await this.schoolFacade.handleGoogleCalendarOAuthCallback(code, schoolId); + return res.redirect(`${instructorAppUrl}/settings?google_calendar=success`); + } catch (err) { + return res.redirect(`${instructorAppUrl}/settings?google_calendar=error&message=${err.message}`); + } + } + + @UseGuards(CompositeAuthGuard, SchoolGuard) + @Get(':id/google-calendar/status') + async getGoogleCalendarStatus(@Param('id') id: number): Promise<{ connected: boolean }> { + return this.schoolFacade.getGoogleCalendarStatus(id); + } + + @UseGuards(CompositeAuthGuard, SchoolGuard) + @Delete(':id/google-calendar/disconnect') + @HttpCode(204) + async disconnectGoogleCalendar(@Param('id') id: number): Promise { + await this.schoolFacade.disconnectGoogleCalendar(id); + } + + @UseGuards(CompositeAuthGuard, SchoolGuard) + @Get(':id/google-calendar/calendars') + async getAvailableCalendars(@Param('id') id: number): Promise> { + return this.schoolFacade.getAvailableGoogleCalendars(id); + } + + @UseGuards(CompositeAuthGuard, SchoolGuard) + @Put(':id/google-calendar/calendar') + @HttpCode(204) + async updateCalendarId(@Param('id') id: number, @Body() body: { calendarId: string }): Promise { + await this.schoolFacade.updateGoogleCalendarId(id, body.calendarId); + } } diff --git a/src/training/school/domain/school-config.ts b/src/training/school/domain/school-config.ts index b33ea49..2690343 100644 --- a/src/training/school/domain/school-config.ts +++ b/src/training/school/domain/school-config.ts @@ -1,6 +1,26 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Expose, Type } from "class-transformer"; -import { IsBoolean, IsOptional, IsString, ValidateNested } from "class-validator"; +import { IsBoolean, IsDate, IsOptional, IsString, ValidateNested } from "class-validator"; + +@Expose() +export class GoogleCalendarConfig { + @ApiProperty() + @IsString() + accessToken: string; + + @ApiProperty() + @IsString() + refreshToken: string; + + @ApiProperty() + @IsString() + calendarId: string; + + @ApiProperty() + @IsDate() + @Type(() => Date) + tokenExpiry: Date; +} @Expose() export class TandemModuleDto { @@ -43,4 +63,10 @@ export class SchoolConfig { @ValidateNested() @Type(() => TandemModuleDto) tandemModule: TandemModuleDto; + + @ApiPropertyOptional() + @IsOptional() + @ValidateNested() + @Type(() => GoogleCalendarConfig) + googleCalendar?: GoogleCalendarConfig; } diff --git a/src/training/school/school.facade.ts b/src/training/school/school.facade.ts index 9450093..d684218 100644 --- a/src/training/school/school.facade.ts +++ b/src/training/school/school.facade.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { plainToClass, plainToInstance } from 'class-transformer'; import { User } from '../../user/domain/user.entity'; import { UserRepository } from '../../user/user.repository'; @@ -7,14 +7,17 @@ import { SchoolDto } from './interface/school-dto'; import { School } from './domain/school.entity'; import { SchoolRepository } from './school.repository'; import {SchoolException} from "./exception/school.exception"; -import { SchoolConfig } from './domain/school-config'; +import { SchoolConfig, GoogleCalendarConfig } from './domain/school-config'; +import { GoogleCalendarService, OAuthTokens } from '../../shared/services/google-calendar.service'; @Injectable() export class SchoolFacade { + private readonly logger = new Logger(SchoolFacade.name); constructor( private schoolRepository: SchoolRepository, - private userRepository: UserRepository) { } + private userRepository: UserRepository, + private googleCalendarService: GoogleCalendarService) { } async createSchool(token: any, schoolDto: SchoolDto): Promise { // Check name, address1, plz, city, phone, email @@ -97,4 +100,125 @@ export class SchoolFacade { const school: School = await this.schoolRepository.getSchoolById(id); return plainToClass(SchoolDto, school); } + + async getGoogleCalendarStatus(id: number): Promise<{ connected: boolean }> { + const school: School = await this.schoolRepository.getSchoolById(id); + + if (!school) { + throw SchoolException.notFoundException(); + } + + const connected = !!(school.configuration?.googleCalendar?.accessToken && school.configuration?.googleCalendar?.refreshToken); + return { connected }; + } + + async disconnectGoogleCalendar(id: number): Promise { + const school: School = await this.schoolRepository.getSchoolById(id); + + if (!school) { + throw SchoolException.notFoundException(); + } + + if (school.configuration) { + school.configuration.googleCalendar = null; + await this.schoolRepository.save(school); + } + } + + async handleGoogleCalendarOAuthCallback(code: string, schoolId: number): Promise { + const school: School = await this.schoolRepository.getSchoolById(schoolId); + + if (!school) { + throw SchoolException.notFoundException(); + } + + try { + const tokens: OAuthTokens = await this.googleCalendarService.exchangeCodeForTokens(code); + + if (!school.configuration) { + school.configuration = { schoolModule: null, tandemModule: null }; + } + + const googleCalendarConfig: GoogleCalendarConfig = { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + calendarId: 'primary', + tokenExpiry: tokens.tokenExpiry + }; + + school.configuration.googleCalendar = googleCalendarConfig; + await this.schoolRepository.save(school); + + this.logger.debug(`Google Calendar connected for school ${schoolId}`); + } catch (error) { + this.logger.error(`Failed to handle OAuth callback for school ${schoolId}:`, error); + throw error; + } + } + + async refreshGoogleCalendarToken(schoolId: number): Promise { + const school: School = await this.schoolRepository.getSchoolById(schoolId); + + if (!school) { + throw SchoolException.notFoundException(); + } + + if (!school.configuration?.googleCalendar) { + throw new Error('Google Calendar not configured for this school'); + } + + try { + const tokens: OAuthTokens = await this.googleCalendarService.refreshAccessToken( + school.configuration.googleCalendar.refreshToken + ); + + school.configuration.googleCalendar.accessToken = tokens.accessToken; + school.configuration.googleCalendar.tokenExpiry = tokens.tokenExpiry; + + await this.schoolRepository.save(school); + + this.logger.debug(`Access token refreshed for school ${schoolId}`); + } catch (error) { + this.logger.error(`Failed to refresh access token for school ${schoolId}:`, error); + throw error; + } + } + + async getAvailableGoogleCalendars(schoolId: number): Promise> { + const school: School = await this.schoolRepository.getSchoolById(schoolId); + + if (!school) { + throw SchoolException.notFoundException(); + } + + if (!school.configuration?.googleCalendar) { + throw new Error('Google Calendar not configured for this school'); + } + + // Check if token needs refresh + if (this.googleCalendarService.isTokenExpired(school.configuration.googleCalendar.tokenExpiry)) { + await this.refreshGoogleCalendarToken(schoolId); + // Reload school after token refresh + const refreshedSchool = await this.schoolRepository.getSchoolById(schoolId); + return this.googleCalendarService.getAvailableCalendars(refreshedSchool); + } + + return this.googleCalendarService.getAvailableCalendars(school); + } + + async updateGoogleCalendarId(schoolId: number, calendarId: string): Promise { + const school: School = await this.schoolRepository.getSchoolById(schoolId); + + if (!school) { + throw SchoolException.notFoundException(); + } + + if (!school.configuration?.googleCalendar) { + throw new Error('Google Calendar not configured for this school'); + } + + school.configuration.googleCalendar.calendarId = calendarId; + await this.schoolRepository.save(school); + this.logger.debug(`Calendar ID updated to ${calendarId} for school ${schoolId}`); + } } diff --git a/src/training/school/school.module.ts b/src/training/school/school.module.ts index cad3431..0ce4588 100644 --- a/src/training/school/school.module.ts +++ b/src/training/school/school.module.ts @@ -5,10 +5,12 @@ import { UserModule } from '../../user/user.module'; import { School } from './domain/school.entity'; import { SchoolFacade } from './school.facade'; import { SchoolRepository } from './school.repository'; +import { SharedModule } from 'src/shared/shared.module'; @Module({ imports: [ - UserModule, + UserModule, + SharedModule, forwardRef(() => StudentModule), TypeOrmModule.forFeature([School]) ], diff --git a/src/training/training.module.ts b/src/training/training.module.ts index 64bbae7..0dcfec6 100644 --- a/src/training/training.module.ts +++ b/src/training/training.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppointmentModule } from './appointment/appointment.module'; import { ControlSheetModule } from './control-sheet/control-sheet.module'; @@ -15,9 +16,12 @@ import { NoteModule } from './note/note.module'; import { EmergencyContactModule } from './emergency-contact/emergency-contact.module'; import { TandemPilotModule } from './tandem-pilot/tandem-pilot.module'; import { TandemPilotController } from './controller/tandem-pilot.controller'; +import { SharedModule } from '../shared/shared.module'; @Module({ imports: [ + ConfigModule, + SharedModule, TypeOrmModule.forFeature([]), AppointmentModule, SubscriptionModule, From c3655d86f4d6e552731ce53942a61d21ca394f5f Mon Sep 17 00:00:00 2001 From: Yannick Lagger Date: Sat, 11 Apr 2026 23:40:24 +0200 Subject: [PATCH 2/7] Add school google calendar e2e tests --- .../school/exception/school.exception.ts | 6 +- src/training/school/school.facade.ts | 6 +- src/training/school/school.module.ts | 2 +- .../__snapshots__/school.e2e-spec.ts.snap | 20 ++ test/cases/school.e2e-spec.ts | 193 ++++++++++++++++++ test/mock-google-calendar.service.ts | 69 +++++++ test/setup.ts | 4 + 7 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 test/mock-google-calendar.service.ts diff --git a/src/training/school/exception/school.exception.ts b/src/training/school/exception/school.exception.ts index 0eeafc5..e5e34d7 100644 --- a/src/training/school/exception/school.exception.ts +++ b/src/training/school/exception/school.exception.ts @@ -1,4 +1,4 @@ -import {ConflictException, NotFoundException, UnprocessableEntityException} from "@nestjs/common"; +import {BadRequestException, ConflictException, NotFoundException, UnprocessableEntityException} from "@nestjs/common"; export class SchoolException { @@ -21,4 +21,8 @@ export class SchoolException { public static instructorNotFoundException() { throw new NotFoundException("The instructor was not found.") } + + public static googleCalendarNotConfiguredException() { + throw new BadRequestException("Google Calendar not configured for this school") + } } diff --git a/src/training/school/school.facade.ts b/src/training/school/school.facade.ts index d684218..2078562 100644 --- a/src/training/school/school.facade.ts +++ b/src/training/school/school.facade.ts @@ -164,7 +164,7 @@ export class SchoolFacade { } if (!school.configuration?.googleCalendar) { - throw new Error('Google Calendar not configured for this school'); + throw SchoolException.googleCalendarNotConfiguredException(); } try { @@ -192,7 +192,7 @@ export class SchoolFacade { } if (!school.configuration?.googleCalendar) { - throw new Error('Google Calendar not configured for this school'); + throw SchoolException.googleCalendarNotConfiguredException(); } // Check if token needs refresh @@ -214,7 +214,7 @@ export class SchoolFacade { } if (!school.configuration?.googleCalendar) { - throw new Error('Google Calendar not configured for this school'); + throw SchoolException.googleCalendarNotConfiguredException(); } school.configuration.googleCalendar.calendarId = calendarId; diff --git a/src/training/school/school.module.ts b/src/training/school/school.module.ts index 0ce4588..5c363e1 100644 --- a/src/training/school/school.module.ts +++ b/src/training/school/school.module.ts @@ -5,7 +5,7 @@ import { UserModule } from '../../user/user.module'; import { School } from './domain/school.entity'; import { SchoolFacade } from './school.facade'; import { SchoolRepository } from './school.repository'; -import { SharedModule } from 'src/shared/shared.module'; +import { SharedModule } from '../../shared/shared.module'; @Module({ imports: [ diff --git a/test/cases/__snapshots__/school.e2e-spec.ts.snap b/test/cases/__snapshots__/school.e2e-spec.ts.snap index 84cc320..1fbac74 100644 --- a/test/cases/__snapshots__/school.e2e-spec.ts.snap +++ b/test/cases/__snapshots__/school.e2e-spec.ts.snap @@ -46,6 +46,26 @@ exports[`Schools (e2e) /schools (POST) 2`] = ` } `; +exports[`Schools (e2e) /schools/:id/google-calendar/calendars (GET) - success 1`] = ` +[ + { + "id": "primary", + "primary": true, + "summary": "Primary Calendar", + }, + { + "id": "calendar-2", + "primary": false, + "summary": "Secondary Calendar", + }, + { + "id": "calendar-3", + "primary": false, + "summary": "Test Calendar", + }, +] +`; + exports[`Schools (e2e) /schools/configuration (PUT) 1`] = ` { "address1": "address", diff --git a/test/cases/school.e2e-spec.ts b/test/cases/school.e2e-spec.ts index 2cd7884..224327d 100644 --- a/test/cases/school.e2e-spec.ts +++ b/test/cases/school.e2e-spec.ts @@ -89,6 +89,199 @@ describe('Schools (e2e)', () => { await assertSchool(testInstance, response.body, plainToClass(SchoolDto, school)); }); }); + + it('/schools/:id/google-calendar/status (GET) - not connected', async () => { + // given + const school = Testdata.createSchool("School 1"); + await testInstance.schoolRepository.save(school); + const teamMember = Testdata.createTeamMember(school, await testInstance.getDefaultUser(), true); + await testInstance.teamMemberRepository.save(teamMember); + const keycloakToken = JwtTestHelper.createKeycloakToken(); + + //when + return request(testInstance.app.getHttpServer()) + .get(`/schools/${school.id}/google-calendar/status`) + .set('Authorization', `Bearer ${keycloakToken}`) + .expect(200) + .then(async (response) => { + expect(response.body).toEqual({ connected: false }); + }); + }); + + it('/schools/:id/google-calendar/status (GET) - connected', async () => { + // given + const school = Testdata.createSchool("School 1"); + school.configuration = { + ...school.configuration, + googleCalendar: { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + calendarId: 'primary', + tokenExpiry: new Date(Date.now() + 3600000) + } + }; + await testInstance.schoolRepository.save(school); + const teamMember = Testdata.createTeamMember(school, await testInstance.getDefaultUser(), true); + await testInstance.teamMemberRepository.save(teamMember); + const keycloakToken = JwtTestHelper.createKeycloakToken(); + + //when + return request(testInstance.app.getHttpServer()) + .get(`/schools/${school.id}/google-calendar/status`) + .set('Authorization', `Bearer ${keycloakToken}`) + .expect(200) + .then(async (response) => { + expect(response.body).toEqual({ connected: true }); + }); + }); + + it('/schools/:id/google-calendar/disconnect (DELETE) - disconnect connected calendar', async () => { + // given + const school = Testdata.createSchool("School 1"); + school.configuration = { + ...school.configuration, + googleCalendar: { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + calendarId: 'primary', + tokenExpiry: new Date(Date.now() + 3600000) + } + }; + await testInstance.schoolRepository.save(school); + const teamMember = Testdata.createTeamMember(school, await testInstance.getDefaultUser(), true); + await testInstance.teamMemberRepository.save(teamMember); + const keycloakToken = JwtTestHelper.createKeycloakToken(); + + //when + await request(testInstance.app.getHttpServer()) + .delete(`/schools/${school.id}/google-calendar/disconnect`) + .set('Authorization', `Bearer ${keycloakToken}`) + .expect(204); + + // then - verify calendar is disconnected + const updatedSchool = await testInstance.schoolRepository.findOne({ + where: { id: school.id } + }); + expect(updatedSchool.configuration.googleCalendar).toBeNull(); + }); + + it('/schools/:id/google-calendar/disconnect (DELETE) - disconnect when not connected', async () => { + // given + const school = Testdata.createSchool("School 1"); + await testInstance.schoolRepository.save(school); + const teamMember = Testdata.createTeamMember(school, await testInstance.getDefaultUser(), true); + await testInstance.teamMemberRepository.save(teamMember); + const keycloakToken = JwtTestHelper.createKeycloakToken(); + + //when + await request(testInstance.app.getHttpServer()) + .delete(`/schools/${school.id}/google-calendar/disconnect`) + .set('Authorization', `Bearer ${keycloakToken}`) + .expect(204); + + // then - verify school still exists and googleCalendar is set to null + const updatedSchool = await testInstance.schoolRepository.findOne({ + where: { id: school.id } + }); + expect(updatedSchool).toBeDefined(); + expect(updatedSchool.configuration.googleCalendar).toBeNull(); + }); + + it('/schools/:id/google-calendar/calendars (GET) - not configured', async () => { + // given + const school = Testdata.createSchool("School 1"); + await testInstance.schoolRepository.save(school); + const teamMember = Testdata.createTeamMember(school, await testInstance.getDefaultUser(), true); + await testInstance.teamMemberRepository.save(teamMember); + const keycloakToken = JwtTestHelper.createKeycloakToken(); + + //when + return request(testInstance.app.getHttpServer()) + .get(`/schools/${school.id}/google-calendar/calendars`) + .set('Authorization', `Bearer ${keycloakToken}`) + .expect(400) + .then(async (response) => { + expect(response.body.message).toContain('Google Calendar not configured'); + }); + }); + + it('/schools/:id/google-calendar/calendars (GET) - success', async () => { + // given + const school = Testdata.createSchool("School 1"); + school.configuration = { + ...school.configuration, + googleCalendar: { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + calendarId: 'primary', + tokenExpiry: new Date(Date.now() + 3600000) + } + }; + await testInstance.schoolRepository.save(school); + const teamMember = Testdata.createTeamMember(school, await testInstance.getDefaultUser(), true); + await testInstance.teamMemberRepository.save(teamMember); + const keycloakToken = JwtTestHelper.createKeycloakToken(); + + //when + return request(testInstance.app.getHttpServer()) + .get(`/schools/${school.id}/google-calendar/calendars`) + .set('Authorization', `Bearer ${keycloakToken}`) + .expect(200) + .then(async (response) => { + expect(response.body).toHaveLength(3); + expect(response.body).toMatchSnapshot(); + }); + }); + + it('/schools/:id/google-calendar/calendar (PUT) - not configured', async () => { + // given + const school = Testdata.createSchool("School 1"); + await testInstance.schoolRepository.save(school); + const teamMember = Testdata.createTeamMember(school, await testInstance.getDefaultUser(), true); + await testInstance.teamMemberRepository.save(teamMember); + const keycloakToken = JwtTestHelper.createKeycloakToken(); + + //when + return request(testInstance.app.getHttpServer()) + .put(`/schools/${school.id}/google-calendar/calendar`) + .set('Authorization', `Bearer ${keycloakToken}`) + .send({ calendarId: 'calendar-2' }) + .expect(400) + .then(async (response) => { + expect(response.body.message).toContain('Google Calendar not configured'); + }); + }); + + it('/schools/:id/google-calendar/calendar (PUT) - success', async () => { + // given + const school = Testdata.createSchool("School 1"); + school.configuration = { + ...school.configuration, + googleCalendar: { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + calendarId: 'primary', + tokenExpiry: new Date(Date.now() + 3600000) + } + }; + await testInstance.schoolRepository.save(school); + const teamMember = Testdata.createTeamMember(school, await testInstance.getDefaultUser(), true); + await testInstance.teamMemberRepository.save(teamMember); + const keycloakToken = JwtTestHelper.createKeycloakToken(); + + //when + await request(testInstance.app.getHttpServer()) + .put(`/schools/${school.id}/google-calendar/calendar`) + .set('Authorization', `Bearer ${keycloakToken}`) + .send({ calendarId: 'calendar-2' }) + .expect(204); + + // then - verify calendar ID was updated + const updatedSchool = await testInstance.schoolRepository.findOne({ + where: { id: school.id } + }); + expect(updatedSchool.configuration.googleCalendar.calendarId).toBe('calendar-2'); + }); }); describe('Schools tandem pilots (e2e)', () => { diff --git a/test/mock-google-calendar.service.ts b/test/mock-google-calendar.service.ts new file mode 100644 index 0000000..f625fa9 --- /dev/null +++ b/test/mock-google-calendar.service.ts @@ -0,0 +1,69 @@ +import { OAuthTokens } from '../src/shared/services/google-calendar.service'; +import { School } from '../src/training/school/domain/school.entity'; +import { Appointment } from '../src/training/appointment/appointment.entity'; + +export class MockGoogleCalendarService { + getAuthUrl(schoolId: number): string { + return `https://mock-google-auth-url.com?state=${schoolId}`; + } + + async exchangeCodeForTokens(code: string): Promise { + return { + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + tokenExpiry: new Date(Date.now() + 3600000) + }; + } + + async refreshAccessToken(refreshToken: string): Promise { + return { + accessToken: 'mock-new-access-token', + refreshToken: refreshToken, + tokenExpiry: new Date(Date.now() + 3600000) + }; + } + + async createEvent(appointment: Appointment, school: School): Promise { + return 'mock-event-id-' + appointment.id; + } + + async updateEvent(appointment: Appointment, school: School): Promise { + return; + } + + async deleteEvent(appointment: Appointment, school: School): Promise { + return; + } + + async getAvailableCalendars(school: School): Promise> { + return [ + { + id: 'primary', + summary: 'Primary Calendar', + primary: true + }, + { + id: 'calendar-2', + summary: 'Secondary Calendar', + primary: false + }, + { + id: 'calendar-3', + summary: 'Test Calendar', + primary: false + } + ]; + } + + isTokenExpired(tokenExpiry: Date): boolean { + if (!tokenExpiry) { + return true; + } + + const expiryDate = new Date(tokenExpiry); + const now = new Date(); + const bufferTime = 5 * 60 * 1000; + + return expiryDate.getTime() - now.getTime() < bufferTime; + } +} diff --git a/test/setup.ts b/test/setup.ts index 3582bb0..ed395fb 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -14,6 +14,8 @@ import { FlightRepository } from "../src/flight/flight.repository"; import { VERSION_NEUTRAL, VersioningType } from "@nestjs/common"; import { KeycloakService } from "../src/auth/service/keycloak.service"; import { MockKeycloakService } from "./mock-keycloak.service"; +import { GoogleCalendarService } from "../src/shared/services/google-calendar.service"; +import { MockGoogleCalendarService } from "./mock-google-calendar.service"; import { SchoolRepository } from "../src/training/school/school.repository"; import { TeamMemberRepository } from "../src/training/team-member/team-member.repository"; import { EnrollmentRepository } from "../src/training/enrollment/enrollment.repository"; @@ -70,6 +72,8 @@ const init = async () => { .useClass(MockKeycloakStrategy) .overrideProvider(KeycloakService) .useClass(MockKeycloakService) + .overrideProvider(GoogleCalendarService) + .useClass(MockGoogleCalendarService) .compile(); const app = moduleFixture.createNestApplication(); From b00d3843cc79ec8c66787f138da3596a8cddbb2f Mon Sep 17 00:00:00 2001 From: Yannick Lagger Date: Mon, 13 Apr 2026 19:43:15 +0200 Subject: [PATCH 3/7] Fix google callback uri + add e2e test --- src/training/controller/school.controller.ts | 23 +++++-- .../__snapshots__/school.e2e-spec.ts.snap | 9 +++ test/cases/school.e2e-spec.ts | 63 +++++++++++++++++++ test/setup.ts | 1 + 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/training/controller/school.controller.ts b/src/training/controller/school.controller.ts index 356818e..0c3d932 100644 --- a/src/training/controller/school.controller.ts +++ b/src/training/controller/school.controller.ts @@ -100,20 +100,33 @@ export class SchoolController { ) { const instructorAppUrl = this.configService.get('INSTRUCTOR_APP_URL'); + // Try to extract schoolId from state for all redirects + let schoolId: number | null = null; + if (state) { + try { + const parsedState = JSON.parse(state); + schoolId = parsedState.schoolId; + } catch (e) { + // Invalid state, continue without schoolId + } + } + if (error) { - return res.redirect(`${instructorAppUrl}/settings?google_calendar=error&message=${error}`); + const schoolParam = schoolId ? `&school=${schoolId}` : ''; + return res.redirect(`${instructorAppUrl}/configuration?google_calendar=error&message=${error}${schoolParam}`); } if (!code || !state) { - return res.redirect(`${instructorAppUrl}/settings?google_calendar=error&message=no_code_or_state`); + const schoolParam = schoolId ? `&school=${schoolId}` : ''; + return res.redirect(`${instructorAppUrl}/configuration?google_calendar=error&message=no_code_or_state${schoolParam}`); } try { - const { schoolId } = JSON.parse(state); await this.schoolFacade.handleGoogleCalendarOAuthCallback(code, schoolId); - return res.redirect(`${instructorAppUrl}/settings?google_calendar=success`); + return res.redirect(`${instructorAppUrl}/configuration?google_calendar=success&school=${schoolId}`); } catch (err) { - return res.redirect(`${instructorAppUrl}/settings?google_calendar=error&message=${err.message}`); + const schoolParam = schoolId ? `&school=${schoolId}` : ''; + return res.redirect(`${instructorAppUrl}/configuration?google_calendar=error&message=${err.message}${schoolParam}`); } } diff --git a/test/cases/__snapshots__/school.e2e-spec.ts.snap b/test/cases/__snapshots__/school.e2e-spec.ts.snap index 1fbac74..6217c8a 100644 --- a/test/cases/__snapshots__/school.e2e-spec.ts.snap +++ b/test/cases/__snapshots__/school.e2e-spec.ts.snap @@ -112,6 +112,15 @@ exports[`Schools (e2e) /schools/configuration (PUT) 2`] = ` } `; +exports[`Schools (e2e) /schools/google-calendar/callback (GET) - success 1`] = ` +{ + "accessToken": "mock-access-token", + "calendarId": "primary", + "refreshToken": "mock-refresh-token", + "tokenExpiry": Any, +} +`; + exports[`Schools tandem pilots (e2e) /schools/:id/tandem-pilots (GET) 1`] = ` [ { diff --git a/test/cases/school.e2e-spec.ts b/test/cases/school.e2e-spec.ts index 224327d..68e40ed 100644 --- a/test/cases/school.e2e-spec.ts +++ b/test/cases/school.e2e-spec.ts @@ -282,6 +282,69 @@ describe('Schools (e2e)', () => { }); expect(updatedSchool.configuration.googleCalendar.calendarId).toBe('calendar-2'); }); + + it('/schools/google-calendar/callback (GET) - with error', async () => { + //when + return request(testInstance.app.getHttpServer()) + .get('/schools/google-calendar/callback') + .query({ + error: 'access_denied', + state: JSON.stringify({ schoolId: 1 }) + }) + .expect(302) + .then(async (response) => { + expect(response.headers.location).toBe('https://instructor.test.com/configuration?google_calendar=error&message=access_denied&school=1'); + }); + }); + + it('/schools/google-calendar/callback (GET) - missing code', async () => { + //when + return request(testInstance.app.getHttpServer()) + .get('/schools/google-calendar/callback') + .query({ state: JSON.stringify({ schoolId: 1 }) }) + .expect(302) + .then(async (response) => { + expect(response.headers.location).toBe('https://instructor.test.com/configuration?google_calendar=error&message=no_code_or_state&school=1'); + }); + }); + + it('/schools/google-calendar/callback (GET) - missing state', async () => { + //when + return request(testInstance.app.getHttpServer()) + .get('/schools/google-calendar/callback') + .query({ code: 'test-code' }) + .expect(302) + .then(async (response) => { + expect(response.headers.location).toBe('https://instructor.test.com/configuration?google_calendar=error&message=no_code_or_state'); + }); + }); + + it('/schools/google-calendar/callback (GET) - success', async () => { + // given + const school = Testdata.createSchool("School 1"); + await testInstance.schoolRepository.save(school); + + //when + await request(testInstance.app.getHttpServer()) + .get('/schools/google-calendar/callback') + .query({ + code: 'test-authorization-code', + state: JSON.stringify({ schoolId: school.id }) + }) + .expect(302) + .then(async (response) => { + expect(response.headers.location).toBe(`https://instructor.test.com/configuration?google_calendar=success&school=${school.id}`); + }); + + // then - verify Google Calendar was configured + const updatedSchool = await testInstance.schoolRepository.findOne({ + where: { id: school.id } + }); + expect(updatedSchool.configuration.googleCalendar).toBeDefined(); + expect(updatedSchool.configuration.googleCalendar).toMatchSnapshot({ + tokenExpiry: expect.any(String) + }); + }); }); describe('Schools tandem pilots (e2e)', () => { diff --git a/test/setup.ts b/test/setup.ts index ed395fb..5384c61 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -38,6 +38,7 @@ const init = async () => { process.env.STRIPE_SECRET_KEY = 'test'; process.env.ORIGIN_INSTRUCTOR = Testdata.INSTRUCTOR_APP_ORIGIN; process.env.ORIGIN_MOBILE = Testdata.MOBILE_APP_ORIGIN; + process.env.INSTRUCTOR_APP_URL = 'https://instructor.test.com'; process.env.ANDROID_MINIMAL_VERSION_BUILD = '1'; process.env.IOS_MINIMAL_VERSION_BUILD = '1'; process.env.ANDROID_LATEST_BUILD = '2'; From afed38584e95805a5f30ffc08da53678921adf87 Mon Sep 17 00:00:00 2001 From: Yannick Lagger Date: Mon, 13 Apr 2026 20:07:13 +0200 Subject: [PATCH 4/7] Add google calendar auth url --- src/training/controller/school.controller.ts | 6 ++++++ src/training/school/school.facade.ts | 11 +++++++++++ test/cases/school.e2e-spec.ts | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/src/training/controller/school.controller.ts b/src/training/controller/school.controller.ts index 0c3d932..bab8d44 100644 --- a/src/training/controller/school.controller.ts +++ b/src/training/controller/school.controller.ts @@ -136,6 +136,12 @@ export class SchoolController { return this.schoolFacade.getGoogleCalendarStatus(id); } + @UseGuards(CompositeAuthGuard, SchoolGuard) + @Get(':id/google-calendar/auth') + async getGoogleCalendarAuthUrl(@Param('id') id: number): Promise<{ authUrl: string }> { + return this.schoolFacade.getGoogleCalendarAuthUrl(id); + } + @UseGuards(CompositeAuthGuard, SchoolGuard) @Delete(':id/google-calendar/disconnect') @HttpCode(204) diff --git a/src/training/school/school.facade.ts b/src/training/school/school.facade.ts index 2078562..31d8289 100644 --- a/src/training/school/school.facade.ts +++ b/src/training/school/school.facade.ts @@ -112,6 +112,17 @@ export class SchoolFacade { return { connected }; } + async getGoogleCalendarAuthUrl(id: number): Promise<{ authUrl: string }> { + const school: School = await this.schoolRepository.getSchoolById(id); + + if (!school) { + throw SchoolException.notFoundException(); + } + + const authUrl = this.googleCalendarService.getAuthUrl(id); + return { authUrl }; + } + async disconnectGoogleCalendar(id: number): Promise { const school: School = await this.schoolRepository.getSchoolById(id); diff --git a/test/cases/school.e2e-spec.ts b/test/cases/school.e2e-spec.ts index 68e40ed..d257ded 100644 --- a/test/cases/school.e2e-spec.ts +++ b/test/cases/school.e2e-spec.ts @@ -135,6 +135,25 @@ describe('Schools (e2e)', () => { }); }); + it('/schools/:id/google-calendar/auth (GET) - get auth URL', async () => { + // given + const school = Testdata.createSchool("School 1"); + await testInstance.schoolRepository.save(school); + const teamMember = Testdata.createTeamMember(school, await testInstance.getDefaultUser(), true); + await testInstance.teamMemberRepository.save(teamMember); + const keycloakToken = JwtTestHelper.createKeycloakToken(); + + //when + return request(testInstance.app.getHttpServer()) + .get(`/schools/${school.id}/google-calendar/auth`) + .set('Authorization', `Bearer ${keycloakToken}`) + .expect(200) + .then(async (response) => { + expect(response.body.authUrl).toBeDefined(); + expect(response.body.authUrl).toBe(`https://mock-google-auth-url.com?state=${school.id}`); + }); + }); + it('/schools/:id/google-calendar/disconnect (DELETE) - disconnect connected calendar', async () => { // given const school = Testdata.createSchool("School 1"); From 4ba3cb0cb7db3ae0b3e2873010da865589960150 Mon Sep 17 00:00:00 2001 From: Yannick Lagger Date: Tue, 14 Apr 2026 21:23:52 +0200 Subject: [PATCH 5/7] Fix security issue --- GOOGLE_CALENDAR_SETUP.md | 124 -------------------- src/training/school/domain/school-config.ts | 4 +- 2 files changed, 2 insertions(+), 126 deletions(-) delete mode 100644 GOOGLE_CALENDAR_SETUP.md diff --git a/GOOGLE_CALENDAR_SETUP.md b/GOOGLE_CALENDAR_SETUP.md deleted file mode 100644 index ea9a744..0000000 --- a/GOOGLE_CALENDAR_SETUP.md +++ /dev/null @@ -1,124 +0,0 @@ -# Google Calendar Integration Setup - -This document describes how to set up the Google Calendar integration for Flightbook. - -## Google Cloud Console Setup - -### 1. Create OAuth 2.0 Credentials - -1. Go to [Google Cloud Console](https://console.cloud.google.com/) -2. Create a new project or select an existing one -3. Enable the **Google Calendar API**: - - Navigate to "APIs & Services" → "Library" - - Search for "Google Calendar API" - - Click "Enable" - -4. Create OAuth 2.0 credentials: - - Go to "APIs & Services" → "Credentials" - - Click "Create Credentials" → "OAuth 2.0 Client ID" - - Choose "Web application" as application type - - Configure the OAuth consent screen if prompted - -5. Add authorized redirect URIs: - - Add your backend callback URL: `https://api.flightbook.com/schools/google-calendar/callback` - - Note: We use a single redirect URI for all schools. The school ID is passed via the OAuth `state` parameter - -6. Copy the **Client ID** and **Client Secret** - -## Backend Environment Variables - -Add the following environment variables to your `.env` file: - -```bash -# Google OAuth Credentials (from Google Cloud Console) -GOOGLE_CLIENT_ID=your-client-id-here.apps.googleusercontent.com -GOOGLE_CLIENT_SECRET=your-client-secret-here - -# Backend API URL (where OAuth callbacks are received) -API_URL=https://api.flightbook.com - -# Frontend URL (where users are redirected after OAuth) -INSTRUCTOR_APP_URL=https://instructor-app.flightbook.com -``` - -### Variable Descriptions - -- **GOOGLE_CLIENT_ID**: Your Google OAuth 2.0 client ID (public, can be shared with frontend) -- **GOOGLE_CLIENT_SECRET**: Your Google OAuth 2.0 client secret (keep secure, backend only) -- **API_URL**: The base URL of your backend API -- **INSTRUCTOR_APP_URL**: The URL of your Instructor-App frontend - -## Frontend Environment Variables - -Add the following to your frontend environment configuration: - -**File**: `/Flightbook-InstructorApp/src/environments/environment.ts` - -```typescript -export const environment = { - production: false, - baseUrl: 'http://localhost:8282', - googleClientId: 'your-client-id-here.apps.googleusercontent.com' -}; -``` - -**File**: `/Flightbook-InstructorApp/src/environments/environment.prod.ts` - -```typescript -export const environment = { - production: true, - baseUrl: 'https://api.flightbook.com', - googleClientId: 'your-client-id-here.apps.googleusercontent.com' -}; -``` - -## OAuth Flow - -1. User clicks "Connect Google Calendar" in school settings -2. Frontend redirects to Google OAuth URL with backend callback -3. User authenticates and grants calendar permissions -4. Google redirects to backend: `GET /schools/:id/google-calendar/callback?code=AUTH_CODE` -5. Backend exchanges code for tokens and stores them encrypted -6. Backend redirects to frontend: `https://instructor-app.com/settings?google_calendar=success` -7. Frontend detects success and updates UI - -## Database Migration - -Run the migration to add the `google_calendar_event_id` column: - -```bash -npm run migration:run -``` - -## Testing - -After setup, test the integration: - -1. Log in to Instructor-App as a school admin -2. Navigate to School Settings -3. Click "Connect Google Calendar" -4. Authenticate with Google -5. Verify connection status shows "Connected" -6. Create an appointment and check it appears in Google Calendar - -## Security Notes - -- OAuth tokens are stored encrypted in the database -- `GOOGLE_CLIENT_SECRET` must never be exposed to the frontend -- Each school authenticates with their own Google account -- Tokens are automatically refreshed before expiry - -## Troubleshooting - -### "Redirect URI mismatch" error -- Ensure the redirect URI in Google Cloud Console exactly matches: `{API_URL}/schools/google-calendar/callback` -- Check for trailing slashes - -### "Invalid client" error -- Verify `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are correct -- Ensure the OAuth consent screen is configured - -### Appointments not syncing -- Check backend logs for errors -- Verify the school has a valid Google Calendar connection -- Ensure tokens haven't expired (they auto-refresh) diff --git a/src/training/school/domain/school-config.ts b/src/training/school/domain/school-config.ts index 2690343..11dfded 100644 --- a/src/training/school/domain/school-config.ts +++ b/src/training/school/domain/school-config.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { Expose, Type } from "class-transformer"; +import { Exclude, Expose, Type } from "class-transformer"; import { IsBoolean, IsDate, IsOptional, IsString, ValidateNested } from "class-validator"; @Expose() @@ -64,7 +64,7 @@ export class SchoolConfig { @Type(() => TandemModuleDto) tandemModule: TandemModuleDto; - @ApiPropertyOptional() + @Exclude() @IsOptional() @ValidateNested() @Type(() => GoogleCalendarConfig) From 7c1256ebfbdb67ea8171b308fd2239804ac07a06 Mon Sep 17 00:00:00 2001 From: Yannick Lagger Date: Sun, 19 Apr 2026 21:57:42 +0200 Subject: [PATCH 6/7] Fix google calendar scope + improve exception handling --- .../services/google-calendar.service.ts | 18 +++++++------ src/training/school/domain/school-config.ts | 7 +++++- .../school/exception/school.exception.ts | 4 +++ src/training/school/school.facade.ts | 25 +++++++++++-------- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/shared/services/google-calendar.service.ts b/src/shared/services/google-calendar.service.ts index 226eb9f..7299dc4 100644 --- a/src/shared/services/google-calendar.service.ts +++ b/src/shared/services/google-calendar.service.ts @@ -35,7 +35,7 @@ export class GoogleCalendarService { const authUrl = oauth2Client.generateAuthUrl({ access_type: 'offline', - scope: ['https://www.googleapis.com/auth/calendar'], + scope: ['https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/calendar.readonly'], state: JSON.stringify({ schoolId }), prompt: 'consent' }); @@ -173,11 +173,13 @@ export class GoogleCalendarService { const response = await calendar.calendarList.list(); - return response.data.items?.map(cal => ({ - id: cal.id || '', - summary: cal.summary || '', - primary: cal.primary || false - })) || []; + return response.data.items + ?.filter(cal => cal.accessRole === 'owner' || cal.accessRole === 'writer') + .map(cal => ({ + id: cal.id || '', + summary: cal.summary || '', + primary: cal.primary || false + })) || []; } catch (error) { this.logger.error(`Failed to fetch calendars for school ${school.id}:`, error); throw error; @@ -222,7 +224,9 @@ export class GoogleCalendarService { end: { dateTime: endDateTime.toISOString(), timeZone: 'Europe/Zurich' - } + }, + guestsCanModify: false, + guestsCanInviteOthers: false, }; } diff --git a/src/training/school/domain/school-config.ts b/src/training/school/domain/school-config.ts index 11dfded..25fe6ac 100644 --- a/src/training/school/domain/school-config.ts +++ b/src/training/school/domain/school-config.ts @@ -4,18 +4,22 @@ import { IsBoolean, IsDate, IsOptional, IsString, ValidateNested } from "class-v @Expose() export class GoogleCalendarConfig { + @Exclude() @ApiProperty() @IsString() accessToken: string; + @Exclude() @ApiProperty() @IsString() refreshToken: string; + @Expose() @ApiProperty() @IsString() calendarId: string; + @Exclude() @ApiProperty() @IsDate() @Type(() => Date) @@ -64,7 +68,8 @@ export class SchoolConfig { @Type(() => TandemModuleDto) tandemModule: TandemModuleDto; - @Exclude() + @Expose() + @ApiPropertyOptional() @IsOptional() @ValidateNested() @Type(() => GoogleCalendarConfig) diff --git a/src/training/school/exception/school.exception.ts b/src/training/school/exception/school.exception.ts index e5e34d7..72f32aa 100644 --- a/src/training/school/exception/school.exception.ts +++ b/src/training/school/exception/school.exception.ts @@ -25,4 +25,8 @@ export class SchoolException { public static googleCalendarNotConfiguredException() { throw new BadRequestException("Google Calendar not configured for this school") } + + public static googleCalendarTokenExpiredException() { + throw new UnprocessableEntityException("GOOGLE_CALENDAR_TOKEN_EXPIRED") + } } diff --git a/src/training/school/school.facade.ts b/src/training/school/school.facade.ts index 31d8289..5fd75b0 100644 --- a/src/training/school/school.facade.ts +++ b/src/training/school/school.facade.ts @@ -190,8 +190,8 @@ export class SchoolFacade { this.logger.debug(`Access token refreshed for school ${schoolId}`); } catch (error) { - this.logger.error(`Failed to refresh access token for school ${schoolId}:`, error); - throw error; + this.logger.debug(`Failed to refresh access token for school ${schoolId}:`, error); + throw SchoolException.googleCalendarTokenExpiredException(); } } @@ -206,15 +206,20 @@ export class SchoolFacade { throw SchoolException.googleCalendarNotConfiguredException(); } - // Check if token needs refresh - if (this.googleCalendarService.isTokenExpired(school.configuration.googleCalendar.tokenExpiry)) { - await this.refreshGoogleCalendarToken(schoolId); - // Reload school after token refresh - const refreshedSchool = await this.schoolRepository.getSchoolById(schoolId); - return this.googleCalendarService.getAvailableCalendars(refreshedSchool); - } + try { + // Check if token needs refresh + if (this.googleCalendarService.isTokenExpired(school.configuration.googleCalendar.tokenExpiry)) { + await this.refreshGoogleCalendarToken(schoolId); + // Reload school after token refresh + const refreshedSchool = await this.schoolRepository.getSchoolById(schoolId); + return await this.googleCalendarService.getAvailableCalendars(refreshedSchool); + } - return this.googleCalendarService.getAvailableCalendars(school); + return await this.googleCalendarService.getAvailableCalendars(school); + } catch (error) { + this.logger.debug(`Failed to get calendars for school ${schoolId}:`, error); + throw SchoolException.googleCalendarTokenExpiredException(); + } } async updateGoogleCalendarId(schoolId: number, calendarId: string): Promise { From 6bf8306c29f4607f9d7b0cfad2a23be6bfc04d4f Mon Sep 17 00:00:00 2001 From: Yannick Lagger Date: Mon, 20 Apr 2026 21:24:36 +0200 Subject: [PATCH 7/7] Improve google calendar appointment formatter --- src/i18n/de/translation.json | 8 +- src/i18n/fr/translation.json | 8 +- .../services/google-calendar.service.ts | 147 +++++++++++++++++- 3 files changed, 154 insertions(+), 9 deletions(-) diff --git a/src/i18n/de/translation.json b/src/i18n/de/translation.json index d506967..0c5627d 100644 --- a/src/i18n/de/translation.json +++ b/src/i18n/de/translation.json @@ -36,6 +36,12 @@ "ANNOUNCED": "Angesagt", "CONFIRMED": "Bestätigt", "CANCELED": "Abgesagt" - } + }, + "state": "Status", + "meetingPoint": "Treffpunkt", + "maxPeople": "Max Teilnehmer*innen", + "instructor": "Fluglehrer*in", + "takeOffCoordinator": "Starthelfer*in", + "description": "Beschreibung" } } \ No newline at end of file diff --git a/src/i18n/fr/translation.json b/src/i18n/fr/translation.json index e93ebf8..e6f76b1 100644 --- a/src/i18n/fr/translation.json +++ b/src/i18n/fr/translation.json @@ -36,6 +36,12 @@ "ANNOUNCED": "Annoncé", "CONFIRMED": "Confirmé", "CANCELED": "Annulé" - } + }, + "state": "Statut", + "meetingPoint": "Point de rencontre", + "maxPeople": "Max participants", + "instructor": "Instructeur", + "takeOffCoordinator": "Aide au décollage", + "description": "Description" } } \ No newline at end of file diff --git a/src/shared/services/google-calendar.service.ts b/src/shared/services/google-calendar.service.ts index 7299dc4..f1164bf 100644 --- a/src/shared/services/google-calendar.service.ts +++ b/src/shared/services/google-calendar.service.ts @@ -4,6 +4,7 @@ import { google, Auth } from 'googleapis'; import { School } from '../../training/school/domain/school.entity'; import { Appointment } from '../../training/appointment/appointment.entity'; import { I18nContext } from 'nestjs-i18n'; +import * as moment from 'moment'; export interface OAuthTokens { accessToken: string; @@ -52,7 +53,7 @@ export class GoogleCalendarService { try { const { tokens } = await oauth2Client.getToken(code); - + if (!tokens.access_token || !tokens.refresh_token) { throw new Error('Failed to obtain access or refresh token'); } @@ -80,7 +81,7 @@ export class GoogleCalendarService { try { const { credentials } = await oauth2Client.refreshAccessToken(); - + return { accessToken: credentials.access_token, refreshToken: refreshToken, @@ -172,7 +173,7 @@ export class GoogleCalendarService { const calendar = google.calendar({ version: 'v3', auth: authClient }); const response = await calendar.calendarList.list(); - + return response.data.items ?.filter(cal => cal.accessRole === 'owner' || cal.accessRole === 'writer') .map(cal => ({ @@ -213,18 +214,73 @@ export class GoogleCalendarService { const translatedState = i18n.t(`translation.appointment.stateValue.${appointment.state as any}`, { lang: appointment.school.language }); + // Build description with all fields + const descriptionParts: string[] = []; + + // Status + const statusLabel = i18n.t('translation.appointment.state', { lang: appointment.school.language }); + descriptionParts.push(`${statusLabel}: ${translatedState}`); + + // Meeting Point with date/time + const meetingPointLabel = i18n.t('translation.appointment.meetingPoint', { lang: appointment.school.language }); + const formattedDateTime = moment.utc(startDateTime).format('DD.MM.YYYY HH:mm'); + descriptionParts.push(`${meetingPointLabel}: ${formattedDateTime} ${appointment.meetingPoint || ''}`); + + // Max People + if (appointment.maxPeople) { + const maxPeopleLabel = i18n.t('translation.appointment.maxPeople', { lang: appointment.school.language }); + descriptionParts.push(`${maxPeopleLabel}: ${appointment.maxPeople}`); + } + + // Instructor + if (appointment.instructor) { + const instructorLabel = i18n.t('translation.appointment.instructor', { lang: appointment.school.language }); + const instructorName = `${appointment.instructor.firstname} ${appointment.instructor.lastname}`; + descriptionParts.push(`${instructorLabel}: ${instructorName}`); + } + + // Take Off Coordinator + if (appointment.takeOffCoordinatorText) { + const takeOffCoordinatorLabel = i18n.t('translation.appointment.takeOffCoordinator', { lang: appointment.school.language }); + descriptionParts.push(`${takeOffCoordinatorLabel}: ${appointment.takeOffCoordinatorText}`); + } + + // Description + if (appointment.description) { + const descriptionLabel = i18n.t('translation.appointment.description', { lang: appointment.school.language }); + descriptionParts.push(`${descriptionLabel}: ${appointment.description}`); + } + + const description = descriptionParts.join('
'); + + // Determine color from appointment type + const colorId = appointment.type?.color + ? this.mapColorToGoogleCalendarId(appointment.type.color) + : '9'; // Default Blueberry + return { - summary: appointment.type? `${appointment.type.name} / ${translatedState}` : `Flightbook Appointment / ${translatedState}`, + summary: appointment.type ? `${appointment.type.name} / ${translatedState}` : `Flightbook Appointment / ${translatedState}`, location: appointment.meetingPoint || '', - description: appointment.description || '', + description: description, + // Full day event + colorId: colorId, start: { - dateTime: startDateTime.toISOString(), + date: moment(startDateTime).format('YYYY-MM-DD'), timeZone: 'Europe/Zurich' }, end: { - dateTime: endDateTime.toISOString(), + date: moment(endDateTime).format('YYYY-MM-DD'), timeZone: 'Europe/Zurich' }, + // For time-based events, use: + // start: { + // dateTime: startDateTime.toISOString(), + // timeZone: 'Europe/Zurich' + // }, + // end: { + // dateTime: endDateTime.toISOString(), + // timeZone: 'Europe/Zurich' + // }, guestsCanModify: false, guestsCanInviteOthers: false, }; @@ -241,4 +297,81 @@ export class GoogleCalendarService { return expiryDate.getTime() - now.getTime() < bufferTime; } + + // Add this helper method to map custom colors to Google Calendar color IDs + private mapColorToGoogleCalendarId(color: string): string { + // Google Calendar color palette with their RGB values + const googleColors: { id: string; rgb: [number, number, number] }[] = [ + { id: '1', rgb: [164, 189, 252] }, // Lavender + { id: '2', rgb: [122, 231, 191] }, // Sage + { id: '3', rgb: [219, 173, 255] }, // Grape + { id: '4', rgb: [255, 136, 124] }, // Flamingo + { id: '5', rgb: [251, 215, 91] }, // Banana + { id: '6', rgb: [255, 184, 120] }, // Tangerine + { id: '7', rgb: [70, 214, 219] }, // Peacock + { id: '8', rgb: [225, 225, 225] }, // Graphite + { id: '9', rgb: [84, 132, 237] }, // Blueberry + { id: '10', rgb: [81, 183, 73] }, // Basil + { id: '11', rgb: [220, 33, 39] }, // Tomato + ]; + + // Parse the input color (supports hex, rgb, rgba) + const rgb = this.parseColor(color); + if (!rgb) { + return '9'; // Default to Blueberry + } + + // Find the closest color using Euclidean distance + let closestId = '9'; + let minDistance = Infinity; + + for (const googleColor of googleColors) { + const distance = Math.sqrt( + Math.pow(rgb[0] - googleColor.rgb[0], 2) + + Math.pow(rgb[1] - googleColor.rgb[1], 2) + + Math.pow(rgb[2] - googleColor.rgb[2], 2) + ); + + if (distance < minDistance) { + minDistance = distance; + closestId = googleColor.id; + } + } + + return closestId; + } + + private parseColor(color: string): [number, number, number] | null { + if (!color) return null; + + // Handle hex format (#rrggbb or #rgb) + if (color.startsWith('#')) { + const hex = color.slice(1); + if (hex.length === 3) { + return [ + parseInt(hex[0] + hex[0], 16), + parseInt(hex[1] + hex[1], 16), + parseInt(hex[2] + hex[2], 16), + ]; + } else if (hex.length === 6) { + return [ + parseInt(hex.slice(0, 2), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(4, 6), 16), + ]; + } + } + + // Handle rgb/rgba format: rgb(r, g, b) or rgba(r, g, b, a) + const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (rgbMatch) { + return [ + parseInt(rgbMatch[1]), + parseInt(rgbMatch[2]), + parseInt(rgbMatch[3]), + ]; + } + + return null; + } }