3 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 6fda2a579e feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen
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 <noreply@anthropic.com>
2026-09-10 18:04:41 +02:00
Florian WildeandClaude Opus 5 79e6c0d556 feat(kalender): Datenmodell für Kalenderkonten und Termine
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 <noreply@anthropic.com>
2026-09-10 18:04:41 +02:00
Florian WildeandClaude Opus 5 a6bcaf8982 Merge: Meilenstein 2 – Aufgaben und Projekte (0.2.0)
Schnellerfassung, Inbox mit Einsortieren, Aufgabenliste mit Filtern in der
Adresszeile, Ideen als eigene Ansicht und Projekte mit Fortschritt.

Geprüft vor dem Merge: npm run pruefen mit 131 Unit-Tests, 30
Integrationsprüfungen, Migrationstest und 14 E2E-Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:47:43 +02:00
17 changed files with 1475 additions and 3 deletions
@@ -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;
@@ -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';
+91 -3
View File
@@ -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")
}
+337
View File
@@ -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> = {},
): 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([])
})
})
+217
View File
@@ -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<T extends { beginn: Date; ende: Date }>(
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)
}
+104
View File
@@ -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([])
})
})
+118
View File
@@ -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,
}
}
+130
View File
@@ -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([])
})
})
+124
View File
@@ -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,
}
}
+56
View File
@@ -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'
}
}
+84
View File
@@ -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:<iv>:<marke>:<inhalt>", 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
}
+8
View File
@@ -0,0 +1,8 @@
{
"kind": "calendar#events",
"etag": "\"BEISPIEL-LISTE-3\"",
"summary": "person@example.invalid",
"timeZone": "Europe/Berlin",
"nextSyncToken": "BEISPIEL-SYNC-2",
"items": []
}
+29
View File
@@ -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" }
}
]
}
+27
View File
@@ -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"
}
]
}
+21
View File
@@ -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"
}
}
]
}
+29
View File
@@ -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" }
}
]
}
+21
View File
@@ -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" }
}
]
}