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 + } +}