Files
planer/src/server/kalender/schreiben.ts
T
Florian WildeandClaude Opus 5 9c1c916b58 feat(zeitbloecke): Schreibweg in den Kalender und Rückweg zurück
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 <noreply@anthropic.com>
2026-09-10 20:49:52 +02:00

226 lines
6.5 KiB
TypeScript

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<string, unknown> {
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<string, unknown> | null,
etag: string | null,
): Promise<Response> {
const kopfzeilen: Record<string, string> = {
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<SchreibErgebnis> {
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<SchreibErgebnis> {
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<void> {
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
}
}