From 79e6c0d5568c9ee9bc7ebade084fde1e131b450f Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 18:04:41 +0200 Subject: [PATCH 01/12] =?UTF-8?q?feat(kalender):=20Datenmodell=20f=C3=BCr?= =?UTF-8?q?=20Kalenderkonten=20und=20Termine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kalenderkonto mit Tokens und Abgleichverweis, Termin als örtlicher Spiegel mit genau so viel, wie die Belegungsrechnung und die Wochenansicht brauchen. Bereich bekommt sein Standard-Kalenderkonto für M4. Die Tokens liegen verschlüsselt (AES-256-GCM). Wer die Datenbank oder eine Sicherung in die Hände bekommt, hätte sonst direkten Zugriff auf fremde Kalender — das wiegt schwerer als bei den OIDC-Tokens aus M1, die nur die Anmeldung betreffen. Der Schlüssel wird aus AUTH_SECRET abgeleitet statt separat konfiguriert: eine weitere Pflichtvariable wäre eine weitere Stelle, an der die Einrichtung scheitert. kalenderId ist nicht optional, sondern trägt den Wert "standard" für den Standardkalender — Prisma lässt in einem zusammengesetzten Unique-Schlüssel kein null zu. Co-Authored-By: Claude Opus 5 --- .../migration.sql | 70 ++++++++++++++ .../migration.sql | 9 ++ prisma/schema.prisma | 94 ++++++++++++++++++- src/server/krypto.ts | 84 +++++++++++++++++ 4 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260910155036_kalenderkonten_und_termine/migration.sql create mode 100644 prisma/migrations/20260910155856_kalenderid_verpflichtend/migration.sql create mode 100644 src/server/krypto.ts diff --git a/prisma/migrations/20260910155036_kalenderkonten_und_termine/migration.sql b/prisma/migrations/20260910155036_kalenderkonten_und_termine/migration.sql new file mode 100644 index 0000000..91ce289 --- /dev/null +++ b/prisma/migrations/20260910155036_kalenderkonten_und_termine/migration.sql @@ -0,0 +1,70 @@ +-- CreateEnum +CREATE TYPE "KalenderAnbieter" AS ENUM ('GRAPH', 'GOOGLE'); + +-- CreateEnum +CREATE TYPE "ZeigtAls" AS ENUM ('FREI', 'GEBUCHT', 'MIT_VORBEHALT', 'ABWESEND', 'WOANDERS', 'UNBEKANNT'); + +-- AlterTable +ALTER TABLE "bereich" ADD COLUMN "standardKalenderkontoId" TEXT; + +-- CreateTable +CREATE TABLE "kalenderkonto" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "anbieter" "KalenderAnbieter" NOT NULL, + "anzeigename" TEXT NOT NULL, + "extKontoId" TEXT NOT NULL, + "kalenderId" TEXT, + "zugriffstoken" TEXT, + "aktualisierungstoken" TEXT, + "tokenGueltigBis" TIMESTAMP(3), + "bereiche" TEXT, + "deltaVerweis" TEXT, + "letzterAbgleich" TIMESTAMP(3), + "letzterFehler" TEXT, + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "kalenderkonto_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "termin" ( + "id" TEXT NOT NULL, + "kalenderkontoId" TEXT NOT NULL, + "extId" TEXT NOT NULL, + "etag" TEXT, + "betreff" TEXT, + "beginn" TIMESTAMP(3) NOT NULL, + "ende" TIMESTAMP(3) NOT NULL, + "ganztaegig" BOOLEAN NOT NULL DEFAULT false, + "zeigtAls" "ZeigtAls" NOT NULL DEFAULT 'GEBUCHT', + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "termin_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "kalenderkonto_benutzerId_aktiv_idx" ON "kalenderkonto"("benutzerId", "aktiv"); + +-- CreateIndex +CREATE UNIQUE INDEX "kalenderkonto_benutzerId_anbieter_extKontoId_kalenderId_key" ON "kalenderkonto"("benutzerId", "anbieter", "extKontoId", "kalenderId"); + +-- CreateIndex +CREATE INDEX "termin_kalenderkontoId_beginn_idx" ON "termin"("kalenderkontoId", "beginn"); + +-- CreateIndex +CREATE INDEX "termin_beginn_ende_idx" ON "termin"("beginn", "ende"); + +-- CreateIndex +CREATE UNIQUE INDEX "termin_kalenderkontoId_extId_key" ON "termin"("kalenderkontoId", "extId"); + +-- AddForeignKey +ALTER TABLE "bereich" ADD CONSTRAINT "bereich_standardKalenderkontoId_fkey" FOREIGN KEY ("standardKalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kalenderkonto" ADD CONSTRAINT "kalenderkonto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "termin" ADD CONSTRAINT "termin_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260910155856_kalenderid_verpflichtend/migration.sql b/prisma/migrations/20260910155856_kalenderid_verpflichtend/migration.sql new file mode 100644 index 0000000..c915eca --- /dev/null +++ b/prisma/migrations/20260910155856_kalenderid_verpflichtend/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `kalenderId` on table `kalenderkonto` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "kalenderkonto" ALTER COLUMN "kalenderId" SET NOT NULL, +ALTER COLUMN "kalenderId" SET DEFAULT 'standard'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 38e858b..8c3c8d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,9 +31,10 @@ model Benutzer { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt - konten Konto[] - passkeys Passkey[] - audit AuditEintrag[] + konten Konto[] + passkeys Passkey[] + audit AuditEintrag[] + kalenderkonten Kalenderkonto[] @@index([aktiv]) @@map("benutzer") @@ -121,6 +122,10 @@ model Bereich { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt + /// In welchen Kalender Zeitbloecke dieses Bereichs geschrieben werden. + standardKalenderkontoId String? + standardKalenderkonto Kalenderkonto? @relation("StandardKalenderkonto", fields: [standardKalenderkontoId], references: [id], onDelete: SetNull) + aufgaben Aufgabe[] projekte Projekt[] @@ -252,3 +257,86 @@ model AuditEintrag { @@index([benutzerId]) @@map("audit_eintrag") } + +enum KalenderAnbieter { + GRAPH + GOOGLE +} + +/// Wie der Termin die Zeit belegt. Google kennt nur frei/gebucht, Graph mehr. +enum ZeigtAls { + FREI + GEBUCHT + MIT_VORBEHALT + ABWESEND + WOANDERS + UNBEKANNT +} + +/// Ein verbundener Kalender bei Microsoft Graph oder Google. +model Kalenderkonto { + id String @id @default(cuid()) + benutzerId String + anbieter KalenderAnbieter + /// Frei waehlbare Bezeichnung, z. B. "Firma Outlook". + anzeigename String + /// Kennung beim Anbieter, meist die Mailadresse. Dient dem Wiedererkennen. + extKontoId String + /// Welcher Kalender. Bei Google "primary", bei Graph eine Kalender-ID. + /// "standard" bedeutet: der Standardkalender des Postfachs. Nicht optional, + /// weil Prisma in einem zusammengesetzten Unique-Schluessel kein null zulaesst. + kalenderId String @default("standard") + + /// Verschluesselt abgelegt, siehe src/server/krypto.ts. + zugriffstoken String? + aktualisierungstoken String? + tokenGueltigBis DateTime? + /// Erteilte Berechtigungen, kommagetrennt – zum Erkennen fehlender Rechte. + bereiche String? + + /// deltaLink bei Graph, syncToken bei Google. Opak, nie auswerten. + deltaVerweis String? + letzterAbgleich DateTime? + /// Klartextmeldung des letzten Fehlschlags, ohne Tokens. + letzterFehler String? + + aktiv Boolean @default(true) + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + termine Termin[] + standardFuerBereiche Bereich[] @relation("StandardKalenderkonto") + + /// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer. + @@unique([benutzerId, anbieter, extKontoId, kalenderId]) + @@index([benutzerId, aktiv]) + @@map("kalenderkonto") +} + +/// Ortlicher Spiegel eines Kalendertermins. Nur so viel, wie fuer die +/// Belegungsrechnung und die Wochenansicht gebraucht wird. +model Termin { + id String @id @default(cuid()) + kalenderkontoId String + /// Kennung des Termins beim Anbieter. + extId String + /// Fuer bedingte Schreibzugriffe ab M4 (If-Match). + etag String? + + betreff String? + beginn DateTime + ende DateTime + ganztaegig Boolean @default(false) + zeigtAls ZeigtAls @default(GEBUCHT) + + aktualisiertAm DateTime @updatedAt + + kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade) + + @@unique([kalenderkontoId, extId]) + @@index([kalenderkontoId, beginn]) + @@index([beginn, ende]) + @@map("termin") +} diff --git a/src/server/krypto.ts b/src/server/krypto.ts new file mode 100644 index 0000000..b61dd7b --- /dev/null +++ b/src/server/krypto.ts @@ -0,0 +1,84 @@ +import 'server-only' +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto' +import { umgebung } from '@/server/umgebung' + +/** + * Verschlüsselung für Zugangs- und Aktualisierungstoken der Kalenderkonten. + * + * Wer die Datenbank oder eine Sicherung in die Hände bekommt, hätte sonst + * direkten Zugriff auf fremde Kalender und Postfächer. Das wiegt schwerer als + * bei den OIDC-Tokens aus M1, die nur die Anmeldung betreffen. + * + * Der Schlüssel wird aus AUTH_SECRET abgeleitet, nicht separat konfiguriert: + * eine zusätzliche Pflichtvariable wäre eine weitere Stelle, an der bei der + * Einrichtung etwas schiefgeht. Wer AUTH_SECRET wechselt, muss die + * Kalenderkonten neu verbinden – das ist gewollt, denn mit dem alten Geheimnis + * sind auch die alten Sitzungen ungültig. + */ + +const ALGORITHMUS = 'aes-256-gcm' +const SCHLUESSEL_LAENGE = 32 +const IV_LAENGE = 12 +const MARKE = 'v1' + +let zwischengespeichert: Buffer | null = null + +function schluessel(): Buffer { + if (zwischengespeichert) return zwischengespeichert + const abgeleitet = hkdfSync( + 'sha256', + Buffer.from(umgebung().AUTH_SECRET, 'utf8'), + Buffer.alloc(0), + Buffer.from('planer:kalender-token', 'utf8'), + SCHLUESSEL_LAENGE, + ) + zwischengespeichert = Buffer.from(abgeleitet) + return zwischengespeichert +} + +/** + * Verschlüsselt einen Text. Das Ergebnis ist selbstbeschreibend: + * "v1:::", alles base64url. + */ +export function verschluesseln(klartext: string): string { + const iv = randomBytes(IV_LAENGE) + const chiffre = createCipheriv(ALGORITHMUS, schluessel(), iv) + const inhalt = Buffer.concat([chiffre.update(klartext, 'utf8'), chiffre.final()]) + const echtheitsmarke = chiffre.getAuthTag() + + return [ + MARKE, + iv.toString('base64url'), + echtheitsmarke.toString('base64url'), + inhalt.toString('base64url'), + ].join(':') +} + +/** + * Entschlüsselt einen Text. Gibt null zurück, wenn er nicht zum Schlüssel + * passt oder verändert wurde – das ist kein Absturzgrund, sondern heißt: + * das Konto muss neu verbunden werden. + */ +export function entschluesseln(gespeichert: string | null): string | null { + if (!gespeichert) return null + + const teile = gespeichert.split(':') + if (teile.length !== 4 || teile[0] !== MARKE) return null + + try { + const iv = Buffer.from(teile[1] ?? '', 'base64url') + const echtheitsmarke = Buffer.from(teile[2] ?? '', 'base64url') + const inhalt = Buffer.from(teile[3] ?? '', 'base64url') + + const dechiffre = createDecipheriv(ALGORITHMUS, schluessel(), iv) + dechiffre.setAuthTag(echtheitsmarke) + return Buffer.concat([dechiffre.update(inhalt), dechiffre.final()]).toString('utf8') + } catch { + return null + } +} + +/** Nur für Tests: erzwingt beim nächsten Aufruf ein erneutes Ableiten. */ +export function schluesselVergessen(): void { + zwischengespeichert = null +} From 6fda2a579e862c03dfc99c3e3e7b1ec561fea9ae Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 18:04:41 +0200 Subject: [PATCH 02/12] feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reine Logik ohne fetch: die Antworten beider Anbieter werden mit zod geprüft und in eine gemeinsame Form gebracht. Geprüft wird gegen anonymisierte Beispielantworten in test/fixtures — 61 Tests. Zwei Stellen, an denen bewusst abgebrochen statt geraten wird: - Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir fragen mit Prefer: outlook.timezone="UTC" an; kommt etwas anderes zurück, wäre jede Umrechnung geraten und würde Termine um Stunden verschieben. - Ein unbekanntes showAs wird UNBEKANNT und zählt damit als belegt. Lieber eine Stunde zu viel als eine Doppelbuchung. Zur Belegung: Ganztägige Termine zählen nicht — ein Urlaubstag oder Geburtstag im Kalender blockiert sonst den ganzen Tag, obwohl er keine Arbeitszeit kostet. Ebenso wenig zählen ausdrücklich als frei markierte Termine. Überlappende Termine werden zusammengelegt, damit eine Doppelbuchung nicht doppelt zählt. Co-Authored-By: Claude Opus 5 --- src/lib/kalender/belegung.test.ts | 337 ++++++++++++++++++ src/lib/kalender/belegung.ts | 217 +++++++++++ src/lib/kalender/google.test.ts | 104 ++++++ src/lib/kalender/google.ts | 118 ++++++ src/lib/kalender/graph.test.ts | 130 +++++++ src/lib/kalender/graph.ts | 124 +++++++ src/lib/kalender/typen.ts | 56 +++ .../fixtures/kalender/google-events-leer.json | 8 + .../kalender/google-events-seite1.json | 29 ++ .../kalender/google-events-seite2.json | 27 ++ .../kalender/graph-delta-fremde-zeitzone.json | 21 ++ .../fixtures/kalender/graph-delta-seite1.json | 29 ++ .../fixtures/kalender/graph-delta-seite2.json | 21 ++ 13 files changed, 1221 insertions(+) create mode 100644 src/lib/kalender/belegung.test.ts create mode 100644 src/lib/kalender/belegung.ts create mode 100644 src/lib/kalender/google.test.ts create mode 100644 src/lib/kalender/google.ts create mode 100644 src/lib/kalender/graph.test.ts create mode 100644 src/lib/kalender/graph.ts create mode 100644 src/lib/kalender/typen.ts create mode 100644 test/fixtures/kalender/google-events-leer.json create mode 100644 test/fixtures/kalender/google-events-seite1.json create mode 100644 test/fixtures/kalender/google-events-seite2.json create mode 100644 test/fixtures/kalender/graph-delta-fremde-zeitzone.json create mode 100644 test/fixtures/kalender/graph-delta-seite1.json create mode 100644 test/fixtures/kalender/graph-delta-seite2.json diff --git a/src/lib/kalender/belegung.test.ts b/src/lib/kalender/belegung.test.ts new file mode 100644 index 0000000..d0acc0c --- /dev/null +++ b/src/lib/kalender/belegung.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from 'vitest' +import { + arbeitsfensterAmTag, + belegteMinuten, + belegtZeit, + freieFenster, + kapazitaetAmTag, + tagesBelegung, + termineAmTag, + ueberschneidungMinuten, + type BelegenderTermin, + type BereichKapazitaet, +} from './belegung' + +// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1. +const dienstag = new Date('2026-03-17T09:00:00Z') + +const bereich: BereichKapazitaet = { + kapazitaetMo: 360, + kapazitaetDi: 240, + kapazitaetMi: 360, + kapazitaetDo: 360, + kapazitaetFr: 120, + kapazitaetSa: 0, + kapazitaetSo: 0, + // 08:00 bis 17:00 Berliner Zeit. + arbeitsfensterVon: 480, + arbeitsfensterBis: 1020, +} + +function termin( + vonUtc: string, + bisUtc: string, + teil: Partial = {}, +): BelegenderTermin { + return { + beginn: new Date(vonUtc), + ende: new Date(bisUtc), + ganztaegig: false, + zeigtAls: 'GEBUCHT', + ...teil, + } +} + +describe('belegtZeit', () => { + it('zählt einen gewöhnlichen Termin', () => { + expect(belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'))).toBe(true) + }) + + it('zählt ganztägige Termine nicht', () => { + // Ein Urlaubstag oder Geburtstag kostet keine Arbeitszeit im Fenster. + expect( + belegtZeit( + termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true }), + ), + ).toBe(false) + }) + + it('zählt als frei markierte Termine nicht', () => { + expect( + belegtZeit( + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls: 'FREI' }), + ), + ).toBe(false) + }) + + it('zählt Vorbehalt und Abwesenheit sehr wohl', () => { + for (const zeigtAls of [ + 'MIT_VORBEHALT', + 'ABWESEND', + 'WOANDERS', + 'UNBEKANNT', + ] as const) { + expect( + belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls })), + ).toBe(true) + } + }) +}) + +describe('ueberschneidungMinuten', () => { + const fenster = { + von: new Date('2026-03-17T08:00:00Z'), + bis: new Date('2026-03-17T12:00:00Z'), + } + + it('rechnet eine vollständige Überschneidung', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T09:00:00Z'), bis: new Date('2026-03-17T10:00:00Z') }, + fenster, + ), + ).toBe(60) + }) + + it('schneidet an den Rändern ab', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T07:00:00Z'), bis: new Date('2026-03-17T09:00:00Z') }, + fenster, + ), + ).toBe(60) + }) + + it('ist ohne Überschneidung null', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T13:00:00Z'), bis: new Date('2026-03-17T14:00:00Z') }, + fenster, + ), + ).toBe(0) + }) + + it('zählt reines Aneinandergrenzen nicht als Überschneidung', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T12:00:00Z'), bis: new Date('2026-03-17T13:00:00Z') }, + fenster, + ), + ).toBe(0) + }) +}) + +describe('arbeitsfensterAmTag', () => { + it('legt das Fenster in Berliner Ortszeit', () => { + const fenster = arbeitsfensterAmTag(dienstag, 480, 1020) + // 08:00 Berlin = 07:00 UTC in der Winterzeit. + expect(fenster.von.toISOString()).toBe('2026-03-17T07:00:00.000Z') + expect(fenster.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z') + }) + + it('verschiebt sich mit der Sommerzeit', () => { + // Ab dem 29. März 2026 gilt in Berlin UTC+2. + const fenster = arbeitsfensterAmTag(new Date('2026-04-07T09:00:00Z'), 480, 1020) + expect(fenster.von.toISOString()).toBe('2026-04-07T06:00:00.000Z') + expect(fenster.bis.toISOString()).toBe('2026-04-07T15:00:00.000Z') + }) +}) + +describe('kapazitaetAmTag', () => { + it('nimmt den richtigen Wochentag', () => { + expect(kapazitaetAmTag(bereich, dienstag)).toBe(240) + // Montag, 16. März. + expect(kapazitaetAmTag(bereich, new Date('2026-03-16T09:00:00Z'))).toBe(360) + // Sonntag, 22. März. + expect(kapazitaetAmTag(bereich, new Date('2026-03-22T09:00:00Z'))).toBe(0) + }) +}) + +describe('belegteMinuten', () => { + const fenster = arbeitsfensterAmTag(dienstag, 480, 1020) + + it('ist ohne Termine null', () => { + expect(belegteMinuten([], fenster)).toBe(0) + }) + + it('summiert getrennte Termine', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'), + ], + fenster, + ), + ).toBe(120) + }) + + it('legt überlappende Termine zusammen, statt doppelt zu zählen', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'), + termin('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'), + ], + fenster, + ), + ).toBe(180) + }) + + it('legt lückenlos aneinandergrenzende Termine zusammen', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'), + ], + fenster, + ), + ).toBe(120) + }) + + it('zählt einen vollständig enthaltenen Termin nur einmal', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T12:00:00Z'), + termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'), + ], + fenster, + ), + ).toBe(240) + }) + + it('schneidet an den Rändern des Arbeitsfensters ab', () => { + // 06:00–08:00 UTC = 07:00–09:00 Berlin; nur die zweite Stunde zählt. + expect( + belegteMinuten([termin('2026-03-17T06:00:00Z', '2026-03-17T08:00:00Z')], fenster), + ).toBe(60) + }) + + it('lässt Termine außerhalb des Fensters ganz weg', () => { + // 20:00 Berlin, das Fenster endet um 17:00. + expect( + belegteMinuten([termin('2026-03-17T19:00:00Z', '2026-03-17T20:00:00Z')], fenster), + ).toBe(0) + }) + + it('lässt ganztägige und freie Termine weg', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z', { ganztaegig: true }), + termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z', { zeigtAls: 'FREI' }), + ], + fenster, + ), + ).toBe(0) + }) +}) + +describe('tagesBelegung', () => { + it('rechnet Kapazität und Termine gegeneinander', () => { + const belegung = tagesBelegung( + bereich, + [termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z')], + dienstag, + ) + expect(belegung.kapazitaet).toBe(240) + expect(belegung.belegt).toBe(120) + expect(belegung.frei).toBe(120) + expect(belegung.prozent).toBe(50) + expect(belegung.ueberbucht).toBe(false) + }) + + it('erkennt einen überbuchten Tag', () => { + const belegung = tagesBelegung( + bereich, + [termin('2026-03-17T07:00:00Z', '2026-03-17T13:00:00Z')], + dienstag, + ) + expect(belegung.belegt).toBe(360) + expect(belegung.frei).toBe(0) + expect(belegung.ueberbucht).toBe(true) + expect(belegung.prozent).toBe(150) + }) + + it('behandelt einen Tag ohne Kapazität als voll', () => { + // Sonntag: keine Kapazität, also gibt es dort nichts zu planen. + const belegung = tagesBelegung(bereich, [], new Date('2026-03-22T09:00:00Z')) + expect(belegung.kapazitaet).toBe(0) + expect(belegung.prozent).toBe(100) + expect(belegung.frei).toBe(0) + }) +}) + +describe('freieFenster', () => { + const fenster = arbeitsfensterAmTag(dienstag, 480, 1020) + + it('gibt ohne Termine das ganze Arbeitsfenster zurück', () => { + const luecken = freieFenster([], fenster) + expect(luecken).toHaveLength(1) + expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T07:00:00.000Z') + expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z') + }) + + it('findet die Lücke zwischen zwei Terminen', () => { + const luecken = freieFenster( + [ + termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-17T11:00:00Z', '2026-03-17T16:00:00Z'), + ], + fenster, + ) + expect(luecken).toHaveLength(1) + expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T09:00:00.000Z') + expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T11:00:00.000Z') + }) + + it('lässt zu kurze Lücken weg', () => { + const luecken = freieFenster( + [ + termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'), + // Nur zehn Minuten Pause. + termin('2026-03-17T09:10:00Z', '2026-03-17T16:00:00Z'), + ], + fenster, + 15, + ) + expect(luecken).toEqual([]) + }) + + it('ist bei durchgehender Belegung leer', () => { + expect( + freieFenster([termin('2026-03-17T07:00:00Z', '2026-03-17T16:00:00Z')], fenster), + ).toEqual([]) + }) + + it('übergeht ganztägige Termine und findet den Tag frei', () => { + const luecken = freieFenster( + [termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true })], + fenster, + ) + expect(luecken).toHaveLength(1) + }) +}) + +describe('termineAmTag', () => { + it('nimmt, was den Berliner Kalendertag berührt', () => { + const liste = [ + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-18T08:00:00Z', '2026-03-18T09:00:00Z'), + ] + expect(termineAmTag(liste, dienstag)).toHaveLength(1) + }) + + it('nimmt auch einen Termin, der in den Tag hineinragt', () => { + const uebernacht = termin('2026-03-16T22:00:00Z', '2026-03-17T02:00:00Z') + expect(termineAmTag([uebernacht], dienstag)).toHaveLength(1) + }) + + it('lässt einen Termin weg, der genau am Tagesbeginn endet', () => { + // 23:00 UTC am 16. = 00:00 Berlin am 17., also gerade noch der Vortag. + const davor = termin('2026-03-16T21:00:00Z', '2026-03-16T23:00:00Z') + expect(termineAmTag([davor], dienstag)).toEqual([]) + }) +}) diff --git a/src/lib/kalender/belegung.ts b/src/lib/kalender/belegung.ts new file mode 100644 index 0000000..94306b9 --- /dev/null +++ b/src/lib/kalender/belegung.ts @@ -0,0 +1,217 @@ +import type { ZeigtAls } from '@/generated/prisma/enums' +import { tagesBeginn, tagesEnde, wochentagIndex } from '@/lib/zeit' + +/** + * Wie voll ein Tag schon ist. + * + * Reine Logik mit dem Zeitpunkt als Parameter. Alles rechnet in Minuten und in + * Europe/Berlin — dieselbe Tagesgrenze wie überall sonst im Planer. + */ + +export type BelegenderTermin = { + beginn: Date + ende: Date + ganztaegig: boolean + zeigtAls: ZeigtAls +} + +export type Zeitfenster = { von: Date; bis: Date } + +/** + * Zählt ein Termin gegen die Kapazität? + * + * Ganztägige Termine nicht — ein Urlaubstag oder ein Geburtstag im Kalender + * blockiert sonst den ganzen Tag, obwohl er gar keine Arbeitszeit kostet. + * Ebenso wenig zählen Termine, die ausdrücklich als frei markiert sind. + */ +export function belegtZeit(termin: BelegenderTermin): boolean { + if (termin.ganztaegig) return false + if (termin.zeigtAls === 'FREI') return false + return true +} + +/** Überschneidung zweier Zeiträume in Minuten; ohne Überschneidung null. */ +export function ueberschneidungMinuten(a: Zeitfenster, b: Zeitfenster): number { + const von = Math.max(a.von.getTime(), b.von.getTime()) + const bis = Math.min(a.bis.getTime(), b.bis.getTime()) + if (bis <= von) return 0 + return Math.round((bis - von) / 60_000) +} + +/** Das Arbeitsfenster eines Bereichs an einem bestimmten Tag. */ +export function arbeitsfensterAmTag( + tag: Date, + arbeitsfensterVon: number, + arbeitsfensterBis: number, +): Zeitfenster { + const beginn = tagesBeginn(tag) + return { + von: new Date(beginn.getTime() + arbeitsfensterVon * 60_000), + bis: new Date(beginn.getTime() + arbeitsfensterBis * 60_000), + } +} + +/** + * Wie viele Minuten des Arbeitsfensters an diesem Tag schon durch Termine + * belegt sind. + * + * Überlappende Termine werden zusammengelegt, damit eine Doppelbuchung nicht + * doppelt zählt. + */ +export function belegteMinuten( + termine: readonly BelegenderTermin[], + fenster: Zeitfenster, +): number { + const abschnitte = termine + .filter(belegtZeit) + .map((t) => ({ von: t.beginn, bis: t.ende })) + .filter((t) => ueberschneidungMinuten(t, fenster) > 0) + .map((t) => ({ + von: new Date(Math.max(t.von.getTime(), fenster.von.getTime())), + bis: new Date(Math.min(t.bis.getTime(), fenster.bis.getTime())), + })) + .sort((a, b) => a.von.getTime() - b.von.getTime()) + + let summe = 0 + let offenVon: Date | null = null + let offenBis: Date | null = null + + for (const abschnitt of abschnitte) { + if (offenVon === null || offenBis === null) { + offenVon = abschnitt.von + offenBis = abschnitt.bis + continue + } + if (abschnitt.von.getTime() <= offenBis.getTime()) { + // Überlappt oder schließt an – zusammenlegen. + if (abschnitt.bis.getTime() > offenBis.getTime()) offenBis = abschnitt.bis + continue + } + summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000) + offenVon = abschnitt.von + offenBis = abschnitt.bis + } + + if (offenVon !== null && offenBis !== null) { + summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000) + } + + return summe +} + +export type TagesBelegung = { + /** Tageskapazität des Bereichs an diesem Wochentag, in Minuten. */ + kapazitaet: number + /** Durch Termine belegte Minuten innerhalb des Arbeitsfensters. */ + belegt: number + /** Was von der Kapazität übrig bleibt; nie negativ. */ + frei: number + /** Belegung in Prozent der Kapazität, gerundet. Ohne Kapazität 100. */ + prozent: number + /** Mehr Termine als Kapazität – der Tag ist überbucht. */ + ueberbucht: boolean +} + +export type BereichKapazitaet = { + kapazitaetMo: number + kapazitaetDi: number + kapazitaetMi: number + kapazitaetDo: number + kapazitaetFr: number + kapazitaetSa: number + kapazitaetSo: number + arbeitsfensterVon: number + arbeitsfensterBis: number +} + +const KAPAZITAET_JE_TAG = [ + 'kapazitaetMo', + 'kapazitaetDi', + 'kapazitaetMi', + 'kapazitaetDo', + 'kapazitaetFr', + 'kapazitaetSa', + 'kapazitaetSo', +] as const + +/** Kapazität des Bereichs an genau diesem Tag. */ +export function kapazitaetAmTag(bereich: BereichKapazitaet, tag: Date): number { + const feld = KAPAZITAET_JE_TAG[wochentagIndex(tag)] + return feld ? bereich[feld] : 0 +} + +/** + * Rechnet Kapazität und Termine eines Tages gegeneinander. + * + * Gezählt wird nur, was ins Arbeitsfenster fällt: ein Termin um 22 Uhr + * verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um 17 Uhr + * endet. + */ +export function tagesBelegung( + bereich: BereichKapazitaet, + termine: readonly BelegenderTermin[], + tag: Date, +): TagesBelegung { + const kapazitaet = kapazitaetAmTag(bereich, tag) + const fenster = arbeitsfensterAmTag( + tag, + bereich.arbeitsfensterVon, + bereich.arbeitsfensterBis, + ) + const belegt = belegteMinuten(termine, fenster) + + return { + kapazitaet, + belegt, + frei: Math.max(0, kapazitaet - belegt), + prozent: kapazitaet === 0 ? 100 : Math.round((belegt / kapazitaet) * 100), + ueberbucht: belegt > kapazitaet, + } +} + +/** + * Freie Abschnitte innerhalb des Arbeitsfensters eines Tages — die Lücken, + * in die ab M4 Zeitblöcke gelegt werden können. + */ +export function freieFenster( + termine: readonly BelegenderTermin[], + fenster: Zeitfenster, + mindestensMinuten = 15, +): Zeitfenster[] { + const belegt = termine + .filter(belegtZeit) + .map((t) => ({ + von: new Date(Math.max(t.beginn.getTime(), fenster.von.getTime())), + bis: new Date(Math.min(t.ende.getTime(), fenster.bis.getTime())), + })) + .filter((t) => t.bis.getTime() > t.von.getTime()) + .sort((a, b) => a.von.getTime() - b.von.getTime()) + + const luecken: Zeitfenster[] = [] + let cursor = fenster.von + + for (const abschnitt of belegt) { + if (abschnitt.von.getTime() > cursor.getTime()) { + luecken.push({ von: cursor, bis: abschnitt.von }) + } + if (abschnitt.bis.getTime() > cursor.getTime()) cursor = abschnitt.bis + } + + if (cursor.getTime() < fenster.bis.getTime()) { + luecken.push({ von: cursor, bis: fenster.bis }) + } + + return luecken.filter( + (l) => (l.bis.getTime() - l.von.getTime()) / 60_000 >= mindestensMinuten, + ) +} + +/** Termine, die an diesem Kalendertag überhaupt vorkommen. */ +export function termineAmTag( + termine: readonly T[], + tag: Date, +): T[] { + const von = tagesBeginn(tag).getTime() + const bis = tagesEnde(tag).getTime() + return termine.filter((t) => t.beginn.getTime() <= bis && t.ende.getTime() > von) +} diff --git a/src/lib/kalender/google.test.ts b/src/lib/kalender/google.test.ts new file mode 100644 index 0000000..8c6a274 --- /dev/null +++ b/src/lib/kalender/google.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { googleSeiteLesen, googleZeitpunkt, zeigtAlsAusGoogle } from './google' +import { AntwortUnbrauchbar } from './typen' + +function fixture(name: string): unknown { + return JSON.parse( + readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'), + ) as unknown +} + +describe('zeigtAlsAusGoogle', () => { + it('kennt nur frei und gebucht', () => { + expect(zeigtAlsAusGoogle('transparent')).toBe('FREI') + expect(zeigtAlsAusGoogle('opaque')).toBe('GEBUCHT') + }) + + it('nimmt ein fehlendes Feld als gebucht', () => { + // Google lässt transparency weg, wenn der Termin die Zeit belegt. + expect(zeigtAlsAusGoogle(undefined)).toBe('GEBUCHT') + }) +}) + +describe('googleZeitpunkt', () => { + it('liest eine Zeit mit Versatz', () => { + const ergebnis = googleZeitpunkt({ dateTime: '2026-03-17T14:00:00+01:00' }) + expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-17T13:00:00.000Z') + expect(ergebnis.ganztaegig).toBe(false) + }) + + it('erkennt ein reines Datum als ganztägig', () => { + const ergebnis = googleZeitpunkt({ date: '2026-03-20' }) + expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-20T00:00:00.000Z') + expect(ergebnis.ganztaegig).toBe(true) + }) + + it('bricht ab, wenn beides fehlt', () => { + expect(() => googleZeitpunkt({})).toThrow(AntwortUnbrauchbar) + }) + + it('bricht bei unlesbaren Angaben ab', () => { + expect(() => googleZeitpunkt({ dateTime: 'irgendwann' })).toThrow(AntwortUnbrauchbar) + expect(() => googleZeitpunkt({ date: '2026-13-45' })).toThrow(AntwortUnbrauchbar) + }) +}) + +describe('googleSeiteLesen', () => { + it('liest die erste Seite samt Seitenverweis', () => { + const seite = googleSeiteLesen(fixture('google-events-seite1')) + expect(seite.geaendert).toHaveLength(2) + expect(seite.weiter).toBe('BEISPIEL-SEITE-2') + expect(seite.deltaVerweis).toBeNull() + }) + + it('rechnet den Versatz nach UTC um', () => { + const [erster] = googleSeiteLesen(fixture('google-events-seite1')).geaendert + expect(erster?.extId).toBe('beispiel-termin-a') + expect(erster?.betreff).toBe('Zahnarzt') + expect(erster?.beginn.toISOString()).toBe('2026-03-17T13:00:00.000Z') + expect(erster?.ende.toISOString()).toBe('2026-03-17T14:00:00.000Z') + expect(erster?.ganztaegig).toBe(false) + }) + + it('erkennt ganztägige Termine am reinen Datum', () => { + const ganztaegig = googleSeiteLesen(fixture('google-events-seite1')).geaendert[1] + expect(ganztaegig?.ganztaegig).toBe(true) + expect(ganztaegig?.betreff).toBe('Urlaubstag') + }) + + it('liest die letzte Seite mit dem Sync-Verweis', () => { + const seite = googleSeiteLesen(fixture('google-events-seite2')) + expect(seite.weiter).toBeNull() + expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-1') + }) + + it('behandelt abgesagte Termine als entfernt', () => { + const seite = googleSeiteLesen(fixture('google-events-seite2')) + expect(seite.entfernt).toEqual(['beispiel-termin-d']) + expect(seite.geaendert.map((t) => t.extId)).toEqual(['beispiel-termin-c']) + }) + + it('erkennt einen durchlässigen Termin als frei', () => { + const [frei] = googleSeiteLesen(fixture('google-events-seite2')).geaendert + expect(frei?.zeigtAls).toBe('FREI') + }) + + it('kommt mit einer leeren Antwort zurecht', () => { + const seite = googleSeiteLesen(fixture('google-events-leer')) + expect(seite.geaendert).toEqual([]) + expect(seite.entfernt).toEqual([]) + expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-2') + }) + + it('weist eine Antwort mit falschem Aufbau ab', () => { + expect(() => googleSeiteLesen({ items: 'keine Liste' })).toThrow(AntwortUnbrauchbar) + }) + + it('übergeht Einträge ohne Zeitraum', () => { + const seite = googleSeiteLesen({ + items: [{ id: 'ohne-zeit', status: 'confirmed', summary: 'Kein Zeitraum' }], + }) + expect(seite.geaendert).toEqual([]) + }) +}) diff --git a/src/lib/kalender/google.ts b/src/lib/kalender/google.ts new file mode 100644 index 0000000..074333d --- /dev/null +++ b/src/lib/kalender/google.ts @@ -0,0 +1,118 @@ +import { z } from 'zod' +import type { ZeigtAls } from '@/generated/prisma/enums' +import { + AntwortUnbrauchbar, + type AbgleichSeite, + type RohTermin, +} from '@/lib/kalender/typen' + +/** + * Lesen der Antworten der Google-Kalender-API (`events.list`). + * + * Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die + * gemeinsame Form zurück. + */ + +/** + * Google liefert entweder `date` (ganztägig, reines Datum) oder `dateTime` + * (mit Versatz). Genau eines von beidem. + */ +const googleZeit = z.looseObject({ + date: z.string().optional(), + dateTime: z.string().optional(), + timeZone: z.string().optional(), +}) + +const googleTermin = z.looseObject({ + id: z.string().min(1), + etag: z.string().optional(), + status: z.string().optional(), + summary: z.string().nullable().optional(), + start: googleZeit.optional(), + end: googleZeit.optional(), + transparency: z.string().optional(), +}) + +const googleAntwort = z.looseObject({ + items: z.array(googleTermin).default([]), + nextPageToken: z.string().optional(), + nextSyncToken: z.string().optional(), +}) + +/** + * Google kennt nur zwei Zustände: `transparency: "transparent"` heißt frei, + * alles andere (auch das Fehlen des Feldes) heißt gebucht. + */ +export function zeigtAlsAusGoogle(transparency: string | undefined): ZeigtAls { + return transparency === 'transparent' ? 'FREI' : 'GEBUCHT' +} + +export type GoogleZeit = { date?: string | undefined; dateTime?: string | undefined } + +/** + * Wandelt Googles Zeitangabe in einen Zeitpunkt und sagt, ob sie ganztägig + * war. Ein reines Datum wird als Mitternacht UTC gelesen — ganztägige Termine + * zählen ohnehin nicht zur Belegung, es geht nur um die Einordnung im Tag. + */ +export function googleZeitpunkt(zeit: GoogleZeit): { + zeitpunkt: Date + ganztaegig: boolean +} { + if (zeit.date) { + const zeitpunkt = new Date(`${zeit.date}T00:00:00Z`) + if (Number.isNaN(zeitpunkt.getTime())) { + throw new AntwortUnbrauchbar('Google', 'Datumsangabe nicht lesbar') + } + return { zeitpunkt, ganztaegig: true } + } + + if (zeit.dateTime) { + const zeitpunkt = new Date(zeit.dateTime) + if (Number.isNaN(zeitpunkt.getTime())) { + throw new AntwortUnbrauchbar('Google', 'Zeitangabe nicht lesbar') + } + return { zeitpunkt, ganztaegig: false } + } + + throw new AntwortUnbrauchbar('Google', 'Zeitangabe fehlt') +} + +/** Liest eine Antwortseite von `events.list`. */ +export function googleSeiteLesen(inhalt: unknown): AbgleichSeite { + const gelesen = googleAntwort.safeParse(inhalt) + if (!gelesen.success) { + throw new AntwortUnbrauchbar('Google', 'unerwarteter Aufbau') + } + + const geaendert: RohTermin[] = [] + const entfernt: string[] = [] + + for (const eintrag of gelesen.data.items) { + if (eintrag.status === 'cancelled') { + entfernt.push(eintrag.id) + continue + } + + if (!eintrag.start || !eintrag.end) continue + + const beginn = googleZeitpunkt(eintrag.start) + const ende = googleZeitpunkt(eintrag.end) + + geaendert.push({ + extId: eintrag.id, + etag: eintrag.etag ?? null, + betreff: eintrag.summary ?? null, + beginn: beginn.zeitpunkt, + ende: ende.zeitpunkt, + ganztaegig: beginn.ganztaegig, + zeigtAls: zeigtAlsAusGoogle(eintrag.transparency), + }) + } + + return { + geaendert, + entfernt, + weiter: gelesen.data.nextPageToken ?? null, + deltaVerweis: gelesen.data.nextSyncToken ?? null, + } +} diff --git a/src/lib/kalender/graph.test.ts b/src/lib/kalender/graph.test.ts new file mode 100644 index 0000000..34d942a --- /dev/null +++ b/src/lib/kalender/graph.test.ts @@ -0,0 +1,130 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { graphSeiteLesen, graphZeitpunkt, zeigtAlsAusGraph } from './graph' +import { AntwortUnbrauchbar } from './typen' + +/** Beispielantworten aus test/fixtures, anonymisiert. */ +function fixture(name: string): unknown { + return JSON.parse( + readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'), + ) as unknown +} + +describe('zeigtAlsAusGraph', () => { + it('übersetzt die bekannten Werte', () => { + expect(zeigtAlsAusGraph('free')).toBe('FREI') + expect(zeigtAlsAusGraph('busy')).toBe('GEBUCHT') + expect(zeigtAlsAusGraph('tentative')).toBe('MIT_VORBEHALT') + expect(zeigtAlsAusGraph('oof')).toBe('ABWESEND') + expect(zeigtAlsAusGraph('workingElsewhere')).toBe('WOANDERS') + }) + + it('nimmt Unbekanntes als unbekannt, statt es zu raten', () => { + expect(zeigtAlsAusGraph('etwasNeues')).toBe('UNBEKANNT') + expect(zeigtAlsAusGraph(undefined)).toBe('UNBEKANNT') + }) +}) + +describe('graphZeitpunkt', () => { + it('liest eine UTC-Angabe', () => { + expect( + graphZeitpunkt({ + dateTime: '2026-03-17T08:00:00.0000000', + timeZone: 'UTC', + }).toISOString(), + ).toBe('2026-03-17T08:00:00.000Z') + }) + + it('kürzt die sieben Nachkommastellen, die Date nicht versteht', () => { + expect( + graphZeitpunkt({ + dateTime: '2026-03-17T08:00:00.1234567', + timeZone: 'UTC', + }).toISOString(), + ).toBe('2026-03-17T08:00:00.123Z') + }) + + it('kommt auch ohne Nachkommastellen zurecht', () => { + expect( + graphZeitpunkt({ dateTime: '2026-03-17T08:00:00', timeZone: 'UTC' }).toISOString(), + ).toBe('2026-03-17T08:00:00.000Z') + }) + + it('bricht bei einer anderen Zeitzone ab, statt Termine zu verschieben', () => { + expect(() => + graphZeitpunkt({ + dateTime: '2026-03-17T09:00:00.0000000', + timeZone: 'W. Europe Standard Time', + }), + ).toThrow(AntwortUnbrauchbar) + }) + + it('bricht bei unlesbarer Zeitangabe ab', () => { + expect(() => graphZeitpunkt({ dateTime: 'morgen früh', timeZone: 'UTC' })).toThrow( + AntwortUnbrauchbar, + ) + }) +}) + +describe('graphSeiteLesen', () => { + it('liest die erste Seite samt Verweis auf die nächste', () => { + const seite = graphSeiteLesen(fixture('graph-delta-seite1')) + + expect(seite.geaendert).toHaveLength(2) + expect(seite.entfernt).toEqual([]) + expect(seite.weiter).toContain('$skiptoken=') + expect(seite.deltaVerweis).toBeNull() + }) + + it('übernimmt Betreff, Kennung und Etag', () => { + const [erster] = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert + expect(erster?.extId).toBe('BEISPIEL-TERMIN-1') + expect(erster?.betreff).toBe('Wochenbesprechung') + expect(erster?.etag).toContain('BEISPIELETAG1') + expect(erster?.beginn.toISOString()).toBe('2026-03-17T08:00:00.000Z') + expect(erster?.ende.toISOString()).toBe('2026-03-17T09:00:00.000Z') + expect(erster?.ganztaegig).toBe(false) + expect(erster?.zeigtAls).toBe('GEBUCHT') + }) + + it('erkennt ganztägige Termine', () => { + const ganztaegig = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert[1] + expect(ganztaegig?.ganztaegig).toBe(true) + expect(ganztaegig?.zeigtAls).toBe('ABWESEND') + }) + + it('liest die letzte Seite mit dem Delta-Verweis für die nächste Runde', () => { + const seite = graphSeiteLesen(fixture('graph-delta-seite2')) + expect(seite.weiter).toBeNull() + expect(seite.deltaVerweis).toContain('$deltatoken=') + }) + + it('trennt Entferntes von Geändertem', () => { + const seite = graphSeiteLesen(fixture('graph-delta-seite2')) + expect(seite.entfernt).toEqual(['BEISPIEL-TERMIN-9']) + expect(seite.geaendert.map((t) => t.extId)).toEqual(['BEISPIEL-TERMIN-3']) + }) + + it('erkennt einen als frei markierten Termin', () => { + const [frei] = graphSeiteLesen(fixture('graph-delta-seite2')).geaendert + expect(frei?.zeigtAls).toBe('FREI') + }) + + it('bricht ab, wenn Graph eine andere Zeitzone liefert', () => { + expect(() => graphSeiteLesen(fixture('graph-delta-fremde-zeitzone'))).toThrow( + AntwortUnbrauchbar, + ) + }) + + it('weist eine Antwort ohne value-Liste ab', () => { + expect(() => graphSeiteLesen({ irgendwas: true })).toThrow(AntwortUnbrauchbar) + }) + + it('übergeht Einträge ohne Zeitraum, statt abzubrechen', () => { + const seite = graphSeiteLesen({ + value: [{ id: 'ohne-zeit', subject: 'Kein Zeitraum' }], + }) + expect(seite.geaendert).toEqual([]) + expect(seite.entfernt).toEqual([]) + }) +}) diff --git a/src/lib/kalender/graph.ts b/src/lib/kalender/graph.ts new file mode 100644 index 0000000..fc605c4 --- /dev/null +++ b/src/lib/kalender/graph.ts @@ -0,0 +1,124 @@ +import { z } from 'zod' +import type { ZeigtAls } from '@/generated/prisma/enums' +import { + AntwortUnbrauchbar, + type AbgleichSeite, + type RohTermin, +} from '@/lib/kalender/typen' + +/** + * Lesen der Antworten von Microsoft Graph (`/me/calendarView/delta`). + * + * Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die + * gemeinsame Form zurück. Kein fetch, keine Tokens — dadurch gegen die + * Beispielantworten in test/fixtures prüfbar. + */ + +/** + * Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir fragen + * mit `Prefer: outlook.timezone="UTC"` an; kommt trotzdem etwas anderes + * zurück, wäre jede Umrechnung geraten – dann lieber abbrechen. + */ +const graphZeit = z.object({ + dateTime: z.string().min(1), + timeZone: z.string().min(1), +}) + +const graphTermin = z.looseObject({ + id: z.string().min(1), + '@odata.etag': z.string().optional(), + '@removed': z.looseObject({ reason: z.string().optional() }).optional(), + subject: z.string().nullable().optional(), + start: graphZeit.optional(), + end: graphZeit.optional(), + isAllDay: z.boolean().optional(), + showAs: z.string().optional(), +}) + +const graphAntwort = z.looseObject({ + value: z.array(graphTermin), + '@odata.nextLink': z.string().optional(), + '@odata.deltaLink': z.string().optional(), +}) + +/** showAs von Graph auf unsere Belegungsarten. */ +export function zeigtAlsAusGraph(showAs: string | undefined): ZeigtAls { + switch (showAs) { + case 'free': + return 'FREI' + case 'busy': + return 'GEBUCHT' + case 'tentative': + return 'MIT_VORBEHALT' + case 'oof': + return 'ABWESEND' + case 'workingElsewhere': + return 'WOANDERS' + default: + return 'UNBEKANNT' + } +} + +/** + * Wandelt Graphs `{ dateTime, timeZone }` in einen Zeitpunkt. + * + * Der Text trägt keinen Versatz, deshalb wird er als UTC gelesen. Meldet + * Graph eine andere Zeitzone, ist die Anfrage falsch gestellt worden — das + * schweigend zu übergehen hieße, Termine um Stunden zu verschieben. + */ +export function graphZeitpunkt(zeit: { dateTime: string; timeZone: string }): Date { + if (zeit.timeZone.toUpperCase() !== 'UTC') { + throw new AntwortUnbrauchbar( + 'Graph', + `unerwartete Zeitzone "${zeit.timeZone}", erwartet wurde UTC`, + ) + } + + // "2016-12-09T20:30:00.0000000" – die sieben Nachkommastellen versteht + // Date nicht, drei genügen. + const gekuerzt = zeit.dateTime.replace(/(\.\d{3})\d+$/, '$1') + const zeitpunkt = new Date(`${gekuerzt}Z`) + + if (Number.isNaN(zeitpunkt.getTime())) { + throw new AntwortUnbrauchbar('Graph', 'Zeitangabe nicht lesbar') + } + return zeitpunkt +} + +/** Liest eine Antwortseite von `/me/calendarView/delta`. */ +export function graphSeiteLesen(inhalt: unknown): AbgleichSeite { + const gelesen = graphAntwort.safeParse(inhalt) + if (!gelesen.success) { + throw new AntwortUnbrauchbar('Graph', 'unerwarteter Aufbau') + } + + const geaendert: RohTermin[] = [] + const entfernt: string[] = [] + + for (const eintrag of gelesen.data.value) { + if (eintrag['@removed']) { + entfernt.push(eintrag.id) + continue + } + + // Ein Eintrag ohne Zeitraum ist kein Termin, den wir einplanen könnten. + if (!eintrag.start || !eintrag.end) continue + + geaendert.push({ + extId: eintrag.id, + etag: eintrag['@odata.etag'] ?? null, + betreff: eintrag.subject ?? null, + beginn: graphZeitpunkt(eintrag.start), + ende: graphZeitpunkt(eintrag.end), + ganztaegig: eintrag.isAllDay === true, + zeigtAls: zeigtAlsAusGraph(eintrag.showAs), + }) + } + + return { + geaendert, + entfernt, + weiter: gelesen.data['@odata.nextLink'] ?? null, + deltaVerweis: gelesen.data['@odata.deltaLink'] ?? null, + } +} diff --git a/src/lib/kalender/typen.ts b/src/lib/kalender/typen.ts new file mode 100644 index 0000000..daa129b --- /dev/null +++ b/src/lib/kalender/typen.ts @@ -0,0 +1,56 @@ +import type { ZeigtAls } from '@/generated/prisma/enums' + +/** + * Gemeinsame Form eines Termins, unabhängig vom Anbieter. + * + * Graph und Google liefern sehr verschiedene Antworten; alles, was danach + * kommt — Belegungsrechnung, Wochenansicht, Schreibweg ab M4 — arbeitet nur + * noch mit dieser Form. + */ +export type RohTermin = { + /** Kennung beim Anbieter. */ + extId: string + /** Für bedingte Schreibzugriffe ab M4. */ + etag: string | null + betreff: string | null + beginn: Date + ende: Date + ganztaegig: boolean + zeigtAls: ZeigtAls +} + +/** + * Ergebnis einer einzelnen Abfrageseite. + * + * Genau eines von `weiter` und `deltaVerweis` ist gesetzt: solange `weiter` + * steht, gibt es weitere Seiten; kommt `deltaVerweis`, ist die Runde zu Ende + * und der Verweis wird für den nächsten Abgleich aufbewahrt. + */ +export type AbgleichSeite = { + geaendert: RohTermin[] + /** Kennungen entfernter Termine. */ + entfernt: string[] + /** Nächste Seite derselben Runde, sonst null. */ + weiter: string | null + /** Ausgangspunkt für die nächste Runde, sonst null. */ + deltaVerweis: string | null +} + +/** Fehler beim Lesen einer Anbieterantwort – Inhalt nie im Klartext ins Log. */ +export class AntwortUnbrauchbar extends Error { + constructor(anbieter: string, grund: string) { + super(`Antwort von ${anbieter} nicht verwertbar: ${grund}`) + this.name = 'AntwortUnbrauchbar' + } +} + +/** + * Der Sitzungsverweis ist abgelaufen; es muss von vorn abgeglichen werden. + * Graph antwortet dann mit 410, Google ebenfalls mit 410. + */ +export class VerweisAbgelaufen extends Error { + constructor(anbieter: string) { + super(`Der Abgleichverweis von ${anbieter} ist abgelaufen.`) + this.name = 'VerweisAbgelaufen' + } +} diff --git a/test/fixtures/kalender/google-events-leer.json b/test/fixtures/kalender/google-events-leer.json new file mode 100644 index 0000000..3501b61 --- /dev/null +++ b/test/fixtures/kalender/google-events-leer.json @@ -0,0 +1,8 @@ +{ + "kind": "calendar#events", + "etag": "\"BEISPIEL-LISTE-3\"", + "summary": "person@example.invalid", + "timeZone": "Europe/Berlin", + "nextSyncToken": "BEISPIEL-SYNC-2", + "items": [] +} diff --git a/test/fixtures/kalender/google-events-seite1.json b/test/fixtures/kalender/google-events-seite1.json new file mode 100644 index 0000000..b607d44 --- /dev/null +++ b/test/fixtures/kalender/google-events-seite1.json @@ -0,0 +1,29 @@ +{ + "kind": "calendar#events", + "etag": "\"BEISPIEL-LISTE-1\"", + "summary": "person@example.invalid", + "timeZone": "Europe/Berlin", + "nextPageToken": "BEISPIEL-SEITE-2", + "items": [ + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG10\"", + "id": "beispiel-termin-a", + "status": "confirmed", + "summary": "Zahnarzt", + "updated": "2026-03-10T09:00:00.000Z", + "start": { "dateTime": "2026-03-17T14:00:00+01:00", "timeZone": "Europe/Berlin" }, + "end": { "dateTime": "2026-03-17T15:00:00+01:00", "timeZone": "Europe/Berlin" } + }, + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG11\"", + "id": "beispiel-termin-b", + "status": "confirmed", + "summary": "Urlaubstag", + "updated": "2026-03-11T09:00:00.000Z", + "start": { "date": "2026-03-20" }, + "end": { "date": "2026-03-21" } + } + ] +} diff --git a/test/fixtures/kalender/google-events-seite2.json b/test/fixtures/kalender/google-events-seite2.json new file mode 100644 index 0000000..6fc1bea --- /dev/null +++ b/test/fixtures/kalender/google-events-seite2.json @@ -0,0 +1,27 @@ +{ + "kind": "calendar#events", + "etag": "\"BEISPIEL-LISTE-2\"", + "summary": "person@example.invalid", + "timeZone": "Europe/Berlin", + "nextSyncToken": "BEISPIEL-SYNC-1", + "items": [ + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG12\"", + "id": "beispiel-termin-c", + "status": "confirmed", + "summary": "Sport", + "transparency": "transparent", + "updated": "2026-03-12T09:00:00.000Z", + "start": { "dateTime": "2026-03-17T18:00:00+01:00", "timeZone": "Europe/Berlin" }, + "end": { "dateTime": "2026-03-17T19:30:00+01:00", "timeZone": "Europe/Berlin" } + }, + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG13\"", + "id": "beispiel-termin-d", + "status": "cancelled", + "updated": "2026-03-13T09:00:00.000Z" + } + ] +} diff --git a/test/fixtures/kalender/graph-delta-fremde-zeitzone.json b/test/fixtures/kalender/graph-delta-fremde-zeitzone.json new file mode 100644 index 0000000..4c5da01 --- /dev/null +++ b/test/fixtures/kalender/graph-delta-fremde-zeitzone.json @@ -0,0 +1,21 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(event)", + "@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=BEISPIEL-DELTA-2", + "value": [ + { + "@odata.type": "#microsoft.graph.event", + "id": "BEISPIEL-TERMIN-4", + "subject": "Falsch angefragt", + "isAllDay": false, + "showAs": "busy", + "start": { + "dateTime": "2026-03-17T09:00:00.0000000", + "timeZone": "W. Europe Standard Time" + }, + "end": { + "dateTime": "2026-03-17T10:00:00.0000000", + "timeZone": "W. Europe Standard Time" + } + } + ] +} diff --git a/test/fixtures/kalender/graph-delta-seite1.json b/test/fixtures/kalender/graph-delta-seite1.json new file mode 100644 index 0000000..9dcf3ab --- /dev/null +++ b/test/fixtures/kalender/graph-delta-seite1.json @@ -0,0 +1,29 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(event)", + "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=BEISPIEL-SKIP-1", + "value": [ + { + "@odata.type": "#microsoft.graph.event", + "@odata.etag": "W/\"BEISPIELETAG1==\"", + "id": "BEISPIEL-TERMIN-1", + "subject": "Wochenbesprechung", + "isAllDay": false, + "showAs": "busy", + "start": { "dateTime": "2026-03-17T08:00:00.0000000", "timeZone": "UTC" }, + "end": { "dateTime": "2026-03-17T09:00:00.0000000", "timeZone": "UTC" }, + "organizer": { + "emailAddress": { "name": "Beispiel Person", "address": "person@example.invalid" } + } + }, + { + "@odata.type": "#microsoft.graph.event", + "@odata.etag": "W/\"BEISPIELETAG2==\"", + "id": "BEISPIEL-TERMIN-2", + "subject": "Betriebsausflug", + "isAllDay": true, + "showAs": "oof", + "start": { "dateTime": "2026-03-18T00:00:00.0000000", "timeZone": "UTC" }, + "end": { "dateTime": "2026-03-19T00:00:00.0000000", "timeZone": "UTC" } + } + ] +} diff --git a/test/fixtures/kalender/graph-delta-seite2.json b/test/fixtures/kalender/graph-delta-seite2.json new file mode 100644 index 0000000..1613c0f --- /dev/null +++ b/test/fixtures/kalender/graph-delta-seite2.json @@ -0,0 +1,21 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(event)", + "@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=BEISPIEL-DELTA-1", + "value": [ + { + "@odata.type": "#microsoft.graph.event", + "@odata.etag": "W/\"BEISPIELETAG3==\"", + "id": "BEISPIEL-TERMIN-3", + "subject": "Mittagspause", + "isAllDay": false, + "showAs": "free", + "start": { "dateTime": "2026-03-17T11:00:00.0000000", "timeZone": "UTC" }, + "end": { "dateTime": "2026-03-17T12:00:00.0000000", "timeZone": "UTC" } + }, + { + "@odata.type": "#microsoft.graph.event", + "id": "BEISPIEL-TERMIN-9", + "@removed": { "reason": "deleted" } + } + ] +} From 9946e7c3080a1f5358c40a7b9f5886ccd93395dc Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 18:05:06 +0200 Subject: [PATCH 03/12] =?UTF-8?q?feat(kalender):=20OAuth=20und=20Abgleich?= =?UTF-8?q?=20f=C3=BCr=20Graph=20und=20Google?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbinden, Token erneuern und seitenweise abrufen — mit blankem fetch, ohne SDK. Es geht um drei Aufrufe je Anbieter, und die Antworten werden ohnehin mit zod geprüft; ein SDK brächte vor allem Abhängigkeiten und blinde Flecken. Entscheidungen: - Der Zustandswert im OAuth-Ablauf ist mit AUTH_SECRET signiert und trägt die Benutzerkennung. Ohne das ließe sich ein fremdes Kalenderkonto an ein beliebiges Konto hängen. - Die Berechtigungen schließen das Schreiben ein, obwohl M3 nur liest. Sonst müsste ab M4 jedes Konto neu zustimmen — und die Zustimmung ist genau der Schritt, an dem Einrichtung scheitert. - Läuft der Abgleichverweis ab (beide Anbieter antworten mit 410), wird er verworfen und von vorn gelesen. Der Ablauf ist so gebaut, dass ein vollständiger Durchlauf jederzeit möglich ist. - Googles syncToken trägt die Zeitgrenzen nicht mit; Folgeabgleiche liefern also auch Änderungen außerhalb unseres Fensters. Was daneben liegt, wird verworfen — sonst wüchse der Spiegel unbegrenzt. Die Instanzregel greift serverseitig: Google gibt es nur daheim, in der Firmeninstanz liefert die Route 404 statt eines Hinweises. Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der enthält das Client-Secret oft im Echo. Co-Authored-By: Claude Opus 5 --- .../kalender/rueckleitung/[anbieter]/route.ts | 136 +++++++ .../kalender/verbinden/[anbieter]/route.ts | 52 +++ src/server/kalender/abgleich.ts | 356 ++++++++++++++++++ src/server/kalender/oauth.ts | 209 ++++++++++ src/server/kalender/verbinden.ts | 91 +++++ src/server/umgebung.ts | 16 + 6 files changed, 860 insertions(+) create mode 100644 src/app/api/kalender/rueckleitung/[anbieter]/route.ts create mode 100644 src/app/api/kalender/verbinden/[anbieter]/route.ts create mode 100644 src/server/kalender/abgleich.ts create mode 100644 src/server/kalender/oauth.ts create mode 100644 src/server/kalender/verbinden.ts diff --git a/src/app/api/kalender/rueckleitung/[anbieter]/route.ts b/src/app/api/kalender/rueckleitung/[anbieter]/route.ts new file mode 100644 index 0000000..fd4a449 --- /dev/null +++ b/src/app/api/kalender/rueckleitung/[anbieter]/route.ts @@ -0,0 +1,136 @@ +import { NextResponse } from 'next/server' +import { notFound } from 'next/navigation' +import { auditSchreiben } from '@/server/audit' +import { db } from '@/server/db' +import { erzwingeFaehigkeit } from '@/server/instanz' +import { kontoAbgleichen } from '@/server/kalender/abgleich' +import { codeEinloesen } from '@/server/kalender/oauth' +import { + alsAnbieter, + faehigkeitFuer, + istAnbieter, + zustandLesen, +} from '@/server/kalender/verbinden' +import { verschluesseln } from '@/server/krypto' +import { umgebung } from '@/server/umgebung' + +export const dynamic = 'force-dynamic' + +function zurueck(nachricht: string, art: 'ok' | 'fehler'): NextResponse { + const ziel = new URL('/kalenderkonten', umgebung().APP_URL) + ziel.searchParams.set(art, nachricht) + return NextResponse.redirect(ziel) +} + +/** + * Nimmt die Rückleitung des Anbieters entgegen und legt das Kalenderkonto an. + * + * Der Zustandswert ist signiert und trägt die Benutzerkennung. Ohne diese + * Prüfung ließe sich ein fremdes Kalenderkonto an ein beliebiges Konto hängen. + */ +export async function GET( + anfrage: Request, + { params }: { params: Promise<{ anbieter: string }> }, +) { + const { anbieter: roh } = await params + if (!istAnbieter(roh)) notFound() + + const anbieter = alsAnbieter(roh) + erzwingeFaehigkeit(faehigkeitFuer(anbieter)) + + const adresse = new URL(anfrage.url) + const fehlerAnbieter = adresse.searchParams.get('error') + if (fehlerAnbieter) { + // Meist "access_denied", weil die Zustimmung abgebrochen wurde. + return zurueck('Die Verbindung wurde abgebrochen.', 'fehler') + } + + const zustand = zustandLesen(adresse.searchParams.get('state')) + if (!zustand || zustand.anbieter !== anbieter) { + return zurueck('Die Anfrage war ungültig oder zu alt. Bitte neu beginnen.', 'fehler') + } + + const code = adresse.searchParams.get('code') + if (!code) { + return zurueck('Der Anbieter hat keinen Code zurückgegeben.', 'fehler') + } + + const benutzer = await db.benutzer.findUnique({ + where: { id: zustand.benutzerId }, + select: { id: true, email: true, aktiv: true }, + }) + if (!benutzer?.aktiv) { + return zurueck('Das Konto ist nicht mehr aktiv.', 'fehler') + } + + try { + const satz = await codeEinloesen(anbieter, code) + + if (!satz.aktualisierungstoken) { + // Ohne das wäre die Verbindung nach einer Stunde tot. + return zurueck( + 'Der Anbieter hat kein Aktualisierungstoken herausgegeben. Bitte die Zustimmung erneut erteilen.', + 'fehler', + ) + } + + const extKontoId = satz.kennung ?? `${anbieter.toLowerCase()}-konto` + const kalenderId = anbieter === 'GOOGLE' ? 'primary' : 'standard' + + const konto = await db.kalenderkonto.upsert({ + where: { + benutzerId_anbieter_extKontoId_kalenderId: { + benutzerId: benutzer.id, + anbieter, + extKontoId, + kalenderId, + }, + }, + update: { + zugriffstoken: verschluesseln(satz.zugriffstoken), + aktualisierungstoken: verschluesseln(satz.aktualisierungstoken), + tokenGueltigBis: satz.gueltigBis, + bereiche: satz.bereiche, + aktiv: true, + letzterFehler: null, + // Neu verbunden heißt: von vorn abgleichen. + deltaVerweis: null, + }, + create: { + benutzerId: benutzer.id, + anbieter, + anzeigename: + satz.kennung ?? (anbieter === 'GRAPH' ? 'Microsoft 365' : 'Google Kalender'), + extKontoId, + kalenderId, + zugriffstoken: verschluesseln(satz.zugriffstoken), + aktualisierungstoken: verschluesseln(satz.aktualisierungstoken), + tokenGueltigBis: satz.gueltigBis, + bereiche: satz.bereiche, + }, + select: { id: true, anzeigename: true }, + }) + + await auditSchreiben({ + akteur: { id: benutzer.id, email: benutzer.email }, + aktion: 'kalenderkonto.verbunden', + objektTyp: 'Kalenderkonto', + objektId: konto.id, + // Weder Tokens noch Termininhalte – nur, welches Konto verbunden wurde. + details: { anbieter, anzeigename: konto.anzeigename }, + }) + + const bericht = await kontoAbgleichen(konto.id) + + return zurueck( + bericht.ok + ? `„${konto.anzeigename}" verbunden, ${bericht.geaendert} Termin(e) gelesen.` + : `„${konto.anzeigename}" verbunden, der erste Abgleich schlug aber fehl.`, + bericht.ok ? 'ok' : 'fehler', + ) + } catch (fehler) { + const meldung = + fehler instanceof Error ? fehler.message : 'Die Verbindung ist fehlgeschlagen.' + return zurueck(meldung, 'fehler') + } +} diff --git a/src/app/api/kalender/verbinden/[anbieter]/route.ts b/src/app/api/kalender/verbinden/[anbieter]/route.ts new file mode 100644 index 0000000..1e85795 --- /dev/null +++ b/src/app/api/kalender/verbinden/[anbieter]/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server' +import { notFound } from 'next/navigation' +import { autorisierungsAdresse } from '@/server/kalender/oauth' +import { + alsAnbieter, + faehigkeitFuer, + istAnbieter, + zustandErzeugen, +} from '@/server/kalender/verbinden' +import { angemeldet } from '@/server/berechtigung' +import { erzwingeFaehigkeit } from '@/server/instanz' + +export const dynamic = 'force-dynamic' + +/** + * Startet das Verbinden eines Kalenderkontos und leitet zum Anbieter weiter. + * + * Der Riegel sitzt hier serverseitig: Google gibt es nur daheim. In der + * Firmeninstanz liefert diese Route 404 — nicht etwa einen Hinweis, dass es + * die Möglichkeit anderswo gäbe. + */ +export async function GET( + _anfrage: Request, + { params }: { params: Promise<{ anbieter: string }> }, +) { + const { anbieter: roh } = await params + if (!istAnbieter(roh)) notFound() + + const anbieter = alsAnbieter(roh) + erzwingeFaehigkeit(faehigkeitFuer(anbieter)) + + const benutzer = await angemeldet() + if (!benutzer) { + return NextResponse.redirect( + new URL('/anmelden?weiter=%2Fkalenderkonten', process.env.APP_URL ?? ''), + ) + } + + try { + const zustand = zustandErzeugen(anbieter, benutzer.id) + return NextResponse.redirect(autorisierungsAdresse(anbieter, zustand)) + } catch (fehler) { + const meldung = + fehler instanceof Error ? fehler.message : 'Der Anbieter ist nicht eingerichtet.' + return NextResponse.redirect( + new URL( + `/kalenderkonten?fehler=${encodeURIComponent(meldung)}`, + process.env.APP_URL ?? '', + ), + ) + } +} diff --git a/src/server/kalender/abgleich.ts b/src/server/kalender/abgleich.ts new file mode 100644 index 0000000..26fe4e7 --- /dev/null +++ b/src/server/kalender/abgleich.ts @@ -0,0 +1,356 @@ +import 'server-only' +import type { KalenderAnbieter } from '@/generated/prisma/enums' +import { graphSeiteLesen } from '@/lib/kalender/graph' +import { googleSeiteLesen } from '@/lib/kalender/google' +import { + VerweisAbgelaufen, + type AbgleichSeite, + type RohTermin, +} from '@/lib/kalender/typen' +import { tagesBeginn } from '@/lib/zeit' +import { db } from '@/server/db' +import { entschluesseln, verschluesseln } from '@/server/krypto' +import { tokenErneuern } from '@/server/kalender/oauth' +import { jetzt } from '@/server/zeitgeber' + +/** + * Abgleich der Kalender. + * + * Der Ablauf ist bei beiden Anbietern derselbe: Zugriffstoken auffrischen, + * Seiten abrufen, bis ein Verweis für die nächste Runde kommt, und die + * gelesenen Termine in den örtlichen Spiegel schreiben. + * + * Beide Anbieter kennen einen Verweis, der abläuft (HTTP 410). Dann wird er + * verworfen und von vorn abgeglichen — deshalb ist der Abgleich so gebaut, + * dass ein vollständiger Durchlauf jederzeit möglich ist. + */ + +/** Wie weit in die Zukunft und Vergangenheit gelesen wird. */ +export const FENSTER_TAGE_ZURUECK = 7 +export const FENSTER_TAGE_VORAUS = 90 + +/** Höchstzahl Seiten je Runde – schützt vor einer Endlosschleife. */ +const SEITEN_GRENZE = 50 + +export type AbgleichBericht = { + kalenderkontoId: string + anzeigename: string + geaendert: number + entfernt: number + ok: boolean + fehler?: string +} + +type Konto = { + id: string + anbieter: KalenderAnbieter + anzeigename: string + kalenderId: string + zugriffstoken: string | null + aktualisierungstoken: string | null + tokenGueltigBis: Date | null + deltaVerweis: string | null +} + +/** Zeitfenster, das abgeglichen wird. */ +export function abgleichFenster(zeitpunkt: Date): { von: Date; bis: Date } { + const heute = tagesBeginn(zeitpunkt) + return { + von: new Date(heute.getTime() - FENSTER_TAGE_ZURUECK * 86_400_000), + bis: new Date(heute.getTime() + FENSTER_TAGE_VORAUS * 86_400_000), + } +} + +/** + * Gibt ein gültiges Zugriffstoken zurück und erneuert es, wenn es bald + * abläuft. Eine Minute Vorlauf, damit ein Abruf nicht mitten im Ablauf + * scheitert. + */ +async function gueltigesToken(konto: Konto, zeitpunkt: Date): Promise { + const vorhanden = entschluesseln(konto.zugriffstoken) + const laeuftBald = + !konto.tokenGueltigBis || + konto.tokenGueltigBis.getTime() - zeitpunkt.getTime() < 60_000 + + if (vorhanden && !laeuftBald) return vorhanden + + const aktualisierung = entschluesseln(konto.aktualisierungstoken) + if (!aktualisierung) { + throw new Error( + 'Kein Aktualisierungstoken hinterlegt. Das Konto muss neu verbunden werden.', + ) + } + + const satz = await tokenErneuern(konto.anbieter, aktualisierung, zeitpunkt) + + await db.kalenderkonto.update({ + where: { id: konto.id }, + data: { + zugriffstoken: verschluesseln(satz.zugriffstoken), + ...(satz.aktualisierungstoken + ? { aktualisierungstoken: verschluesseln(satz.aktualisierungstoken) } + : {}), + tokenGueltigBis: satz.gueltigBis, + }, + }) + + return satz.zugriffstoken +} + +/** Adresse der ersten Seite einer Runde. */ +function ersteAdresse(konto: Konto, fenster: { von: Date; bis: Date }): string { + if (konto.deltaVerweis) return konto.deltaVerweis + + if (konto.anbieter === 'GRAPH') { + // "standard" heißt: der Standardkalender des Postfachs. + const pfad = + konto.kalenderId === 'standard' + ? '/me/calendarView/delta' + : `/me/calendars/${encodeURIComponent(konto.kalenderId)}/calendarView/delta` + const parameter = new URLSearchParams({ + startDateTime: fenster.von.toISOString(), + endDateTime: fenster.bis.toISOString(), + }) + return `https://graph.microsoft.com/v1.0${pfad}?${parameter.toString()}` + } + + const kalender = encodeURIComponent( + konto.kalenderId === 'standard' ? 'primary' : konto.kalenderId, + ) + const parameter = new URLSearchParams({ + timeMin: fenster.von.toISOString(), + timeMax: fenster.bis.toISOString(), + // Serien werden aufgelöst; uns interessieren die einzelnen Vorkommen. + singleEvents: 'true', + showDeleted: 'true', + maxResults: '250', + }) + return `https://www.googleapis.com/calendar/v3/calendars/${kalender}/events?${parameter.toString()}` +} + +/** Adresse der nächsten Seite; Google liefert nur einen Token, keine URL. */ +function naechsteAdresse(konto: Konto, vorher: string, weiter: string): string { + if (konto.anbieter === 'GRAPH') return weiter + + const adresse = new URL(vorher) + adresse.searchParams.set('pageToken', weiter) + return adresse.toString() +} + +/** Verweis für die nächste Runde; Google gibt nur einen Token zurück. */ +function verweisFuerNaechsteRunde( + konto: Konto, + vorher: string, + deltaVerweis: string, +): string { + if (konto.anbieter === 'GRAPH') return deltaVerweis + + // Der syncToken verträgt sich nicht mit timeMin/timeMax – die müssen raus. + const adresse = new URL(vorher) + adresse.searchParams.delete('timeMin') + adresse.searchParams.delete('timeMax') + adresse.searchParams.delete('pageToken') + adresse.searchParams.set('syncToken', deltaVerweis) + return adresse.toString() +} + +async function seiteAbrufen( + konto: Konto, + adresse: string, + token: string, +): Promise { + const kopfzeilen: Record = { Authorization: `Bearer ${token}` } + if (konto.anbieter === 'GRAPH') { + // Ohne das liefert Graph Zeiten in der Zeitzone des Postfachs, und der + // Text trägt keinen Versatz – die Umrechnung wäre geraten. + kopfzeilen.Prefer = 'outlook.timezone="UTC", odata.maxpagesize=100' + } + + const antwort = await fetch(adresse, { headers: kopfzeilen }) + + if (antwort.status === 410) { + throw new VerweisAbgelaufen(konto.anbieter) + } + if (!antwort.ok) { + throw new Error(`Der Anbieter antwortete mit ${antwort.status}.`) + } + + const inhalt: unknown = await antwort.json() + return konto.anbieter === 'GRAPH' ? graphSeiteLesen(inhalt) : googleSeiteLesen(inhalt) +} + +/** + * Schreibt die gelesenen Termine in den örtlichen Spiegel. + * + * Googles Folgeabgleiche liefern auch Änderungen außerhalb unseres Fensters, + * weil der syncToken die Zeitgrenzen nicht mitträgt. Was daneben liegt, wird + * hier verworfen — sonst wüchse der Spiegel unbegrenzt. + */ +async function spiegeln( + kalenderkontoId: string, + seite: AbgleichSeite, + fenster: { von: Date; bis: Date }, +): Promise<{ geaendert: number; entfernt: number }> { + const imFenster = (termin: RohTermin): boolean => + termin.ende.getTime() > fenster.von.getTime() && + termin.beginn.getTime() < fenster.bis.getTime() + + const zuSchreiben = seite.geaendert.filter(imFenster) + const ausserhalb = seite.geaendert.filter((t) => !imFenster(t)).map((t) => t.extId) + + for (const termin of zuSchreiben) { + await db.termin.upsert({ + where: { kalenderkontoId_extId: { kalenderkontoId, extId: termin.extId } }, + update: { + etag: termin.etag, + betreff: termin.betreff, + beginn: termin.beginn, + ende: termin.ende, + ganztaegig: termin.ganztaegig, + zeigtAls: termin.zeigtAls, + }, + create: { + kalenderkontoId, + extId: termin.extId, + etag: termin.etag, + betreff: termin.betreff, + beginn: termin.beginn, + ende: termin.ende, + ganztaegig: termin.ganztaegig, + zeigtAls: termin.zeigtAls, + }, + }) + } + + const zuEntfernen = [...seite.entfernt, ...ausserhalb] + const entfernt = + zuEntfernen.length > 0 + ? ( + await db.termin.deleteMany({ + where: { kalenderkontoId, extId: { in: zuEntfernen } }, + }) + ).count + : 0 + + return { geaendert: zuSchreiben.length, entfernt } +} + +/** Gleicht ein einzelnes Konto ab. */ +export async function kontoAbgleichen( + kalenderkontoId: string, + zeitpunkt: Date = jetzt(), +): 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) { + return { + kalenderkontoId, + anzeigename: '(unbekannt)', + geaendert: 0, + entfernt: 0, + ok: false, + fehler: 'Dieses Kalenderkonto gibt es nicht mehr.', + } + } + + const fenster = abgleichFenster(zeitpunkt) + let geaendert = 0 + let entfernt = 0 + + try { + const token = await gueltigesToken(konto, zeitpunkt) + let adresse = ersteAdresse(konto, fenster) + let neuerVerweis: string | null = null + + for (let seitenzahl = 0; seitenzahl < SEITEN_GRENZE; seitenzahl++) { + let seite: AbgleichSeite + try { + seite = await seiteAbrufen(konto, adresse, token) + } catch (fehler) { + if (fehler instanceof VerweisAbgelaufen && konto.deltaVerweis) { + // Von vorn: Verweis verwerfen und den vollen Zeitraum neu lesen. + await db.kalenderkonto.update({ + where: { id: konto.id }, + data: { deltaVerweis: null }, + }) + await db.termin.deleteMany({ where: { kalenderkontoId: konto.id } }) + return kontoAbgleichen(kalenderkontoId, zeitpunkt) + } + throw fehler + } + + const bilanz = await spiegeln(konto.id, seite, fenster) + geaendert += bilanz.geaendert + entfernt += bilanz.entfernt + + if (seite.deltaVerweis) { + neuerVerweis = verweisFuerNaechsteRunde(konto, adresse, seite.deltaVerweis) + break + } + if (!seite.weiter) break + adresse = naechsteAdresse(konto, adresse, seite.weiter) + } + + await db.kalenderkonto.update({ + where: { id: konto.id }, + data: { + ...(neuerVerweis ? { deltaVerweis: neuerVerweis } : {}), + letzterAbgleich: zeitpunkt, + letzterFehler: null, + }, + }) + + return { + kalenderkontoId: konto.id, + anzeigename: konto.anzeigename, + geaendert, + entfernt, + ok: true, + } + } catch (fehler) { + const meldung = + fehler instanceof Error ? fehler.message : 'Unbekannter Fehler beim Abgleich.' + + await db.kalenderkonto.update({ + where: { id: konto.id }, + data: { letzterAbgleich: zeitpunkt, letzterFehler: meldung }, + }) + + return { + kalenderkontoId: konto.id, + anzeigename: konto.anzeigename, + geaendert, + entfernt, + ok: false, + fehler: meldung, + } + } +} + +/** Gleicht alle aktiven Konten eines Benutzers ab. */ +export async function alleKontenAbgleichen( + benutzerId: string, + zeitpunkt: Date = jetzt(), +): Promise { + const konten = await db.kalenderkonto.findMany({ + where: { benutzerId, aktiv: true }, + select: { id: true }, + }) + + const berichte: AbgleichBericht[] = [] + for (const konto of konten) { + berichte.push(await kontoAbgleichen(konto.id, zeitpunkt)) + } + return berichte +} diff --git a/src/server/kalender/oauth.ts b/src/server/kalender/oauth.ts new file mode 100644 index 0000000..81c524e --- /dev/null +++ b/src/server/kalender/oauth.ts @@ -0,0 +1,209 @@ +import 'server-only' +import { z } from 'zod' +import type { KalenderAnbieter } from '@/generated/prisma/enums' +import { umgebung } from '@/server/umgebung' + +/** + * OAuth für die Kalenderanbieter. + * + * Bewusst mit blankem fetch statt mit einem SDK: es geht um drei Aufrufe je + * Anbieter, und die Antworten werden ohnehin mit zod geprüft. Ein SDK würde + * hier vor allem Abhängigkeiten und blinde Flecken hinzufügen. + * + * Die Berechtigungen schließen das Schreiben ein, obwohl M3 nur liest. Sonst + * müsste ab M4 jedes Konto neu zustimmen — und die Zustimmung ist genau der + * Schritt, an dem Einrichtung scheitert. + */ + +const GRAPH_BEREICHE = ['offline_access', 'openid', 'email', 'Calendars.ReadWrite'] +const GOOGLE_BEREICHE = [ + 'https://www.googleapis.com/auth/calendar.events', + 'https://www.googleapis.com/auth/calendar.readonly', + 'openid', + 'email', +] + +export type AnbieterEinstellung = { + anbieter: KalenderAnbieter + clientId: string + clientSecret: string + autorisierungsUrl: string + tokenUrl: string + bereiche: string[] +} + +export function anbieterEinstellung(anbieter: KalenderAnbieter): AnbieterEinstellung { + const einst = umgebung() + + if (anbieter === 'GRAPH') { + if (!einst.graphAktiv) { + throw new Error('Microsoft Graph ist nicht eingerichtet (GRAPH_CLIENT_ID fehlt).') + } + const tenant = einst.GRAPH_TENANT_ID || 'organizations' + return { + anbieter, + clientId: einst.GRAPH_CLIENT_ID, + clientSecret: einst.GRAPH_CLIENT_SECRET, + autorisierungsUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`, + tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, + bereiche: GRAPH_BEREICHE, + } + } + + if (!einst.googleAktiv) { + throw new Error('Google ist nicht eingerichtet (GOOGLE_CLIENT_ID fehlt).') + } + return { + anbieter, + clientId: einst.GOOGLE_CLIENT_ID, + clientSecret: einst.GOOGLE_CLIENT_SECRET, + autorisierungsUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + bereiche: GOOGLE_BEREICHE, + } +} + +/** Der Rückleit-URI, den es beim Anbieter zu hinterlegen gilt. */ +export function rueckleitUrl(anbieter: KalenderAnbieter): string { + return `${umgebung().APP_URL}/api/kalender/rueckleitung/${anbieter.toLowerCase()}` +} + +/** Adresse, zu der der Browser für die Zustimmung geschickt wird. */ +export function autorisierungsAdresse( + anbieter: KalenderAnbieter, + zustand: string, +): string { + const einst = anbieterEinstellung(anbieter) + const parameter = new URLSearchParams({ + client_id: einst.clientId, + response_type: 'code', + redirect_uri: rueckleitUrl(anbieter), + scope: einst.bereiche.join(' '), + state: zustand, + }) + + if (anbieter === 'GOOGLE') { + // Ohne beides gibt Google kein Aktualisierungstoken heraus – und ohne das + // wäre die Verbindung nach einer Stunde wieder tot. + parameter.set('access_type', 'offline') + parameter.set('prompt', 'consent') + } else { + parameter.set('response_mode', 'query') + } + + return `${einst.autorisierungsUrl}?${parameter.toString()}` +} + +const tokenAntwort = z.looseObject({ + access_token: z.string().min(1), + refresh_token: z.string().min(1).optional(), + expires_in: z.coerce.number().int().positive().optional(), + scope: z.string().optional(), + id_token: z.string().optional(), + token_type: z.string().optional(), +}) + +export type Tokensatz = { + zugriffstoken: string + aktualisierungstoken: string | null + gueltigBis: Date | null + bereiche: string | null + /** Aus dem id_token gelesen, wenn vorhanden – dient dem Wiedererkennen. */ + kennung: string | null +} + +/** Liest die E-Mail aus einem id_token, ohne die Signatur zu prüfen. */ +function kennungAus(idToken: string | undefined): string | null { + if (!idToken) return null + const teile = idToken.split('.') + if (teile.length < 2) return null + try { + const nutzlast = JSON.parse( + Buffer.from(teile[1] ?? '', 'base64url').toString('utf8'), + ) as Record + // Die Signatur wird bewusst nicht geprüft: das Token kommt gerade eben + // über eine TLS-Verbindung direkt vom Anbieter, und der Wert dient nur + // der Anzeige und dem Wiedererkennen – nie einer Berechtigung. + const wert = nutzlast.email ?? nutzlast.preferred_username ?? nutzlast.upn + return typeof wert === 'string' ? wert : null + } catch { + return null + } +} + +async function tokenAbrufen( + anbieter: KalenderAnbieter, + koerper: URLSearchParams, + jetzt: Date, +): Promise { + const einst = anbieterEinstellung(anbieter) + koerper.set('client_id', einst.clientId) + koerper.set('client_secret', einst.clientSecret) + + const antwort = await fetch(einst.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: koerper, + }) + + if (!antwort.ok) { + // Der Antwortkörper enthält oft das Client-Secret im Klartext-Echo – + // deshalb kommt hier nur der Status heraus. + throw new Error(`Der Anbieter hat den Token-Aufruf mit ${antwort.status} abgelehnt.`) + } + + const gelesen = tokenAntwort.safeParse(await antwort.json()) + if (!gelesen.success) { + throw new Error('Die Token-Antwort des Anbieters war nicht verwertbar.') + } + + return { + zugriffstoken: gelesen.data.access_token, + aktualisierungstoken: gelesen.data.refresh_token ?? null, + gueltigBis: gelesen.data.expires_in + ? new Date(jetzt.getTime() + gelesen.data.expires_in * 1000) + : null, + bereiche: gelesen.data.scope ?? null, + kennung: kennungAus(gelesen.data.id_token), + } +} + +/** Tauscht den Code aus der Rückleitung gegen Tokens. */ +export async function codeEinloesen( + anbieter: KalenderAnbieter, + code: string, + jetzt: Date = new Date(), +): Promise { + return tokenAbrufen( + anbieter, + new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: rueckleitUrl(anbieter), + }), + jetzt, + ) +} + +/** + * Holt ein frisches Zugriffstoken. Google schickt dabei kein neues + * Aktualisierungstoken mit – das alte bleibt gültig und wird behalten. + */ +export async function tokenErneuern( + anbieter: KalenderAnbieter, + aktualisierungstoken: string, + jetzt: Date = new Date(), +): Promise { + const satz = await tokenAbrufen( + anbieter, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: aktualisierungstoken, + }), + jetzt, + ) + return { + ...satz, + aktualisierungstoken: satz.aktualisierungstoken ?? aktualisierungstoken, + } +} diff --git a/src/server/kalender/verbinden.ts b/src/server/kalender/verbinden.ts new file mode 100644 index 0000000..4ac68e6 --- /dev/null +++ b/src/server/kalender/verbinden.ts @@ -0,0 +1,91 @@ +import 'server-only' +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto' +import type { KalenderAnbieter } from '@/generated/prisma/enums' +import type { Faehigkeit } from '@/lib/instanz' +import { umgebung } from '@/server/umgebung' + +/** + * Zustandswert für den OAuth-Ablauf. + * + * Er trägt Anbieter und Benutzer und ist mit AUTH_SECRET signiert. Ohne das + * könnte jemand einen Rückleit-Aufruf unterschieben und ein fremdes + * Kalenderkonto an das eigene Konto hängen. + */ + +const TRENNER = '.' + +export function faehigkeitFuer(anbieter: KalenderAnbieter): Faehigkeit { + return anbieter === 'GRAPH' ? 'kalender:graph' : 'kalender:google' +} + +export function istAnbieter(wert: string): wert is Lowercase { + return wert === 'graph' || wert === 'google' +} + +export function alsAnbieter(wert: string): KalenderAnbieter { + return wert.toUpperCase() as KalenderAnbieter +} + +function signieren(nutzlast: string): string { + return createHmac('sha256', umgebung().AUTH_SECRET).update(nutzlast).digest('base64url') +} + +/** Erzeugt einen signierten Zustandswert. */ +export function zustandErzeugen( + anbieter: KalenderAnbieter, + benutzerId: string, + jetzt: Date = new Date(), +): string { + const nutzlast = Buffer.from( + JSON.stringify({ + a: anbieter, + b: benutzerId, + z: jetzt.getTime(), + n: randomBytes(9).toString('base64url'), + }), + 'utf8', + ).toString('base64url') + + return `${nutzlast}${TRENNER}${signieren(nutzlast)}` +} + +export type Zustand = { anbieter: KalenderAnbieter; benutzerId: string } + +/** Gültigkeit des Zustandswerts – die Zustimmung dauert keine Stunde. */ +const ZUSTAND_GUELTIG_MINUTEN = 30 + +/** + * Prüft den Zustandswert. Gibt null zurück, wenn er nicht stimmt, verändert + * wurde oder zu alt ist. + */ +export function zustandLesen( + wert: string | null, + jetzt: Date = new Date(), +): Zustand | null { + if (!wert) return null + + const [nutzlast, unterschrift] = wert.split(TRENNER) + if (!nutzlast || !unterschrift) return null + + const erwartet = Buffer.from(signieren(nutzlast), 'utf8') + const erhalten = Buffer.from(unterschrift, 'utf8') + if (erwartet.length !== erhalten.length) return null + if (!timingSafeEqual(erwartet, erhalten)) return null + + try { + const inhalt = JSON.parse(Buffer.from(nutzlast, 'base64url').toString('utf8')) as { + a?: unknown + b?: unknown + z?: unknown + } + + if (typeof inhalt.a !== 'string' || typeof inhalt.b !== 'string') return null + if (typeof inhalt.z !== 'number') return null + if (jetzt.getTime() - inhalt.z > ZUSTAND_GUELTIG_MINUTEN * 60_000) return null + if (inhalt.a !== 'GRAPH' && inhalt.a !== 'GOOGLE') return null + + return { anbieter: inhalt.a, benutzerId: inhalt.b } + } catch { + return null + } +} diff --git a/src/server/umgebung.ts b/src/server/umgebung.ts index 5900dfb..533c9a4 100644 --- a/src/server/umgebung.ts +++ b/src/server/umgebung.ts @@ -30,6 +30,17 @@ const schema = z.object({ WEBAUTHN_RP_NAME: z.string().min(1).default('Planer'), WEBAUTHN_ORIGIN: z.url(), + // --- Kalenderanbieter (M3) --- + // Graph gibt es in beiden Instanzen, Google nur daheim. Welche Werte + // tatsaechlich benutzt werden duerfen, entscheidet src/lib/instanz.ts. + GRAPH_CLIENT_ID: z.string().trim().default(''), + GRAPH_CLIENT_SECRET: z.string().trim().default(''), + /// Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant. + GRAPH_TENANT_ID: z.string().trim().default('organizations'), + + GOOGLE_CLIENT_ID: z.string().trim().default(''), + GOOGLE_CLIENT_SECRET: z.string().trim().default(''), + DEMO_MODUS: jaNein.default(false), }) @@ -37,6 +48,9 @@ export type Umgebung = z.infer & { instanz: Instanz /** OIDC ist nur eingerichtet, wenn alle vier Werte gesetzt sind. */ oidcAktiv: boolean + /** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */ + graphAktiv: boolean + googleAktiv: boolean } let zwischengespeichert: Umgebung | null = null @@ -56,6 +70,8 @@ function lesen(): Umgebung { oidcAktiv: Boolean( werte.OIDC_ISSUER && werte.OIDC_CLIENT_ID && werte.OIDC_CLIENT_SECRET, ), + graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET), + googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET), } } From 0c67b73a627a54c38e2f02724b786b888025ed21 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 18:05:07 +0200 Subject: [PATCH 04/12] =?UTF-8?q?feat(kalender):=20Seite=20f=C3=BCr=20die?= =?UTF-8?q?=20Kalenderkonten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbinden, umbenennen, von Hand abgleichen und trennen. Alle Aktionen arbeiten nur auf Konten des angemeldeten Benutzers — die Kennung geht in die where-Bedingung, nicht in einen nachträglichen Vergleich. Ein Anbieter erscheint nur, wenn die Instanz ihn erlaubt *und* er konfiguriert ist. Ist er erlaubt, aber nicht konfiguriert, sagt die Seite das ausdrücklich, statt den Knopf wortlos wegzulassen. Co-Authored-By: Claude Opus 5 --- src/app/(app)/kalenderkonten/konto-karte.tsx | 149 +++++++++++++++++++ src/app/(app)/kalenderkonten/meldung.tsx | 33 ++++ src/app/(app)/kalenderkonten/page.tsx | 140 +++++++++++++++++ src/components/kopfleiste.tsx | 1 + src/server/aktionen/kalenderkonten.ts | 126 ++++++++++++++++ 5 files changed, 449 insertions(+) create mode 100644 src/app/(app)/kalenderkonten/konto-karte.tsx create mode 100644 src/app/(app)/kalenderkonten/meldung.tsx create mode 100644 src/app/(app)/kalenderkonten/page.tsx create mode 100644 src/server/aktionen/kalenderkonten.ts diff --git a/src/app/(app)/kalenderkonten/konto-karte.tsx b/src/app/(app)/kalenderkonten/konto-karte.tsx new file mode 100644 index 0000000..ff11214 --- /dev/null +++ b/src/app/(app)/kalenderkonten/konto-karte.tsx @@ -0,0 +1,149 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { Loader2, RefreshCw, Unplug } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Kennzeichen } from '@/components/ui/kennzeichen' +import { + jetztAbgleichen, + kontoTrennen, + kontoUmbenennen, + type Rueckmeldung, +} from '@/server/aktionen/kalenderkonten' + +export type KontoAnzeige = { + id: string + anbieter: 'GRAPH' | 'GOOGLE' + anbieterName: string + anzeigename: string + extKontoId: string + termine: number + letzterAbgleich: string | null + letzterFehler: string | null +} + +export function KontoKarte({ konto }: { konto: KontoAnzeige }) { + const router = useRouter() + const [laeuft, starte] = useTransition() + const [name, setName] = useState(konto.anzeigename) + const [trennenBestaetigt, setTrennenBestaetigt] = useState(false) + + function mitRueckmeldung(tun: () => Promise) { + starte(async () => { + const antwort = await tun() + if (antwort.ok) { + toast.success(antwort.meldung) + router.refresh() + } else { + toast.error(antwort.fehler) + } + }) + } + + function felder(werte: Record): FormData { + const daten = new FormData() + for (const [schluessel, wert] of Object.entries(werte)) daten.set(schluessel, wert) + return daten + } + + return ( + + + {konto.anzeigename} + {konto.anbieterName} + {konto.letzterFehler && Fehler} + + + +
+
Konto
+
{konto.extKontoId}
+
Gespiegelte Termine
+
{konto.termine}
+
Letzter Abgleich
+
{konto.letzterAbgleich ?? 'noch nie'}
+
+ + {konto.letzterFehler && ( +

+ {konto.letzterFehler} +

+ )} + +
+
+ + setName(e.target.value)} + maxLength={60} + disabled={laeuft} + /> +
+ +
+ +
+ + + +
+ + {trennenBestaetigt && ( +

+ Die gespiegelten Termine verschwinden dabei. Im Kalender selbst bleibt alles + unangetastet. +

+ )} +
+
+ ) +} diff --git a/src/app/(app)/kalenderkonten/meldung.tsx b/src/app/(app)/kalenderkonten/meldung.tsx new file mode 100644 index 0000000..8ac2ea5 --- /dev/null +++ b/src/app/(app)/kalenderkonten/meldung.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' + +/** + * Zeigt die Rückmeldung aus der Adresszeile, die die OAuth-Rückleitung dort + * hinterlassen hat, und räumt sie danach weg — sonst erschiene sie bei jedem + * Neuladen erneut. + */ +export function Meldung({ + ok, + fehler, +}: { + ok?: string | undefined + fehler?: string | undefined +}) { + const router = useRouter() + const gezeigt = useRef(false) + + useEffect(() => { + if (gezeigt.current) return + if (!ok && !fehler) return + + gezeigt.current = true + if (ok) toast.success(ok) + if (fehler) toast.error(fehler) + router.replace('/kalenderkonten') + }, [ok, fehler, router]) + + return null +} diff --git a/src/app/(app)/kalenderkonten/page.tsx b/src/app/(app)/kalenderkonten/page.tsx new file mode 100644 index 0000000..d124171 --- /dev/null +++ b/src/app/(app)/kalenderkonten/page.tsx @@ -0,0 +1,140 @@ +import type { Metadata } from 'next' +import { CalendarDays, Plus } from 'lucide-react' +import { KontoKarte } from '@/app/(app)/kalenderkonten/konto-karte' +import { Meldung } from '@/app/(app)/kalenderkonten/meldung' +import { Button } from '@/components/ui/button' +import { + Card, + CardBeschreibung, + CardInhalt, + CardKopf, + CardTitel, +} from '@/components/ui/card' +import type { KalenderAnbieter } from '@/generated/prisma/enums' +import { alsDatumUhrzeit } from '@/lib/zeit' +import { verlangeAnmeldung } from '@/server/berechtigung' +import { db } from '@/server/db' +import { erlaubteKalenderAnbieter } from '@/server/instanz' +import { umgebung } from '@/server/umgebung' + +export const metadata: Metadata = { title: 'Kalenderkonten' } + +const ANBIETER_NAME: Record = { + GRAPH: 'Microsoft 365', + GOOGLE: 'Google', +} + +export default async function KalenderkontenSeite({ + searchParams, +}: { + searchParams: Promise<{ ok?: string; fehler?: string }> +}) { + const benutzer = await verlangeAnmeldung('/kalenderkonten') + const parameter = await searchParams + const einst = umgebung() + + // Welche Anbieter es hier überhaupt gibt, entscheidet die Instanz; ob sie + // benutzbar sind, hängt zusätzlich an der Konfiguration. + const erlaubt = erlaubteKalenderAnbieter() + const verfuegbar = erlaubt.filter((anbieter) => + anbieter === 'GRAPH' ? einst.graphAktiv : einst.googleAktiv, + ) + const fehlendKonfiguriert = erlaubt.filter((a) => !verfuegbar.includes(a)) + + const konten = await db.kalenderkonto.findMany({ + where: { benutzerId: benutzer.id }, + orderBy: [{ anbieter: 'asc' }, { anzeigename: 'asc' }], + select: { + id: true, + anbieter: true, + anzeigename: true, + extKontoId: true, + letzterAbgleich: true, + letzterFehler: true, + _count: { select: { termine: true } }, + }, + }) + + return ( +
+
+

+ + Kalenderkonten +

+

+ Der Planer liest die Termine, um zu wissen, wann überhaupt Platz ist. Ab M4 + schreibt er Zeitblöcke in denselben Kalender zurück. +

+
+ + + + + + Kalender verbinden + + Du wirst zum Anbieter geschickt und kommst nach der Zustimmung hierher zurück. + Der Planer speichert keine Zugangsdaten im Klartext. + + + + {verfuegbar.length === 0 ? ( +

+ In dieser Instanz ist kein Kalenderanbieter eingerichtet. +

+ ) : ( +
+ {verfuegbar.map((anbieter) => ( + + ))} +
+ )} + + {fehlendKonfiguriert.length > 0 && ( +

+ {fehlendKonfiguriert.map((a) => ANBIETER_NAME[a]).join(' und ')} wäre hier + erlaubt, ist aber noch nicht konfiguriert. Siehe README, Abschnitt + Umgebungsvariablen. +

+ )} +
+
+ +
+

Verbundene Konten

+ + {konten.length === 0 ? ( + + + Noch kein Kalender verbunden. + + + ) : ( + konten.map((konto) => ( + + )) + )} +
+
+ ) +} diff --git a/src/components/kopfleiste.tsx b/src/components/kopfleiste.tsx index 0b8d027..f547cc3 100644 --- a/src/components/kopfleiste.tsx +++ b/src/components/kopfleiste.tsx @@ -33,6 +33,7 @@ export function Kopfleiste({ { pfad: '/projekte', name: 'Projekte' }, { pfad: '/ideen', name: 'Ideen' }, { pfad: '/bereiche', name: 'Bereiche' }, + { pfad: '/kalenderkonten', name: 'Kalender' }, { pfad: '/einstellungen', name: 'Einstellungen' }, { pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true }, ] diff --git a/src/server/aktionen/kalenderkonten.ts b/src/server/aktionen/kalenderkonten.ts new file mode 100644 index 0000000..2301719 --- /dev/null +++ b/src/server/aktionen/kalenderkonten.ts @@ -0,0 +1,126 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' +import { kontoAbgleichen } from '@/server/kalender/abgleich' + +export type Rueckmeldung = { ok: true; meldung: string } | { ok: false; fehler: string } + +const eingabe = z.object({ id: z.string().min(1) }) + +/** + * Alle Aktionen arbeiten nur auf Konten des angemeldeten Benutzers — die + * Kennung geht in die where-Bedingung, nicht in einen nachträglichen + * Vergleich. + */ +async function eigenesKonto(id: string, benutzerId: string) { + return db.kalenderkonto.findFirst({ + where: { id, benutzerId }, + select: { id: true, anzeigename: true, anbieter: true }, + }) +} + +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 + } +} + +export async function jetztAbgleichen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + + const gelesen = eingabe.safeParse({ id: daten.get('id') }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id) + if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } + + const bericht = await kontoAbgleichen(konto.id) + revalidatePath('/kalenderkonten') + + if (!bericht.ok) { + return { ok: false, fehler: bericht.fehler ?? 'Der Abgleich schlug fehl.' } + } + return { + ok: true, + meldung: `„${konto.anzeigename}": ${bericht.geaendert} Termin(e) gelesen, ${bericht.entfernt} entfernt.`, + } +} + +const umbenennen = z.object({ + id: z.string().min(1), + anzeigename: z.string().trim().min(2, 'Mindestens zwei Zeichen.').max(60), +}) + +export async function kontoUmbenennen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + + const gelesen = umbenennen.safeParse({ + id: daten.get('id'), + anzeigename: daten.get('anzeigename'), + }) + if (!gelesen.success) { + return { ok: false, fehler: 'Bitte einen Namen mit mindestens zwei Zeichen angeben.' } + } + + const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id) + if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } + + await db.kalenderkonto.update({ + where: { id: konto.id }, + data: { anzeigename: gelesen.data.anzeigename }, + }) + + await auditSchreiben({ + akteur: { id: geprueft.akteur.id, email: geprueft.akteur.email }, + aktion: 'kalenderkonto.umbenannt', + objektTyp: 'Kalenderkonto', + objektId: konto.id, + details: { vorher: konto.anzeigename, nachher: gelesen.data.anzeigename }, + }) + + revalidatePath('/kalenderkonten') + return { ok: true, meldung: 'Name gespeichert.' } +} + +/** + * Trennt ein Kalenderkonto. Die gespiegelten Termine verschwinden mit ihm — + * sie stammen aus dem Kalender und sind dort weiterhin vorhanden. + */ +export async function kontoTrennen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + + const gelesen = eingabe.safeParse({ id: daten.get('id') }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id) + if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } + + await db.kalenderkonto.delete({ where: { id: konto.id } }) + + await auditSchreiben({ + akteur: { id: geprueft.akteur.id, email: geprueft.akteur.email }, + aktion: 'kalenderkonto.getrennt', + objektTyp: 'Kalenderkonto', + objektId: konto.id, + details: { anbieter: konto.anbieter, anzeigename: konto.anzeigename }, + }) + + revalidatePath('/kalenderkonten') + revalidatePath('/') + return { + ok: true, + meldung: `„${konto.anzeigename}" getrennt. Die Termine bleiben im Kalender selbst erhalten.`, + } +} From 42c98c8b290eac2ca29cd055a0b094331cb18369 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 18:05:07 +0200 Subject: [PATCH 05/12] =?UTF-8?q?feat(kalender):=20Hintergrundjobs=20f?= =?UTF-8?q?=C3=BCr=20Abgleich=20und=20Aufr=C3=A4umen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alle 15 Minuten abgleichen, nachts alte Termine und abgelaufene WebAuthn-Herausforderungen entfernen. Ohne das Aufräumen bliebe der Terminspiegel für immer stehen. Der Riegel über globalThis ist nötig, weil Next im Entwicklungsbetrieb Module mehrfach lädt — sonst liefen nach ein paar Änderungen ein Dutzend Zeitpläne parallel. Im Demo-Modus starten gar keine Jobs: die Screenshot-Instanz soll sich nicht selbst verändern, während sie abfotografiert wird. Protokolliert werden nur Zahlen, nie Kontonamen oder Termininhalte. Co-Authored-By: Claude Opus 5 --- src/instrumentation.ts | 15 +++++++++ src/server/jobs.ts | 72 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/instrumentation.ts create mode 100644 src/server/jobs.ts diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..91c9d1b --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,15 @@ +/** + * Wird von Next einmal beim Hochfahren des Servers ausgeführt. + * + * Nur in der Node-Laufzeit: im Edge-Umfeld gibt es weder Prisma noch + * node-cron. Der Demo-Modus startet bewusst keine Jobs — die + * Screenshot-Instanz soll sich nicht selbst verändern, während sie + * abfotografiert wird. + */ +export async function register(): Promise { + if (process.env.NEXT_RUNTIME !== 'nodejs') return + if (process.env.DEMO_MODUS === 'true') return + + const { jobsStarten } = await import('@/server/jobs') + jobsStarten() +} diff --git a/src/server/jobs.ts b/src/server/jobs.ts new file mode 100644 index 0000000..a7df519 --- /dev/null +++ b/src/server/jobs.ts @@ -0,0 +1,72 @@ +import 'server-only' +import cron from 'node-cron' +import { db } from '@/server/db' +import { abgleichFenster, kontoAbgleichen } from '@/server/kalender/abgleich' +import { abgelaufeneChallengesEntfernen } from '@/server/auth/passkey' +import { jetzt } from '@/server/zeitgeber' + +/** + * Hintergrundjobs. + * + * Werden einmal je Prozess gestartet. Im Entwicklungsbetrieb lädt Next Module + * mehrfach neu — deshalb der Riegel über globalThis, sonst liefen nach ein + * paar Änderungen ein Dutzend Zeitpläne parallel. + */ + +const globalerBereich = globalThis as unknown as { planerJobs?: boolean } + +/** Alle 15 Minuten abgleichen; öfter bringt nichts und kostet nur Aufrufe. */ +const ABGLEICH_PLAN = '*/15 * * * *' +/** Einmal nachts aufräumen. */ +const AUFRAEUM_PLAN = '17 3 * * *' + +async function alleKontenAbgleichen(): Promise { + const zeitpunkt = jetzt() + const konten = await db.kalenderkonto.findMany({ + where: { aktiv: true }, + select: { id: true }, + }) + + let ok = 0 + let gescheitert = 0 + for (const konto of konten) { + const bericht = await kontoAbgleichen(konto.id, zeitpunkt) + if (bericht.ok) ok++ + else gescheitert++ + } + + if (gescheitert > 0) { + // Nur Zahlen, keine Namen und keine Termininhalte. + console.warn( + `Kalenderabgleich: ${ok} Konto/Konten erfolgreich, ${gescheitert} fehlgeschlagen.`, + ) + } +} + +/** + * Entfernt Termine, die aus dem Abgleichfenster herausgewandert sind. + * Ohne das bliebe der Spiegel für immer stehen. + */ +async function alteTermineEntfernen(): Promise { + const fenster = abgleichFenster(jetzt()) + await db.termin.deleteMany({ where: { ende: { lt: fenster.von } } }) +} + +export function jobsStarten(): void { + if (globalerBereich.planerJobs) return + globalerBereich.planerJobs = true + + cron.schedule(ABGLEICH_PLAN, () => { + void alleKontenAbgleichen().catch((fehler: unknown) => { + console.error('Kalenderabgleich abgebrochen:', fehler) + }) + }) + + cron.schedule(AUFRAEUM_PLAN, () => { + void Promise.all([alteTermineEntfernen(), abgelaufeneChallengesEntfernen(jetzt())]) + .then(() => undefined) + .catch((fehler: unknown) => { + console.error('Aufräumen abgebrochen:', fehler) + }) + }) +} From d8d3227c82896e1d74f41929b44f7d13d4565b90 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 18:12:04 +0200 Subject: [PATCH 06/12] feat(kalender): Belegung des Tages auf der Startseite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jeder Bereich zeigt, wie voll der heutige Tag schon ist: Termine aus allen verbundenen Kalendern gegen die Tageskapazität dieses Bereichs. Alle Kalender zählen gegen jeden Bereich, weil ein Termin die Zeit ohnehin blockiert, gleich zu welchem Lebensbereich er gehört. Wer um 14 Uhr beim Zahnarzt sitzt, kann zur selben Zeit nichts für die Firma tun. Ohne verbundenen Kalender bleibt der Balken weg. Er wäre sonst immer bei null und sähe nach "alles frei" aus — eine Zahl, die lügt, ist schlechter als keine. Co-Authored-By: Claude Opus 5 --- src/app/(app)/page.tsx | 50 ++++++++++++++++------ src/server/abfragen/kalender.ts | 75 +++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 src/server/abfragen/kalender.ts diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 164dcd7..bfb27b8 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -1,6 +1,7 @@ import Link from 'next/link' import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste' import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung' +import { Fortschrittsbalken } from '@/components/fortschrittsbalken' import { Card, CardBeschreibung, @@ -16,6 +17,7 @@ import { projekteZurAuswahlLaden, zaehlstaende, } from '@/server/abfragen/aufgaben' +import { belegungHeute, hatKalenderkonto } from '@/server/abfragen/kalender' import { verlangeAnmeldung } from '@/server/berechtigung' import { db } from '@/server/db' import { jetzt } from '@/server/zeitgeber' @@ -24,16 +26,19 @@ export default async function StartSeite() { const benutzer = await verlangeAnmeldung('/') const heute = jetzt() - const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([ - aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200), - bereicheLaden(), - projekteZurAuswahlLaden(), - zaehlstaende(), - db.bereich.findMany({ - where: { aktiv: true }, - orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], - }), - ]) + const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] = + await Promise.all([ + aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200), + bereicheLaden(), + projekteZurAuswahlLaden(), + zaehlstaende(), + db.bereich.findMany({ + where: { aktiv: true }, + orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], + }), + belegungHeute(benutzer.id, heute), + hatKalenderkonto(benutzer.id), + ]) // Was jetzt zählt: überfällig, heute oder morgen fällig. const dran = alleOffenen.filter( @@ -108,9 +113,10 @@ export default async function StartSeite() {