import { Injectable } from '@nestjs/common'; export interface GraphCalendarEventInput { title: string; date: Date; } interface CachedToken { accessToken: string; expiresAt: number; } const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'; // All-day events render on the correct date regardless of viewer timezone, // but Graph still requires a timeZone on the start/end payload - fixed to // the org's own zone since this app has no per-user timezone setting. const EVENT_TIME_ZONE = 'Europe/Berlin'; // Refresh a little before actual expiry so a request never races an // about-to-expire cached token. const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000; function tokenEndpoint(tenantId: string): string { return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; } function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } // Reuses the same Entra app registration as interactive SSO login // (OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET) for the app-only // Graph client-credentials flow, instead of a second, separate app // registration - one enterprise application covers both, as long as it // also has the "Calendars.ReadWrite" Application permission (admin- // consented) alongside its delegated login scopes. OIDC_ISSUER_URL is // "https://login.microsoftonline.com//v2.0" - the tenant id is // its first path segment. function tenantIdFromIssuerUrl(issuerUrl: string): string | null { try { const segments = new URL(issuerUrl).pathname.split('/').filter(Boolean); return segments[0] ?? null; } catch { return null; } } @Injectable() export class GraphClientService { private cachedToken: CachedToken | null = null; isConfigured(): boolean { const issuerUrl = process.env.OIDC_ISSUER_URL; return Boolean( issuerUrl && tenantIdFromIssuerUrl(issuerUrl) && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET, ); } private async getAccessToken(): Promise { if (this.cachedToken && this.cachedToken.expiresAt > Date.now()) { return this.cachedToken.accessToken; } const issuerUrl = process.env.OIDC_ISSUER_URL; const clientId = process.env.OIDC_CLIENT_ID; const clientSecret = process.env.OIDC_CLIENT_SECRET; const tenantId = issuerUrl ? tenantIdFromIssuerUrl(issuerUrl) : null; if (!tenantId || !clientId || !clientSecret) { throw new Error( 'M365-Kalender-Synchronisation ist nicht konfiguriert (SSO-Env-Variablen fehlen)', ); } let response: Response; try { response = await fetch(tokenEndpoint(tenantId), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, scope: 'https://graph.microsoft.com/.default', }), }); } catch (error: unknown) { throw new Error( `Token-Anfrage an Microsoft fehlgeschlagen: ${getErrorMessage(error)}`, ); } if (!response.ok) { throw new Error( `Token-Anfrage an Microsoft fehlgeschlagen (${response.status}): ${await response.text()}`, ); } const data = (await response.json()) as { access_token: string; expires_in: number; }; this.cachedToken = { accessToken: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_BUFFER_MS, }; return this.cachedToken.accessToken; } private allDayEventBody( input: GraphCalendarEventInput, ): Record { const day = input.date.toISOString().slice(0, 10); const nextDay = new Date(input.date); nextDay.setUTCDate(nextDay.getUTCDate() + 1); const nextDayString = nextDay.toISOString().slice(0, 10); return { subject: input.title, isAllDay: true, start: { dateTime: `${day}T00:00:00.0000000`, timeZone: EVENT_TIME_ZONE, }, end: { dateTime: `${nextDayString}T00:00:00.0000000`, timeZone: EVENT_TIME_ZONE, }, }; } async createEvent( groupId: string, input: GraphCalendarEventInput, ): Promise { const token = await this.getAccessToken(); const response = await fetch(`${GRAPH_BASE_URL}/groups/${groupId}/events`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(this.allDayEventBody(input)), }); if (!response.ok) { throw new Error( `Graph createEvent fehlgeschlagen (${response.status}): ${await response.text()}`, ); } const data = (await response.json()) as { id: string }; return data.id; } async updateEvent( groupId: string, eventId: string, input: GraphCalendarEventInput, ): Promise { const token = await this.getAccessToken(); const response = await fetch( `${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(this.allDayEventBody(input)), }, ); if (!response.ok) { throw new Error( `Graph updateEvent fehlgeschlagen (${response.status}): ${await response.text()}`, ); } } async deleteEvent(groupId: string, eventId: string): Promise { const token = await this.getAccessToken(); const response = await fetch( `${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }, ); // A 404 means the event is already gone - treat as success so a retry // after a partially-applied delete doesn't throw. if (!response.ok && response.status !== 404) { throw new Error( `Graph deleteEvent fehlgeschlagen (${response.status}): ${await response.text()}`, ); } } }