Reine Logik ohne fetch: die Antworten beider Anbieter werden mit zod geprüft und in eine gemeinsame Form gebracht. Geprüft wird gegen anonymisierte Beispielantworten in test/fixtures — 61 Tests. Zwei Stellen, an denen bewusst abgebrochen statt geraten wird: - Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir fragen mit Prefer: outlook.timezone="UTC" an; kommt etwas anderes zurück, wäre jede Umrechnung geraten und würde Termine um Stunden verschieben. - Ein unbekanntes showAs wird UNBEKANNT und zählt damit als belegt. Lieber eine Stunde zu viel als eine Doppelbuchung. Zur Belegung: Ganztägige Termine zählen nicht — ein Urlaubstag oder Geburtstag im Kalender blockiert sonst den ganzen Tag, obwohl er keine Arbeitszeit kostet. Ebenso wenig zählen ausdrücklich als frei markierte Termine. Überlappende Termine werden zusammengelegt, damit eine Doppelbuchung nicht doppelt zählt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
3.6 KiB
TypeScript
125 lines
3.6 KiB
TypeScript
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,
|
||
}
|
||
}
|