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:
co-authored by
Claude Opus 5
parent
a6bcaf8982
commit
79e6c0d556
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user