feat: Anmeldung mit Pflicht-2FA, Rollen und Audit-Log

Auth.js mit Zugangsdaten-Provider: E-Mail, Passwort und TOTP-Code werden
in einem Formular abgefragt. Ein zweistufiger Ablauf muesste zwischen den
Schritten einen Zwischenzustand halten - genau das ist die uebliche
Schwachstelle, und am Handy ist ein Formular ohnehin schneller.

Anmeldung:
- Sperre wird VOR dem Passwortvergleich geprueft, damit ein gesperrter
  Zugang nicht einmal Argon2-Rechenzeit kostet.
- Bei unbekannter Adresse wird gegen einen Dummy-Hash gerechnet und die
  Antwort auf eine Mindestdauer gestreckt. Sonst verraet die Antwortzeit,
  welche Adressen existieren.
- Wiederherstellungscodes werden nach Gebrauch entfernt.
- Veraltete Argon2-Parameter werden beim erfolgreichen Login nachgezogen.

Rate-Limiting in der Datenbank statt Redis - ein Server, wenige Benutzer,
kein Grund fuer eine weitere Komponente. Gezaehlt wird nach E-Mail und
nach Anschluss, damit weder viele Anschluesse noch viele Adressen helfen.

Zugriffsschutz (sitzung.ts):
- Die Sitzung allein entscheidet nichts. Bei jedem Aufruf wird der
  Benutzer frisch geladen und die Sitzungsgeneration verglichen - so
  lassen sich alle Anmeldungen auf einen Schlag beenden.
- erfordereZugang() prueft immer gegen die betreuteId, nie nur im UI.
- Erneute Bestaetigung vor sensiblen Aktionen ueber ein signiertes,
  kurzlebiges Cookie statt ueber Serverzustand.

Audit-Log: aenderungErmitteln() protokolliert nur die tatsaechlich
geaenderten Felder; Passwoerter und TOTP-Geheimnisse werden ersetzt, nie
gespeichert. Ein Fehler im Protokoll bricht die Fachaktion nicht ab -
ausser die Aktion laeuft in einer Transaktion, dann faellt beides.

CSP mit Nonce pro Anfrage in der Middleware: keine Fremdquellen, kein
CDN, keine Fonts von aussen. Fachseiten werden nicht zwischengespeichert.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:46:20 +02:00
parent c49810d092
commit bd1c7b801f
9 changed files with 1031 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
import 'server-only'
import { headers } from 'next/headers'
import type { AuditAktion, Prisma } from '@prisma/client'
import { db } from '@/lib/db'
import { verschluesseln } from '@/lib/crypto/feld'
import { ipHashen } from '@/lib/crypto/hash'
/**
* Audit-Log.
*
* Jede schreibende Aktion und jeder Dokumentzugriff wird protokolliert:
* wer, wann, was, alter Wert -> neuer Wert. Der Eintrag ist unveraenderlich
* (Datenbank-Trigger, siehe prisma/sql/audit-schutz.sql).
*
* Das ist nicht nur Datenschutz-Kuer. Als ehrenamtlicher Betreuer muss ich
* gegenueber dem Betreuungsgericht und moeglichen Miterben belegen koennen,
* was ich wann getan habe. Das Log ist dieser Beleg.
*
* Die geaenderten Werte werden verschluesselt abgelegt - dort stehen sonst
* Diagnosen und Kontostaende im Klartext.
*/
export type Aenderung = Record<string, { alt: unknown; neu: unknown }>
type ProtokollEingabe = {
aktion: AuditAktion
entitaet: string
entitaetId?: string | null
bezeichnung?: string | null
betreuteId?: string | null
userId?: string | null
userEmail?: string | null
userName?: string | null
aenderung?: Aenderung | null
/** Optional: laeuft die Aktion in einer Transaktion mit? */
tx?: Prisma.TransactionClient
}
/** Felder, die nie ins Log gehoeren - auch nicht verschluesselt. */
const NIEMALS_PROTOKOLLIEREN = new Set([
'passwortHash',
'passwort',
'passwortWiederholung',
'totpSecretVerschluesselt',
'wiederherstellungscodes',
])
/**
* Liest IP und User-Agent aus der laufenden Anfrage. Ausserhalb eines
* Request-Kontexts (z.B. im Seed oder in einem Cron-Job) gibt es beides
* nicht - dann bleibt das Feld leer, statt dass die Aktion scheitert.
*/
async function anfragekontext(): Promise<{ ipHash: string | null; userAgent: string | null }> {
try {
const kopf = await headers()
// Hinter dem Reverse Proxy steht die echte Adresse in X-Forwarded-For.
const weitergeleitet = kopf.get('x-forwarded-for')?.split(',')[0]?.trim()
const ip = weitergeleitet || kopf.get('x-real-ip') || null
return {
ipHash: ipHashen(ip),
userAgent: kopf.get('user-agent')?.slice(0, 250) ?? null,
}
} catch {
return { ipHash: null, userAgent: null }
}
}
/**
* Schreibt einen Eintrag. Wirft bewusst nicht: ein Fehler im Protokoll
* darf die eigentliche Fachaktion nicht abbrechen - er wird geloggt und
* faellt beim naechsten Blick in die Serverlogs auf.
*
* Laeuft die Aktion in einer Transaktion (tx uebergeben), gilt das nicht:
* dann soll der Protokolleintrag zusammen mit der Aenderung stehen oder
* fallen.
*/
export async function protokollieren(eingabe: ProtokollEingabe): Promise<void> {
const { tx, aenderung, ...rest } = eingabe
const klient = tx ?? db
try {
const kontext = await anfragekontext()
const gefiltert = aenderung ? aenderungFiltern(aenderung) : null
await klient.auditLog.create({
data: {
aktion: rest.aktion,
entitaet: rest.entitaet,
entitaetId: rest.entitaetId ?? null,
bezeichnung: rest.bezeichnung ?? null,
betreuteId: rest.betreuteId ?? null,
userId: rest.userId ?? null,
userEmail: rest.userEmail ?? null,
userName: rest.userName ?? null,
aenderungVerschluesselt:
gefiltert && Object.keys(gefiltert).length > 0
? verschluesseln(JSON.stringify(gefiltert), 'aenderungVerschluesselt')
: null,
ipHash: kontext.ipHash,
userAgent: kontext.userAgent,
},
})
} catch (fehler) {
if (tx) throw fehler
console.error('[audit] Eintrag konnte nicht geschrieben werden:', fehler)
}
}
function aenderungFiltern(aenderung: Aenderung): Aenderung {
const ergebnis: Aenderung = {}
for (const [feld, werte] of Object.entries(aenderung)) {
if (NIEMALS_PROTOKOLLIEREN.has(feld)) {
ergebnis[feld] = { alt: '[entfernt]', neu: '[entfernt]' }
continue
}
ergebnis[feld] = werte
}
return ergebnis
}
/**
* Vergleicht zwei Datensaetze und liefert nur die tatsaechlich geaenderten
* Felder. So steht im Log nicht bei jedem Speichern der ganze Datensatz.
*
* Verschluesselte Felder werden am Suffix erkannt: verglichen wird der
* Klartext (den der Aufrufer mitgibt), protokolliert ebenfalls der
* Klartext - der Eintrag selbst ist ja verschluesselt.
*/
export function aenderungErmitteln(
vorher: Record<string, unknown>,
nachher: Record<string, unknown>,
): Aenderung {
const aenderung: Aenderung = {}
for (const feld of Object.keys(nachher)) {
if (feld.endsWith('Verschluesselt')) continue // Umschlaege aendern sich immer
if (['aktualisiertAm', 'erstelltAm', 'id'].includes(feld)) continue
const alt = vorher[feld]
const neu = nachher[feld]
if (!gleich(alt, neu)) {
aenderung[feld] = { alt: normalisieren(alt), neu: normalisieren(neu) }
}
}
return aenderung
}
function gleich(a: unknown, b: unknown): boolean {
if (a === b) return true
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
if (a instanceof Date || b instanceof Date) {
return normalisieren(a) === normalisieren(b)
}
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((wert, i) => gleich(wert, b[i]))
}
if (a === null || a === undefined) return b === null || b === undefined
return false
}
/** BigInt und Date sind nicht JSON-faehig - hier in Text wandeln. */
function normalisieren(wert: unknown): unknown {
if (wert instanceof Date) return wert.toISOString()
if (typeof wert === 'bigint') return wert.toString()
return wert ?? null
}
+107
View File
@@ -0,0 +1,107 @@
import 'server-only'
import { db } from '@/lib/db'
import { env } from '@/lib/env'
import { ipHashen } from '@/lib/crypto/hash'
/**
* Rate-Limiting am Anmeldeformular.
*
* Bewusst in der Datenbank statt in Redis: die Anwendung laeuft auf einem
* einzelnen kleinen Server, eine zusaetzliche Komponente waere Aufwand ohne
* Gegenwert. Bei ein paar Benutzern und wenigen Anmeldungen pro Tag ist die
* Tabelle winzig; ein Aufraeumen nach 30 Tagen haelt sie klein.
*
* Gezaehlt wird nach E-Mail UND nach Anschluss (IP-Hash). Damit hilft es
* weder, dieselbe Adresse von vielen Anschluessen zu probieren, noch viele
* Adressen von einem Anschluss.
*/
export type SperrErgebnis =
| { gesperrt: false; verbleibendeVersuche: number }
| { gesperrt: true; nochSekunden: number }
function fensterStart(): Date {
return new Date(Date.now() - env.loginFensterSekunden * 1000)
}
/**
* Prueft, ob eine Anmeldung ueberhaupt versucht werden darf. Wird VOR dem
* Passwortvergleich aufgerufen - ein gesperrter Zugang soll gar nicht erst
* Rechenzeit fuer Argon2 kosten.
*/
export async function anmeldungErlaubt(
email: string,
ip: string | null,
): Promise<SperrErgebnis> {
const seit = fensterStart()
const kennung = email.trim().toLowerCase()
const ipH = ipHashen(ip)
const fehlversuche = await db.loginVersuch.findMany({
where: {
erfolg: false,
zeitpunkt: { gte: seit },
OR: [{ email: kennung }, ...(ipH ? [{ ipHash: ipH }] : [])],
},
orderBy: { zeitpunkt: 'desc' },
select: { zeitpunkt: true },
take: env.loginMaxVersuche + 1,
})
if (fehlversuche.length >= env.loginMaxVersuche) {
const juengster = fehlversuche[0]!.zeitpunkt.getTime()
const frei = juengster + env.loginSperreSekunden * 1000
const nochSekunden = Math.max(1, Math.ceil((frei - Date.now()) / 1000))
return { gesperrt: true, nochSekunden }
}
return { gesperrt: false, verbleibendeVersuche: env.loginMaxVersuche - fehlversuche.length }
}
/** Haelt einen Versuch fest. Der Grund hilft spaeter beim Nachvollziehen. */
export async function versuchFesthalten(
email: string,
ip: string | null,
erfolg: boolean,
grund?: string,
): Promise<void> {
await db.loginVersuch.create({
data: {
email: email.trim().toLowerCase(),
ipHash: ipHashen(ip),
erfolg,
grund: grund ?? null,
},
})
}
/**
* Nach einer erfolgreichen Anmeldung werden die Fehlversuche dieser
* Adresse geloescht - sonst bliebe der Zaehler bis zum Fensterende stehen.
*/
export async function fehlversucheZuruecksetzen(email: string): Promise<void> {
await db.loginVersuch.deleteMany({
where: { email: email.trim().toLowerCase(), erfolg: false },
})
}
/** Aufraeumen; wird vom Wartungslauf aufgerufen. */
export async function alteVersucheEntfernen(): Promise<number> {
const grenze = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
const { count } = await db.loginVersuch.deleteMany({
where: { zeitpunkt: { lt: grenze } },
})
return count
}
/**
* Gleicht die Antwortzeit an. Ohne das verraet ein schneller "Benutzer
* unbekannt" gegenueber einem langsamen "Passwort falsch", welche
* E-Mail-Adressen es gibt.
*/
export async function aufMindestdauerWarten(begonnenAm: number, mindestMs = 400): Promise<void> {
const vergangen = Date.now() - begonnenAm
if (vergangen < mindestMs) {
await new Promise((fertig) => setTimeout(fertig, mindestMs - vergangen))
}
}
+89
View File
@@ -0,0 +1,89 @@
import type { Modul, Rolle } from '@prisma/client'
/**
* Rollen und Modulfreigaben - ohne "server-only", weil die Beschriftungen
* auch im Browser gebraucht werden.
*
* Wichtig: Diese Datei entscheidet nichts allein. Der eigentliche
* Zugriffsschutz sitzt in src/lib/auth/sitzung.ts und prueft serverseitig
* gegen die betreuteId. Hier stehen nur die Regeln, damit sie an einem Ort
* nachlesbar sind.
*/
export const ROLLEN_BESCHRIFTUNG: Record<Rolle, string> = {
BETREUER: 'Betreuer',
MITLESER: 'Mitleser',
ADMIN: 'Administrator',
}
export const ROLLEN_ERKLAERUNG: Record<Rolle, string> = {
BETREUER: 'Darf alles sehen und ändern, was zu den zugeordneten Betreuten gehört.',
MITLESER: 'Darf nur lesen, und nur die ausdrücklich freigegebenen Bereiche.',
ADMIN: 'Verwaltet Benutzer und Systemeinstellungen.',
}
export const MODUL_BESCHRIFTUNG: Record<Modul, string> = {
STAMMDATEN: 'Stammdaten',
VORGAENGE: 'Vorgänge',
FRISTEN: 'Fristen',
DOKUMENTE: 'Dokumente',
FINANZEN: 'Vermögen & Kassenbuch',
SOZIALHILFE: 'Sozialhilfe',
IMMOBILIE: 'Immobilie',
VERTRAEGE: 'Versicherungen & Verträge',
GESUNDHEIT: 'Heim & Pflege',
KONTAKTE: 'Kontakte',
BERICHTE: 'Berichte',
}
export const ALLE_MODULE: Modul[] = [
'STAMMDATEN',
'VORGAENGE',
'FRISTEN',
'DOKUMENTE',
'FINANZEN',
'SOZIALHILFE',
'IMMOBILIE',
'VERTRAEGE',
'GESUNDHEIT',
'KONTAKTE',
'BERICHTE',
]
/**
* Module mit besonders sensiblen Inhalten. Fuer sie ist eine Freigabe an
* Mitleser eine bewusste Entscheidung - die Oberflaeche weist darauf hin.
*/
export const BESONDERS_SENSIBEL: Modul[] = ['GESUNDHEIT', 'FINANZEN', 'SOZIALHILFE']
export type Zugriffsart = 'lesen' | 'schreiben'
export type Zugang = {
rolle: Rolle
freigegebeneModule: Modul[]
}
/**
* Die eine Regel, nach der entschieden wird:
*
* - BETREUER darf alles, lesen wie schreiben.
* - ADMIN darf alles (er verwaltet die Anwendung).
* - MITLESER darf ausschliesslich lesen, und nur freigegebene Module.
*/
export function darf(zugang: Zugang | null, modul: Modul, art: Zugriffsart = 'lesen'): boolean {
if (!zugang) return false
if (zugang.rolle === 'BETREUER' || zugang.rolle === 'ADMIN') return true
if (zugang.rolle === 'MITLESER') {
if (art === 'schreiben') return false
return zugang.freigegebeneModule.includes(modul)
}
return false
}
/** Kurzform fuer die Oberflaeche. */
export function darfSchreiben(zugang: Zugang | null, modul: Modul): boolean {
return darf(zugang, modul, 'schreiben')
}
+213
View File
@@ -0,0 +1,213 @@
import 'server-only'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { createHmac, timingSafeEqual } from 'node:crypto'
import type { Betreute, Modul, User } from '@prisma/client'
import { auth } from '@/auth'
import { db, nichtGeloescht } from '@/lib/db'
import { env } from '@/lib/env'
import { darf, type Zugang, type Zugriffsart } from '@/lib/auth/rollen'
/**
* Zugriffsschutz.
*
* Grundsatz: Es wird immer serverseitig gegen die betreuteId geprueft, nie
* nur im UI ausgeblendet. Jede Seite und jede Server Action, die Daten einer
* betreuten Person anfasst, geht durch erfordereZugang().
*/
export const COOKIE_AKTIVE_BETREUTE = 'bc_betreute'
export const COOKIE_REAUTH = 'bc_reauth'
export class ZugriffVerweigert extends Error {
constructor(nachricht = 'Für diesen Bereich fehlt dir die Berechtigung.') {
super(nachricht)
this.name = 'ZugriffVerweigert'
}
}
/**
* Der angemeldete Benutzer, frisch aus der Datenbank.
*
* Die Sitzung allein reicht nicht: Rolle, Aktiv-Status und die
* Sitzungsgeneration koennen sich geaendert haben, seit das Token
* ausgestellt wurde. Stimmt die Generation nicht mehr, ist die Sitzung
* ungueltig - so lassen sich alle Anmeldungen eines Benutzers auf einen
* Schlag beenden (Passwortwechsel, Verdacht auf Kompromittierung).
*/
export async function aktuellerBenutzer(): Promise<User | null> {
const sitzung = await auth()
if (!sitzung?.user?.id) return null
const benutzer = await db.user.findUnique({ where: { id: sitzung.user.id } })
if (!benutzer || !benutzer.aktiv || benutzer.geloeschtAm) return null
if (benutzer.sitzungsGeneration !== sitzung.user.sitzungsGeneration) return null
return benutzer
}
/** Wie aktuellerBenutzer, leitet aber zum Anmeldeformular um. */
export async function erfordereBenutzer(zielNachAnmeldung?: string): Promise<User> {
const benutzer = await aktuellerBenutzer()
if (!benutzer) {
const ziel = zielNachAnmeldung ? `?weiter=${encodeURIComponent(zielNachAnmeldung)}` : ''
redirect(`/anmelden${ziel}`)
}
return benutzer
}
/**
* Erzwingt den eingerichteten zweiten Faktor. Wird im App-Layout
* aufgerufen: ohne TOTP kommt niemand an Fachdaten.
*/
export async function erfordereZweitenFaktor(benutzer: User): Promise<void> {
if (!benutzer.totpAktiviertAm || !benutzer.totpSecretVerschluesselt) {
redirect('/zwei-faktor/einrichten')
}
}
// -----------------------------------------------------------------------------
// Zugriff auf eine betreute Person
// -----------------------------------------------------------------------------
export type ZugangsKontext = {
benutzer: User
betreute: Betreute
zugang: Zugang
/** Bequemer Test fuer die Oberflaeche. */
darfSchreiben: (modul: Modul) => boolean
}
/**
* Prueft, ob der Benutzer auf diese betreute Person und dieses Modul
* zugreifen darf. Wirft ZugriffVerweigert, wenn nicht.
*/
export async function erfordereZugang(
betreuteId: string,
modul: Modul,
art: Zugriffsart = 'lesen',
): Promise<ZugangsKontext> {
const benutzer = await erfordereBenutzer()
const betreute = await db.betreute.findFirst({
where: { id: betreuteId, ...nichtGeloescht },
})
if (!betreute) throw new ZugriffVerweigert('Diese betreute Person gibt es nicht.')
const eintrag = await db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId: benutzer.id, betreuteId } },
select: { rolle: true, freigegebeneModule: true },
})
// Administratoren haben Zugriff, auch ohne eigenen Eintrag - sie
// verwalten die Anwendung. Jeder Zugriff steht im Audit-Log.
const zugang: Zugang | null = eintrag
? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule }
: benutzer.rolle === 'ADMIN'
? { rolle: 'ADMIN', freigegebeneModule: [] }
: null
if (!darf(zugang, modul, art)) {
throw new ZugriffVerweigert()
}
return {
benutzer,
betreute,
zugang: zugang!,
darfSchreiben: (m: Modul) => darf(zugang, m, 'schreiben'),
}
}
/** Alle betreuten Personen, auf die der Benutzer Zugriff hat. */
export async function meineBetreuten(benutzer: User): Promise<Betreute[]> {
if (benutzer.rolle === 'ADMIN') {
return db.betreute.findMany({
where: nichtGeloescht,
orderBy: [{ nachname: 'asc' }, { vorname: 'asc' }],
})
}
const zugaenge = await db.betreuungsZugang.findMany({
where: { userId: benutzer.id, betreute: nichtGeloescht },
include: { betreute: true },
orderBy: { betreute: { nachname: 'asc' } },
})
return zugaenge.map((z) => z.betreute)
}
/**
* Die gerade ausgewaehlte betreute Person. Merkt sich die Auswahl in einem
* Cookie; bei nur einer betreuten Person - der Regelfall - wird sie
* automatisch gewaehlt.
*/
export async function aktiveBetreute(benutzer: User): Promise<Betreute | null> {
const alle = await meineBetreuten(benutzer)
if (alle.length === 0) return null
const keks = await cookies()
const gemerkt = keks.get(COOKIE_AKTIVE_BETREUTE)?.value
const treffer = gemerkt ? alle.find((b) => b.id === gemerkt) : undefined
return treffer ?? alle[0]!
}
// -----------------------------------------------------------------------------
// Erneute Bestaetigung vor sensiblen Aktionen
// -----------------------------------------------------------------------------
/**
* Vor Export, endgueltigem Loeschen und Benutzerverwaltung muss die
* Anmeldung frisch sein. Statt eines serverseitigen Zustands wird ein
* signiertes, kurzlebiges Cookie gesetzt: <userId>.<ablauf>.<signatur>.
* Ohne den AUTH_SECRET laesst es sich nicht faelschen.
*/
function reauthSignieren(userId: string, ablauf: number): string {
const geheim = process.env.AUTH_SECRET ?? ''
return createHmac('sha256', geheim).update(`${userId}.${ablauf}`).digest('base64url')
}
export async function reauthBestaetigen(userId: string): Promise<void> {
const ablauf = Math.floor(Date.now() / 1000) + env.reauthMaxAlterSekunden
const keks = await cookies()
keks.set(COOKIE_REAUTH, `${userId}.${ablauf}.${reauthSignieren(userId, ablauf)}`, {
httpOnly: true,
sameSite: 'strict',
secure: env.istProduktion,
path: '/',
maxAge: env.reauthMaxAlterSekunden,
})
}
export async function reauthGueltig(userId: string): Promise<boolean> {
const keks = await cookies()
const wert = keks.get(COOKIE_REAUTH)?.value
if (!wert) return false
const [id, ablaufText, signatur] = wert.split('.')
if (!id || !ablaufText || !signatur) return false
if (id !== userId) return false
const ablauf = Number.parseInt(ablaufText, 10)
if (Number.isNaN(ablauf) || ablauf < Math.floor(Date.now() / 1000)) return false
const erwartet = Buffer.from(reauthSignieren(id, ablauf))
const vorhanden = Buffer.from(signatur)
if (erwartet.length !== vorhanden.length) return false
return timingSafeEqual(erwartet, vorhanden)
}
/**
* Leitet zur Bestaetigungsseite um, wenn die Anmeldung zu lange her ist.
* Der Rueckweg steht im Parameter "weiter".
*/
export async function erfordereFrischeAnmeldung(benutzer: User, weiter: string): Promise<void> {
if (await reauthGueltig(benutzer.id)) return
redirect(`/bestaetigen?weiter=${encodeURIComponent(weiter)}`)
}
export async function reauthVerwerfen(): Promise<void> {
const keks = await cookies()
keks.delete(COOKIE_REAUTH)
}
+59
View File
@@ -0,0 +1,59 @@
import 'server-only'
import { PrismaClient } from '@prisma/client'
/**
* Prisma-Client als Singleton.
*
* Im Entwicklungsmodus laedt Next.js Module bei jeder Aenderung neu. Ohne
* diesen Umweg ueber globalThis entstuende bei jedem Speichern eine neue
* Verbindung, bis Postgres keine mehr annimmt.
*/
const globalFuerPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalFuerPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? [{ emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }]
: [{ emit: 'stdout', level: 'error' }],
})
if (process.env.NODE_ENV !== 'production') {
globalFuerPrisma.prisma = db
}
/**
* Standardfilter: Der Papierkorb wird ueberall ausgeblendet. Wer die
* geloeschten Datensaetze sehen will, fragt sie ausdruecklich ab.
*/
export const nichtGeloescht = { geloeschtAm: null } as const
/**
* Stichtag, ab dem ein Datensatz im Papierkorb endgueltig entfernt wird.
* 30 Tage - lang genug fuer ein "oh, das brauchte ich doch noch".
*/
export const PAPIERKORB_TAGE = 30
export function papierkorbGrenze(jetzt = new Date()): Date {
const grenze = new Date(jetzt)
grenze.setDate(grenze.getDate() - PAPIERKORB_TAGE)
return grenze
}
/**
* BigInt laesst sich nicht als JSON serialisieren. Server Components
* reichen Daten an Client Components weiter - deshalb hier ein Helfer,
* der Cent-Betraege auf dem Weg nach draussen in Number wandelt.
* Cent-Betraege bis 90 Billionen Euro bleiben dabei exakt.
*/
export function centZuZahl(wert: bigint | null | undefined): number | null {
if (wert === null || wert === undefined) return null
if (wert > BigInt(Number.MAX_SAFE_INTEGER) || wert < BigInt(-Number.MAX_SAFE_INTEGER)) {
throw new Error(`Betrag ${wert} liegt ausserhalb des sicher darstellbaren Bereichs.`)
}
return Number(wert)
}