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>
226 lines
6.5 KiB
TypeScript
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
|
|
}
|
|
}
|