feat(bereiche): Bereiche verwalten mit Rollenprüfung und Audit

Bereiche mit Farbe, Arbeitsfenster und Tageskapazität – die erste
schreibende Funktion und damit der Beleg, dass die Kette trägt: zod prüft
jede Eingabe, die Rolle wird in der Seite und noch einmal in der Server
Action geprüft, jede Änderung hinterlässt einen Audit-Eintrag.

Die Audit-Details werden vor dem Speichern gesäubert: Felder, deren Name
nach Passwort, Token, Betreff oder Mailinhalt aussieht, werden ersetzt.
Das gilt auch in verschachtelten Objekten und Listen.

Dazu die Protokollseite für die Verwaltung und die Startseite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 13:02:49 +02:00
co-authored by Claude Opus 5
parent cc013fd6a1
commit e9a2b4b463
14 changed files with 1432 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { ENTFERNT, auditDetails, detailsSaeubern } from './audit'
describe('auditDetails', () => {
it('laesst fachliche Felder unangetastet', () => {
expect(auditDetails({ name: 'Gewerbe', farbe: '#2563eb', reihenfolge: 2 })).toEqual({
name: 'Gewerbe',
farbe: '#2563eb',
reihenfolge: 2,
})
})
it('gibt fuer nichts auch nichts zurueck', () => {
expect(auditDetails(undefined)).toBeUndefined()
})
it('entfernt Passwoerter und Tokens', () => {
expect(
auditDetails({
email: 'a@b.de',
passwort: 'geheim123',
passwortHash: '$2b$12$...',
access_token: 'ey...',
}),
).toEqual({
email: 'a@b.de',
passwort: ENTFERNT,
passwortHash: ENTFERNT,
access_token: ENTFERNT,
})
})
it('entfernt Termin-Betreffs und Mailinhalte', () => {
expect(
auditDetails({ betreff: 'Personalgespräch', body: 'Text', dauer: 30 }),
).toEqual({ betreff: ENTFERNT, body: ENTFERNT, dauer: 30 })
})
it('achtet nicht auf Gross- und Kleinschreibung', () => {
expect(auditDetails({ ClientSecret: 'x', REFRESH_TOKEN: 'y' })).toEqual({
ClientSecret: ENTFERNT,
REFRESH_TOKEN: ENTFERNT,
})
})
})
describe('detailsSaeubern', () => {
it('greift auch in verschachtelten Objekten', () => {
expect(detailsSaeubern({ konto: { anbieter: 'GRAPH', id_token: 'ey...' } })).toEqual({
konto: { anbieter: 'GRAPH', id_token: ENTFERNT },
})
})
it('greift in Listen', () => {
expect(detailsSaeubern([{ name: 'a', token: 'x' }])).toEqual([
{ name: 'a', token: ENTFERNT },
])
})
it('schreibt Datumsangaben als ISO-Zeichenkette', () => {
expect(detailsSaeubern({ am: new Date('2026-09-10T12:00:00Z') })).toEqual({
am: '2026-09-10T12:00:00.000Z',
})
})
it('schneidet zu tiefe Verschachtelung ab, statt endlos abzusteigen', () => {
expect(detailsSaeubern({ a: { b: { c: { d: { e: { f: 1 } } } } } })).toEqual({
a: { b: { c: { d: { e: ENTFERNT } } } },
})
})
it('kommt mit einfachen Werten und null zurecht', () => {
expect(detailsSaeubern('text')).toBe('text')
expect(detailsSaeubern(42)).toBe(42)
expect(detailsSaeubern(null)).toBeNull()
})
})
+66
View File
@@ -0,0 +1,66 @@
/**
* Aufbereitung der Audit-Details. Reine Logik, damit sie ohne Datenbank
* geprueft werden kann.
*
* In die Details gehoeren nur fachliche Felder – niemals Tokens, Passwoerter,
* Mailinhalte oder Termin-Betreffs. Was hier landet, steht spaeter im
* Klartext in der Datenbank und in jeder Sicherung.
*/
/** Feldnamen, die auch versehentlich nie in einem Audit-Eintrag landen duerfen. */
const VERBOTEN = [
'passwort',
'password',
'token',
'secret',
'geheim',
'clientsecret',
'betreff',
'subject',
'inhalt',
'body',
'nachricht',
]
export const ENTFERNT = '[entfernt]'
function istVerdaechtig(schluessel: string): boolean {
const klein = schluessel.toLowerCase()
return VERBOTEN.some((v) => klein.includes(v))
}
/**
* Zweiter Riegel gegen Unachtsamkeit: Felder mit verdaechtigem Namen werden
* ersetzt, statt gespeichert zu werden. Wirkt auch in verschachtelten
* Objekten und in Listen.
*/
export function detailsSaeubern(wert: unknown, tiefe = 0): unknown {
// Tief verschachtelte Strukturen gehoeren nicht in ein Audit-Detail; ab
// einer gewissen Tiefe wird abgeschnitten statt endlos abgestiegen.
if (tiefe > 4) return ENTFERNT
if (Array.isArray(wert)) {
return wert.map((eintrag) => detailsSaeubern(eintrag, tiefe + 1))
}
if (wert !== null && typeof wert === 'object') {
if (wert instanceof Date) return wert.toISOString()
const sauber: Record<string, unknown> = {}
for (const [schluessel, inhalt] of Object.entries(wert)) {
sauber[schluessel] = istVerdaechtig(schluessel)
? ENTFERNT
: detailsSaeubern(inhalt, tiefe + 1)
}
return sauber
}
return wert
}
/** Bequemer Einstieg fuer den Normalfall: ein flaches Objekt. */
export function auditDetails(
details: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!details) return undefined
return detailsSaeubern(details) as Record<string, unknown>
}
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest'
import {
KAPAZITAET_FELDER,
bereichSchema,
farbeSchema,
kapazitaetAmTag,
wochenKapazitaet,
} from './bereich'
const gueltig = {
name: 'Gewerbe',
farbe: '#2563EB',
reihenfolge: 1,
aktiv: true,
arbeitsfensterVon: '08:00',
arbeitsfensterBis: '17:00',
kapazitaetMo: 240,
kapazitaetDi: 240,
kapazitaetMi: 240,
kapazitaetDo: 240,
kapazitaetFr: 120,
kapazitaetSa: 0,
kapazitaetSo: 0,
}
describe('farbeSchema', () => {
it('nimmt sechsstellige und dreistellige Hex-Farben', () => {
expect(farbeSchema.parse('#2563eb')).toBe('#2563eb')
expect(farbeSchema.parse('#abc')).toBe('#abc')
})
it('macht Grossbuchstaben klein, damit Vergleiche stimmen', () => {
expect(farbeSchema.parse('#2563EB')).toBe('#2563eb')
})
it('weist Farben ohne Raute und mit falscher Laenge ab', () => {
expect(farbeSchema.safeParse('2563eb').success).toBe(false)
expect(farbeSchema.safeParse('#12345').success).toBe(false)
expect(farbeSchema.safeParse('blau').success).toBe(false)
})
})
describe('bereichSchema', () => {
it('nimmt einen vollstaendigen Bereich an und rechnet Uhrzeiten in Minuten um', () => {
const ergebnis = bereichSchema.parse(gueltig)
expect(ergebnis.arbeitsfensterVon).toBe(480)
expect(ergebnis.arbeitsfensterBis).toBe(1020)
expect(ergebnis.farbe).toBe('#2563eb')
})
it('verlangt einen Namen mit mindestens zwei Zeichen', () => {
const ergebnis = bereichSchema.safeParse({ ...gueltig, name: 'A' })
expect(ergebnis.success).toBe(false)
})
it('weist ein Arbeitsfenster ab, das vor seinem Beginn endet', () => {
const ergebnis = bereichSchema.safeParse({
...gueltig,
arbeitsfensterVon: '17:00',
arbeitsfensterBis: '08:00',
})
expect(ergebnis.success).toBe(false)
if (!ergebnis.success) {
expect(ergebnis.error.issues[0]?.message).toMatch(/nach seinem Beginn enden/)
}
})
it('weist ein Arbeitsfenster ohne Dauer ab', () => {
const ergebnis = bereichSchema.safeParse({
...gueltig,
arbeitsfensterVon: '08:00',
arbeitsfensterBis: '08:00',
})
expect(ergebnis.success).toBe(false)
})
it('weist eine Kapazitaet ab, die laenger ist als das Arbeitsfenster', () => {
// Fenster 08:00–17:00 sind 540 Minuten.
const ergebnis = bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 600 })
expect(ergebnis.success).toBe(false)
if (!ergebnis.success) {
expect(ergebnis.error.issues[0]?.message).toMatch(/Arbeitsfenster/)
}
})
it('laesst eine Kapazitaet genau in Fensterlaenge zu', () => {
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 540 }).success).toBe(true)
})
it('weist negative und uebergrosse Kapazitaeten ab', () => {
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: -1 }).success).toBe(false)
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 1441 }).success).toBe(
false,
)
})
it('weist eine unsinnige Uhrzeit ab', () => {
expect(
bereichSchema.safeParse({ ...gueltig, arbeitsfensterVon: '25:00' }).success,
).toBe(false)
})
})
describe('wochenKapazitaet', () => {
it('summiert alle sieben Tage', () => {
const bereich = bereichSchema.parse(gueltig)
expect(wochenKapazitaet(bereich)).toBe(1080)
})
it('ist null, wenn nichts eingeplant ist', () => {
const leer = Object.fromEntries(KAPAZITAET_FELDER.map((f) => [f, 0])) as Record<
(typeof KAPAZITAET_FELDER)[number],
number
>
expect(wochenKapazitaet(leer)).toBe(0)
})
})
describe('kapazitaetAmTag', () => {
it('trifft den richtigen Wochentag, Montag ist null', () => {
const bereich = bereichSchema.parse(gueltig)
expect(kapazitaetAmTag(bereich, 0)).toBe(240)
expect(kapazitaetAmTag(bereich, 4)).toBe(120)
expect(kapazitaetAmTag(bereich, 6)).toBe(0)
})
it('gibt fuer einen Index ausserhalb der Woche null zurueck', () => {
const bereich = bereichSchema.parse(gueltig)
expect(kapazitaetAmTag(bereich, 7)).toBe(0)
expect(kapazitaetAmTag(bereich, -1)).toBe(0)
})
})
+100
View File
@@ -0,0 +1,100 @@
import { z } from 'zod'
import { ausUhrzeit } from '@/lib/zeit'
/**
* Regeln fuer Bereiche. Reine Logik, damit Formular und Server Action
* dieselbe Antwort geben.
*/
export const WOCHENTAGE = [
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
'Sonntag',
] as const
export const KAPAZITAET_FELDER = [
'kapazitaetMo',
'kapazitaetDi',
'kapazitaetMi',
'kapazitaetDo',
'kapazitaetFr',
'kapazitaetSa',
'kapazitaetSo',
] as const
export type KapazitaetFeld = (typeof KAPAZITAET_FELDER)[number]
/** Hex-Farbe mit Raute, drei- oder sechsstellig. */
export const farbeSchema = z
.string()
.trim()
.regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 'Bitte eine Farbe wie #2563eb angeben.')
.transform((f) => f.toLowerCase())
const uhrzeitSchema = z
.string()
.trim()
.refine((w) => ausUhrzeit(w) !== null, 'Bitte eine Uhrzeit wie 08:00 angeben.')
.transform((w) => ausUhrzeit(w) as number)
/** Eine Tageskapazitaet: 0 bis 24 Stunden, in Minuten. */
const kapazitaetSchema = z.coerce
.number()
.int('Bitte ganze Minuten angeben.')
.min(0, 'Nicht weniger als 0 Minuten.')
.max(1440, 'Nicht mehr als 1440 Minuten, also 24 Stunden.')
export const bereichSchema = z
.object({
name: z
.string()
.trim()
.min(2, 'Mindestens zwei Zeichen.')
.max(40, 'Höchstens 40 Zeichen.'),
farbe: farbeSchema,
reihenfolge: z.coerce.number().int().min(0).max(999).default(0),
aktiv: z.coerce.boolean().default(true),
arbeitsfensterVon: uhrzeitSchema,
arbeitsfensterBis: uhrzeitSchema,
kapazitaetMo: kapazitaetSchema,
kapazitaetDi: kapazitaetSchema,
kapazitaetMi: kapazitaetSchema,
kapazitaetDo: kapazitaetSchema,
kapazitaetFr: kapazitaetSchema,
kapazitaetSa: kapazitaetSchema,
kapazitaetSo: kapazitaetSchema,
})
.refine((b) => b.arbeitsfensterVon < b.arbeitsfensterBis, {
path: ['arbeitsfensterBis'],
message: 'Das Arbeitsfenster muss nach seinem Beginn enden.',
})
.refine(
(b) =>
KAPAZITAET_FELDER.every(
(feld) => b[feld] <= b.arbeitsfensterBis - b.arbeitsfensterVon,
),
{
path: ['kapazitaetMo'],
message: 'Keine Tageskapazität darf länger sein als das Arbeitsfenster.',
},
)
export type BereichEingabe = z.infer<typeof bereichSchema>
/** Summe der Wochenkapazitaet in Minuten. */
export function wochenKapazitaet(bereich: Record<KapazitaetFeld, number>): number {
return KAPAZITAET_FELDER.reduce((summe, feld) => summe + bereich[feld], 0)
}
/** Kapazitaet des Wochentags, 0 = Montag. */
export function kapazitaetAmTag(
bereich: Record<KapazitaetFeld, number>,
wochentagIndex: number,
): number {
const feld = KAPAZITAET_FELDER[wochentagIndex]
return feld ? bereich[feld] : 0
}