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..0c5627d 100644 --- a/src/i18n/de/translation.json +++ b/src/i18n/de/translation.json @@ -30,5 +30,18 @@ "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" + }, + "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 75f20d7..e6f76b1 100644 --- a/src/i18n/fr/translation.json +++ b/src/i18n/fr/translation.json @@ -30,5 +30,18 @@ "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é" + }, + "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 new file mode 100644 index 0000000..f1164bf --- /dev/null +++ b/src/shared/services/google-calendar.service.ts @@ -0,0 +1,377 @@ +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'; +import * as moment from 'moment'; + +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.events', 'https://www.googleapis.com/auth/calendar.readonly'], + 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 + ?.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; + } + } + + + 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 }); + + // 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}`, + location: appointment.meetingPoint || '', + description: description, + // Full day event + colorId: colorId, + start: { + date: moment(startDateTime).format('YYYY-MM-DD'), + timeZone: 'Europe/Zurich' + }, + end: { + 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, + }; + } + + 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; + } + + // 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; + } +} 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..bab8d44 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,76 @@ 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'); + + // 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) { + const schoolParam = schoolId ? `&school=${schoolId}` : ''; + return res.redirect(`${instructorAppUrl}/configuration?google_calendar=error&message=${error}${schoolParam}`); + } + + if (!code || !state) { + const schoolParam = schoolId ? `&school=${schoolId}` : ''; + return res.redirect(`${instructorAppUrl}/configuration?google_calendar=error&message=no_code_or_state${schoolParam}`); + } + + try { + await this.schoolFacade.handleGoogleCalendarOAuthCallback(code, schoolId); + return res.redirect(`${instructorAppUrl}/configuration?google_calendar=success&school=${schoolId}`); + } catch (err) { + const schoolParam = schoolId ? `&school=${schoolId}` : ''; + return res.redirect(`${instructorAppUrl}/configuration?google_calendar=error&message=${err.message}${schoolParam}`); + } + } + + @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) + @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) + 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..25fe6ac 100644 --- a/src/training/school/domain/school-config.ts +++ b/src/training/school/domain/school-config.ts @@ -1,6 +1,30 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { Expose, Type } from "class-transformer"; -import { IsBoolean, IsOptional, IsString, ValidateNested } from "class-validator"; +import { Exclude, Expose, Type } from "class-transformer"; +import { IsBoolean, IsDate, IsOptional, IsString, ValidateNested } from "class-validator"; + +@Expose() +export class GoogleCalendarConfig { + @Exclude() + @ApiProperty() + @IsString() + accessToken: string; + + @Exclude() + @ApiProperty() + @IsString() + refreshToken: string; + + @Expose() + @ApiProperty() + @IsString() + calendarId: string; + + @Exclude() + @ApiProperty() + @IsDate() + @Type(() => Date) + tokenExpiry: Date; +} @Expose() export class TandemModuleDto { @@ -43,4 +67,11 @@ export class SchoolConfig { @ValidateNested() @Type(() => TandemModuleDto) tandemModule: TandemModuleDto; + + @Expose() + @ApiPropertyOptional() + @IsOptional() + @ValidateNested() + @Type(() => GoogleCalendarConfig) + googleCalendar?: GoogleCalendarConfig; } diff --git a/src/training/school/exception/school.exception.ts b/src/training/school/exception/school.exception.ts index 0eeafc5..72f32aa 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,12 @@ 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") + } + + 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 9450093..5fd75b0 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,141 @@ 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 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); + + 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 SchoolException.googleCalendarNotConfiguredException(); + } + + 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.debug(`Failed to refresh access token for school ${schoolId}:`, error); + throw SchoolException.googleCalendarTokenExpiredException(); + } + } + + async getAvailableGoogleCalendars(schoolId: number): Promise> { + const school: School = await this.schoolRepository.getSchoolById(schoolId); + + if (!school) { + throw SchoolException.notFoundException(); + } + + if (!school.configuration?.googleCalendar) { + throw SchoolException.googleCalendarNotConfiguredException(); + } + + 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 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 { + const school: School = await this.schoolRepository.getSchoolById(schoolId); + + if (!school) { + throw SchoolException.notFoundException(); + } + + if (!school.configuration?.googleCalendar) { + throw SchoolException.googleCalendarNotConfiguredException(); + } + + 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..5c363e1 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 '../../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, diff --git a/test/cases/__snapshots__/school.e2e-spec.ts.snap b/test/cases/__snapshots__/school.e2e-spec.ts.snap index 84cc320..6217c8a 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", @@ -92,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 2cd7884..d257ded 100644 --- a/test/cases/school.e2e-spec.ts +++ b/test/cases/school.e2e-spec.ts @@ -89,6 +89,281 @@ 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/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"); + 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'); + }); + + 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/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..5384c61 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"; @@ -36,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'; @@ -70,6 +73,8 @@ const init = async () => { .useClass(MockKeycloakStrategy) .overrideProvider(KeycloakService) .useClass(MockKeycloakService) + .overrideProvider(GoogleCalendarService) + .useClass(MockGoogleCalendarService) .compile(); const app = moduleFixture.createNestApplication();