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>
This commit is contained in:
Florian Wilde
2026-09-10 18:04:41 +02:00
co-authored by Claude Opus 5
parent a6bcaf8982
commit 79e6c0d556
4 changed files with 254 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()) erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt aktualisiertAm DateTime @updatedAt
konten Konto[] konten Konto[]
passkeys Passkey[] passkeys Passkey[]
audit AuditEintrag[] audit AuditEintrag[]
kalenderkonten Kalenderkonto[]
@@index([aktiv]) @@index([aktiv])
@@map("benutzer") @@map("benutzer")
@@ -121,6 +122,10 @@ model Bereich {
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt 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[] aufgaben Aufgabe[]
projekte Projekt[] projekte Projekt[]
@@ -252,3 +257,86 @@ model AuditEintrag {
@@index([benutzerId]) @@index([benutzerId])
@@map("audit_eintrag") @@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")
}
+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
}