import 'server-only' import { z } from 'zod' import type { KalenderAnbieter } from '@/generated/prisma/enums' /** * Schreiben in die Kalender der Anbieter. * * Jeder Schreibzugriff auf einen bestehenden Termin geht mit `If-Match` und * dem zuletzt gelesenen Etag heraus. Hat inzwischen jemand anderes den Termin * geändert, antwortet der Anbieter mit 412 — dann **gewinnt der Kalender**, * und der Planer übernimmt dessen Stand, statt ihn zu überschreiben. */ /** Der Termin wurde inzwischen anderswo geändert. */ export class KalenderGewinnt extends Error { constructor(anbieter: string) { super(`Der Termin wurde in ${anbieter} zwischenzeitlich geändert.`) this.name = 'KalenderGewinnt' } } /** Der Termin ist im Kalender nicht mehr vorhanden. */ export class TerminFort extends Error { constructor(anbieter: string) { super(`Der Termin ist in ${anbieter} nicht mehr vorhanden.`) this.name = 'TerminFort' } } export type SchreibEingabe = { anbieter: KalenderAnbieter kalenderId: string token: string betreff: string beschreibung: string | null beginn: Date ende: Date } export type SchreibErgebnis = { extId: string; etag: string | null } const graphAntwort = z.looseObject({ id: z.string().min(1), '@odata.etag': z.string().optional(), }) const googleAntwort = z.looseObject({ id: z.string().min(1), etag: z.string().optional(), }) function graphBasis(kalenderId: string): string { return kalenderId === 'standard' ? 'https://graph.microsoft.com/v1.0/me/events' : `https://graph.microsoft.com/v1.0/me/calendars/${encodeURIComponent(kalenderId)}/events` } function googleBasis(kalenderId: string): string { const kalender = encodeURIComponent(kalenderId === 'standard' ? 'primary' : kalenderId) return `https://www.googleapis.com/calendar/v3/calendars/${kalender}/events` } /** * Der Rumpf des Termins. * * Graph erwartet Zeit und Zone getrennt; wir schicken UTC, damit auf beiden * Wegen dasselbe gilt wie beim Lesen. Google nimmt einen ISO-Zeitpunkt mit * Versatz direkt entgegen. */ function rumpf(eingabe: SchreibEingabe): Record { if (eingabe.anbieter === 'GRAPH') { return { subject: eingabe.betreff, body: { contentType: 'text', content: eingabe.beschreibung ?? '' }, start: { dateTime: eingabe.beginn.toISOString(), timeZone: 'UTC' }, end: { dateTime: eingabe.ende.toISOString(), timeZone: 'UTC' }, showAs: 'busy', // Damit im Kalender erkennbar ist, woher der Termin stammt. categories: ['Planer'], } } return { summary: eingabe.betreff, description: eingabe.beschreibung ?? '', start: { dateTime: eingabe.beginn.toISOString() }, end: { dateTime: eingabe.ende.toISOString() }, transparency: 'opaque', source: { title: 'Planer', url: process.env.APP_URL ?? '' }, } } async function senden( anbieter: KalenderAnbieter, adresse: string, verfahren: 'POST' | 'PATCH' | 'DELETE', token: string, inhalt: Record | null, etag: string | null, ): Promise { const kopfzeilen: Record = { Authorization: `Bearer ${token}`, } if (inhalt) kopfzeilen['Content-Type'] = 'application/json' // Ohne Etag kein If-Match: ein Schreibzugriff ohne Vergleichsstand darf // nicht als "unverändert" durchgehen. if (etag) kopfzeilen['If-Match'] = etag const antwort = await fetch(adresse, { method: verfahren, headers: kopfzeilen, ...(inhalt ? { body: JSON.stringify(inhalt) } : {}), }) if (antwort.status === 412) throw new KalenderGewinnt(anbieter) if (antwort.status === 404 || antwort.status === 410) throw new TerminFort(anbieter) return antwort } /** Legt einen Termin an. */ export async function terminAnlegen(eingabe: SchreibEingabe): Promise { const adresse = eingabe.anbieter === 'GRAPH' ? graphBasis(eingabe.kalenderId) : googleBasis(eingabe.kalenderId) const antwort = await senden( eingabe.anbieter, adresse, 'POST', eingabe.token, rumpf(eingabe), null, ) if (!antwort.ok) { throw new Error(`Der Termin ließ sich nicht anlegen (${antwort.status}).`) } const inhalt: unknown = await antwort.json() if (eingabe.anbieter === 'GRAPH') { const gelesen = graphAntwort.safeParse(inhalt) if (!gelesen.success) throw new Error('Die Antwort von Graph war nicht verwertbar.') return { extId: gelesen.data.id, etag: gelesen.data['@odata.etag'] ?? null } } const gelesen = googleAntwort.safeParse(inhalt) if (!gelesen.success) throw new Error('Die Antwort von Google war nicht verwertbar.') return { extId: gelesen.data.id, etag: gelesen.data.etag ?? null } } /** Ändert einen bestehenden Termin. */ export async function terminAendern( eingabe: SchreibEingabe & { extId: string; etag: string | null }, ): Promise { const basis = eingabe.anbieter === 'GRAPH' ? graphBasis(eingabe.kalenderId) : googleBasis(eingabe.kalenderId) const adresse = `${basis}/${encodeURIComponent(eingabe.extId)}` const antwort = await senden( eingabe.anbieter, adresse, 'PATCH', eingabe.token, rumpf(eingabe), eingabe.etag, ) if (!antwort.ok) { throw new Error(`Der Termin ließ sich nicht ändern (${antwort.status}).`) } const inhalt: unknown = await antwort.json() if (eingabe.anbieter === 'GRAPH') { const gelesen = graphAntwort.safeParse(inhalt) return { extId: eingabe.extId, etag: gelesen.success ? (gelesen.data['@odata.etag'] ?? null) : null, } } const gelesen = googleAntwort.safeParse(inhalt) return { extId: eingabe.extId, etag: gelesen.success ? (gelesen.data.etag ?? null) : null, } } /** * Entfernt einen Termin. Ist er ohnehin schon fort, gilt das als erledigt — * das Ziel ist erreicht. */ export async function terminEntfernen(eingabe: { anbieter: KalenderAnbieter kalenderId: string token: string extId: string etag: string | null }): Promise { const basis = eingabe.anbieter === 'GRAPH' ? graphBasis(eingabe.kalenderId) : googleBasis(eingabe.kalenderId) const adresse = `${basis}/${encodeURIComponent(eingabe.extId)}` try { const antwort = await senden( eingabe.anbieter, adresse, 'DELETE', eingabe.token, null, eingabe.etag, ) if (!antwort.ok && antwort.status !== 204) { throw new Error(`Der Termin ließ sich nicht entfernen (${antwort.status}).`) } } catch (fehler) { if (fehler instanceof TerminFort) return throw fehler } }