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';
|
||||
@@ -34,6 +34,7 @@ model Benutzer {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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