From 9c1c916b581738886f3dbdf760a4fbb6f1fd94ea Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 20:49:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(zeitbloecke):=20Schreibweg=20in=20den=20Ka?= =?UTF-8?q?lender=20und=20R=C3=BCckweg=20zur=C3=BCck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anlegen, verschieben und lösen schreiben in den Kalender des Anbieters. Jeder Zugriff auf einen bestehenden Termin geht mit If-Match und dem zuletzt gelesenen Etag heraus; antwortet der Anbieter mit 412, gewinnt der Kalender: der Planer schreibt nicht, verwirft seinen Fingerabdruck und überlässt dem nächsten Abgleich, den fremden Stand zu holen. Der Rückweg läuft nach jedem Abgleich und vergleicht die eigenen Zeitblöcke mit dem aufgefrischten Spiegel. Wurde im Kalender verschoben, zieht der Planer nach. Wurde dort gelöscht, verliert der Zeitblock seine Verankerung und die Aufgabe fällt zurück auf offen, wenn nichts anderes mehr für sie geplant ist. Zwei Entscheidungen zum Fehlerfall: - Scheitert das Schreiben beim Anlegen, bleibt der Zeitblock trotzdem bestehen, mit vermerktem Fehler. Ihn zu verwerfen hieße, die Planung wegen eines Netzproblems zu vergessen; das Verschieben trägt ihn nach. - Scheitert das Entfernen, verschwindet der Zeitblock dennoch. Bliebe er stehen, weil der Kalender gerade nicht erreichbar ist, wäre die Planung blockiert. Co-Authored-By: Claude Opus 5 --- src/server/aktionen/zeitbloecke.ts | 352 +++++++++++++++++++++++++++++ src/server/kalender/abgleich.ts | 31 +++ src/server/kalender/rueckweg.ts | 93 ++++++++ src/server/kalender/schreiben.ts | 225 ++++++++++++++++++ 4 files changed, 701 insertions(+) create mode 100644 src/server/aktionen/zeitbloecke.ts create mode 100644 src/server/kalender/rueckweg.ts create mode 100644 src/server/kalender/schreiben.ts diff --git a/src/server/aktionen/zeitbloecke.ts b/src/server/aktionen/zeitbloecke.ts new file mode 100644 index 0000000..19f68cc --- /dev/null +++ b/src/server/aktionen/zeitbloecke.ts @@ -0,0 +1,352 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { + zeitblockSchema, + zeitraumHash, + LAGE_FEHLER_TEXT, + lagePruefen, +} from '@/lib/zeitblock' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' +import { gueltigesTokenFuer } from '@/server/kalender/abgleich' +import { + KalenderGewinnt, + terminAendern, + terminAnlegen, + terminEntfernen, +} from '@/server/kalender/schreiben' +import { jetzt } from '@/server/zeitgeber' + +export type Rueckmeldung = + { ok: true; meldung: string; id?: string } | { ok: false; fehler: string } + +async function akteurOderFehler() { + try { + return { akteur: await pruefeRolle('BENUTZER') } + } catch (fehler) { + if (fehler instanceof NichtErlaubt) { + return { antwort: { ok: false as const, fehler: fehler.message } } + } + throw fehler + } +} + +/** + * Lädt einen Zeitblock, aber nur, wenn er dem angemeldeten Benutzer gehört — + * die Kennung geht über das Kalenderkonto in die where-Bedingung. + */ +async function eigenerZeitblock(id: string, benutzerId: string) { + return db.zeitblock.findFirst({ + where: { id, kalenderkonto: { benutzerId } }, + select: { + id: true, + beginn: true, + ende: true, + extId: true, + etag: true, + hash: true, + aufgabe: { select: { id: true, titel: true, notiz: true, status: true } }, + kalenderkonto: { + select: { id: true, anbieter: true, kalenderId: true, anzeigename: true }, + }, + }, + }) +} + +/** Was im Kalender als Betreff steht. */ +function betreffFuer(titel: string): string { + return titel +} + +// --- Anlegen --------------------------------------------------------------- + +/** + * Legt einen Zeitblock an und schreibt ihn in den Kalender. + * + * Klappt das Schreiben nicht, bleibt der Zeitblock trotzdem bestehen — mit + * vermerktem Fehler. Ihn wieder zu verwerfen hieße, die Planung des Benutzers + * wegen eines Netzproblems zu vergessen; der nächste Versuch kann ihn + * nachtragen. + */ +export async function zeitblockAnlegen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = zeitblockSchema.safeParse({ + aufgabeId: daten.get('aufgabeId'), + kalenderkontoId: daten.get('kalenderkontoId'), + beginn: daten.get('beginn'), + ende: daten.get('ende'), + }) + if (!gelesen.success) { + const lage = gelesen.error.issues.find((i) => i.path.includes('ende')) + return { ok: false, fehler: lage?.message ?? 'Die Angaben stimmen nicht.' } + } + + const [aufgabe, konto] = await Promise.all([ + db.aufgabe.findUnique({ + where: { id: gelesen.data.aufgabeId }, + select: { id: true, titel: true, notiz: true, status: true }, + }), + db.kalenderkonto.findFirst({ + where: { id: gelesen.data.kalenderkontoId, benutzerId: akteur.id, aktiv: true }, + select: { id: true, anbieter: true, kalenderId: true, anzeigename: true }, + }), + ]) + + if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht.' } + if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } + + const zeitblock = await db.zeitblock.create({ + data: { + aufgabeId: aufgabe.id, + kalenderkontoId: konto.id, + beginn: gelesen.data.beginn, + ende: gelesen.data.ende, + }, + select: { id: true }, + }) + + // Eine eingeplante Aufgabe ist geplant, nicht mehr bloß offen. + if (aufgabe.status === 'OFFEN') { + await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } }) + } + + let schreibfehler: string | null = null + try { + const token = await gueltigesTokenFuer(konto.id, jetzt()) + const ergebnis = await terminAnlegen({ + anbieter: konto.anbieter, + kalenderId: konto.kalenderId, + token, + betreff: betreffFuer(aufgabe.titel), + beschreibung: aufgabe.notiz, + beginn: gelesen.data.beginn, + ende: gelesen.data.ende, + }) + + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { + extId: ergebnis.extId, + etag: ergebnis.etag, + hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende), + letzterFehler: null, + }, + }) + } catch (fehler) { + schreibfehler = + fehler instanceof Error ? fehler.message : 'Der Termin ließ sich nicht anlegen.' + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { letzterFehler: schreibfehler }, + }) + } + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'zeitblock.angelegt', + objektTyp: 'Zeitblock', + objektId: zeitblock.id, + details: { + aufgabe: aufgabe.titel, + kalender: konto.anzeigename, + geschrieben: schreibfehler === null, + }, + }) + + revalidatePath('/woche') + revalidatePath('/aufgaben') + revalidatePath('/') + + return schreibfehler + ? { + ok: false, + fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${schreibfehler}`, + } + : { ok: true, id: zeitblock.id, meldung: `„${aufgabe.titel}" eingeplant.` } +} + +// --- Verschieben ----------------------------------------------------------- + +const verschieben = z.object({ + id: z.string().min(1), + beginn: z.coerce.date(), + ende: z.coerce.date(), +}) + +/** + * Verschiebt einen Zeitblock und zieht den Kalender nach. + * + * Antwortet der Anbieter mit 412, hat jemand anderes den Termin inzwischen + * angefasst. Dann gewinnt der Kalender: der Planer schreibt nicht, sondern + * meldet das und überlässt dem nächsten Abgleich, den fremden Stand zu holen. + */ +export async function zeitblockVerschieben(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = verschieben.safeParse({ + id: daten.get('id'), + beginn: daten.get('beginn'), + ende: daten.get('ende'), + }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const lage = lagePruefen(gelesen.data.beginn, gelesen.data.ende) + if (lage) return { ok: false, fehler: LAGE_FEHLER_TEXT[lage] } + + const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id) + if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' } + + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { beginn: gelesen.data.beginn, ende: gelesen.data.ende }, + }) + + let meldung = `„${zeitblock.aufgabe.titel}" verschoben.` + let ok = true + + try { + const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt()) + + if (zeitblock.extId) { + const ergebnis = await terminAendern({ + anbieter: zeitblock.kalenderkonto.anbieter, + kalenderId: zeitblock.kalenderkonto.kalenderId, + token, + extId: zeitblock.extId, + etag: zeitblock.etag, + betreff: betreffFuer(zeitblock.aufgabe.titel), + beschreibung: zeitblock.aufgabe.notiz, + beginn: gelesen.data.beginn, + ende: gelesen.data.ende, + }) + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { + etag: ergebnis.etag, + hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende), + letzterFehler: null, + }, + }) + } else { + // Beim Anlegen war der Kalender nicht erreichbar – jetzt nachtragen. + const ergebnis = await terminAnlegen({ + anbieter: zeitblock.kalenderkonto.anbieter, + kalenderId: zeitblock.kalenderkonto.kalenderId, + token, + betreff: betreffFuer(zeitblock.aufgabe.titel), + beschreibung: zeitblock.aufgabe.notiz, + beginn: gelesen.data.beginn, + ende: gelesen.data.ende, + }) + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { + extId: ergebnis.extId, + etag: ergebnis.etag, + hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende), + letzterFehler: null, + }, + }) + } + } catch (fehler) { + ok = false + if (fehler instanceof KalenderGewinnt) { + meldung = + 'Der Termin wurde inzwischen im Kalender geändert. Der Kalender gewinnt — der nächste Abgleich holt den dortigen Stand.' + // Den eigenen Fingerabdruck verwerfen, damit der Abgleich die fremde + // Änderung als solche erkennt. + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { hash: null, letzterFehler: meldung }, + }) + } else { + meldung = + fehler instanceof Error ? fehler.message : 'Der Kalender ließ sich nicht ändern.' + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { letzterFehler: meldung }, + }) + } + } + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'zeitblock.verschoben', + objektTyp: 'Zeitblock', + objektId: zeitblock.id, + details: { aufgabe: zeitblock.aufgabe.titel, geschrieben: ok }, + }) + + revalidatePath('/woche') + revalidatePath('/') + return ok ? { ok: true, meldung } : { ok: false, fehler: meldung } +} + +// --- Lösen ----------------------------------------------------------------- + +const loesen = z.object({ id: z.string().min(1) }) + +/** Nimmt einen Zeitblock zurück und entfernt den Termin aus dem Kalender. */ +export async function zeitblockLoesen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = loesen.safeParse({ id: daten.get('id') }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id) + if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' } + + let hinweis = '' + if (zeitblock.extId) { + try { + const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt()) + await terminEntfernen({ + anbieter: zeitblock.kalenderkonto.anbieter, + kalenderId: zeitblock.kalenderkonto.kalenderId, + token, + extId: zeitblock.extId, + etag: zeitblock.etag, + }) + } catch { + // Der Zeitblock verschwindet trotzdem. Bliebe er stehen, weil der + // Kalender gerade nicht erreichbar ist, wäre die Planung blockiert. + hinweis = ' Der Termin im Kalender ließ sich nicht entfernen.' + } + } + + await db.zeitblock.delete({ where: { id: zeitblock.id } }) + + // Bleibt keine Planung übrig, ist die Aufgabe wieder schlicht offen. + const nochGeplant = await db.zeitblock.count({ + where: { aufgabeId: zeitblock.aufgabe.id }, + }) + if (nochGeplant === 0 && zeitblock.aufgabe.status === 'GEPLANT') { + await db.aufgabe.update({ + where: { id: zeitblock.aufgabe.id }, + data: { status: 'OFFEN' }, + }) + } + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'zeitblock.geloest', + objektTyp: 'Zeitblock', + objektId: zeitblock.id, + details: { aufgabe: zeitblock.aufgabe.titel }, + }) + + revalidatePath('/woche') + revalidatePath('/aufgaben') + revalidatePath('/') + return { ok: true, meldung: `„${zeitblock.aufgabe.titel}" ausgeplant.${hinweis}` } +} diff --git a/src/server/kalender/abgleich.ts b/src/server/kalender/abgleich.ts index 26fe4e7..e40478a 100644 --- a/src/server/kalender/abgleich.ts +++ b/src/server/kalender/abgleich.ts @@ -11,6 +11,7 @@ import { tagesBeginn } from '@/lib/zeit' import { db } from '@/server/db' import { entschluesseln, verschluesseln } from '@/server/krypto' import { tokenErneuern } from '@/server/kalender/oauth' +import { rueckwegAnwenden } from '@/server/kalender/rueckweg' import { jetzt } from '@/server/zeitgeber' /** @@ -97,6 +98,31 @@ async function gueltigesToken(konto: Konto, zeitpunkt: Date): Promise { return satz.zugriffstoken } +/** + * Gültiges Zugriffstoken für ein Konto – für den Schreibweg aus M4, der + * nicht den ganzen Abgleich braucht. + */ +export async function gueltigesTokenFuer( + kalenderkontoId: string, + zeitpunkt: Date, +): Promise { + const konto = await db.kalenderkonto.findUnique({ + where: { id: kalenderkontoId }, + select: { + id: true, + anbieter: true, + anzeigename: true, + kalenderId: true, + zugriffstoken: true, + aktualisierungstoken: true, + tokenGueltigBis: true, + deltaVerweis: true, + }, + }) + if (!konto) throw new Error('Dieses Kalenderkonto gibt es nicht.') + return gueltigesToken(konto, zeitpunkt) +} + /** Adresse der ersten Seite einer Runde. */ function ersteAdresse(konto: Konto, fenster: { von: Date; bis: Date }): string { if (konto.deltaVerweis) return konto.deltaVerweis @@ -302,6 +328,11 @@ export async function kontoAbgleichen( adresse = naechsteAdresse(konto, adresse, seite.weiter) } + // Erst den Spiegel auffrischen, dann die eigenen Zeitblöcke damit + // vergleichen: Was jemand im Kalender verschoben hat, holt der Planer + // sich hier zurück. + await rueckwegAnwenden(konto.id) + await db.kalenderkonto.update({ where: { id: konto.id }, data: { diff --git a/src/server/kalender/rueckweg.ts b/src/server/kalender/rueckweg.ts new file mode 100644 index 0000000..964e27d --- /dev/null +++ b/src/server/kalender/rueckweg.ts @@ -0,0 +1,93 @@ +import 'server-only' +import { abgleichEntscheiden, zeitraumHash } from '@/lib/zeitblock' +import { db } from '@/server/db' + +/** + * Der Rückweg: Änderungen an Zeitblöcken aus dem Kalender übernehmen. + * + * Läuft nach jedem Abgleich eines Kontos. Für jeden Zeitblock wird verglichen, + * was im Kalender steht — der örtliche Spiegel (Tabelle `termin`) ist dafür + * bereits auf dem neuesten Stand. + * + * **Der Kalender gewinnt.** Wer dort etwas verschiebt, meint es so; der Planer + * zieht nach, statt zurückzuschreiben. Sonst zöge die App dem Benutzer den + * Termin unter den Händen weg. + */ + +export type RueckwegBericht = { + uebernommen: number + geloest: number +} + +export async function rueckwegAnwenden( + kalenderkontoId: string, +): Promise { + const zeitbloecke = await db.zeitblock.findMany({ + where: { kalenderkontoId, extId: { not: null } }, + select: { + id: true, + extId: true, + beginn: true, + ende: true, + hash: true, + aufgabeId: true, + }, + }) + if (zeitbloecke.length === 0) return { uebernommen: 0, geloest: 0 } + + const extIds = zeitbloecke.map((z) => z.extId).filter((id): id is string => id !== null) + + const termine = await db.termin.findMany({ + where: { kalenderkontoId, extId: { in: extIds } }, + select: { extId: true, beginn: true, ende: true, etag: true }, + }) + const nachExtId = new Map(termine.map((t) => [t.extId, t])) + + let uebernommen = 0 + let geloest = 0 + + for (const zeitblock of zeitbloecke) { + const imKalender = nachExtId.get(zeitblock.extId as string) ?? null + const entscheidung = abgleichEntscheiden( + { hash: zeitblock.hash, beginn: zeitblock.beginn, ende: zeitblock.ende }, + imKalender ? { beginn: imKalender.beginn, ende: imKalender.ende } : null, + ) + + if (entscheidung.tun === 'nichts') continue + + if (entscheidung.tun === 'loesen') { + // Im Kalender gelöscht: der Zeitblock verliert seine Verankerung. Die + // Aufgabe bleibt bestehen und fällt zurück auf offen, wenn nichts + // anderes mehr für sie geplant ist. + await db.zeitblock.delete({ where: { id: zeitblock.id } }) + geloest++ + + const nochGeplant = await db.zeitblock.count({ + where: { aufgabeId: zeitblock.aufgabeId }, + }) + if (nochGeplant === 0) { + await db.aufgabe.updateMany({ + where: { id: zeitblock.aufgabeId, status: 'GEPLANT' }, + data: { status: 'OFFEN' }, + }) + } + continue + } + + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { + beginn: entscheidung.beginn, + ende: entscheidung.ende, + // Den Fingerabdruck auf den übernommenen Stand setzen, damit die + // nächste Runde ihn wiedererkennt und nichts mehr tut. + hash: zeitraumHash(entscheidung.beginn, entscheidung.ende), + etag: imKalender?.etag ?? null, + letzterFehler: null, + }, + }) + uebernommen++ + } + + return { uebernommen, geloest } +} diff --git a/src/server/kalender/schreiben.ts b/src/server/kalender/schreiben.ts new file mode 100644 index 0000000..746e003 --- /dev/null +++ b/src/server/kalender/schreiben.ts @@ -0,0 +1,225 @@ +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 + } +}