import { z } from 'zod' import type { ZeigtAls } from '@/generated/prisma/enums' import { AntwortUnbrauchbar, type AbgleichSeite, type RohTermin, } from '@/lib/kalender/typen' /** * Lesen der Antworten von Microsoft Graph (`/me/calendarView/delta`). * * Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die * gemeinsame Form zurück. Kein fetch, keine Tokens — dadurch gegen die * Beispielantworten in test/fixtures prüfbar. */ /** * Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir fragen * mit `Prefer: outlook.timezone="UTC"` an; kommt trotzdem etwas anderes * zurück, wäre jede Umrechnung geraten – dann lieber abbrechen. */ const graphZeit = z.object({ dateTime: z.string().min(1), timeZone: z.string().min(1), }) const graphTermin = z.looseObject({ id: z.string().min(1), '@odata.etag': z.string().optional(), '@removed': z.looseObject({ reason: z.string().optional() }).optional(), subject: z.string().nullable().optional(), start: graphZeit.optional(), end: graphZeit.optional(), isAllDay: z.boolean().optional(), showAs: z.string().optional(), }) const graphAntwort = z.looseObject({ value: z.array(graphTermin), '@odata.nextLink': z.string().optional(), '@odata.deltaLink': z.string().optional(), }) /** showAs von Graph auf unsere Belegungsarten. */ export function zeigtAlsAusGraph(showAs: string | undefined): ZeigtAls { switch (showAs) { case 'free': return 'FREI' case 'busy': return 'GEBUCHT' case 'tentative': return 'MIT_VORBEHALT' case 'oof': return 'ABWESEND' case 'workingElsewhere': return 'WOANDERS' default: return 'UNBEKANNT' } } /** * Wandelt Graphs `{ dateTime, timeZone }` in einen Zeitpunkt. * * Der Text trägt keinen Versatz, deshalb wird er als UTC gelesen. Meldet * Graph eine andere Zeitzone, ist die Anfrage falsch gestellt worden — das * schweigend zu übergehen hieße, Termine um Stunden zu verschieben. */ export function graphZeitpunkt(zeit: { dateTime: string; timeZone: string }): Date { if (zeit.timeZone.toUpperCase() !== 'UTC') { throw new AntwortUnbrauchbar( 'Graph', `unerwartete Zeitzone "${zeit.timeZone}", erwartet wurde UTC`, ) } // "2016-12-09T20:30:00.0000000" – die sieben Nachkommastellen versteht // Date nicht, drei genügen. const gekuerzt = zeit.dateTime.replace(/(\.\d{3})\d+$/, '$1') const zeitpunkt = new Date(`${gekuerzt}Z`) if (Number.isNaN(zeitpunkt.getTime())) { throw new AntwortUnbrauchbar('Graph', 'Zeitangabe nicht lesbar') } return zeitpunkt } /** Liest eine Antwortseite von `/me/calendarView/delta`. */ export function graphSeiteLesen(inhalt: unknown): AbgleichSeite { const gelesen = graphAntwort.safeParse(inhalt) if (!gelesen.success) { throw new AntwortUnbrauchbar('Graph', 'unerwarteter Aufbau') } const geaendert: RohTermin[] = [] const entfernt: string[] = [] for (const eintrag of gelesen.data.value) { if (eintrag['@removed']) { entfernt.push(eintrag.id) continue } // Ein Eintrag ohne Zeitraum ist kein Termin, den wir einplanen könnten. if (!eintrag.start || !eintrag.end) continue geaendert.push({ extId: eintrag.id, etag: eintrag['@odata.etag'] ?? null, betreff: eintrag.subject ?? null, beginn: graphZeitpunkt(eintrag.start), ende: graphZeitpunkt(eintrag.end), ganztaegig: eintrag.isAllDay === true, zeigtAls: zeigtAlsAusGraph(eintrag.showAs), }) } return { geaendert, entfernt, weiter: gelesen.data['@odata.nextLink'] ?? null, deltaVerweis: gelesen.data['@odata.deltaLink'] ?? null, } }