feat: Vollstaendiges Datenmodell als Prisma-Schema
Alle Fachentitaeten der Betreuung in einem Schema - bewusst komplett, damit spaetere Etappen keine Migration am Fundament brauchen. Kernentscheidungen: - Geldbetraege durchgaengig als BigInt in Cent. Eine Rechnungslegung nach § 1865 BGB darf keine Rundungsfehler erben. - Felder mit Suffix Verschluesselt tragen einen AES-256-GCM-Umschlag. Wo trotzdem gesucht werden muss (IBAN), steht ein HMAC-Blindindex daneben. - Fristen und Stichtage sind @db.Date, nicht DateTime - sonst verschiebt die Zeitzone eine Frist um einen Tag. - Frist.systemSchluessel macht die aus dem Bestellungsdatum abgeleiteten Pflichtfristen idempotent. - Genehmigung.beschlussDokumentId ist die technische Voraussetzung dafuer, dass ein Vorgang ohne Beschluss nicht auf VOLLZOGEN gesetzt werden kann. - DokumentVerknuepfung mit echten Fremdschluesseln statt loser Typ/ID- Spalte: eine Akte muss referenzsicher sein. - Wertstand und Saldostand sind Historien - Werte werden nie ueberschrieben. Dazu zwei SQL-Skripte, die Prisma nicht abbilden kann: - volltextsuche.sql: tsvector-Trigger (deutsch) mit Gewichtung A/B/C, plus Trigram-Indizes fuer unscharfe Absendersuche. - audit-schutz.sql: Trigger, die UPDATE und DELETE auf audit_log verweigern. Das Log ist Nachweis gegenueber Gericht und Miterben, also nur anhaengend - auch gegen psql. Etappe: 1 Phase: Fundament
This commit is contained in:
+5
-2
@@ -20,9 +20,12 @@
|
|||||||
"db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql",
|
"db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql",
|
||||||
"db:seed": "prisma db seed",
|
"db:seed": "prisma db seed",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"setup": "prisma migrate deploy && npm run db:volltext && npm run db:seed",
|
"setup": "prisma migrate deploy && npm run db:sql && npm run db:seed",
|
||||||
"schluessel": "node scripts/schluessel-erzeugen.mjs",
|
"schluessel": "node scripts/schluessel-erzeugen.mjs",
|
||||||
"postinstall": "prisma generate"
|
"postinstall": "prisma generate",
|
||||||
|
"db:audit-schutz": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/audit-schutz.sql",
|
||||||
|
"db:sql": "npm run db:volltext && npm run db:audit-schutz",
|
||||||
|
"protokoll": "node scripts/bauprotokoll.mjs"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Audit-Log unveraenderlich machen
|
||||||
|
-- =============================================================================
|
||||||
|
--
|
||||||
|
-- Aufruf: npm run db:audit-schutz
|
||||||
|
--
|
||||||
|
-- Das Audit-Log ist mein Nachweis gegenueber Betreuungsgericht und moeglichen
|
||||||
|
-- Miterben. Es muss deshalb nur anhaengend sein: UPDATE und DELETE werden auf
|
||||||
|
-- Datenbankebene verweigert - auch dann, wenn die Anwendung selbst es
|
||||||
|
-- versuchen wuerde oder jemand mit psql direkt an der Datenbank sitzt.
|
||||||
|
--
|
||||||
|
-- Einzige Ausnahme: das Aufraeumen von Eintraegen, die aelter als die
|
||||||
|
-- Aufbewahrungsfrist sind. Dafuer gibt es die Funktion
|
||||||
|
-- audit_log_aufraeumen(intervall), die den Schutz kontrolliert umgeht.
|
||||||
|
--
|
||||||
|
-- Das Skript ist idempotent.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION audit_log_ist_unveraenderlich()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Die Aufraeum-Funktion setzt diese Sitzungsvariable, um gezielt loeschen
|
||||||
|
-- zu duerfen. Alles andere wird abgewiesen.
|
||||||
|
IF current_setting('betreuung.audit_aufraeumen', true) = 'ja' THEN
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Das Audit-Log ist unveraenderlich. % auf audit_log ist nicht zulaessig.',
|
||||||
|
TG_OP
|
||||||
|
USING ERRCODE = 'insufficient_privilege';
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS audit_log_kein_update ON audit_log;
|
||||||
|
CREATE TRIGGER audit_log_kein_update
|
||||||
|
BEFORE UPDATE ON audit_log
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_log_ist_unveraenderlich();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS audit_log_kein_delete ON audit_log;
|
||||||
|
CREATE TRIGGER audit_log_kein_delete
|
||||||
|
BEFORE DELETE ON audit_log
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_log_ist_unveraenderlich();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Kontrolliertes Aufraeumen alter Eintraege.
|
||||||
|
-- Beispiel: SELECT audit_log_aufraeumen('10 years');
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION audit_log_aufraeumen(aufbewahrung interval)
|
||||||
|
RETURNS bigint AS $$
|
||||||
|
DECLARE
|
||||||
|
geloescht bigint;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('betreuung.audit_aufraeumen', 'ja', true);
|
||||||
|
DELETE FROM audit_log WHERE zeitpunkt < now() - aufbewahrung;
|
||||||
|
GET DIAGNOSTICS geloescht = ROW_COUNT;
|
||||||
|
PERFORM set_config('betreuung.audit_aufraeumen', 'nein', true);
|
||||||
|
RETURN geloescht;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Aufraeumen der Login-Versuche (Rate-Limiting-Historie, 30 Tage genuegen).
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION login_versuche_aufraeumen()
|
||||||
|
RETURNS bigint AS $$
|
||||||
|
DECLARE
|
||||||
|
geloescht bigint;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM login_versuch WHERE zeitpunkt < now() - interval '30 days';
|
||||||
|
GET DIAGNOSTICS geloescht = ROW_COUNT;
|
||||||
|
RETURN geloescht;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Volltextsuche ueber Dokumente (deutsche Konfiguration)
|
||||||
|
-- =============================================================================
|
||||||
|
--
|
||||||
|
-- Aufruf: npm run db:volltext
|
||||||
|
--
|
||||||
|
-- Die Spalte "suchvektor" legt Prisma als Unsupported("tsvector") an. Befuellt
|
||||||
|
-- wird sie hier per Trigger, damit jede Aenderung an Titel, Absender,
|
||||||
|
-- Aktenzeichen oder OCR-Text sofort in der Suche ankommt.
|
||||||
|
--
|
||||||
|
-- Gewichtung:
|
||||||
|
-- A = Titel (schlaegt am staerksten durch)
|
||||||
|
-- B = Absender, Aktenzeichen, Dateiname
|
||||||
|
-- C = OCR-Volltext
|
||||||
|
--
|
||||||
|
-- Das Skript ist idempotent und kann nach jeder Migration erneut laufen.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Fuzzy-Suche fuer Absendernamen ("Bezirk Oberfr." findet "Bezirk Oberfranken").
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
-- Erlaubt das Entfernen von Akzenten in der Suche.
|
||||||
|
CREATE EXTENSION IF NOT EXISTS unaccent;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION dokument_suchvektor_aktualisieren()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.suchvektor :=
|
||||||
|
setweight(to_tsvector('german', coalesce(NEW.titel, '')), 'A')
|
||||||
|
|| setweight(to_tsvector('german', coalesce(NEW.absender, '')), 'B')
|
||||||
|
|| setweight(to_tsvector('german', coalesce(NEW.empfaenger, '')), 'B')
|
||||||
|
|| setweight(to_tsvector('german', coalesce(NEW.aktenzeichen, '')), 'B')
|
||||||
|
|| setweight(to_tsvector('german', coalesce(NEW.dateiname, '')), 'B')
|
||||||
|
|| setweight(to_tsvector('german', coalesce(NEW."ocrText", '')), 'C');
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS dokument_suchvektor ON dokument;
|
||||||
|
|
||||||
|
CREATE TRIGGER dokument_suchvektor
|
||||||
|
BEFORE INSERT OR UPDATE OF titel, absender, empfaenger, aktenzeichen, dateiname, "ocrText"
|
||||||
|
ON dokument
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION dokument_suchvektor_aktualisieren();
|
||||||
|
|
||||||
|
-- Bestehende Zeilen einmalig nachziehen (z.B. nach einem Restore).
|
||||||
|
UPDATE dokument SET titel = titel WHERE suchvektor IS NULL;
|
||||||
|
|
||||||
|
-- Trigram-Index fuer die unscharfe Suche nach Absendern.
|
||||||
|
CREATE INDEX IF NOT EXISTS dokument_absender_trgm
|
||||||
|
ON dokument USING gin (absender gin_trgm_ops);
|
||||||
|
|
||||||
|
-- Trigram-Index fuer die Vorgangssuche.
|
||||||
|
CREATE INDEX IF NOT EXISTS vorgang_titel_trgm
|
||||||
|
ON vorgang USING gin (titel gin_trgm_ops);
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Bauprotokoll aus der Git-Historie erzeugen.
|
||||||
|
*
|
||||||
|
* Aufruf: npm run protokoll -> Tabelle auf der Konsole
|
||||||
|
* npm run protokoll -- --md -> Markdown fuer docs/BAUPROTOKOLL.md
|
||||||
|
*
|
||||||
|
* Grundlage sind die Commit-Trailer "Etappe:" und "Phase:". Jeder Commit
|
||||||
|
* traegt sie, damit sich im Nachhinein nachvollziehen laesst, wie lange
|
||||||
|
* welche Etappe gedauert hat - fuer die Kostenrechnung und als Nachweis,
|
||||||
|
* was wann entstanden ist.
|
||||||
|
*
|
||||||
|
* Die Dauer einer Etappe wird gemessen vom Ende der vorherigen Etappe
|
||||||
|
* (bzw. vom Baubeginn) bis zum letzten Commit der Etappe. Pausen zwischen
|
||||||
|
* zwei Commits, die laenger als PAUSE_SCHWELLE_MIN dauern, werden als
|
||||||
|
* Pause gewertet und nicht als Arbeitszeit gezaehlt.
|
||||||
|
*/
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
|
||||||
|
/** Laengere Luecken gelten als Pause und zaehlen nicht als Arbeitszeit. */
|
||||||
|
const PAUSE_SCHWELLE_MIN = 30
|
||||||
|
|
||||||
|
const TRENNER = '' // ASCII Unit Separator - kommt in Commit-Texten nicht vor
|
||||||
|
const SATZ_TRENNER = '' // Record Separator
|
||||||
|
|
||||||
|
function commitsLesen() {
|
||||||
|
const roh = execSync(
|
||||||
|
`git log --reverse --format="%H${TRENNER}%at${TRENNER}%s${TRENNER}%b${SATZ_TRENNER}"`,
|
||||||
|
{ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return roh
|
||||||
|
.split(SATZ_TRENNER)
|
||||||
|
.map((eintrag) => eintrag.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((eintrag) => {
|
||||||
|
const [hash, zeitstempel, betreff, koerper = ''] = eintrag.split(TRENNER)
|
||||||
|
const etappe = koerper.match(/^Etappe:\s*(.+)$/m)?.[1]?.trim() ?? 'ohne'
|
||||||
|
const phase = koerper.match(/^Phase:\s*(.+)$/m)?.[1]?.trim() ?? ''
|
||||||
|
return {
|
||||||
|
hash: hash.slice(0, 8),
|
||||||
|
zeit: new Date(Number(zeitstempel) * 1000),
|
||||||
|
betreff,
|
||||||
|
etappe,
|
||||||
|
phase,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arbeitszeit als Summe der Abstaende zwischen aufeinanderfolgenden
|
||||||
|
* Commits, wobei zu grosse Abstaende auf die Pausenschwelle gedeckelt
|
||||||
|
* werden. Der allererste Commit bekommt die Schwelle als Anlaufzeit
|
||||||
|
* gutgeschrieben - vorher wurde ja auch schon gearbeitet.
|
||||||
|
*/
|
||||||
|
function arbeitsminuten(commits) {
|
||||||
|
if (commits.length === 0) return 0
|
||||||
|
let summe = PAUSE_SCHWELLE_MIN
|
||||||
|
for (let i = 1; i < commits.length; i++) {
|
||||||
|
const diff = (commits[i].zeit - commits[i - 1].zeit) / 60000
|
||||||
|
summe += Math.min(diff, PAUSE_SCHWELLE_MIN)
|
||||||
|
}
|
||||||
|
return summe
|
||||||
|
}
|
||||||
|
|
||||||
|
function dauerText(minuten) {
|
||||||
|
const m = Math.round(minuten)
|
||||||
|
const std = Math.floor(m / 60)
|
||||||
|
const rest = m % 60
|
||||||
|
if (std === 0) return `${rest} min`
|
||||||
|
return `${std} h ${String(rest).padStart(2, '0')} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatZeit(datum) {
|
||||||
|
return datum.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const commits = commitsLesen()
|
||||||
|
if (commits.length === 0) {
|
||||||
|
console.error('Keine Commits gefunden.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Etappe gruppieren, Reihenfolge des ersten Auftretens beibehalten.
|
||||||
|
const gruppen = new Map()
|
||||||
|
for (const commit of commits) {
|
||||||
|
if (!gruppen.has(commit.etappe)) gruppen.set(commit.etappe, [])
|
||||||
|
gruppen.get(commit.etappe).push(commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeilen = [...gruppen.entries()].map(([etappe, eintraege]) => ({
|
||||||
|
etappe,
|
||||||
|
phase: eintraege.find((e) => e.phase)?.phase ?? '',
|
||||||
|
commits: eintraege.length,
|
||||||
|
von: eintraege[0].zeit,
|
||||||
|
bis: eintraege[eintraege.length - 1].zeit,
|
||||||
|
minuten: arbeitsminuten(eintraege),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const gesamtMinuten = zeilen.reduce((s, z) => s + z.minuten, 0)
|
||||||
|
const gesamtCommits = commits.length
|
||||||
|
const spanneMinuten = (commits[commits.length - 1].zeit - commits[0].zeit) / 60000
|
||||||
|
|
||||||
|
const alsMarkdown = process.argv.includes('--md')
|
||||||
|
|
||||||
|
if (alsMarkdown) {
|
||||||
|
const out = []
|
||||||
|
out.push('# Bauprotokoll')
|
||||||
|
out.push('')
|
||||||
|
out.push('Automatisch aus der Git-Historie erzeugt: `npm run protokoll -- --md`.')
|
||||||
|
out.push('Grundlage sind die Commit-Trailer `Etappe:` und `Phase:`.')
|
||||||
|
out.push('')
|
||||||
|
out.push(
|
||||||
|
`Reine Arbeitszeit wird als Summe der Commit-Abstaende gemessen, gedeckelt auf ` +
|
||||||
|
`${PAUSE_SCHWELLE_MIN} Minuten je Abstand. Laengere Luecken gelten als Pause.`,
|
||||||
|
)
|
||||||
|
out.push('')
|
||||||
|
out.push('| Etappe | Phase | Commits | Beginn | Ende | Arbeitszeit |')
|
||||||
|
out.push('|---|---|---:|---|---|---:|')
|
||||||
|
for (const z of zeilen) {
|
||||||
|
out.push(
|
||||||
|
`| ${z.etappe} | ${z.phase} | ${z.commits} | ${formatZeit(z.von)} | ` +
|
||||||
|
`${formatZeit(z.bis)} | ${dauerText(z.minuten)} |`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
out.push(
|
||||||
|
`| **Gesamt** | | **${gesamtCommits}** | ${formatZeit(commits[0].zeit)} | ` +
|
||||||
|
`${formatZeit(commits[commits.length - 1].zeit)} | **${dauerText(gesamtMinuten)}** |`,
|
||||||
|
)
|
||||||
|
out.push('')
|
||||||
|
out.push(`Kalendarische Spanne vom ersten bis zum letzten Commit: ${dauerText(spanneMinuten)}.`)
|
||||||
|
out.push('')
|
||||||
|
out.push('## Commits im Einzelnen')
|
||||||
|
out.push('')
|
||||||
|
out.push('| Zeitpunkt | Etappe | Commit | Betreff |')
|
||||||
|
out.push('|---|---|---|---|')
|
||||||
|
for (const c of commits) {
|
||||||
|
out.push(`| ${formatZeit(c.zeit)} | ${c.etappe} | \`${c.hash}\` | ${c.betreff} |`)
|
||||||
|
}
|
||||||
|
console.log(out.join('\n'))
|
||||||
|
} else {
|
||||||
|
console.log('')
|
||||||
|
console.log('Bauprotokoll')
|
||||||
|
console.log('='.repeat(74))
|
||||||
|
for (const z of zeilen) {
|
||||||
|
console.log(
|
||||||
|
`Etappe ${z.etappe.padEnd(4)} ${z.phase.padEnd(16)} ` +
|
||||||
|
`${String(z.commits).padStart(3)} Commits ${dauerText(z.minuten).padStart(10)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.log('-'.repeat(74))
|
||||||
|
console.log(
|
||||||
|
`Gesamt${' '.repeat(17)}${String(gesamtCommits).padStart(3)} Commits ` +
|
||||||
|
`${dauerText(gesamtMinuten).padStart(10)}`,
|
||||||
|
)
|
||||||
|
console.log(`Kalendarische Spanne: ${dauerText(spanneMinuten)}`)
|
||||||
|
console.log('')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user