Compare commits
3
Commits
v0.2.0
...
6fda2a579e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fda2a579e | ||
|
|
79e6c0d556 | ||
|
|
a6bcaf8982 |
@@ -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,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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"kind": "calendar#events",
|
||||||
|
"etag": "\"BEISPIEL-LISTE-3\"",
|
||||||
|
"summary": "person@example.invalid",
|
||||||
|
"timeZone": "Europe/Berlin",
|
||||||
|
"nextSyncToken": "BEISPIEL-SYNC-2",
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user