Compare commits
36 Commits
11cf9083fa
...
v0.13.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aad517ce23 | ||
|
|
d1e80dd19f | ||
|
|
e968cf9761 | ||
|
|
b1b121bb23 | ||
|
|
db9c5b7482 | ||
|
|
344a33805a | ||
|
|
b4d4ee8d3c | ||
|
|
6fd7831784 | ||
|
|
5813e6209c | ||
|
|
790ab20651 | ||
|
|
ae59e745c0 | ||
|
|
8e5d06aafb | ||
|
|
835ad9f0a7 | ||
|
|
c9d71b0d54 | ||
|
|
6df1d2961b | ||
|
|
77de1627c5 | ||
|
|
374f4ade31 | ||
|
|
34b1d8d2a6 | ||
|
|
8f638df387 | ||
|
|
5156fe62da | ||
|
|
2a16dc2200 | ||
|
|
f5ad08cebd | ||
|
|
72005fc326 | ||
|
|
e0218337e7 | ||
|
|
31caa8f66a | ||
|
|
db373e51ce | ||
|
|
917b51299b | ||
|
|
d54636f57a | ||
|
|
15bf277bee | ||
|
|
da91c3e2c1 | ||
|
|
bec34b5988 | ||
|
|
8b08b39725 | ||
|
|
e929e5bf26 | ||
|
|
667343fa81 | ||
|
|
e9e386df85 | ||
|
|
bfe52c73eb |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Build output
|
||||
dist/
|
||||
dossiers/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
551
CLAUDE.md
551
CLAUDE.md
@@ -1,18 +1,289 @@
|
||||
# Deklarix
|
||||
|
||||
> Projekt für deklarix.de und deklarix.com
|
||||
> KI-Antragsprüfung für Unternehmen: Mitarbeitende beschreiben ein
|
||||
> KI-Vorhaben in eigenen Worten, das System leitet Datenklasse,
|
||||
> KI-VO-Einstufung und Anforderungsprofil ab, gleicht sie gegen einen
|
||||
> gepflegten Werkzeugkatalog ab und erzeugt einen begründeten
|
||||
> Entscheidungsvorschlag. Ein Mensch entscheidet, das System bereitet
|
||||
> nur vor.
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
## Produktwechsel (2026-08-28)
|
||||
|
||||
| Backend | Frontend (geplant) |
|
||||
|---------|---------------------|
|
||||
| Go 1.26 | React 19, TypeScript 5.9 |
|
||||
| net/http (Standard-Library-first) | Vite 8, Tailwind CSS |
|
||||
| SQLite (geplant) | enterprise.css Design-System (enconf-Pattern) |
|
||||
Deklarix war ursprünglich eine Pre-Publish-Kennzeichnungsprüfung für
|
||||
Werbe-Content (UWG/MStV) für Creator, Agenturen, Marken und Kanzleien.
|
||||
Dieser Scope wurde **komplett verworfen** und durch das oben
|
||||
beschriebene Produkt ersetzt — kein Feature-Zusatz, ein Produktwechsel.
|
||||
Wiederverwendet wurden nur produktunabhängige Bausteine: Auth/Session/
|
||||
Account-Mandantentrennung, das Prinzip "Regelwerk als versionierte
|
||||
YAML-Dateien, kein ML für die Bewertung", die apt/systemd/Gitea-
|
||||
Deployment-Pipeline und das Design-System. Alles Werberecht-Spezifische
|
||||
(Kennzeichnungsregeln, Beweisarchiv für Social-Media-Posts, Instagram/
|
||||
TikTok-OAuth, Insights-Erinnerung) wurde entfernt, nicht nur ersetzt —
|
||||
siehe Migration `0008_pivot_ki_antragspruefung` für den genauen Schnitt.
|
||||
|
||||
**Pfad:** `/var/www/deklarix` | **Git:** `https://git.netcell-it.de/projekte/deklarix` | **Branch:** `main`
|
||||
---
|
||||
|
||||
## Grundregel
|
||||
|
||||
**Das System entscheidet nicht. Es bereitet vor.** Der/die
|
||||
Verantwortliche bestätigt, ändert oder überschreibt einen
|
||||
Bewertungsvorschlag — mit Begründungsfeld bei Abweichung. Jede
|
||||
Entscheidung wird als Registereintrag protokolliert. Die Bewertung wird
|
||||
**nie** ohne ihre vollständige Herleitung angezeigt (welche Antwort
|
||||
welche Anforderung ausgelöst hat) — ohne das ist ein Vorschlag im Audit
|
||||
wertlos.
|
||||
|
||||
Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
|
||||
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
|
||||
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
|
||||
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
|
||||
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/
|
||||
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung
|
||||
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
|
||||
|
||||
---
|
||||
|
||||
## Fünf Ebenen, fünf Rollen
|
||||
|
||||
Die Oberfläche gliedert sich in fünf Ebenen mit steigender Sensibilität.
|
||||
Vier Mandanten-Rollen sind eine Voreinstellung, kein fest verdrahtetes
|
||||
Modell (spätere Ausbaustufe: konfigurierbare Rollen) — `betreiber` ist
|
||||
technisch getrennt vom Mandantenbereich, keine Voreinstellung.
|
||||
|
||||
| Ebene | Zugang | Rolle(n) | Zweck |
|
||||
|---|---|---|---|
|
||||
| 1 — Öffentlich | ohne Konto | — | Anmeldung, Passwort zurücksetzen, Einladung annehmen, Antrag über Mandantenlink |
|
||||
| 2 — Mitarbeiter | Konto nötig | `mitarbeiter` | Antrag stellen, eigene Anträge verfolgen, Rückfragen beantworten, freigegebene Werkzeuge einsehen. Bewusst schlank — dauert der Antrag zu lange, wird er umgangen |
|
||||
| 3 — Fachebene | Konto nötig | `verantwortlicher` (Entscheidungsrecht), `pruefer` (identische Sicht, kein Entscheidungsrecht) | Posteingang offener Anträge, Bewertungsvorschlag mit Herleitung, entscheiden, Register, Berichte/Exporte, Audit-Log lesend |
|
||||
| 4 — Mandanten-Verwaltung | Konto nötig | `admin` | Nutzer/Rollen, Abteilungen, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen, Rechnungsdaten — bezogen auf GENAU EINEN Mandanten |
|
||||
| 5 — Plattform | Konto nötig, technisch getrennt | `betreiber` | Werkzeugkatalog, Regelwerk-Versionierung, Mandantenverwaltung/Freischaltung, Nutzungssicht — Netcell-IT-Personal, plattformweit über alle Mandanten |
|
||||
|
||||
**Funktionstrennung bleibt bestehen**, auch wenn in kleinen Firmen
|
||||
dieselbe Person Admin und Verantwortlicher ist — das sind zwei Rollen
|
||||
auf demselben Menschen, keine verschmolzene Rolle. `admin` (Ebene 4,
|
||||
Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5,
|
||||
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
|
||||
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
|
||||
ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
|
||||
`admin` hieß.
|
||||
|
||||
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
|
||||
je Handler), nicht als grob unterschiedene Seitenbereiche.
|
||||
|
||||
**Ebene 4 — Nutzerverwaltung ist umgesetzt** (`internal/web/admin_handlers.go`,
|
||||
Middleware `requireAdmin`): `GET /verwaltung/nutzer` listet alle Logins
|
||||
des eigenen Mandanten, `GET/POST /verwaltung/nutzer/neu` legt einen
|
||||
weiteren Login mit einer der vier Mandanten-Rollen an (`mitarbeiter`,
|
||||
`verantwortlicher`, `pruefer`, `admin` — `betreiber` kann kein
|
||||
Mandanten-Admin vergeben, das ist Ebene 5). Es gibt noch **keine**
|
||||
Einladungsmail — der Admin setzt das Initialpasswort direkt im
|
||||
Formular und gibt es auf einem anderen Weg weiter (konsistent mit dem
|
||||
Onboarding-Stand unten: Einladungslink/CSV-Import/SSO sind noch nicht
|
||||
gebaut). Das schließt die Lücke, dass Ebene 3 (Fachebene) bisher nur
|
||||
über einen manuellen SQL-Insert nutzbar war, weil die Firma-
|
||||
Registrierung ausschließlich einen `admin`-Nutzer erzeugt. Abteilungen-
|
||||
Verwaltung, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen und
|
||||
Rechnungsdaten (Ebene 4 laut Tabelle oben) sind weiterhin nicht gebaut.
|
||||
|
||||
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
||||
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
||||
`AccountID` gegen den angemeldeten Account, wie bisher) — die
|
||||
Zielvorgabe "Isolation auf Datenbankebene erzwungen" (Postgres Row-
|
||||
Level Security) ist eine bewusst noch nicht getroffene Entscheidung,
|
||||
siehe Offene Punkte unten.
|
||||
|
||||
---
|
||||
|
||||
## Fragebogen (Ebene 2, "Antrag stellen")
|
||||
|
||||
Adaptiv: Folgefragen erscheinen nur, wenn die Antwort sie auslöst.
|
||||
Alltagssprache, keine Fachbegriffe — die stehen nur in der Ableitung.
|
||||
|
||||
**A — Vorhaben:** `titel`, `beschreibung` (Freitext: was soll die KI
|
||||
tun?), `ergebnis` (Freitext: was soll herauskommen?), `haeufigkeit`
|
||||
(einmalig/gelegentlich/täglich/automatisiert), `abteilung` (aus
|
||||
Stammdaten).
|
||||
|
||||
**B — Daten** (bestimmt die Datenklasse). Mehrfachauswahl Ja/Nein/
|
||||
Unsicher, "Unsicher" zählt wie "Ja": Namen/E-Mail/Personenangaben (B1),
|
||||
Gesundheit/Religion/Herkunft/Gewerkschaft/Sexualleben (B2), Patienten-/
|
||||
Mandanten-/Versichertendaten (B3), Bewerbungs-/Personal-/
|
||||
Leistungsdaten Beschäftigter (B4), von Kunden anvertraute Daten (B5),
|
||||
Konstruktions-/Kalkulations-/Vertragsunterlagen (B6), nur allgemein
|
||||
zugängliche/erfundene Inhalte (B7).
|
||||
|
||||
**C — Verwendung des Ergebnisses** (bestimmt die KI-VO-Einstufung):
|
||||
geht das Ergebnis unverändert nach außen (C1)? beeinflusst es eine
|
||||
Entscheidung über einen Menschen (C2, Folgefrage: Einstellung/
|
||||
Beförderung/Kündigung, Kreditwürdigkeit, Leistungsbewilligung, Zugang
|
||||
zu Bildung, medizinische Beurteilung, keins davon)? erkennt/bewertet es
|
||||
Emotionen, Verhalten oder biometrische Merkmale (C3)? läuft es ohne
|
||||
menschliche Prüfung (C4)? merkt der Empfänger, dass es von einer KI
|
||||
stammt (C5)?
|
||||
|
||||
**D — Werkzeug:** ggf. schon ein Werkzeug im Sinn (optional, Freitext),
|
||||
Firmen- oder privater Zugang, Firmen- oder Privatgerät.
|
||||
|
||||
---
|
||||
|
||||
## Ableitungen (`rules/*.yaml`, `internal/rules`)
|
||||
|
||||
**Datenklasse** (`rules/datenklasse.yaml`) — höchste zutreffende Stufe
|
||||
gewinnt: `oeffentlich` (B7) < `intern` (B6) < `auftragsdaten` (B5) <
|
||||
`personenbezogen` (B1, B4) < `berufsgeheimnis` (B3, § 203 StGB) <
|
||||
`besondere_kategorie` (B2, Art. 9 DSGVO). **Die Rangfolge ist eine
|
||||
Annahme** (Spezifikation nennt keine expliziten Ränge) — siehe
|
||||
`rules/OPEN.md`, vor Produktiveinsatz mit dem/der Datenschutz-
|
||||
beauftragten bestätigen.
|
||||
|
||||
**KI-VO-Einstufung** (`rules/kivo_einstufung.yaml`) — Prüfreihenfolge,
|
||||
erste zutreffende Stufe gewinnt: `verboten` (Art. 5 KI-VO: C3 mit
|
||||
Emotionserkennung am Arbeitsplatz, Social Scoring oder biometrischer
|
||||
Kategorisierung) → `hochrisiko` (C2 mit Beschäftigung/Kreditwürdigkeit/
|
||||
Leistungsbewilligung/Bildung/Gesundheit) → `transparenzpflicht` (C1 ja
|
||||
und C5 nein) → `minimal` (alles übrige). Die genauen Fragebogen-Felder
|
||||
für die drei "verboten"-Varianten sind noch nicht abschließend
|
||||
spezifiziert — siehe `rules/OPEN.md`.
|
||||
|
||||
**Anforderungsprofil** (`rules/anforderungen.yaml`) — aus Datenklasse
|
||||
und/oder Einstufung abgeleitete Menge von Anforderungen an ein
|
||||
Werkzeug: `avv_erforderlich`, `eu_verarbeitung`,
|
||||
`kein_training_auf_eingabe`, `loeschfrist_max_tage`,
|
||||
`menschliche_aufsicht`, `kennzeichnungspflicht`, `dsfa_erforderlich`.
|
||||
|
||||
`internal/rules` lädt und validiert diese drei Dateien (eindeutige IDs,
|
||||
eindeutige Ränge, jede Anforderung braucht mindestens einen Auslöser)
|
||||
und wertet sie seit Schritt 3 auch aus: `EvaluateDatenklasse`,
|
||||
`EvaluateEinstufung`, `IstVerboten`, `DeriveAnforderungen` operieren auf
|
||||
`rules.Antworten` (geparst aus `antrag.antworten` via `ParseAntworten`)
|
||||
— reine, für sich getestete Funktionen ohne DB-/Web-Zugriff.
|
||||
`FilterWerkzeuge`/`ErfuelltAnforderung` filtern einen Werkzeugkatalog
|
||||
hart gegen die abgeleiteten Anforderungen (`WerkzeugEigenschaften` ist
|
||||
ein schlanker, von `store.Werkzeug` unabhängiger Typ, damit
|
||||
`internal/rules` weiterhin ohne `internal/store` auskommt). Seit
|
||||
Schritt 4 wird das auch tatsächlich aufgerufen: `POST /antraege`
|
||||
berechnet nach dem Einreichen sofort eine `bewertung` (append-only,
|
||||
`internal/web/antrag_handlers.go:bewerteAntrag`) — bei `verboten`
|
||||
(K.-o.-Prüfung) ohne Werkzeugsuche — und `GET /antraege/{id}` zeigt sie
|
||||
inklusive Herleitung, RegelwerkVersion/KatalogVersion und dem
|
||||
Pflicht-Hinweis "Das System entscheidet nicht. Es bereitet vor."
|
||||
|
||||
---
|
||||
|
||||
## Werkzeugkatalog (`werkzeug`, `werkzeug_sperre`)
|
||||
|
||||
Der eigentliche Wert des Produkts. Zentral gepflegt (Ebene 5,
|
||||
`account_id IS NULL`), für alle Mandanten identisch sichtbar; jeder
|
||||
Mandant kann zusätzlich eigene Einträge (`account_id` gesetzt) und
|
||||
Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den
|
||||
zentralen Katalog zu verändern.
|
||||
|
||||
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
|
||||
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt-
|
||||
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
|
||||
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
|
||||
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
|
||||
nicht in der UI umgesetzt, siehe Offene Punkte).
|
||||
|
||||
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
||||
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
||||
jeder Bewertung/Entscheidung eingefroren, damit im Audit nachvollziehbar
|
||||
bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
|
||||
|
||||
---
|
||||
|
||||
## Bewertungslogik (geplant, Schritt 3+)
|
||||
|
||||
1. **K.-o.-Prüfung.** Einstufung `verboten` → sofort Ablehnung (Art. 5
|
||||
KI-VO), keine Werkzeugsuche.
|
||||
2. **Harte Filter.** Werkzeuge aussortieren, die eine Pflichtanforderung
|
||||
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
|
||||
Werkzeuge werden im Ergebnis mit Begründung gezeigt.
|
||||
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
|
||||
(Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche)
|
||||
und gegen `geeignete_zwecke` filtern.
|
||||
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
|
||||
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
|
||||
Prüfung.
|
||||
5. **Auflagen ableiten.** Statt Ablehnung Bedingungen erzeugen
|
||||
(Pseudonymisierung, keine Klarnamen, Vier-Augen-Prüfung,
|
||||
Kennzeichnung, Aufbewahrung begrenzen).
|
||||
6. **Vorschlag erzeugen.** Empfehlung, Alternativen, Auflagen,
|
||||
vollständige Herleitung.
|
||||
|
||||
Jede Bewertung wird als eigene Zeile gespeichert (append-only, wie
|
||||
`finding`/`extraction` im alten Produkt) — eine neue Bewertung ersetzt
|
||||
nicht die alte, sie ergänzt sie, damit Nachvollziehbarkeit auch bei
|
||||
mehrfacher Neubewertung eines Antrags erhalten bleibt.
|
||||
|
||||
---
|
||||
|
||||
## Entscheidung, Register, Wiedervorlage (Schritt 5 erledigt, Schritt 6-7 geplant)
|
||||
|
||||
**Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3
|
||||
(Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen —
|
||||
Status "eingereicht" — Anträge des Mandanten, nicht nur die eigenen)
|
||||
und eine Entscheiden-Seite (`GET /faelle/{id}`, `POST
|
||||
/faelle/{id}/entscheiden`, siehe `internal/web/fachebene_handlers.go`).
|
||||
Rollen `verantwortlicher` und `pruefer` sehen dieselbe Sicht
|
||||
(`requireFachebene`), nur `verantwortlicher` hat Entscheidungsrecht —
|
||||
ein `pruefer`-POST auf `/entscheiden` bekommt 403 (er darf wissen, dass
|
||||
der Fall existiert, nur nicht entscheiden; anders als bei
|
||||
`requireFachebene`/`requireBetreiber`, die bei falscher Rolle 404
|
||||
liefern, um die Existenz der Seite selbst zu verbergen).
|
||||
|
||||
Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen /
|
||||
abgelehnt / Rückfrage. **Vorschlag-Vergleich:** `vorschlagFuer` leitet
|
||||
aus der Bewertung ab, was das System vorschlagen würde (`abgelehnt` bei
|
||||
`verboten`, sonst `genehmigt` wenn mindestens ein zulässiges Werkzeug
|
||||
existiert, sonst `rueckfrage`) — weicht die tatsächliche Entscheidung
|
||||
davon ab, ist das Begründungsfeld Pflicht (sonst 400). **Snapshot bei
|
||||
Entscheidung:** `entscheidung.werkzeug_snapshot` (JSONB) friert den
|
||||
vollständigen `store.Werkzeug`-Datensatz zum Entscheidungszeitpunkt ein
|
||||
— ein späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf
|
||||
eine Entscheidung beruhte. Bei "genehmigt"/"genehmigt mit Auflagen" ist
|
||||
ein Werkzeug aus der zulässigen Liste der Bewertung Pflicht (hart
|
||||
geprüft, kein Override eines ausgeschlossenen Werkzeugs). `entscheidung`
|
||||
ist append-only (Migration 0010) wie `bewertung`; jede Entscheidung
|
||||
erzeugt zusätzlich einen `audit_log`-Eintrag (Action
|
||||
`antrag_entschieden`).
|
||||
|
||||
Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
|
||||
`hochrisiko` 6 — `gueltigkeitFuer`). **Noch nicht gebaut (Schritt 7):**
|
||||
die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf
|
||||
der eine aktive Genehmigung beruht (Wiedervorlage).
|
||||
|
||||
**Noch nicht gebaut (Schritt 6):** ein `registereintrag` wird bislang
|
||||
NICHT automatisch aus einer Genehmigung erzeugt, und es gibt keinen
|
||||
PDF-/CSV-Export. Geplant: Zweck, Abteilung, Werkzeug, Datenklasse,
|
||||
Einstufung, Auflagen, Verantwortliche/r, Datum, Gültigkeit.
|
||||
|
||||
---
|
||||
|
||||
## Onboarding
|
||||
|
||||
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
|
||||
mit Firmendaten → Testzugang sofort aktiv → Freischaltung des
|
||||
Bezahlbetriebs nach Prüfung durch den Betreiber (letzteres noch nicht
|
||||
gebaut). Beim Anlegen entstehen automatisch: Account, erster Nutzer mit
|
||||
Rolle `admin` (siehe Rollentabelle — vereinfacht v1: nicht zusätzlich
|
||||
separat als `verantwortlicher` angelegt, siehe Offene Punkte),
|
||||
leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
|
||||
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
|
||||
Ebene 5/Betreiber-Pflege gebaut ist).
|
||||
|
||||
**Mitarbeiter, geplante Reihenfolge:** (1) Einladungslink
|
||||
(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV-
|
||||
Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne
|
||||
vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto
|
||||
entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma-
|
||||
Registrierung.
|
||||
|
||||
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
|
||||
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
|
||||
nicht anzulegen. Noch nicht gebaut (kein Abo-System).
|
||||
|
||||
---
|
||||
|
||||
@@ -21,21 +292,23 @@
|
||||
```
|
||||
/var/www/deklarix/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Entry Point
|
||||
│ └── deklarix/
|
||||
│ └── main.go # Entry Point: Migration, Regelwerk laden, HTTP-Server
|
||||
├── internal/
|
||||
│ ├── config/ # Konfiguration (env-basiert)
|
||||
│ ├── handler/ # HTTP Handler
|
||||
│ └── middleware/ # Auth, Logging, CORS
|
||||
│ ├── auth/ # Passwort-Hashing, Session-Token (produktunabhängig)
|
||||
│ ├── rules/ # Lädt/validiert Datenklasse-/Einstufungs-/Anforderungs-YAML
|
||||
│ ├── store/ # Postgres, Migrationen
|
||||
│ └── web/ # Handler, Templates
|
||||
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
|
||||
├── design/
|
||||
│ └── enterprise.css # Gemeinsames Design-System (enconf-Basis)
|
||||
│ └── enterprise.css # Design-System (enconf-Basis, ungenutzt seit Pivot)
|
||||
├── packaging/
|
||||
│ └── DEBIAN/
|
||||
│ └── control.tmpl # .deb Package-Control-Template
|
||||
│ └── control.tmpl # .deb Package-Control-Template
|
||||
├── scripts/
|
||||
│ ├── build.sh # Cross-Compile amd64 + arm64
|
||||
│ ├── test.sh # Tests + vet + build-check
|
||||
│ └── release.sh # Vollständiger Release-Prozess
|
||||
│ ├── build.sh # Cross-Compile amd64 + arm64
|
||||
│ ├── test.sh # Tests + vet + build-check
|
||||
│ └── release.sh # Vollständiger Release-Prozess
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
└── CLAUDE.md
|
||||
@@ -43,13 +316,99 @@
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell
|
||||
|
||||
- `account` — ein Mandant (ein Unternehmen). Jeder Antrag gehört genau
|
||||
einem Account.
|
||||
- `app_user` — ein Login innerhalb eines Accounts. Rolle ist eine von
|
||||
`mitarbeiter`, `verantwortlicher`, `pruefer`, `admin` (alle vier
|
||||
Mandanten-bezogen) oder `betreiber` (plattformweit, Ebene 5).
|
||||
- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); echte
|
||||
Tabelle statt zustandsloser signierter Tokens, damit Logout eine
|
||||
Sitzung wirklich beendet.
|
||||
- `audit_log` — Protokoll (append-only): jede Antragsänderung, jede
|
||||
Bewertung, jede Entscheidung, jede Katalogänderung mit Zeitstempel
|
||||
und Akteur.
|
||||
- `abteilung` — Stammdaten für Fragebogen-Feld A.abteilung, je Mandant.
|
||||
- `werkzeug` — Katalogeintrag (siehe oben); `account_id` NULL = zentral,
|
||||
gesetzt = mandantenspezifische Ergänzung. NICHT append-only — der
|
||||
Katalog wird gepflegt, eine Entscheidung friert den zu ihrem
|
||||
Zeitpunkt gültigen Datensatz separat ein (geplant).
|
||||
- `werkzeug_sperre` — Sperrung eines (auch zentralen) Katalogeintrags
|
||||
durch einen einzelnen Mandanten.
|
||||
- `antrag` — das Vorhaben aus Fragebogen-Abschnitt A plus die
|
||||
vollständigen Antworten aus B/C/D als JSON (`antworten`, JSONB) — ein
|
||||
starres Spaltenschema könnte den adaptiven Fragebogen nicht abbilden.
|
||||
NICHT append-only (normale Zustandsänderung `entwurf` →
|
||||
`eingereicht` → `entschieden`, wie `submission` es im alten Produkt
|
||||
war).
|
||||
- `bewertung` — der berechnete Vorschlag (Schritt 4).
|
||||
- `entscheidung` — die Entscheidung eines/einer Verantwortlichen über
|
||||
einen Antrag, mit Snapshot des gewählten Werkzeugs (`werkzeug_snapshot`,
|
||||
JSONB) und Ablaufdatum (`gueltig_bis`) bei Genehmigung (Schritt 5).
|
||||
- `registereintrag` — **noch nicht gebaut**, geplant für Schritt 6.
|
||||
|
||||
**Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`,
|
||||
wiederverwendet aus dem alten Produkt), ebenso `bewertung` und
|
||||
`entscheidung`. `registereintrag` wird bei seiner Einführung ebenfalls
|
||||
append-only.
|
||||
|
||||
---
|
||||
|
||||
## Baureihenfolge
|
||||
|
||||
1. ~~Datenmodell, Regelwerk als YAML, Katalogstruktur~~ — **erledigt**
|
||||
(dieser Stand: `account`/`app_user`/`session`/`audit_log`/
|
||||
`abteilung`/`werkzeug`/`werkzeug_sperre`/`antrag` in Postgres,
|
||||
`internal/rules` lädt und validiert die drei YAML-Regelwerke,
|
||||
Web-Layer kompiliert mit Auth + Plattform-Bereich-Gerüst).
|
||||
2. ~~Fragebogen mit adaptiver Logik~~ — **erledigt** (`GET /antraege/neu`
|
||||
einseitiges Formular, `POST /antraege` legt an und reicht direkt ein,
|
||||
`GET /antraege` eigene Anträge, `GET /antraege/{id}` Detail. Adaptive
|
||||
Folgefragen C2/C3 rein per CSS `:has()` ein-/ausgeblendet, kein
|
||||
JavaScript. `antworten`-JSON nutzt exakt die Fakten-Schlüssel aus
|
||||
`rules/*.yaml` (b1-b7, c1-c5, c2_folge, c3_art) — das hat auch die
|
||||
OPEN.md-Frage zu den "verboten"-Fragebogen-Feldern final geklärt.)
|
||||
3. ~~Ableitungen und harte Filter~~ — **erledigt** (`internal/rules`:
|
||||
`EvaluateDatenklasse`/`EvaluateEinstufung`/`DeriveAnforderungen`/
|
||||
`IstVerboten`/`FilterWerkzeuge`, vollständig getestet gegen die
|
||||
echten `rules/*.yaml`-Dateien.)
|
||||
4. ~~Ergebnisdarstellung mit Herleitung~~ — **erledigt** (`POST /antraege`
|
||||
berechnet und speichert eine `bewertung` sofort beim Einreichen,
|
||||
`GET /antraege/{id}` zeigt Datenklasse, KI-VO-Einstufung,
|
||||
Anforderungen, zulässige/ausgeschlossene Werkzeuge — jeweils mit
|
||||
Herleitung — sowie RegelwerkVersion/KatalogVersion und den
|
||||
Pflicht-Hinweis "Das System entscheidet nicht. Es bereitet vor.".
|
||||
`bewertung` ist append-only. Getestet inkl. K.-o.-Prüfung
|
||||
(`verboten` überspringt die Werkzeugsuche) und hartem Filter
|
||||
gegen den Katalog.)
|
||||
5. ~~Entscheidung, Snapshot, Audit-Log~~ — **erledigt** (Ebene 3:
|
||||
`GET /faelle` Posteingang, `GET /faelle/{id}` + `POST
|
||||
/faelle/{id}/entscheiden`. `verantwortlicher` entscheidet,
|
||||
`pruefer` sieht dieselbe Seite ohne Entscheidungsrecht [403 bei
|
||||
Entscheidungsversuch]. Begründung ist Pflicht bei Abweichung vom
|
||||
abgeleiteten Vorschlag (`vorschlagFuer`), Genehmigung friert den
|
||||
vollständigen Werkzeugdatensatz ein und braucht ein zulässiges
|
||||
Werkzeug aus der Bewertung, `entscheidung` ist append-only, jede
|
||||
Entscheidung erzeugt einen `audit_log`-Eintrag.)
|
||||
6. Registereintrag und Export
|
||||
7. Wiedervorlage und Katalog-Benachrichtigung
|
||||
|
||||
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
|
||||
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
|
||||
Nutzung einzelner Personen auswertet), konfigurierbare Rollen mit
|
||||
eigener Oberfläche, Konzernstrukturen mit Vererbung, mandantenspezifische
|
||||
Regelwerke, Workflow-Designer, SAML, Schnittstellen zu Fremdsystemen.
|
||||
|
||||
---
|
||||
|
||||
## Go Commands
|
||||
|
||||
```bash
|
||||
export PATH=$PATH:/usr/local/go/bin # Immer setzen!
|
||||
|
||||
# Entwickeln
|
||||
go run ./cmd/server/
|
||||
go run ./cmd/deklarix/
|
||||
|
||||
# Tests
|
||||
./scripts/test.sh
|
||||
@@ -78,28 +437,56 @@ go vet ./...
|
||||
# 1. Alle Änderungen committen
|
||||
git add -p && git commit -m "feat: ..."
|
||||
|
||||
# 2. Release-Skript (macht Tests → Build → Tag → Push)
|
||||
# 2. Release-Skript (macht Tests → .deb-Build → Gitea-Upload → Tag → Push)
|
||||
./scripts/release.sh 1.2.0
|
||||
|
||||
# Danach liegt in dist/:
|
||||
# deklarix_1.2.0_amd64
|
||||
# deklarix_1.2.0_arm64
|
||||
# deklarix_1.2.0_amd64.deb
|
||||
# deklarix_1.2.0_arm64.deb
|
||||
```
|
||||
|
||||
### Was das Release-Skript tut
|
||||
1. Prüft: sauberer Git-Status (keine uncommitted changes)
|
||||
2. Führt `./scripts/test.sh` aus (vet + race tests + build-check)
|
||||
3. Kompiliert für `linux/amd64` und `linux/arm64`
|
||||
4. Setzt Git-Tag `v<version>` mit Annotierung
|
||||
5. Pusht `main` + Tag nach `origin`
|
||||
2. Führt `./scripts/test.sh` aus (vet + race tests + build-check, braucht `DATABASE_URL`)
|
||||
3. Baut `.deb`-Pakete für `linux/amd64` und `linux/arm64` (`scripts/build.sh`)
|
||||
4. Lädt die Pakete in Giteas Debian-Paketregistrierung hoch (Kanal `testing`,
|
||||
Suites `bookworm` + `trixie`) — siehe „Deployment“ unten
|
||||
5. Setzt Git-Tag `v<version>` mit Annotierung
|
||||
6. Pusht `main` + Tag nach `origin`
|
||||
|
||||
### Deployment (apt, Infrastruktur-Standard wie bei enconf)
|
||||
|
||||
Updates laufen ausschließlich über ein apt-Paket, nie über manuelles
|
||||
Kopieren eines Binaries. `.deb`-Pakete landen in Giteas eingebauter
|
||||
Debian-Paketregistrierung unter der Gitea-Org `projekte` (gleicher
|
||||
Mechanismus wie beim Schwesterprojekt enconf-webpanel). Zielserver
|
||||
richten sich einmalig ein:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
sudo curl -fsSL https://git.netcell-it.de/api/packages/projekte/debian/repository.key \
|
||||
-o /etc/apt/keyrings/deklarix-gitea.asc
|
||||
echo "deb [signed-by=/etc/apt/keyrings/deklarix-gitea.asc] https://git.netcell-it.de/api/packages/projekte/debian $(lsb_release -cs) testing" \
|
||||
| sudo tee /etc/apt/sources.list.d/deklarix.list
|
||||
sudo apt-get update && sudo apt-get install deklarix
|
||||
```
|
||||
|
||||
Danach: `apt upgrade` für Updates, `systemctl {start,stop,status} deklarix`
|
||||
für den Dienst. Konfiguration liegt in `/etc/deklarix/deklarix.env`
|
||||
(aus `deklarix.env.example` beim Erstinstall kopiert, `DATABASE_URL` ist
|
||||
dort absichtlich auskommentiert — der Dienst startet erst, wenn sie
|
||||
gesetzt ist; `postinst` startet ihn sonst bewusst nicht, um keine
|
||||
Crash-Restart-Schleife gegen einen Platzhalter-Host zu erzeugen).
|
||||
Aktuell gibt es nur den Kanal `testing` (kein `stable` — es gibt noch
|
||||
keine Kunden, für die eine Trennung nötig wäre).
|
||||
|
||||
---
|
||||
|
||||
## Testing-Pattern
|
||||
## Testing-Pattern & Qualitätssicherung
|
||||
|
||||
```go
|
||||
// Datei: internal/handler/health_test.go
|
||||
package handler_test
|
||||
// Datei: internal/web/health_test.go
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -117,39 +504,38 @@ func TestHealth(t *testing.T) {
|
||||
}
|
||||
```
|
||||
|
||||
- Tests liegen neben dem Code: `handler/foo_test.go`
|
||||
- Tests liegen neben dem Code: `paket/foo_test.go`
|
||||
- Package: `package foo_test` (Black-Box-Test) oder `package foo` (White-Box)
|
||||
- Race-Detector immer an: `go test -race ./...`
|
||||
- Tabellenbasierte Tests für mehrere Inputs
|
||||
|
||||
---
|
||||
|
||||
## Design-System
|
||||
|
||||
Das Frontend folgt dem **Enterprise Light Theme** aus enconf (`design/enterprise.css`).
|
||||
|
||||
- **Primärfarbe:** `#1677ff` (Blau)
|
||||
- **Sidebar:** Dunkel (`#0B1426` → `#101D33`) mit weißen Icons
|
||||
- **Body:** `#F8FAFC` Hintergrund, `#334155` Text
|
||||
- **Font:** Inter (von `/fonts/inter.css` oder Google Fonts)
|
||||
- **Radius:** 6px / 8px / 10px
|
||||
|
||||
Für neue Frontend-Projekte unter `/var/www/deklarix`:
|
||||
```bash
|
||||
# Frontend-Scaffold (wenn benötigt)
|
||||
npm create vite@latest frontend -- --template react-ts
|
||||
cd frontend && npm install
|
||||
# enterprise.css aus design/ einbinden
|
||||
```
|
||||
**DATABASE_URL und Testsystem.** `internal/store` hat Integrationstests
|
||||
gegen echtes Postgres (u. a. die Append-only-Garantie). Ohne
|
||||
`DATABASE_URL` überspringt `go test` diese Tests einfach — praktisch für
|
||||
schnelle lokale Iteration ohne lokales Postgres. `./scripts/test.sh`
|
||||
(und damit `release.sh`) ist strenger: es bricht ohne `DATABASE_URL` mit
|
||||
Fehler ab, statt die Append-only-Garantie still zu überspringen. Es
|
||||
zieht dafür aber **kein** eigenes Postgres hoch (kein Docker-Zwang auf
|
||||
dem lokalen Dev-Rechner) — das ist Aufgabe des dedizierten Testsystems,
|
||||
dort muss `DATABASE_URL` vorkonfiguriert sein.
|
||||
|
||||
---
|
||||
|
||||
## Domains
|
||||
## Leitplanken, die dauerhaft gelten
|
||||
|
||||
| Domain | Verwendung |
|
||||
|--------|-----------|
|
||||
| deklarix.de | Primär |
|
||||
| deklarix.com | Redirect / International |
|
||||
- **Keine Rechtsdienstleistung.** Deklarix bereitet eine Einstufung vor,
|
||||
entscheidet nicht. Keine Formulierung darf eine individuelle
|
||||
rechtliche Bewertung suggerieren oder Erfolgsaussichten behaupten.
|
||||
- **Keine erfundenen Einstufungen.** Jede Ableitungsregel steht in einer
|
||||
Regel-YAML und wurde dort von Hand geprüft; offene fachliche Fragen
|
||||
stehen in `rules/OPEN.md`, werden nicht geraten.
|
||||
- **Werkzeugkatalog-Zusicherungen müssen belegbar sein.** `quelle` und
|
||||
`letzte_pruefung` sind Pflichtfelder, keine Ausnahme.
|
||||
- **Keine Mitarbeiterüberwachung.** Nichts im Produkt darf auswerten,
|
||||
welche einzelne Person welches Werkzeug wie oft nutzt — das
|
||||
widerspricht dem Vertrauensmodell, auf dem die freiwillige
|
||||
Antragstellung beruht.
|
||||
- **DSGVO und Hosting in der EU.**
|
||||
|
||||
---
|
||||
|
||||
@@ -169,15 +555,35 @@ git push origin main
|
||||
|
||||
### Server-Prozess
|
||||
```bash
|
||||
# Start (manuell)
|
||||
PORT=8080 ./dist/deklarix_latest_amd64 &
|
||||
# Dienst läuft über systemd (installiert per apt, siehe Deployment oben)
|
||||
sudo systemctl start deklarix
|
||||
sudo systemctl status deklarix
|
||||
|
||||
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR)
|
||||
|
||||
# Logs prüfen
|
||||
journalctl -u deklarix -f
|
||||
```
|
||||
|
||||
### Bekannte Server
|
||||
|
||||
| Rolle | Host | Zugang |
|
||||
|---|---|---|
|
||||
| Testsystem + `app.deklarix.de` (DB-Integrationstests, apt-Verifikation, nginx-Reverse-Proxy + Let's-Encrypt-TLS auf den lokalen Dienst) | 89.163.205.4 | SSH `noroot`, sudo passwortlos |
|
||||
| Installer-Endpoint (`get.deklarix.com`) | 89.163.205.110 | noch nicht eingerichtet |
|
||||
| Marketing-Site (`deklarix.de`/`.com`) | 89.163.205.80 | noch nicht eingerichtet |
|
||||
|
||||
---
|
||||
|
||||
## Arbeitsweise für Claude Code
|
||||
|
||||
- Kleine Commits, ein Thema pro Commit
|
||||
- Erst Test, dann Implementierung
|
||||
- Keine neue Abhängigkeit ohne Rückfrage
|
||||
- Fehler nicht verschlucken, keine stillen Fallbacks
|
||||
- Bei fachlicher Unsicherheit: nicht raten, sondern als offene Frage in
|
||||
`rules/OPEN.md` notieren
|
||||
|
||||
## Vor Änderungen
|
||||
|
||||
1. `go vet ./...` — keine Fehler
|
||||
@@ -190,3 +596,36 @@ journalctl -u deklarix -f
|
||||
2. `./scripts/build.sh <version>` → erfolgreich
|
||||
3. Commit mit semantischer Message: `feat:`, `fix:`, `refactor:`, `docs:`
|
||||
4. Bei Release: `./scripts/release.sh <version>`
|
||||
|
||||
---
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
|
||||
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich
|
||||
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt
|
||||
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten
|
||||
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu
|
||||
bleiben, aber die Ableitung kann sich noch ändern.
|
||||
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
|
||||
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`.
|
||||
Weiterhin offen: ob die drei Varianten fachlich vollständig sind
|
||||
(siehe `rules/OPEN.md`, Punkt 3).
|
||||
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
||||
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
||||
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||
— die Frontend-Spezifikation fordert das explizit ("nicht in der
|
||||
Anwendungslogik"), aktuell ist Isolation ausschließlich
|
||||
anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS
|
||||
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
|
||||
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
|
||||
entstehen, die sonst nachträglich migriert werden müssten.
|
||||
- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding** — die
|
||||
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig
|
||||
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt
|
||||
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder
|
||||
zwei `app_user`-Zeilen für dieselbe Person?
|
||||
- **Zentraler Werkzeugkatalog ist leer** — "Werkzeugkatalog aus der
|
||||
Plattform vorbelegt" beim Onboarding ist nicht automatisiert, es gibt
|
||||
noch keine Ebene-5/Betreiber-UI zur Katalogpflege.
|
||||
- Markenanmeldung Deklarix, sobald erster Kunde zahlt.
|
||||
|
||||
71
cmd/deklarix/main.go
Normal file
71
cmd/deklarix/main.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
databaseURL := os.Getenv("DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
log.Fatal("DATABASE_URL is required")
|
||||
}
|
||||
|
||||
if err := store.Migrate(databaseURL); err != nil {
|
||||
log.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
db, err := store.Open(context.Background(), databaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("open store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rulesDir := os.Getenv("RULES_DIR")
|
||||
if rulesDir == "" {
|
||||
rulesDir = "rules"
|
||||
}
|
||||
rulesFS := os.DirFS(rulesDir)
|
||||
// Beim Start laden und validieren, auch wenn die Auswertungslogik
|
||||
// (Phase 3 der Baureihenfolge) sie noch nicht konsumiert — ein
|
||||
// kaputtes Regelwerk soll den Dienst nicht erst beim ersten Antrag
|
||||
// zum Absturz bringen, sondern gar nicht erst starten lassen.
|
||||
datenklasse, err := rules.LoadDatenklasse(rulesFS, "datenklasse.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("load datenklasse-regelwerk from %s: %v", rulesDir, err)
|
||||
}
|
||||
einstufung, err := rules.LoadEinstufung(rulesFS, "kivo_einstufung.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("load ki-vo-einstufung-regelwerk from %s: %v", rulesDir, err)
|
||||
}
|
||||
anforderungen, err := rules.LoadAnforderungen(rulesFS, "anforderungen.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("load anforderungsprofil-regelwerk from %s: %v", rulesDir, err)
|
||||
}
|
||||
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
||||
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
||||
|
||||
server, err := web.NewServer(db, web.Regelwerk{
|
||||
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("web server: %v", err)
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
addr := ":" + port
|
||||
|
||||
log.Printf("Deklarix server starting on %s", addr)
|
||||
if err := http.ListenAndServe(addr, server); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
addr := ":" + port
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"ok":true}`)
|
||||
})
|
||||
|
||||
log.Printf("Deklarix server starting on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
21
go.mod
21
go.mod
@@ -1,3 +1,24 @@
|
||||
module github.com/netcell-it/deklarix
|
||||
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea
|
||||
github.com/go-pdf/fpdf v0.9.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
|
||||
100
go.sum
Normal file
100
go.sum
Normal file
@@ -0,0 +1,100 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3itqE8OT3TgjCMHK4nq2FNGi0au2c=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c=
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
56
internal/auth/auth.go
Normal file
56
internal/auth/auth.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Package auth enthält die reine Logik für Anmeldung: Passwort-Hashing,
|
||||
// Session-Token-Erzeugung. Es fasst keine Datenbank an — das macht
|
||||
// internal/store (account, app_user, session), damit Persistenz an
|
||||
// einer Stelle im Projekt gebündelt bleibt.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// MinPasswordLength ist die einzige Passwort-Regel, die wir prüfen —
|
||||
// keine erzwungenen Sonderzeichen/Ziffern-Vorgaben, die Nutzer nur zu
|
||||
// vorhersehbaren Mustern verleiten.
|
||||
const MinPasswordLength = 8
|
||||
|
||||
// SessionDuration ist die Gültigkeitsdauer einer neu erstellten Sitzung.
|
||||
const SessionDuration = 30 * 24 * time.Hour
|
||||
|
||||
// HashPassword hasht ein Klartext-Passwort für die Speicherung. Lehnt
|
||||
// zu kurze Passwörter ab, statt sie stillschweigend zu hashen.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < MinPasswordLength {
|
||||
return "", fmt.Errorf("auth: passwort muss mindestens %d Zeichen haben", MinPasswordLength)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: passwort hashen: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword prüft ein Klartext-Passwort gegen einen gespeicherten
|
||||
// Hash. Ein Fehler bedeutet: falsches Passwort oder ungültiger Hash —
|
||||
// beides führt zu "Anmeldung abgelehnt", nie zu einem Unterschied, den
|
||||
// ein Angreifer für User-Enumeration nutzen könnte.
|
||||
func VerifyPassword(hash, password string) error {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return fmt.Errorf("auth: passwort ungültig: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSessionToken erzeugt ein kryptographisch zufälliges Session-Token
|
||||
// (32 Byte, hex-kodiert).
|
||||
func NewSessionToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("auth: token generieren: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
50
internal/auth/auth_test.go
Normal file
50
internal/auth/auth_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
func TestHashPasswordRejectsShortPassword(t *testing.T) {
|
||||
if _, err := auth.HashPassword("kurz"); err == nil {
|
||||
t.Fatal("expected error for a too-short password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashAndVerifyRoundTrip(t *testing.T) {
|
||||
hash, err := auth.HashPassword("ein-langes-passwort")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if err := auth.VerifyPassword(hash, "ein-langes-passwort"); err != nil {
|
||||
t.Fatalf("VerifyPassword: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPasswordRejectsWrongPassword(t *testing.T) {
|
||||
hash, err := auth.HashPassword("ein-langes-passwort")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if err := auth.VerifyPassword(hash, "falsches-passwort"); err == nil {
|
||||
t.Fatal("expected error for a wrong password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionTokenIsRandomAndCorrectLength(t *testing.T) {
|
||||
a, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionToken: %v", err)
|
||||
}
|
||||
b, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionToken: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("expected two distinct tokens, got the same value twice")
|
||||
}
|
||||
if len(a) != 64 {
|
||||
t.Fatalf("token length = %d, want 64 (hex of 32 random bytes)", len(a))
|
||||
}
|
||||
}
|
||||
264
internal/rules/evaluate.go
Normal file
264
internal/rules/evaluate.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Antworten ist die im Antrag gespeicherte Fragebogen-Antwort-Menge —
|
||||
// dieselbe Struktur, die internal/web/antrag_handlers.go erzeugt
|
||||
// (antwortenFromForm): ein flaches JSON-Objekt mit denselben
|
||||
// Fakten-Schlüsseln wie in den Regelwerk-YAML-Dateien (b1..b7, c1..c5,
|
||||
// c2_folge, c3_art).
|
||||
type Antworten map[string]any
|
||||
|
||||
// ParseAntworten liest antrag.antworten (rohes JSON aus der Datenbank).
|
||||
func ParseAntworten(raw []byte) (Antworten, error) {
|
||||
if len(raw) == 0 {
|
||||
return Antworten{}, nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("rules: antworten parsen: %w", err)
|
||||
}
|
||||
return Antworten(m), nil
|
||||
}
|
||||
|
||||
// istJaOderUnsicher prüft eine B-Frage. "Unsicher zählt wie Ja" ist eine
|
||||
// Auswertungsregel (siehe CLAUDE.md, Fragebogen-Abschnitt B) — deshalb
|
||||
// hier und nicht schon beim Speichern des Antrags angewendet.
|
||||
func (a Antworten) istJaOderUnsicher(key string) bool {
|
||||
v, _ := a[key].(string)
|
||||
return v == "ja" || v == "unsicher"
|
||||
}
|
||||
|
||||
// DatenklasseErgebnis ist die abgeleitete Datenklasse mit Herleitung —
|
||||
// "Jede Anforderung/Ableitung trägt ihre Herleitung, sonst ist das
|
||||
// Ergebnis im Audit wertlos" (siehe CLAUDE.md).
|
||||
type DatenklasseErgebnis struct {
|
||||
ID string
|
||||
Herleitung string
|
||||
}
|
||||
|
||||
// EvaluateDatenklasse leitet die Datenklasse aus den B-Antworten ab.
|
||||
// "Höchste zutreffende Stufe gewinnt" (höherer Rang gewinnt). Trifft
|
||||
// keine Stufe zu (z. B. wenn versehentlich auch B7 mit "nein"
|
||||
// beantwortet wurde, obwohl keine andere Kategorie zutrifft — ein
|
||||
// eigentlich widersprüchlicher Fragebogen-Zustand), wird konservativ
|
||||
// "intern" angenommen statt "oeffentlich": im Zweifel mehr Schutz, nicht
|
||||
// weniger. Siehe rules/OPEN.md, Punkt 5.
|
||||
func EvaluateDatenklasse(regelwerk DatenklasseRegelwerk, antworten Antworten) DatenklasseErgebnis {
|
||||
const fallback = "intern"
|
||||
var gewinner *DatenklasseStufe
|
||||
var treffer []string
|
||||
for i := range regelwerk.Stufen {
|
||||
st := ®elwerk.Stufen[i]
|
||||
var stTreffer []string
|
||||
for _, ausloeser := range st.Ausloeser {
|
||||
if antworten.istJaOderUnsicher(ausloeser) {
|
||||
stTreffer = append(stTreffer, ausloeser)
|
||||
}
|
||||
}
|
||||
if len(stTreffer) > 0 && (gewinner == nil || st.Rang > gewinner.Rang) {
|
||||
gewinner = st
|
||||
treffer = stTreffer
|
||||
}
|
||||
}
|
||||
if gewinner == nil {
|
||||
return DatenklasseErgebnis{ID: fallback, Herleitung: "keine Kategorie aus Abschnitt B traf eindeutig zu (konservativer Standardwert)"}
|
||||
}
|
||||
return DatenklasseErgebnis{ID: gewinner.ID, Herleitung: "ausgelöst durch " + strings.Join(treffer, ", ")}
|
||||
}
|
||||
|
||||
// variantePasst prüft, ob alle Schlüssel-Werte-Paare einer Variante zu
|
||||
// den Antworten passen (UND-Verknüpfung innerhalb der Variante).
|
||||
func variantePasst(variante EinstufungVariante, antworten Antworten) bool {
|
||||
for key, want := range variante {
|
||||
got, ok := antworten[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch wantVal := want.(type) {
|
||||
case bool:
|
||||
gotBool, ok := got.(bool)
|
||||
if !ok || gotBool != wantVal {
|
||||
return false
|
||||
}
|
||||
case string:
|
||||
gotStr, ok := got.(string)
|
||||
if !ok || gotStr != wantVal {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// EinstufungErgebnis ist die abgeleitete KI-VO-Einstufung mit Herleitung.
|
||||
type EinstufungErgebnis struct {
|
||||
ID string
|
||||
Quelle string // z. B. "Art. 5 KI-VO", leer wenn die Stufe keine Quelle nennt
|
||||
Herleitung string
|
||||
}
|
||||
|
||||
// formatVariante liefert eine deterministische, lesbare Darstellung
|
||||
// einer zutreffenden Variante, z. B. "c2=true, c2_folge=kreditwuerdigkeit".
|
||||
func formatVariante(v EinstufungVariante) string {
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", k, v[k]))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// EvaluateEinstufung leitet die KI-VO-Einstufung aus den C-Antworten ab.
|
||||
// Prüfreihenfolge: die erste zutreffende Stufe gewinnt (siehe
|
||||
// Bewertungslogik, K.-o.-Prüfung) — deshalb steht "verboten" in
|
||||
// rules/kivo_einstufung.yaml an erster Stelle. Liefert einen Fehler nur,
|
||||
// wenn das Regelwerk selbst keine Auffangregel definiert (sollte durch
|
||||
// LoadEinstufung bereits verhindert sein).
|
||||
func EvaluateEinstufung(regelwerk EinstufungRegelwerk, antworten Antworten) (EinstufungErgebnis, error) {
|
||||
for _, st := range regelwerk.Stufen {
|
||||
if len(st.Varianten) == 0 {
|
||||
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: "Auffangregel (keine speziellere Stufe traf zu)"}, nil
|
||||
}
|
||||
for _, variante := range st.Varianten {
|
||||
if variantePasst(variante, antworten) {
|
||||
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: formatVariante(variante)}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return EinstufungErgebnis{}, fmt.Errorf("rules: keine einstufung trifft zu und keine auffangregel definiert")
|
||||
}
|
||||
|
||||
// IstVerboten prüft die K.-o.-Bedingung (Art. 5 KI-VO): bei "verboten"
|
||||
// erfolgt sofortige Ablehnung, keine Werkzeugsuche.
|
||||
func IstVerboten(einstufungID string) bool {
|
||||
return einstufungID == "verboten"
|
||||
}
|
||||
|
||||
// AnforderungErgebnis ist eine abgeleitete Anforderung mit Herleitung.
|
||||
type AnforderungErgebnis struct {
|
||||
ID string
|
||||
Beschreibung string
|
||||
Herleitung string // z. B. "aus Datenklasse personenbezogen"
|
||||
}
|
||||
|
||||
// DeriveAnforderungen leitet aus Datenklasse und Einstufung die Menge
|
||||
// der Anforderungen ab, die ein Werkzeug erfüllen muss.
|
||||
func DeriveAnforderungen(regelwerk AnforderungsRegelwerk, datenklasseID, einstufungID string) []AnforderungErgebnis {
|
||||
var out []AnforderungErgebnis
|
||||
for _, a := range regelwerk.Anforderungen {
|
||||
var gruende []string
|
||||
if containsString(a.AusDatenklassen, datenklasseID) {
|
||||
gruende = append(gruende, "Datenklasse "+datenklasseID)
|
||||
}
|
||||
if containsString(a.AusEinstufungen, einstufungID) {
|
||||
gruende = append(gruende, "Einstufung "+einstufungID)
|
||||
}
|
||||
if len(gruende) > 0 {
|
||||
out = append(out, AnforderungErgebnis{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: "aus " + strings.Join(gruende, " und ")})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsString(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WerkzeugEigenschaften sind die für die harte Filterung relevanten
|
||||
// Felder eines Katalogeintrags — bewusst ein eigener, schlanker Typ
|
||||
// statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so
|
||||
// unabhängig von internal/store und für sich allein testbar.
|
||||
type WerkzeugEigenschaften struct {
|
||||
AVVVerfuegbar bool
|
||||
Verarbeitungsort string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
}
|
||||
|
||||
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
|
||||
// erfüllt. Anforderungen, die einen Prozess statt eine technische
|
||||
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
||||
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
||||
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
||||
// Werkzeug selbst. loeschfrist_max_tage hat noch keine fachlich
|
||||
// festgelegten Tageswerte (rules/OPEN.md, Punkt 4) und wird deshalb
|
||||
// ebenfalls noch nicht hart gefiltert.
|
||||
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
||||
switch anforderungID {
|
||||
case "avv_erforderlich":
|
||||
return w.AVVVerfuegbar
|
||||
case "eu_verarbeitung":
|
||||
return w.Verarbeitungsort == "EU"
|
||||
case "kein_training_auf_eingabe":
|
||||
return w.TrainingStandard
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// AusschlussGrund hält fest, warum ein Werkzeug aussortiert wurde —
|
||||
// auch aussortierte Werkzeuge werden im Ergebnis gezeigt (siehe
|
||||
// Bewertungslogik), nie stillschweigend weggelassen.
|
||||
type AusschlussGrund struct {
|
||||
WerkzeugID string
|
||||
NichtErfuellt []string
|
||||
}
|
||||
|
||||
// WerkzeugKandidat ist ein Katalogeintrag im harten Filter.
|
||||
type WerkzeugKandidat struct {
|
||||
ID string
|
||||
Eigenschaften WerkzeugEigenschaften
|
||||
}
|
||||
|
||||
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
||||
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
||||
// Werkzeug fest, welche Anforderungen fehlten.
|
||||
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||
for _, k := range kandidaten {
|
||||
var fehlend []string
|
||||
for _, reqID := range anforderungIDs {
|
||||
if !ErfuelltAnforderung(reqID, k.Eigenschaften) {
|
||||
fehlend = append(fehlend, reqID)
|
||||
}
|
||||
}
|
||||
if len(fehlend) == 0 {
|
||||
zulaessig = append(zulaessig, k.ID)
|
||||
} else {
|
||||
ausgeschlossen = append(ausgeschlossen, AusschlussGrund{WerkzeugID: k.ID, NichtErfuellt: fehlend})
|
||||
}
|
||||
}
|
||||
return zulaessig, ausgeschlossen
|
||||
}
|
||||
|
||||
// String liefert eine lesbare Begründung, z. B. für die
|
||||
// Ergebnisdarstellung.
|
||||
func (a AusschlussGrund) String() string {
|
||||
return fmt.Sprintf("%s: erfüllt nicht %s", a.WerkzeugID, strings.Join(a.NichtErfuellt, ", "))
|
||||
}
|
||||
|
||||
// RegelwerkVersion liefert eine reproduzierbare Kennung des geladenen
|
||||
// Regelwerk-Stands — wird mit jeder Bewertung eingefroren, damit im
|
||||
// Audit nachvollziehbar bleibt, mit welcher Regelwerk-Version ein
|
||||
// Vorschlag erzeugt wurde (analog zu store.CurrentKatalogVersion für
|
||||
// den Werkzeugkatalog).
|
||||
func RegelwerkVersion(dk DatenklasseRegelwerk, ei EinstufungRegelwerk, an AnforderungsRegelwerk) string {
|
||||
return fmt.Sprintf("dk%d.ei%d.an%d", dk.Version, ei.Version, an.Version)
|
||||
}
|
||||
211
internal/rules/evaluate_test.go
Normal file
211
internal/rules/evaluate_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package rules_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
func loadRealRegelwerke(t *testing.T) (rules.DatenklasseRegelwerk, rules.EinstufungRegelwerk, rules.AnforderungsRegelwerk) {
|
||||
t.Helper()
|
||||
fsys := realRulesFS(t)
|
||||
dk, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDatenklasse: %v", err)
|
||||
}
|
||||
ei, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEinstufung: %v", err)
|
||||
}
|
||||
an, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAnforderungen: %v", err)
|
||||
}
|
||||
return dk, ei, an
|
||||
}
|
||||
|
||||
func TestEvaluateDatenklasseOnlyOeffentlich(t *testing.T) {
|
||||
dk, _, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "ja"}
|
||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||
if got.ID != "oeffentlich" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want oeffentlich", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDatenklasseHighestWins(t *testing.T) {
|
||||
dk, _, _ := loadRealRegelwerke(t)
|
||||
// b1 (personenbezogen) UND b2 (besondere_kategorie) beide ja ->
|
||||
// besondere_kategorie hat den hoeheren Rang und muss gewinnen.
|
||||
antworten := rules.Antworten{"b1": "ja", "b2": "ja", "b7": "nein"}
|
||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||
if got.ID != "besondere_kategorie" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want besondere_kategorie", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDatenklasseUnsicherZaehltWieJa(t *testing.T) {
|
||||
dk, _, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"b3": "unsicher"}
|
||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||
if got.ID != "berufsgeheimnis" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want berufsgeheimnis (unsicher zaehlt wie ja)", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDatenklasseFallsBackToInternWhenNothingMatches(t *testing.T) {
|
||||
dk, _, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "nein"}
|
||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||
if got.ID != "intern" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want intern (konservativer Fallback)", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateEinstufungVerboten(t *testing.T) {
|
||||
_, ei, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"c3": true, "c3_art": "social_scoring"}
|
||||
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got.ID != "verboten" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want verboten", got.ID)
|
||||
}
|
||||
if !rules.IstVerboten(got.ID) {
|
||||
t.Error("IstVerboten sollte true liefern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateEinstufungHochrisiko(t *testing.T) {
|
||||
_, ei, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"c2": true, "c2_folge": "kreditwuerdigkeit", "c3": false}
|
||||
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got.ID != "hochrisiko" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want hochrisiko", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateEinstufungTransparenzpflicht(t *testing.T) {
|
||||
_, ei, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"c1": true, "c2": false, "c3": false, "c5": false}
|
||||
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got.ID != "transparenzpflicht" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want transparenzpflicht", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateEinstufungMinimalFallback(t *testing.T) {
|
||||
_, ei, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"c1": false, "c2": false, "c3": false, "c4": false, "c5": true}
|
||||
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got.ID != "minimal" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want minimal", got.ID)
|
||||
}
|
||||
if rules.IstVerboten(got.ID) {
|
||||
t.Error("IstVerboten sollte fuer minimal false liefern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAnforderungenPersonenbezogen(t *testing.T) {
|
||||
_, _, an := loadRealRegelwerke(t)
|
||||
got := rules.DeriveAnforderungen(an, "personenbezogen", "minimal")
|
||||
want := map[string]bool{"avv_erforderlich": true, "kein_training_auf_eingabe": true, "loeschfrist_max_tage": true}
|
||||
gotSet := map[string]bool{}
|
||||
for _, a := range got {
|
||||
gotSet[a.ID] = true
|
||||
if a.Herleitung == "" {
|
||||
t.Errorf("Anforderung %q hat keine Herleitung", a.ID)
|
||||
}
|
||||
}
|
||||
for id := range want {
|
||||
if !gotSet[id] {
|
||||
t.Errorf("expected Anforderung %q for personenbezogen/minimal, got %v", id, got)
|
||||
}
|
||||
}
|
||||
if gotSet["eu_verarbeitung"] {
|
||||
t.Error("eu_verarbeitung sollte fuer personenbezogen (ohne berufsgeheimnis/besondere_kategorie) nicht ausgeloest werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) {
|
||||
_, _, an := loadRealRegelwerke(t)
|
||||
got := rules.DeriveAnforderungen(an, "oeffentlich", "hochrisiko")
|
||||
gotSet := map[string]bool{}
|
||||
for _, a := range got {
|
||||
gotSet[a.ID] = true
|
||||
}
|
||||
if !gotSet["menschliche_aufsicht"] || !gotSet["dsfa_erforderlich"] {
|
||||
t.Errorf("expected menschliche_aufsicht und dsfa_erforderlich fuer hochrisiko, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungAVV(t *testing.T) {
|
||||
if rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: false}) {
|
||||
t.Error("erwartet: nicht erfuellt ohne AVV")
|
||||
}
|
||||
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}) {
|
||||
t.Error("erwartet: erfuellt mit AVV")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
||||
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||
kandidaten := []rules.WerkzeugKandidat{
|
||||
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungsort: "EU", TrainingStandard: true}},
|
||||
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungsort: "EU", TrainingStandard: true}},
|
||||
}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"})
|
||||
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
||||
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
|
||||
}
|
||||
if len(ausgeschlossen) != 1 || ausgeschlossen[0].WerkzeugID != "ohne-avv" {
|
||||
t.Fatalf("ausgeschlossen = %v, want genau ohne-avv", ausgeschlossen)
|
||||
}
|
||||
if len(ausgeschlossen[0].NichtErfuellt) != 1 || ausgeschlossen[0].NichtErfuellt[0] != "avv_erforderlich" {
|
||||
t.Fatalf("NichtErfuellt = %v, want [avv_erforderlich]", ausgeschlossen[0].NichtErfuellt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
|
||||
kandidaten := []rules.WerkzeugKandidat{{ID: "x"}, {ID: "y"}}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, nil)
|
||||
if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
|
||||
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAntwortenRoundTrip(t *testing.T) {
|
||||
raw := []byte(`{"b1":"ja","c2":true,"c2_folge":"bildung"}`)
|
||||
a, err := rules.ParseAntworten(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAntworten: %v", err)
|
||||
}
|
||||
if a["b1"] != "ja" || a["c2_folge"] != "bildung" {
|
||||
t.Fatalf("ParseAntworten = %+v, unerwartete Werte", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAntwortenEmpty(t *testing.T) {
|
||||
a, err := rules.ParseAntworten(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAntworten: %v", err)
|
||||
}
|
||||
if len(a) != 0 {
|
||||
t.Fatalf("ParseAntworten(nil) = %+v, want empty", a)
|
||||
}
|
||||
}
|
||||
111
internal/rules/loader.go
Normal file
111
internal/rules/loader.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// LoadDatenklasse lädt und validiert die Datenklasse-Ableitungstabelle.
|
||||
func LoadDatenklasse(fsys fs.FS, path string) (DatenklasseRegelwerk, error) {
|
||||
var rw DatenklasseRegelwerk
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse lesen: %w", err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse parsen: %w", err)
|
||||
}
|
||||
if rw.Version < 1 {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: version fehlt oder ungültig")
|
||||
}
|
||||
if len(rw.Stufen) == 0 {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: keine stufen definiert")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
seenRaenge := map[int]string{}
|
||||
for _, st := range rw.Stufen {
|
||||
if st.ID == "" {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe ohne id")
|
||||
}
|
||||
if seenIDs[st.ID] {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: doppelte stufe-id %q", st.ID)
|
||||
}
|
||||
seenIDs[st.ID] = true
|
||||
if other, ok := seenRaenge[st.Rang]; ok {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufen %q und %q teilen sich rang %d — \"höchste Stufe gewinnt\" braucht eindeutige Ränge", st.ID, other, st.Rang)
|
||||
}
|
||||
seenRaenge[st.Rang] = st.ID
|
||||
if len(st.Ausloeser) == 0 {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe %q hat keine ausloeser", st.ID)
|
||||
}
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
// LoadEinstufung lädt und validiert die KI-VO-Einstufungstabelle.
|
||||
func LoadEinstufung(fsys fs.FS, path string) (EinstufungRegelwerk, error) {
|
||||
var rw EinstufungRegelwerk
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung lesen: %w", err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung parsen: %w", err)
|
||||
}
|
||||
if rw.Version < 1 {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: version fehlt oder ungültig")
|
||||
}
|
||||
if len(rw.Stufen) == 0 {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: keine stufen definiert")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for i, st := range rw.Stufen {
|
||||
if st.ID == "" {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe ohne id")
|
||||
}
|
||||
if seenIDs[st.ID] {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: doppelte stufe-id %q", st.ID)
|
||||
}
|
||||
seenIDs[st.ID] = true
|
||||
// Die letzte Stufe darf eine Auffangregel ohne Bedingungen sein
|
||||
// (z. B. "minimal"), alle davor brauchen mindestens eine Variante.
|
||||
if len(st.Varianten) == 0 && i != len(rw.Stufen)-1 {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe %q hat keine varianten (nur die letzte Stufe darf eine Auffangregel sein)", st.ID)
|
||||
}
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
// LoadAnforderungen lädt und validiert die Anforderungsprofil-Tabelle.
|
||||
func LoadAnforderungen(fsys fs.FS, path string) (AnforderungsRegelwerk, error) {
|
||||
var rw AnforderungsRegelwerk
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen lesen: %w", err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen parsen: %w", err)
|
||||
}
|
||||
if rw.Version < 1 {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: version fehlt oder ungültig")
|
||||
}
|
||||
if len(rw.Anforderungen) == 0 {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: keine anforderungen definiert")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for _, a := range rw.Anforderungen {
|
||||
if a.ID == "" {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung ohne id")
|
||||
}
|
||||
if seenIDs[a.ID] {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: doppelte anforderung-id %q", a.ID)
|
||||
}
|
||||
seenIDs[a.ID] = true
|
||||
if len(a.AusDatenklassen) == 0 && len(a.AusEinstufungen) == 0 {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung %q hat weder aus_datenklassen noch aus_einstufungen", a.ID)
|
||||
}
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
132
internal/rules/loader_test.go
Normal file
132
internal/rules/loader_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package rules_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
func realRulesFS(t *testing.T) fs.FS {
|
||||
t.Helper()
|
||||
return os.DirFS("../../rules")
|
||||
}
|
||||
|
||||
func TestLoadDatenklasseRealFile(t *testing.T) {
|
||||
rw, err := rules.LoadDatenklasse(realRulesFS(t), "datenklasse.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDatenklasse: %v", err)
|
||||
}
|
||||
if rw.Version != 1 {
|
||||
t.Fatalf("Version = %d, want 1", rw.Version)
|
||||
}
|
||||
if len(rw.Stufen) != 6 {
|
||||
t.Fatalf("expected 6 Stufen, got %d", len(rw.Stufen))
|
||||
}
|
||||
byID := map[string]rules.DatenklasseStufe{}
|
||||
for _, st := range rw.Stufen {
|
||||
byID[st.ID] = st
|
||||
}
|
||||
for _, id := range []string{"oeffentlich", "intern", "auftragsdaten", "personenbezogen", "berufsgeheimnis", "besondere_kategorie"} {
|
||||
if _, ok := byID[id]; !ok {
|
||||
t.Errorf("expected Stufe %q to be defined", id)
|
||||
}
|
||||
}
|
||||
if byID["besondere_kategorie"].Rang <= byID["personenbezogen"].Rang {
|
||||
t.Error("expected besondere_kategorie to outrank personenbezogen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatenklasseRejectsDuplicateRang(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
|
||||
version: 1
|
||||
stufen:
|
||||
- {id: a, rang: 1, ausloeser: [b1]}
|
||||
- {id: b, rang: 1, ausloeser: [b2]}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
|
||||
t.Fatal("expected an error for duplicate rang values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatenklasseRejectsMissingVersion(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
|
||||
stufen:
|
||||
- {id: a, rang: 1, ausloeser: [b1]}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
|
||||
t.Fatal("expected an error for a missing version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEinstufungRealFile(t *testing.T) {
|
||||
rw, err := rules.LoadEinstufung(realRulesFS(t), "kivo_einstufung.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEinstufung: %v", err)
|
||||
}
|
||||
if len(rw.Stufen) != 4 {
|
||||
t.Fatalf("expected 4 Stufen, got %d", len(rw.Stufen))
|
||||
}
|
||||
if rw.Stufen[0].ID != "verboten" {
|
||||
t.Fatalf("expected 'verboten' to be checked first (K.-o.-Prüfung), got %q", rw.Stufen[0].ID)
|
||||
}
|
||||
if rw.Stufen[len(rw.Stufen)-1].ID != "minimal" {
|
||||
t.Fatalf("expected 'minimal' as the last (catch-all) Stufe, got %q", rw.Stufen[len(rw.Stufen)-1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEinstufungRejectsNonLastStufeWithoutVarianten(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"kivo_einstufung.yaml": &fstest.MapFile{Data: []byte(`
|
||||
version: 1
|
||||
stufen:
|
||||
- {id: verboten, varianten: []}
|
||||
- {id: minimal, varianten: []}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml"); err == nil {
|
||||
t.Fatal("expected an error when a non-last Stufe has no Varianten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAnforderungenRealFile(t *testing.T) {
|
||||
rw, err := rules.LoadAnforderungen(realRulesFS(t), "anforderungen.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAnforderungen: %v", err)
|
||||
}
|
||||
wantIDs := []string{
|
||||
"avv_erforderlich", "eu_verarbeitung", "kein_training_auf_eingabe",
|
||||
"loeschfrist_max_tage", "menschliche_aufsicht", "kennzeichnungspflicht", "dsfa_erforderlich",
|
||||
}
|
||||
if len(rw.Anforderungen) != len(wantIDs) {
|
||||
t.Fatalf("expected %d Anforderungen, got %d", len(wantIDs), len(rw.Anforderungen))
|
||||
}
|
||||
byID := map[string]bool{}
|
||||
for _, a := range rw.Anforderungen {
|
||||
byID[a.ID] = true
|
||||
}
|
||||
for _, id := range wantIDs {
|
||||
if !byID[id] {
|
||||
t.Errorf("expected Anforderung %q to be defined", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAnforderungenRejectsAnforderungWithoutTrigger(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"anforderungen.yaml": &fstest.MapFile{Data: []byte(`
|
||||
version: 1
|
||||
anforderungen:
|
||||
- {id: x}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml"); err == nil {
|
||||
t.Fatal("expected an error for an Anforderung without any trigger")
|
||||
}
|
||||
}
|
||||
68
internal/rules/regelwerk.go
Normal file
68
internal/rules/regelwerk.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Package rules lädt das Bewertungsregelwerk (Datenklasse-Ableitung,
|
||||
// KI-VO-Einstufung, Anforderungsprofil) aus versionierten YAML-Dateien.
|
||||
// Regelbasiert, nicht ML — die Regeln liegen als Daten vor, nicht im
|
||||
// Code (siehe CLAUDE.md). Dieses Paket lädt und validiert nur die
|
||||
// Struktur; die eigentliche Auswertung gegen Fragebogen-Antworten ist
|
||||
// bewusst noch nicht Teil von Phase 1 (siehe rules/OPEN.md und die
|
||||
// Baureihenfolge in CLAUDE.md — "Ableitungen und harte Filter" ist ein
|
||||
// eigener, späterer Schritt, sobald der Fragebogen aus Phase 2 die
|
||||
// exakten Fakten-Feldnamen festlegt).
|
||||
package rules
|
||||
|
||||
// DatenklasseStufe ist eine mögliche Datenklasse mit ihren Auslösern aus
|
||||
// Fragebogen-Abschnitt B. Rang bestimmt, welche Stufe gewinnt, wenn
|
||||
// mehrere Auslöser gleichzeitig zutreffen ("höchste zutreffende Stufe
|
||||
// gewinnt") — höherer Rang gewinnt.
|
||||
type DatenklasseStufe struct {
|
||||
ID string `yaml:"id"`
|
||||
Rang int `yaml:"rang"`
|
||||
Ausloeser []string `yaml:"ausloeser"`
|
||||
Folge string `yaml:"folge"`
|
||||
}
|
||||
|
||||
// DatenklasseRegelwerk ist die vollständige Ableitungstabelle für
|
||||
// Fragebogen-Abschnitt B.
|
||||
type DatenklasseRegelwerk struct {
|
||||
Version int `yaml:"version"`
|
||||
Stufen []DatenklasseStufe `yaml:"stufen"`
|
||||
}
|
||||
|
||||
// EinstufungVariante ist eine mögliche Kombination von Fragebogen-Fakten
|
||||
// (Abschnitt C), die zu einer KI-VO-Einstufung führt — mehrere Varianten
|
||||
// sind ODER-verknüpft, die Felder innerhalb einer Variante UND-verknüpft.
|
||||
// Bewusst als freie Schlüssel-Werte-Paare statt fester Go-Felder: welche
|
||||
// Fakten-Schlüssel es gibt, legt der Fragebogen aus Phase 2 fest, nicht
|
||||
// dieses Paket.
|
||||
type EinstufungVariante map[string]any
|
||||
|
||||
// EinstufungStufe ist eine mögliche KI-VO-Einstufung.
|
||||
type EinstufungStufe struct {
|
||||
ID string `yaml:"id"`
|
||||
Quelle string `yaml:"quelle,omitempty"`
|
||||
Varianten []EinstufungVariante `yaml:"varianten"`
|
||||
}
|
||||
|
||||
// EinstufungRegelwerk ist die vollständige Ableitungstabelle für
|
||||
// Fragebogen-Abschnitt C. Die Reihenfolge der Stufen ist Prüfreihenfolge
|
||||
// (erste zutreffende Stufe gewinnt) — siehe Bewertungslogik, Schritt 1
|
||||
// (K.-o.-Prüfung: "verboten" muss zuerst geprüft werden).
|
||||
type EinstufungRegelwerk struct {
|
||||
Version int `yaml:"version"`
|
||||
Stufen []EinstufungStufe `yaml:"stufen"`
|
||||
}
|
||||
|
||||
// AnforderungsRegel leitet eine einzelne Anforderung aus Datenklasse
|
||||
// und/oder KI-VO-Einstufung ab (z. B. avv_erforderlich).
|
||||
type AnforderungsRegel struct {
|
||||
ID string `yaml:"id"`
|
||||
AusDatenklassen []string `yaml:"aus_datenklassen,omitempty"`
|
||||
AusEinstufungen []string `yaml:"aus_einstufungen,omitempty"`
|
||||
Beschreibung string `yaml:"beschreibung,omitempty"`
|
||||
}
|
||||
|
||||
// AnforderungsRegelwerk ist die vollständige Mapping-Tabelle
|
||||
// Datenklasse/Einstufung -> Anforderungsprofil.
|
||||
type AnforderungsRegelwerk struct {
|
||||
Version int `yaml:"version"`
|
||||
Anforderungen []AnforderungsRegel `yaml:"anforderungen"`
|
||||
}
|
||||
85
internal/store/abteilung.go
Normal file
85
internal/store/abteilung.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Abteilung ist Stammdatum für den Fragebogen (Feld A.abteilung).
|
||||
type Abteilung struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
|
||||
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
|
||||
var a Abteilung
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
|
||||
RETURNING id, account_id, name, created_at
|
||||
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||
if err != nil {
|
||||
return Abteilung{}, fmt.Errorf("store: create abteilung: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
|
||||
// alphabetisch — als Auswahlliste für den Fragebogen.
|
||||
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, name, created_at FROM abteilung
|
||||
WHERE account_id = $1 ORDER BY name
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list abteilungen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Abteilung
|
||||
for rows.Next() {
|
||||
var a Abteilung
|
||||
if err := rows.Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan abteilung: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list abteilungen: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetAbteilung liest eine Abteilung anhand ihrer ID.
|
||||
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
|
||||
var a Abteilung
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Abteilung{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Abteilung{}, fmt.Errorf("store: get abteilung: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
|
||||
// angelegt).
|
||||
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete abteilung: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
62
internal/store/abteilung_test.go
Normal file
62
internal/store/abteilung_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestAbteilungCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
a, err := s.CreateAbteilung(ctx, accID, "IT")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAbteilung: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetAbteilung(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAbteilung: %v", err)
|
||||
}
|
||||
if got.Name != "IT" || got.AccountID != accID {
|
||||
t.Fatalf("GetAbteilung = %+v, unerwartete Werte", got)
|
||||
}
|
||||
|
||||
list, err := s.ListAbteilungenForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAbteilungenForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != a.ID {
|
||||
t.Fatalf("ListAbteilungenForAccount = %+v, want exactly the created Abteilung", list)
|
||||
}
|
||||
|
||||
if err := s.DeleteAbteilung(ctx, a.ID); err != nil {
|
||||
t.Fatalf("DeleteAbteilung: %v", err)
|
||||
}
|
||||
if _, err := s.GetAbteilung(ctx, a.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbteilungIsolatedPerAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
accB := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateAbteilung(ctx, accA, "Marketing"); err != nil {
|
||||
t.Fatalf("CreateAbteilung: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListAbteilungenForAccount(ctx, accB)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAbteilungenForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no Abteilungen for Mandant B, got %+v", list)
|
||||
}
|
||||
}
|
||||
71
internal/store/account.go
Normal file
71
internal/store/account.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
|
||||
// nutzt). Jeder Antrag gehört genau einem Account.
|
||||
type Account struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAccount legt einen neuen Mandanten an.
|
||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||
var a Account
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO account (name) VALUES ($1)
|
||||
RETURNING id, name, created_at
|
||||
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: create account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||
var a Account
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, name, created_at FROM account WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: get account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
|
||||
// Admin-Bereich (Accounts-Verwaltung).
|
||||
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, created_at FROM account ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Account
|
||||
for rows.Next() {
|
||||
var a Account
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan account: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
138
internal/store/admin_test.go
Normal file
138
internal/store/admin_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser mit role=betreiber: %v", err)
|
||||
}
|
||||
if u.Role != "betreiber" {
|
||||
t.Fatalf("Role = %q, want betreiber", u.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppUserRoleRejectsUnknownRole(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateUser(ctx, accID, "unbekannt@example.com", "hash", "kanzlei"); err == nil {
|
||||
t.Fatal("expected the old role 'kanzlei' to be rejected after the product pivot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAccounts(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := s.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
after, err := s.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
if len(after) != len(before)+1 {
|
||||
t.Fatalf("expected exactly one more account, got %d -> %d", len(before), len(after))
|
||||
}
|
||||
found := false
|
||||
for _, a := range after {
|
||||
if a.ID == acc.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected the newly created account in ListAccounts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsersForAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
otherAccID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateUser(ctx, accID, "eins@example.com", "hash", "mitarbeiter"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, accID, "zwei@example.com", "hash", "verantwortlicher"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "mitarbeiter"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListUsersForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsersForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected exactly 2 users for this account, got %d: %+v", len(list), list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogCreateAndList(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
admin, err := s.CreateUser(ctx, accID, "admin-audit@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "manuell geprüft")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuditEntry: %v", err)
|
||||
}
|
||||
if entry.ActorUserID != admin.ID {
|
||||
t.Fatalf("ActorUserID = %q, want %q", entry.ActorUserID, admin.ID)
|
||||
}
|
||||
|
||||
list, err := s.ListAuditLog(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAuditLog: %v", err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
t.Fatal("expected at least one audit entry")
|
||||
}
|
||||
if list[0].ID != entry.ID {
|
||||
t.Fatalf("expected the newest entry first, got %+v", list[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
admin, err := s.CreateUser(ctx, accID, "admin-appendonly@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuditEntry: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE audit_log SET action = 'geaendert' WHERE id = $1`, entry.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on audit_log to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM audit_log WHERE id = $1`, entry.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on audit_log to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
162
internal/store/antrag.go
Normal file
162
internal/store/antrag.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Antrag ist ein Vorhaben aus dem Fragebogen (Abschnitt A) mit den
|
||||
// vollständigen Antworten aus B/C/D als JSON — der Fragebogen ist
|
||||
// adaptiv (Folgefragen hängen von vorherigen Antworten ab), ein starres
|
||||
// Spaltenschema könnte das nicht abbilden. Antworten ist bewusst
|
||||
// []byte (rohes JSON), nicht ein aufgelöster Go-Typ — die Struktur der
|
||||
// Antworten ist Sache von internal/rules (Stufe 2), store speichert nur.
|
||||
type Antrag struct {
|
||||
ID string
|
||||
AccountID string
|
||||
ErstellerUserID string
|
||||
AbteilungID *string
|
||||
Titel string
|
||||
Beschreibung string
|
||||
Ergebnis string
|
||||
Haeufigkeit string
|
||||
Antworten []byte
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const antragColumns = `id, account_id, ersteller_user_id, abteilung_id, titel, beschreibung,
|
||||
ergebnis, haeufigkeit, antworten, status, created_at, updated_at`
|
||||
|
||||
func scanAntrag(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Antrag, error) {
|
||||
var a Antrag
|
||||
err := row.Scan(
|
||||
&a.ID, &a.AccountID, &a.ErstellerUserID, &a.AbteilungID, &a.Titel, &a.Beschreibung,
|
||||
&a.Ergebnis, &a.Haeufigkeit, &a.Antworten, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
|
||||
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+antragColumns,
|
||||
accountID, erstellerUserID, abteilungID, titel,
|
||||
)
|
||||
a, err := scanAntrag(row)
|
||||
if err != nil {
|
||||
return Antrag{}, fmt.Errorf("store: create antrag: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
|
||||
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
|
||||
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
|
||||
a, err := scanAntrag(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Antrag{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Antrag{}, fmt.Errorf("store: get antrag: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// UpdateAntragFelder aktualisiert die Fragebogen-Felder eines Antrags —
|
||||
// solange er im Entwurf ist, kann der Fragebogen adaptiv weiter
|
||||
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
|
||||
// Status hier nicht).
|
||||
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
UPDATE antrag SET
|
||||
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+antragColumns,
|
||||
id, titel, beschreibung, ergebnis, haeufigkeit, antworten,
|
||||
)
|
||||
a, err := scanAntrag(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Antrag{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Antrag{}, fmt.Errorf("store: update antrag felder: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// SetAntragStatus setzt den Status eines Antrags (entwurf -> eingereicht
|
||||
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
|
||||
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
|
||||
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set antrag status: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAntraegeForUser liefert alle Anträge, die ein bestimmter Nutzer
|
||||
// gestellt hat, neueste zuerst — für Ebene 2 ("eigene Anträge
|
||||
// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle
|
||||
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
|
||||
func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC
|
||||
`, erstellerUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list antraege for user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Antrag
|
||||
for rows.Next() {
|
||||
a, err := scanAntrag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan antrag: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list antraege for user: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
||||
// zuerst.
|
||||
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list antraege for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Antrag
|
||||
for rows.Next() {
|
||||
a, err := scanAntrag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan antrag: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list antraege for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
133
internal/store/antrag_test.go
Normal file
133
internal/store/antrag_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testUserID(t *testing.T, s *store.Store, accountID string) string {
|
||||
t.Helper()
|
||||
u, err := s.CreateUser(context.Background(), accountID, "mitarbeiter-"+accountID+"@example.com", "hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func TestAntragCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
abt, err := s.CreateAbteilung(ctx, accID, "Vertrieb")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAbteilung: %v", err)
|
||||
}
|
||||
|
||||
a, err := s.CreateAntrag(ctx, accID, userID, &abt.ID, "Angebotstexte generieren")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
if a.Status != "entwurf" {
|
||||
t.Fatalf("Status = %q, want entwurf", a.Status)
|
||||
}
|
||||
if a.AbteilungID == nil || *a.AbteilungID != abt.ID {
|
||||
t.Fatalf("AbteilungID = %v, want %q", a.AbteilungID, abt.ID)
|
||||
}
|
||||
|
||||
got, err := s.GetAntrag(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAntrag: %v", err)
|
||||
}
|
||||
if got.Titel != "Angebotstexte generieren" {
|
||||
t.Fatalf("Titel = %q, unerwartet", got.Titel)
|
||||
}
|
||||
|
||||
updated, err := s.UpdateAntragFelder(ctx, a.ID, "Angebotstexte generieren", "KI schreibt Angebotstexte", "Fertiger Text zur Freigabe", "gelegentlich", []byte(`{"b1":"nein"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAntragFelder: %v", err)
|
||||
}
|
||||
if updated.Beschreibung != "KI schreibt Angebotstexte" || updated.Haeufigkeit != "gelegentlich" {
|
||||
t.Fatalf("UpdateAntragFelder = %+v, unerwartete Werte", updated)
|
||||
}
|
||||
|
||||
if err := s.SetAntragStatus(ctx, a.ID, "eingereicht"); err != nil {
|
||||
t.Fatalf("SetAntragStatus: %v", err)
|
||||
}
|
||||
got, err = s.GetAntrag(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAntrag nach Statuswechsel: %v", err)
|
||||
}
|
||||
if got.Status != "eingereicht" {
|
||||
t.Fatalf("Status nach SetAntragStatus = %q, want eingereicht", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAntragNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.GetAntrag(context.Background(), "00000000-0000-0000-0000-000000000000")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAntragStatusNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
err := s.SetAntragStatus(context.Background(), "00000000-0000-0000-0000-000000000000", "eingereicht")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAntraegeForUserOnlyReturnsOwnAntraege(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userA := testUserID(t, s, accID)
|
||||
userB, err := s.CreateUser(ctx, accID, "zweiter-mitarbeiter-"+accID+"@example.com", "hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.CreateAntrag(ctx, accID, userA, nil, "Antrag von A"); err != nil {
|
||||
t.Fatalf("CreateAntrag (A): %v", err)
|
||||
}
|
||||
if _, err := s.CreateAntrag(ctx, accID, userB.ID, nil, "Antrag von B"); err != nil {
|
||||
t.Fatalf("CreateAntrag (B): %v", err)
|
||||
}
|
||||
|
||||
listA, err := s.ListAntraegeForUser(ctx, userA)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAntraegeForUser: %v", err)
|
||||
}
|
||||
if len(listA) != 1 || listA[0].Titel != "Antrag von A" {
|
||||
t.Fatalf("ListAntraegeForUser(A) = %+v, want exactly Antrag von A", listA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAntraegeForAccountIsolatesTenants(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
accB := testAccountID(t, s)
|
||||
userA := testUserID(t, s, accA)
|
||||
userB := testUserID(t, s, accB)
|
||||
|
||||
if _, err := s.CreateAntrag(ctx, accA, userA, nil, "Antrag A"); err != nil {
|
||||
t.Fatalf("CreateAntrag (A): %v", err)
|
||||
}
|
||||
if _, err := s.CreateAntrag(ctx, accB, userB, nil, "Antrag B"); err != nil {
|
||||
t.Fatalf("CreateAntrag (B): %v", err)
|
||||
}
|
||||
|
||||
listA, err := s.ListAntraegeForAccount(ctx, accA)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAntraegeForAccount (A): %v", err)
|
||||
}
|
||||
if len(listA) != 1 || listA[0].Titel != "Antrag A" {
|
||||
t.Fatalf("ListAntraegeForAccount (A) = %+v, want exactly Antrag A", listA)
|
||||
}
|
||||
}
|
||||
61
internal/store/auditlog.go
Normal file
61
internal/store/auditlog.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditEntry ist ein Protokolleintrag einer Admin-Aktion. Append-only:
|
||||
// siehe Migration 0004 — ein Protokoll, das man ändern kann, ist kein
|
||||
// Nachweis mehr, aus demselben Grund wie bei finding/extraction.
|
||||
type AuditEntry struct {
|
||||
ID string
|
||||
ActorUserID string
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Details string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
||||
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
||||
var e AuditEntry
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
|
||||
`, actorUserID, action, targetType, targetID, details).Scan(
|
||||
&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return AuditEntry{}, fmt.Errorf("store: create audit entry: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
|
||||
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
|
||||
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list audit log: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []AuditEntry
|
||||
for rows.Next() {
|
||||
var e AuditEntry
|
||||
if err := rows.Scan(&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan audit entry: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list audit log: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
116
internal/store/auth_test.go
Normal file
116
internal/store/auth_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestAccountCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetAccount(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount: %v", err)
|
||||
}
|
||||
if got.Name != "Beispiel Agentur GmbH" {
|
||||
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
byEmail, err := s.GetUserByEmail(ctx, "team@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if byEmail.ID != user.ID {
|
||||
t.Fatalf("GetUserByEmail returned a different user than CreateUser")
|
||||
}
|
||||
|
||||
byID, err := s.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if byID.Email != "team@example.com" || byID.AccountID != accID {
|
||||
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByEmailNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.GetUserByEmail(context.Background(), "nichtvorhanden@example.com")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEmailIsUnique(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "mitarbeiter"); err != nil {
|
||||
t.Fatalf("CreateUser (1): %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "mitarbeiter"); err == nil {
|
||||
t.Fatal("expected error for a duplicate email, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "verantwortlicher")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||
sess, err := s.CreateSession(ctx, "test-token-123", user.ID, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetSession(ctx, sess.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if got.UserID != user.ID {
|
||||
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
||||
}
|
||||
|
||||
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
}
|
||||
if _, err := s.GetSession(ctx, sess.Token); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err after delete = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.GetSession(context.Background(), "unbekanntes-token")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
139
internal/store/bewertung.go
Normal file
139
internal/store/bewertung.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// BewertungAnforderung ist eine abgeleitete Anforderung mit Herleitung,
|
||||
// wie sie in bewertung.anforderungen (JSONB) gespeichert wird.
|
||||
type BewertungAnforderung struct {
|
||||
ID string `json:"id"`
|
||||
Beschreibung string `json:"beschreibung"`
|
||||
Herleitung string `json:"herleitung"`
|
||||
}
|
||||
|
||||
// BewertungAusschluss hält fest, warum ein Werkzeug für eine Bewertung
|
||||
// ausgeschlossen wurde.
|
||||
type BewertungAusschluss struct {
|
||||
WerkzeugID string `json:"werkzeug_id"`
|
||||
NichtErfuellt []string `json:"nicht_erfuellt"`
|
||||
}
|
||||
|
||||
// Bewertung ist der berechnete Vorschlag für einen Antrag. Append-only
|
||||
// — siehe Migration.
|
||||
type Bewertung struct {
|
||||
ID string
|
||||
AntragID string
|
||||
Datenklasse string
|
||||
DatenklasseHerleitung string
|
||||
Einstufung string
|
||||
EinstufungHerleitung string
|
||||
Verboten bool
|
||||
Anforderungen []BewertungAnforderung
|
||||
ZulaessigeWerkzeuge []string
|
||||
AusgeschlosseneWerkzeuge []BewertungAusschluss
|
||||
RegelwerkVersion string
|
||||
KatalogVersion string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const bewertungColumns = `id, antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version, created_at`
|
||||
|
||||
func scanBewertung(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Bewertung, error) {
|
||||
var b Bewertung
|
||||
var anforderungenRaw, ausschlussRaw []byte
|
||||
err := row.Scan(
|
||||
&b.ID, &b.AntragID, &b.Datenklasse, &b.DatenklasseHerleitung, &b.Einstufung, &b.EinstufungHerleitung,
|
||||
&b.Verboten, &anforderungenRaw, &b.ZulaessigeWerkzeuge, &ausschlussRaw, &b.RegelwerkVersion, &b.KatalogVersion, &b.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Bewertung{}, err
|
||||
}
|
||||
if err := json.Unmarshal(anforderungenRaw, &b.Anforderungen); err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: anforderungen unmarshal: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(ausschlussRaw, &b.AusgeschlosseneWerkzeuge); err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge unmarshal: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// BewertungInput bündelt die Felder einer neuen Bewertung — bei elf
|
||||
// Werten lesbarer als eine positionale Parameterliste (gleiches Muster
|
||||
// wie WerkzeugInput).
|
||||
type BewertungInput struct {
|
||||
AntragID string
|
||||
Datenklasse string
|
||||
DatenklasseHerleitung string
|
||||
Einstufung string
|
||||
EinstufungHerleitung string
|
||||
Verboten bool
|
||||
Anforderungen []BewertungAnforderung
|
||||
ZulaessigeWerkzeuge []string
|
||||
AusgeschlosseneWerkzeuge []BewertungAusschluss
|
||||
RegelwerkVersion string
|
||||
KatalogVersion string
|
||||
}
|
||||
|
||||
// CreateBewertung speichert eine berechnete Bewertung für einen Antrag.
|
||||
func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertung, error) {
|
||||
if in.ZulaessigeWerkzeuge == nil {
|
||||
in.ZulaessigeWerkzeuge = []string{}
|
||||
}
|
||||
if in.Anforderungen == nil {
|
||||
in.Anforderungen = []BewertungAnforderung{}
|
||||
}
|
||||
if in.AusgeschlosseneWerkzeuge == nil {
|
||||
in.AusgeschlosseneWerkzeuge = []BewertungAusschluss{}
|
||||
}
|
||||
anforderungenJSON, err := json.Marshal(in.Anforderungen)
|
||||
if err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: anforderungen marshal: %w", err)
|
||||
}
|
||||
ausschlussJSON, err := json.Marshal(in.AusgeschlosseneWerkzeuge)
|
||||
if err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err)
|
||||
}
|
||||
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO bewertung (
|
||||
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING `+bewertungColumns,
|
||||
in.AntragID, in.Datenklasse, in.DatenklasseHerleitung, in.Einstufung, in.EinstufungHerleitung,
|
||||
in.Verboten, anforderungenJSON, in.ZulaessigeWerkzeuge, ausschlussJSON, in.RegelwerkVersion, in.KatalogVersion,
|
||||
)
|
||||
b, err := scanBewertung(row)
|
||||
if err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: create bewertung: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// GetLatestBewertungForAntrag liefert die zuletzt berechnete Bewertung
|
||||
// eines Antrags. Liefert ErrNotFound, wenn noch keine Bewertung
|
||||
// existiert.
|
||||
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT `+bewertungColumns+`
|
||||
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||
`, antragID)
|
||||
b, err := scanBewertung(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Bewertung{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: get latest bewertung: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
126
internal/store/bewertung_test.go
Normal file
126
internal/store/bewertung_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testBewertungInput(antragID string) store.BewertungInput {
|
||||
return store.BewertungInput{
|
||||
AntragID: antragID, Datenklasse: "personenbezogen", DatenklasseHerleitung: "ausgelöst durch b1",
|
||||
Einstufung: "minimal", EinstufungHerleitung: "Auffangregel", Verboten: false,
|
||||
Anforderungen: []store.BewertungAnforderung{
|
||||
{ID: "avv_erforderlich", Beschreibung: "AVV nötig", Herleitung: "aus Datenklasse personenbezogen"},
|
||||
},
|
||||
ZulaessigeWerkzeuge: []string{"werkzeug-a"},
|
||||
AusgeschlosseneWerkzeuge: []store.BewertungAusschluss{
|
||||
{WerkzeugID: "werkzeug-b", NichtErfuellt: []string{"avv_erforderlich"}},
|
||||
},
|
||||
RegelwerkVersion: "dk1.ei1.an1", KatalogVersion: "3-1735300000",
|
||||
}
|
||||
}
|
||||
|
||||
func TestBewertungCreateAndGetLatest(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Testantrag")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
|
||||
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBewertung: %v", err)
|
||||
}
|
||||
if b.Datenklasse != "personenbezogen" || len(b.Anforderungen) != 1 {
|
||||
t.Fatalf("CreateBewertung = %+v, unerwartete Werte", b)
|
||||
}
|
||||
if b.Anforderungen[0].ID != "avv_erforderlich" {
|
||||
t.Fatalf("Anforderungen[0] = %+v, unerwartet", b.Anforderungen[0])
|
||||
}
|
||||
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-b" {
|
||||
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, unerwartet", b.AusgeschlosseneWerkzeuge)
|
||||
}
|
||||
|
||||
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if got.ID != b.ID {
|
||||
t.Fatalf("GetLatestBewertungForAntrag returned a different row than CreateBewertung")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestBewertungForAntragNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Bewertung")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBewertungReturnsNewestWhenMultiple(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Mehrfach bewertet")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID)); err != nil {
|
||||
t.Fatalf("CreateBewertung (1): %v", err)
|
||||
}
|
||||
second := testBewertungInput(antrag.ID)
|
||||
second.Datenklasse = "besondere_kategorie"
|
||||
newest, err := s.CreateBewertung(ctx, second)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBewertung (2): %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if got.ID != newest.ID || got.Datenklasse != "besondere_kategorie" {
|
||||
t.Fatalf("expected the newest bewertung, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBewertungIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Append-only Test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBewertung: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE bewertung SET datenklasse = 'geaendert' WHERE id = $1`, b.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on bewertung to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM bewertung WHERE id = $1`, b.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on bewertung to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
111
internal/store/entscheidung.go
Normal file
111
internal/store/entscheidung.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Entscheidung ist die Entscheidung eines/einer Verantwortlichen über
|
||||
// einen Antrag, auf Basis einer Bewertung (Vorschlag). Append-only —
|
||||
// siehe Migration. WerkzeugSnapshot ist der eingefrorene, vollständige
|
||||
// Werkzeug-Datensatz zum Entscheidungszeitpunkt (nil, wenn kein
|
||||
// Werkzeug gewählt wurde, z. B. bei "abgelehnt") — ein späterer
|
||||
// Katalog-Wandel darf nicht rückwirkend verändern, worauf die
|
||||
// Entscheidung beruhte.
|
||||
type Entscheidung struct {
|
||||
ID string
|
||||
AntragID string
|
||||
BewertungID string
|
||||
EntscheiderUserID string
|
||||
Entscheidung string
|
||||
WerkzeugID *string
|
||||
WerkzeugSnapshot *Werkzeug
|
||||
Begruendung string
|
||||
GueltigBis *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const entscheidungColumns = `id, antrag_id, bewertung_id, entscheider_user_id, entscheidung,
|
||||
werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis, created_at`
|
||||
|
||||
func scanEntscheidung(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Entscheidung, error) {
|
||||
var e Entscheidung
|
||||
var snapshotRaw []byte
|
||||
err := row.Scan(
|
||||
&e.ID, &e.AntragID, &e.BewertungID, &e.EntscheiderUserID, &e.Entscheidung,
|
||||
&e.WerkzeugID, &snapshotRaw, &e.Begruendung, &e.GueltigBis, &e.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Entscheidung{}, err
|
||||
}
|
||||
if snapshotRaw != nil {
|
||||
var w Werkzeug
|
||||
if err := json.Unmarshal(snapshotRaw, &w); err != nil {
|
||||
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot unmarshal: %w", err)
|
||||
}
|
||||
e.WerkzeugSnapshot = &w
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// EntscheidungInput bündelt die Felder einer neuen Entscheidung.
|
||||
type EntscheidungInput struct {
|
||||
AntragID string
|
||||
BewertungID string
|
||||
EntscheiderUserID string
|
||||
Entscheidung string
|
||||
WerkzeugID *string
|
||||
WerkzeugSnapshot *Werkzeug
|
||||
Begruendung string
|
||||
GueltigBis *time.Time
|
||||
}
|
||||
|
||||
// CreateEntscheidung speichert eine Entscheidung über einen Antrag.
|
||||
func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (Entscheidung, error) {
|
||||
var snapshotJSON []byte
|
||||
if in.WerkzeugSnapshot != nil {
|
||||
var err error
|
||||
snapshotJSON, err = json.Marshal(in.WerkzeugSnapshot)
|
||||
if err != nil {
|
||||
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot marshal: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO entscheidung (
|
||||
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING `+entscheidungColumns,
|
||||
in.AntragID, in.BewertungID, in.EntscheiderUserID, in.Entscheidung, in.WerkzeugID, snapshotJSON, in.Begruendung, in.GueltigBis,
|
||||
)
|
||||
e, err := scanEntscheidung(row)
|
||||
if err != nil {
|
||||
return Entscheidung{}, fmt.Errorf("store: create entscheidung: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// GetLatestEntscheidungForAntrag liefert die zuletzt getroffene
|
||||
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
|
||||
// Entscheidung existiert.
|
||||
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT `+entscheidungColumns+`
|
||||
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||
`, antragID)
|
||||
e, err := scanEntscheidung(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Entscheidung{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Entscheidung{}, fmt.Errorf("store: get latest entscheidung: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
115
internal/store/entscheidung_test.go
Normal file
115
internal/store/entscheidung_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testAntragMitBewertung(t *testing.T, s *store.Store, accountID, userID string) (store.Antrag, store.Bewertung) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
antrag, err := s.CreateAntrag(ctx, accountID, userID, nil, "Testantrag")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBewertung: %v", err)
|
||||
}
|
||||
return antrag, b
|
||||
}
|
||||
|
||||
func TestEntscheidungCreateAndGetLatest(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "ChatGPT Enterprise"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug: %v", err)
|
||||
}
|
||||
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
|
||||
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
|
||||
Entscheidung: "genehmigt", WerkzeugID: &w.ID, WerkzeugSnapshot: &w,
|
||||
Begruendung: "", GueltigBis: &gueltigBis,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntscheidung: %v", err)
|
||||
}
|
||||
if e.Entscheidung != "genehmigt" || e.WerkzeugSnapshot == nil || e.WerkzeugSnapshot.Name != "ChatGPT Enterprise" {
|
||||
t.Fatalf("CreateEntscheidung = %+v, unerwartete Werte", e)
|
||||
}
|
||||
|
||||
got, err := s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestEntscheidungForAntrag: %v", err)
|
||||
}
|
||||
if got.ID != e.ID {
|
||||
t.Fatalf("GetLatestEntscheidungForAntrag returned a different row than CreateEntscheidung")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntscheidungOhneWerkzeugHatKeinenSnapshot(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||
|
||||
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
|
||||
Entscheidung: "abgelehnt", Begruendung: "Kein geeignetes Werkzeug im Katalog",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntscheidung: %v", err)
|
||||
}
|
||||
if e.WerkzeugID != nil || e.WerkzeugSnapshot != nil {
|
||||
t.Fatalf("expected no werkzeug/snapshot for an abgelehnt Entscheidung, got %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestEntscheidungForAntragNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Entscheidung")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntscheidungIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID, Entscheidung: "rueckfrage",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntscheidung: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE entscheidung SET entscheidung = 'genehmigt' WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on entscheidung to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM entscheidung WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
48
internal/store/migrate.go
Normal file
48
internal/store/migrate.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Migrate wendet alle ausstehenden Migrationen aus migrations/ an.
|
||||
// Die Migrationen sind im Binary eingebettet (go:embed), damit Deklarix
|
||||
// weiterhin als einzelnes Binary lauffähig bleibt.
|
||||
func Migrate(databaseURL string) error {
|
||||
db, err := sql.Open("pgx", databaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: open db for migration: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
driver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: migration driver: %w", err)
|
||||
}
|
||||
|
||||
source, err := iofs.New(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: migration source: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "pgx5", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: migrate init: %w", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("store: migrate up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
11
internal/store/migrations/0001_init.down.sql
Normal file
11
internal/store/migrations/0001_init.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
DROP TRIGGER IF EXISTS evidence_package_append_only ON evidence_package;
|
||||
DROP TRIGGER IF EXISTS finding_append_only ON finding;
|
||||
DROP TRIGGER IF EXISTS extraction_append_only ON extraction;
|
||||
DROP FUNCTION IF EXISTS forbid_update_delete();
|
||||
|
||||
DROP TABLE IF EXISTS participant;
|
||||
DROP TABLE IF EXISTS evidence_package;
|
||||
DROP TABLE IF EXISTS finding;
|
||||
DROP TABLE IF EXISTS extraction;
|
||||
DROP TABLE IF EXISTS asset;
|
||||
DROP TABLE IF EXISTS submission;
|
||||
89
internal/store/migrations/0001_init.up.sql
Normal file
89
internal/store/migrations/0001_init.up.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE submission (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')),
|
||||
post_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')),
|
||||
caption TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE asset (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')),
|
||||
path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Stufe 1: striktes JSON aus der Claude-Extraktion. Append-only, siehe Trigger unten.
|
||||
CREATE TABLE extraction (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
payload JSONB NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
prompt_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Stufe 2: Ergebnis pro Regel. Append-only, Korrektur = neue Zeile.
|
||||
-- supersedes zeigt auf die alte Zeile, die diese Zeile ersetzt (gesetzt
|
||||
-- beim INSERT der Korrektur, nie per UPDATE — der Trigger würde das
|
||||
-- verbieten). "Aktuell gültig" = Zeilen, auf die kein supersedes zeigt,
|
||||
-- siehe Index unten für den Anti-Join.
|
||||
CREATE TABLE finding (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
extraction_id UUID REFERENCES extraction (id),
|
||||
rule_id TEXT NOT NULL,
|
||||
rule_version INTEGER NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')),
|
||||
message TEXT NOT NULL,
|
||||
supersedes UUID REFERENCES finding (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
|
||||
|
||||
-- Dossier + Beweiskette (Hash, RFC-3161-Token). Append-only.
|
||||
CREATE TABLE evidence_package (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
dossier_path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
timestamp_token BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE participant (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||
name TEXT NOT NULL,
|
||||
vorgegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
freigegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
|
||||
CREATE FUNCTION forbid_update_delete() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'append-only table: % on % is not allowed', TG_OP, TG_TABLE_NAME;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER extraction_append_only
|
||||
BEFORE UPDATE OR DELETE ON extraction
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TRIGGER finding_append_only
|
||||
BEFORE UPDATE OR DELETE ON finding
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TRIGGER evidence_package_append_only
|
||||
BEFORE UPDATE OR DELETE ON evidence_package
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE finding DROP COLUMN title;
|
||||
ALTER TABLE finding DROP COLUMN fix;
|
||||
ALTER TABLE finding DROP COLUMN sources;
|
||||
ALTER TABLE finding ADD COLUMN message TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE finding ALTER COLUMN message DROP DEFAULT;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- finding.message war ein einzelnes Freitextfeld. Fürs Dossier brauchen
|
||||
-- wir Titel, Korrekturvorschlag und Fundstellen aber strukturiert und
|
||||
-- getrennt (siehe internal/dossier), sonst müsste die Anzeige Text
|
||||
-- wieder auseinanderparsen, den wir selbst zusammengefügt haben.
|
||||
ALTER TABLE finding DROP COLUMN message;
|
||||
ALTER TABLE finding ADD COLUMN title TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE finding ADD COLUMN fix TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE finding ADD COLUMN sources TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE finding ALTER COLUMN title DROP DEFAULT;
|
||||
ALTER TABLE finding ALTER COLUMN fix DROP DEFAULT;
|
||||
ALTER TABLE finding ALTER COLUMN sources DROP DEFAULT;
|
||||
4
internal/store/migrations/0003_auth.down.sql
Normal file
4
internal/store/migrations/0003_auth.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE submission DROP COLUMN account_id;
|
||||
DROP TABLE session;
|
||||
DROP TABLE app_user;
|
||||
DROP TABLE account;
|
||||
33
internal/store/migrations/0003_auth.up.sql
Normal file
33
internal/store/migrations/0003_auth.up.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Mandantentrennung: ein account ist der Mandant (Creator, Agentur,
|
||||
-- Marke oder Kanzlei als eigene Organisation), app_user ist ein Login
|
||||
-- innerhalb eines accounts. session ist bewusst eine eigene Tabelle
|
||||
-- statt signierter, zustandsloser Tokens — ein Logout muss eine
|
||||
-- Sitzung wirklich beenden können, nicht nur clientseitig "vergessen"
|
||||
-- werden.
|
||||
CREATE TABLE account (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- "app_user" statt "user", da user ein reserviertes Wort in SQL ist.
|
||||
CREATE TABLE app_user (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE session (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Jeder Beitrag gehört ab jetzt zu genau einem Mandanten. NOT NULL ohne
|
||||
-- Default ist hier sicher, weil noch keine Submission-Zeilen aus der
|
||||
-- Zeit vor Auth existieren (kein Kunde live).
|
||||
ALTER TABLE submission ADD COLUMN account_id UUID NOT NULL REFERENCES account (id);
|
||||
6
internal/store/migrations/0004_admin.down.sql
Normal file
6
internal/store/migrations/0004_admin.down.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
DROP TRIGGER audit_log_append_only ON audit_log;
|
||||
DROP TABLE audit_log;
|
||||
ALTER TABLE account DROP COLUMN verified;
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei'));
|
||||
36
internal/store/migrations/0004_admin.up.sql
Normal file
36
internal/store/migrations/0004_admin.up.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- "admin" ist eine fünfte app_user-Rolle: Betreiber-Personal (Netcell-IT),
|
||||
-- nicht an einen Mandanten-Geschäftszweck (creator/agentur/marke/kanzlei)
|
||||
-- gebunden, sondern zuständig für die Plattform selbst (Accounts,
|
||||
-- Kanzlei-Verzeichnis, Audit-Log). Bewusst KEIN eigenes account_role-Feld
|
||||
-- getrennt von app_user.role — ein Admin-Login ist genauso ein app_user
|
||||
-- wie jeder andere, nur mit einer anderen Rolle. Es gibt bewusst keine
|
||||
-- Selbstregistrierung für "admin" über /register (siehe internal/web) —
|
||||
-- der erste Admin wird per SQL angelegt (siehe CLAUDE.md).
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
|
||||
|
||||
-- verified markiert eine Kanzlei-Account als für das öffentliche,
|
||||
-- kostenlose Kanzlei-Verzeichnis freigegeben (siehe CLAUDE.md, § 49b
|
||||
-- Abs. 3 BRAO: keine Sachvorteile, kein Routing — das Verzeichnis ist
|
||||
-- eine reine Auflistung, keine Vermittlung). Nur für Accounts mit
|
||||
-- mindestens einem Nutzer der Rolle "kanzlei" sinnvoll; das erzwingt die
|
||||
-- Anwendungsschicht, nicht die Datenbank.
|
||||
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Audit-Log für Admin-Aktionen: append-only aus demselben Grund wie
|
||||
-- finding/extraction/evidence_package — ein Protokoll, das man ändern
|
||||
-- kann, ist kein Nachweis mehr.
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER audit_log_append_only
|
||||
BEFORE UPDATE OR DELETE ON audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
@@ -0,0 +1 @@
|
||||
DROP TRIGGER asset_append_only ON asset;
|
||||
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal file
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- asset war in 0001 ohne append-only-Trigger angelegt, weil bis jetzt
|
||||
-- nichts Assets tatsächlich schrieb. Ein hochgeladenes Standbild ist
|
||||
-- Teil der Beweiskette (SHA-256, siehe CLAUDE.md) genau wie extraction/
|
||||
-- finding/evidence_package — es nachträglich austauschen zu können,
|
||||
-- würde denselben Grund unterlaufen, aus dem diese Tabellen append-only
|
||||
-- sind.
|
||||
CREATE TRIGGER asset_append_only
|
||||
BEFORE UPDATE OR DELETE ON asset
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE platform_connection;
|
||||
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Ein Kunde kann seinen eigenen Instagram- oder TikTok-Account per
|
||||
-- OAuth verbinden (siehe internal/socialconnect), damit die
|
||||
-- Beweissicherung einen veröffentlichten Beitrag künftig direkt
|
||||
-- abrufen kann statt ihn manuell hochzuladen. Anders als
|
||||
-- extraction/finding/asset ist das NICHT append-only: Tokens laufen ab
|
||||
-- und werden erneuert, eine Verbindung kann getrennt und neu
|
||||
-- hergestellt werden — das ist ein normaler Konfigurationszustand, kein
|
||||
-- Beweis-Eintrag. Ein Account hat höchstens eine Verbindung pro
|
||||
-- Plattform (erneutes Verbinden ersetzt die alte, siehe
|
||||
-- ON CONFLICT in UpsertPlatformConnection).
|
||||
CREATE TABLE platform_connection (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
|
||||
platform_user_id TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, platform)
|
||||
);
|
||||
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE asset DROP COLUMN purpose;
|
||||
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal file
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Ein Beitrag kann mehr als ein Standbild bekommen: das ursprüngliche
|
||||
-- Beweisfoto beim Prüfen (purpose='initial') und später ein Nachweis
|
||||
-- flüchtiger Kennzahlen (purpose='insights') — Instagram hält Story-
|
||||
-- Insights nach eigener Aussage nur 24 Stunden vor, danach sind sie
|
||||
-- auch über den offiziellen Datenexport nicht mehr zu bekommen.
|
||||
-- Default 'initial' erhält die Bedeutung aller vor dieser Migration
|
||||
-- angelegten Zeilen unverändert.
|
||||
ALTER TABLE asset ADD COLUMN purpose TEXT NOT NULL DEFAULT 'initial'
|
||||
CHECK (purpose IN ('initial', 'insights'));
|
||||
104
internal/store/migrations/0008_pivot_ki_antragspruefung.down.sql
Normal file
104
internal/store/migrations/0008_pivot_ki_antragspruefung.down.sql
Normal file
@@ -0,0 +1,104 @@
|
||||
-- Best-effort-Rückbau auf die Schema-Form des alten Werberecht-Produkts
|
||||
-- (Stand nach Migration 0007) — reine Struktur, keine Daten. Ein
|
||||
-- kompletter Produktwechsel wird in der Praxis nicht zurückgerollt,
|
||||
-- diese Datei existiert nur, damit "migrate down" nicht bricht.
|
||||
|
||||
DROP TABLE IF EXISTS antrag;
|
||||
DROP TABLE IF EXISTS werkzeug_sperre;
|
||||
DROP TABLE IF EXISTS werkzeug;
|
||||
DROP TABLE IF EXISTS abteilung;
|
||||
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
|
||||
|
||||
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE submission (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')),
|
||||
post_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')),
|
||||
caption TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
account_id UUID NOT NULL REFERENCES account (id)
|
||||
);
|
||||
|
||||
CREATE TABLE asset (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')),
|
||||
path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
purpose TEXT NOT NULL DEFAULT 'initial' CHECK (purpose IN ('initial', 'insights'))
|
||||
);
|
||||
CREATE TRIGGER asset_append_only
|
||||
BEFORE UPDATE OR DELETE ON asset
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE extraction (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
payload JSONB NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
prompt_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TRIGGER extraction_append_only
|
||||
BEFORE UPDATE OR DELETE ON extraction
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE finding (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
extraction_id UUID REFERENCES extraction (id),
|
||||
rule_id TEXT NOT NULL,
|
||||
rule_version INTEGER NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')),
|
||||
supersedes UUID REFERENCES finding (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
title TEXT NOT NULL,
|
||||
fix TEXT NOT NULL,
|
||||
sources TEXT[] NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
|
||||
CREATE TRIGGER finding_append_only
|
||||
BEFORE UPDATE OR DELETE ON finding
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE evidence_package (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
dossier_path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
timestamp_token BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TRIGGER evidence_package_append_only
|
||||
BEFORE UPDATE OR DELETE ON evidence_package
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE participant (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||
name TEXT NOT NULL,
|
||||
vorgegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
freigegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE platform_connection (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
|
||||
platform_user_id TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, platform)
|
||||
);
|
||||
117
internal/store/migrations/0008_pivot_ki_antragspruefung.up.sql
Normal file
117
internal/store/migrations/0008_pivot_ki_antragspruefung.up.sql
Normal file
@@ -0,0 +1,117 @@
|
||||
-- Produktwechsel: Deklarix war eine Kennzeichnungsprüfung für
|
||||
-- Werbe-Content (UWG/MStV), wird jetzt eine KI-Antragsprüfung
|
||||
-- (Fragebogen -> Datenklasse/KI-VO-Einstufung -> Werkzeug-Katalog ->
|
||||
-- Entscheidung). Alles, was ausschließlich für das alte Werberecht-
|
||||
-- Produkt existierte, wird entfernt. account/app_user/session/
|
||||
-- audit_log bleiben — Mandantentrennung, Login und
|
||||
-- Protokollierungsprinzip sind produktunabhängig.
|
||||
|
||||
DROP TABLE IF EXISTS platform_connection;
|
||||
DROP TABLE IF EXISTS asset;
|
||||
DROP TABLE IF EXISTS participant;
|
||||
DROP TABLE IF EXISTS evidence_package;
|
||||
DROP TABLE IF EXISTS finding;
|
||||
DROP TABLE IF EXISTS extraction;
|
||||
DROP TABLE IF EXISTS submission;
|
||||
|
||||
-- "verified"/Kanzlei-Verzeichnis gab es nur für die alte Berufsrecht-
|
||||
-- Sonderrolle "kanzlei".
|
||||
ALTER TABLE account DROP COLUMN IF EXISTS verified;
|
||||
|
||||
-- Fünf Rollen (Ebenen 2-5 der Frontend-Spezifikation):
|
||||
-- mitarbeiter Ebene 2 — stellt Anträge, sieht nur eigene
|
||||
-- verantwortlicher Ebene 3 — KI-Verantwortlicher, volles Entscheidungsrecht
|
||||
-- pruefer Ebene 3 — identische Sicht wie verantwortlicher, aber
|
||||
-- ohne Entscheidungsrecht (reine Prüfsicht)
|
||||
-- admin Ebene 4 — Mandanten-Verwaltung (Nutzer, Abteilungen,
|
||||
-- Anmeldeverfahren, eigene Werkzeug-Freigaben) — bezogen
|
||||
-- auf GENAU EINEN Mandanten, nicht plattformweit
|
||||
-- betreiber Ebene 5 — Netcell-IT-Personal, plattformweit
|
||||
-- (Werkzeugkatalog, Regelwerk, Mandantenverwaltung);
|
||||
-- entspricht der alten "admin"-Rolle vor dieser Migration
|
||||
--
|
||||
-- Die alte CHECK-Constraint muss zuerst weg, sonst lehnt sie die
|
||||
-- Datenmigration unten (die neue Rollenwerte wie "betreiber" schreibt)
|
||||
-- sofort ab — die alte Constraint kennt diese Werte ja noch nicht.
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
|
||||
-- Bestehende Zeilen auf gültige neue Werte umstellen, bevor die neue
|
||||
-- Constraint das erzwingt — sonst schlägt sie auf jeder Installation
|
||||
-- mit echten Nutzern fehl. Die alte Rolle "admin" war der Plattform-
|
||||
-- Betreiber (Netcell-IT) — wird zu "betreiber", nicht zum neuen,
|
||||
-- mandantenbezogenen "admin". Alle anderen alten Rollen (creator/agentur/
|
||||
-- marke/kanzlei) waren gewöhnliche Mandanten-Logins ohne Sonderrechte —
|
||||
-- werden zu "mitarbeiter", der schlankesten neuen Rolle.
|
||||
UPDATE app_user SET role = 'betreiber' WHERE role = 'admin';
|
||||
UPDATE app_user SET role = 'mitarbeiter' WHERE role IN ('creator', 'agentur', 'marke', 'kanzlei');
|
||||
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('mitarbeiter', 'verantwortlicher', 'pruefer', 'admin', 'betreiber'));
|
||||
|
||||
-- Stammdaten: Abteilungen zur Auswahl im Fragebogen (Feld A.abteilung).
|
||||
CREATE TABLE abteilung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, name)
|
||||
);
|
||||
|
||||
-- Werkzeugkatalog: der eigentliche Wert des Produkts. account_id NULL
|
||||
-- markiert einen zentral gepflegten, für alle Mandanten identischen
|
||||
-- Katalogeintrag; ein gesetzter account_id ist eine mandantenspezifische
|
||||
-- Ergänzung (siehe Spezifikation "jeder Mandant kann zusätzlich eigene
|
||||
-- Einträge ... führen"). letzte_pruefung und quelle sind Pflicht — jede
|
||||
-- Zusicherung im Katalog muss belegbar sein, siehe CLAUDE.md.
|
||||
CREATE TABLE werkzeug (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES account (id),
|
||||
name TEXT NOT NULL,
|
||||
anbieter TEXT NOT NULL,
|
||||
verarbeitungsort TEXT NOT NULL CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem')),
|
||||
avv_verfuegbar BOOLEAN NOT NULL DEFAULT false,
|
||||
avv_url TEXT NOT NULL DEFAULT '',
|
||||
training_opt_out BOOLEAN NOT NULL DEFAULT false,
|
||||
training_standard BOOLEAN NOT NULL DEFAULT false,
|
||||
aufbewahrung_tage INTEGER NOT NULL DEFAULT 0,
|
||||
zertifizierungen TEXT[] NOT NULL DEFAULT '{}',
|
||||
geeignete_zwecke TEXT[] NOT NULL DEFAULT '{}',
|
||||
einschraenkungen TEXT[] NOT NULL DEFAULT '{}',
|
||||
letzte_pruefung TIMESTAMPTZ NOT NULL,
|
||||
quelle TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Ein Mandant kann einen (auch zentralen) Katalogeintrag für sich
|
||||
-- sperren, ohne den zentralen Katalog selbst zu verändern.
|
||||
CREATE TABLE werkzeug_sperre (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
werkzeug_id UUID NOT NULL REFERENCES werkzeug (id),
|
||||
grund TEXT NOT NULL,
|
||||
gesperrt_am TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, werkzeug_id)
|
||||
);
|
||||
|
||||
-- Antrag: das Vorhaben aus Fragebogen-Abschnitt A, plus die vollständigen
|
||||
-- Antworten aus B/C/D als JSON (adaptiver Fragebogen — welche Folgefragen
|
||||
-- beantwortet wurden, hängt von vorherigen Antworten ab, ein starres
|
||||
-- Spaltenschema würde das nicht abbilden). status ist eine normale
|
||||
-- Zustandsänderung (wie submission es früher war), kein Beweis-Eintrag.
|
||||
CREATE TABLE antrag (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
ersteller_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
abteilung_id UUID REFERENCES abteilung (id),
|
||||
titel TEXT NOT NULL,
|
||||
beschreibung TEXT NOT NULL DEFAULT '',
|
||||
ergebnis TEXT NOT NULL DEFAULT '',
|
||||
haeufigkeit TEXT NOT NULL DEFAULT 'einmalig'
|
||||
CHECK (haeufigkeit IN ('einmalig', 'gelegentlich', 'taeglich', 'automatisiert')),
|
||||
antworten JSONB NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'entwurf'
|
||||
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
1
internal/store/migrations/0009_bewertung.down.sql
Normal file
1
internal/store/migrations/0009_bewertung.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE bewertung;
|
||||
26
internal/store/migrations/0009_bewertung.up.sql
Normal file
26
internal/store/migrations/0009_bewertung.up.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Bewertung: der berechnete Vorschlag für einen Antrag (Datenklasse,
|
||||
-- KI-VO-Einstufung, Anforderungsprofil, zulässige/ausgeschlossene
|
||||
-- Werkzeuge mit Herleitung). Append-only wie finding/extraction im
|
||||
-- alten Produkt — eine neue Bewertung (z. B. nach Änderung der
|
||||
-- Antworten oder des Katalogs) ergänzt die alte, ersetzt sie nicht;
|
||||
-- die Historie bleibt für den Audit erhalten. regelwerk_version und
|
||||
-- katalog_version werden zum Berechnungszeitpunkt eingefroren.
|
||||
CREATE TABLE bewertung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||
datenklasse TEXT NOT NULL,
|
||||
datenklasse_herleitung TEXT NOT NULL,
|
||||
einstufung TEXT NOT NULL,
|
||||
einstufung_herleitung TEXT NOT NULL,
|
||||
verboten BOOLEAN NOT NULL DEFAULT false,
|
||||
anforderungen JSONB NOT NULL DEFAULT '[]',
|
||||
zulaessige_werkzeuge TEXT[] NOT NULL DEFAULT '{}',
|
||||
ausgeschlossene_werkzeuge JSONB NOT NULL DEFAULT '[]',
|
||||
regelwerk_version TEXT NOT NULL,
|
||||
katalog_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER bewertung_append_only
|
||||
BEFORE UPDATE OR DELETE ON bewertung
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
1
internal/store/migrations/0010_entscheidung.down.sql
Normal file
1
internal/store/migrations/0010_entscheidung.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE entscheidung;
|
||||
25
internal/store/migrations/0010_entscheidung.up.sql
Normal file
25
internal/store/migrations/0010_entscheidung.up.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Entscheidung: der Mensch entscheidet, das System bereitet nur vor
|
||||
-- (siehe CLAUDE.md, Grundregel). Eine Entscheidung bezieht sich auf
|
||||
-- genau eine Bewertung (den Vorschlag, auf dessen Basis entschieden
|
||||
-- wurde) und friert optional ein gewähltes Werkzeug als vollständigen
|
||||
-- Datensatz ein (werkzeug_snapshot) — ein späterer Katalog-Wandel darf
|
||||
-- nicht rückwirkend verändern, worauf eine Entscheidung beruhte.
|
||||
-- Append-only wie bewertung/audit_log: eine Entscheidung wird nicht
|
||||
-- korrigiert, sondern durch eine neue Entscheidung auf Basis einer
|
||||
-- neuen Bewertung ersetzt (Neubewertung + erneute Entscheidung).
|
||||
CREATE TABLE entscheidung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||
bewertung_id UUID NOT NULL REFERENCES bewertung (id),
|
||||
entscheider_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
entscheidung TEXT NOT NULL CHECK (entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen', 'abgelehnt', 'rueckfrage')),
|
||||
werkzeug_id UUID REFERENCES werkzeug (id),
|
||||
werkzeug_snapshot JSONB,
|
||||
begruendung TEXT NOT NULL DEFAULT '',
|
||||
gueltig_bis TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER entscheidung_append_only
|
||||
BEFORE UPDATE OR DELETE ON entscheidung
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
62
internal/store/session.go
Normal file
62
internal/store/session.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
||||
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
||||
// eine Session wird immer über ihren Token nachgeschlagen.
|
||||
type Session struct {
|
||||
Token string
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||
var sess Session
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO session (token, user_id, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING token, user_id, expires_at, created_at
|
||||
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: create session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
||||
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
||||
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
||||
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||
var sess Session
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
|
||||
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: get session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// DeleteSession beendet eine Sitzung (Logout).
|
||||
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||
if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
|
||||
return fmt.Errorf("store: delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
internal/store/store.go
Normal file
31
internal/store/store.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Store hält den Verbindungspool zur Postgres-Datenbank.
|
||||
type Store struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Open baut den Verbindungspool auf und prüft ihn mit einem Ping.
|
||||
func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open pool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("store: ping: %w", err)
|
||||
}
|
||||
return &Store{Pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close gibt den Verbindungspool frei.
|
||||
func (s *Store) Close() {
|
||||
s.Pool.Close()
|
||||
}
|
||||
70
internal/store/store_test.go
Normal file
70
internal/store/store_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// Diese Tests brauchen eine laufende Postgres-Instanz und werden ohne
|
||||
// DATABASE_URL übersprungen, statt eine Verbindung vorzutäuschen.
|
||||
func testDatabaseURL(t *testing.T) string {
|
||||
t.Helper()
|
||||
url := os.Getenv("DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("DATABASE_URL nicht gesetzt, überspringe Store-Integrationstest")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func openTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
url := testDatabaseURL(t)
|
||||
if err := store.Migrate(url); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
s, err := store.Open(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// testAccountID legt einen Mandanten an und liefert dessen ID.
|
||||
func testAccountID(t *testing.T, s *store.Store) string {
|
||||
t.Helper()
|
||||
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
func TestMigrateAndOpen(t *testing.T) {
|
||||
url := testDatabaseURL(t)
|
||||
|
||||
if err := store.Migrate(url); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
|
||||
s, err := store.Open(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
var tableCount int
|
||||
err = s.Pool.QueryRow(context.Background(), `
|
||||
SELECT count(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = ANY($1)
|
||||
`, []string{"account", "app_user", "session", "audit_log", "abteilung", "werkzeug", "werkzeug_sperre", "antrag"}).Scan(&tableCount)
|
||||
if err != nil {
|
||||
t.Fatalf("query tables: %v", err)
|
||||
}
|
||||
if tableCount != 8 {
|
||||
t.Fatalf("expected 8 tables, got %d", tableCount)
|
||||
}
|
||||
}
|
||||
103
internal/store/user.go
Normal file
103
internal/store/user.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrNotFound signalisiert, dass eine Abfrage keine Zeile ergeben hat —
|
||||
// abgrenzbar von echten Fehlern (z. B. Verbindungsabbruch), damit
|
||||
// Aufrufer "nicht gefunden" gezielt anders behandeln können (z. B. bei
|
||||
// Login: falsche E-Mail vs. Datenbankfehler).
|
||||
var ErrNotFound = errors.New("store: nicht gefunden")
|
||||
|
||||
// User ist ein Login innerhalb eines Account (Mandanten).
|
||||
type User struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
||||
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
||||
// speichert nur, es hasht nicht selbst.
|
||||
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, account_id, email, password_hash, role, created_at
|
||||
`, accountID, email, passwordHash, role).Scan(
|
||||
&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: create user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
||||
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, email, password_hash, role, created_at
|
||||
FROM app_user WHERE email = $1
|
||||
`, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: get user by email: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// GetUser liest einen Nutzer anhand seiner ID.
|
||||
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, email, password_hash, role, created_at
|
||||
FROM app_user WHERE id = $1
|
||||
`, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: get user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
||||
// Admin-Bereich (Account-Detailansicht).
|
||||
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, email, password_hash, role, created_at
|
||||
FROM app_user WHERE account_id = $1 ORDER BY created_at
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan user: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
301
internal/store/werkzeug.go
Normal file
301
internal/store/werkzeug.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Werkzeug ist ein Eintrag im KI-Werkzeugkatalog — der eigentliche Wert
|
||||
// des Produkts (siehe CLAUDE.md). AccountID nil markiert einen zentral
|
||||
// gepflegten, für alle Mandanten identischen Katalogeintrag; ein
|
||||
// gesetzter AccountID ist eine mandantenspezifische Ergänzung.
|
||||
// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog
|
||||
// muss belegbar sein.
|
||||
type Werkzeug struct {
|
||||
ID string
|
||||
AccountID *string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
Zertifizierungen []string
|
||||
GeeigneteZwecke []string
|
||||
Einschraenkungen []string
|
||||
LetztePruefung time.Time
|
||||
Quelle string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
|
||||
// Create/Update — bei 14 Feldern lesbarer als eine positionale
|
||||
// Parameterliste.
|
||||
type WerkzeugInput struct {
|
||||
AccountID *string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
Zertifizierungen []string
|
||||
GeeigneteZwecke []string
|
||||
Einschraenkungen []string
|
||||
LetztePruefung time.Time
|
||||
Quelle string
|
||||
}
|
||||
|
||||
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
|
||||
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
||||
|
||||
func scanWerkzeug(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Werkzeug, error) {
|
||||
var w Werkzeug
|
||||
err := row.Scan(
|
||||
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
|
||||
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
|
||||
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
||||
)
|
||||
return w, err
|
||||
}
|
||||
|
||||
// normalizeWerkzeugSlices ersetzt nil-Slices durch leere Slices — die
|
||||
// Spalten sind TEXT[] NOT NULL, ein nil-Slice (z. B. wenn eine
|
||||
// Einschränkung optional ist) käme sonst als SQL-NULL an und würde mit
|
||||
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
|
||||
// Fall wie früher bei finding.sources).
|
||||
func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||
if in.Zertifizierungen == nil {
|
||||
in.Zertifizierungen = []string{}
|
||||
}
|
||||
if in.GeeigneteZwecke == nil {
|
||||
in.GeeigneteZwecke = []string{}
|
||||
}
|
||||
if in.Einschraenkungen == nil {
|
||||
in.Einschraenkungen = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO werkzeug (
|
||||
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
|
||||
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING `+werkzeugColumns,
|
||||
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||
)
|
||||
w, err := scanWerkzeug(row)
|
||||
if err != nil {
|
||||
return Werkzeug{}, fmt.Errorf("store: create werkzeug: %w", err)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// UpdateWerkzeug ersetzt die Felder eines bestehenden Katalogeintrags
|
||||
// (z. B. bei einer erneuten Prüfung der Zusicherungen). Werkzeug ist
|
||||
// bewusst nicht append-only — anders als ein Beweisstück ist der
|
||||
// Katalog ein gepflegter, sich änderender Datenbestand; eine Entscheidung
|
||||
// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein.
|
||||
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
UPDATE werkzeug SET
|
||||
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
|
||||
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
|
||||
letzte_pruefung = $13, quelle = $14, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+werkzeugColumns,
|
||||
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||
)
|
||||
w, err := scanWerkzeug(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Werkzeug{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Werkzeug{}, fmt.Errorf("store: update werkzeug: %w", err)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
|
||||
func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
|
||||
w, err := scanWerkzeug(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Werkzeug{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Werkzeug{}, fmt.Errorf("store: get werkzeug: %w", err)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// DeleteWerkzeug entfernt einen Katalogeintrag.
|
||||
func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete werkzeug: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWerkzeugeForAccount liefert den für einen Mandanten sichtbaren
|
||||
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
|
||||
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
|
||||
func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+werkzeugColumns+` FROM werkzeug w
|
||||
WHERE (w.account_id IS NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1
|
||||
)) OR w.account_id = $1
|
||||
ORDER BY w.name
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Werkzeug
|
||||
for rows.Next() {
|
||||
w, err := scanWerkzeug(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListZentraleWerkzeuge liefert den vollständigen zentralen Katalog
|
||||
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
|
||||
// Mandanten-Sperrungen.
|
||||
func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Werkzeug
|
||||
for rows.Next() {
|
||||
w, err := scanWerkzeug(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CurrentKatalogVersion liefert eine reproduzierbare Kennung des
|
||||
// aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird
|
||||
// in jeder Bewertung/Entscheidung eingefroren, damit im Audit
|
||||
// nachvollziehbar bleibt, mit welchem Katalogstand ein Vorschlag
|
||||
// erzeugt wurde.
|
||||
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||
var count int
|
||||
var lastUpdate time.Time
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug
|
||||
`).Scan(&count, &lastUpdate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store: current katalog version: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%d-%d", count, lastUpdate.Unix()), nil
|
||||
}
|
||||
|
||||
// WerkzeugSperre ist die Sperrung eines (auch zentralen) Katalogeintrags
|
||||
// durch einen einzelnen Mandanten — der zentrale Katalog selbst bleibt
|
||||
// dabei unverändert.
|
||||
type WerkzeugSperre struct {
|
||||
ID string
|
||||
AccountID string
|
||||
WerkzeugID string
|
||||
Grund string
|
||||
GesperrtAm time.Time
|
||||
}
|
||||
|
||||
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
|
||||
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
|
||||
var sp WerkzeugSperre
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
|
||||
`, accountID, werkzeugID, grund).Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm)
|
||||
if err != nil {
|
||||
return WerkzeugSperre{}, fmt.Errorf("store: create werkzeug sperre: %w", err)
|
||||
}
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
|
||||
func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2
|
||||
`, accountID, werkzeugID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete werkzeug sperre: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
|
||||
func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, werkzeug_id, grund, gesperrt_am
|
||||
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []WerkzeugSperre
|
||||
for rows.Next() {
|
||||
var sp WerkzeugSperre
|
||||
if err := rows.Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm); err != nil {
|
||||
return nil, fmt.Errorf("store: scan werkzeug sperre: %w", err)
|
||||
}
|
||||
out = append(out, sp)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
219
internal/store/werkzeug_test.go
Normal file
219
internal/store/werkzeug_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
||||
return store.WerkzeugInput{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Anbieter: "Beispiel-Anbieter GmbH",
|
||||
Verarbeitungsort: "EU",
|
||||
AVVVerfuegbar: true,
|
||||
AVVURL: "https://beispiel.example/avv",
|
||||
TrainingOptOut: true,
|
||||
TrainingStandard: true,
|
||||
AufbewahrungTage: 30,
|
||||
Zertifizierungen: []string{"ISO 27001"},
|
||||
GeeigneteZwecke: []string{"Textgenerierung"},
|
||||
Einschraenkungen: nil,
|
||||
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
|
||||
Quelle: "https://beispiel.example/beleg",
|
||||
}
|
||||
}
|
||||
|
||||
func TestWerkzeugCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentrales Werkzeug"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug: %v", err)
|
||||
}
|
||||
if w.AccountID != nil {
|
||||
t.Fatalf("expected AccountID nil fuer zentralen Katalogeintrag, got %v", w.AccountID)
|
||||
}
|
||||
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
|
||||
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
|
||||
}
|
||||
|
||||
got, err := s.GetWerkzeug(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWerkzeug: %v", err)
|
||||
}
|
||||
if got.Name != "Zentrales Werkzeug" {
|
||||
t.Fatalf("Name = %q, want Zentrales Werkzeug", got.Name)
|
||||
}
|
||||
|
||||
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
||||
updateInput.AufbewahrungTage = 14
|
||||
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateWerkzeug: %v", err)
|
||||
}
|
||||
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage != 14 {
|
||||
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
|
||||
}
|
||||
|
||||
if err := s.DeleteWerkzeug(ctx, w.ID); err != nil {
|
||||
t.Fatalf("DeleteWerkzeug: %v", err)
|
||||
}
|
||||
if _, err := s.GetWerkzeug(ctx, w.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWerkzeugNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.UpdateWerkzeug(context.Background(), "00000000-0000-0000-0000-000000000000", testWerkzeugInput(nil, "x"))
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWerkzeugeForAccountIncludesCentralAndOwnEntries(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral A"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug (zentral): %v", err)
|
||||
}
|
||||
eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes Werkzeug"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug (eigenes): %v", err)
|
||||
}
|
||||
|
||||
// Anderer Mandant darf das eigene Werkzeug nicht sehen.
|
||||
otherAcc := testAccountID(t, s)
|
||||
otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount (other): %v", err)
|
||||
}
|
||||
for _, w := range otherList {
|
||||
if w.ID == eigenes.ID {
|
||||
t.Fatal("expected the other account's own werkzeug to stay isolated")
|
||||
}
|
||||
}
|
||||
|
||||
list, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount: %v", err)
|
||||
}
|
||||
byID := map[string]bool{}
|
||||
for _, w := range list {
|
||||
byID[w.ID] = true
|
||||
}
|
||||
if !byID[zentral.ID] || !byID[eigenes.ID] {
|
||||
t.Fatalf("expected both the central and the own werkzeug, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWerkzeugSperreHidesCentralEntry(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zu sperrendes Werkzeug"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.CreateWerkzeugSperre(ctx, accID, zentral.ID, "vom Mandanten intern verboten"); err != nil {
|
||||
t.Fatalf("CreateWerkzeugSperre: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount: %v", err)
|
||||
}
|
||||
for _, w := range list {
|
||||
if w.ID == zentral.ID {
|
||||
t.Fatal("expected the sperred central werkzeug to be hidden for this account")
|
||||
}
|
||||
}
|
||||
|
||||
// Der zentrale Katalog selbst bleibt fuer andere Mandanten sichtbar.
|
||||
otherAcc := testAccountID(t, s)
|
||||
otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount (other): %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, w := range otherList {
|
||||
if w.ID == zentral.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected the central werkzeug to remain visible for a different account")
|
||||
}
|
||||
|
||||
if err := s.DeleteWerkzeugSperre(ctx, accID, zentral.ID); err != nil {
|
||||
t.Fatalf("DeleteWerkzeugSperre: %v", err)
|
||||
}
|
||||
listAfter, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount nach Entsperren: %v", err)
|
||||
}
|
||||
found = false
|
||||
for _, w := range listAfter {
|
||||
if w.ID == zentral.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected the werkzeug to be visible again after DeleteWerkzeugSperre")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentKatalogVersionChangesOnCreate(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := s.CurrentKatalogVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentKatalogVersion: %v", err)
|
||||
}
|
||||
if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Version-Test-Werkzeug")); err != nil {
|
||||
t.Fatalf("CreateWerkzeug: %v", err)
|
||||
}
|
||||
after, err := s.CurrentKatalogVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentKatalogVersion (2): %v", err)
|
||||
}
|
||||
if before == after {
|
||||
t.Fatalf("expected CurrentKatalogVersion to change after adding a werkzeug, got %q both times", before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListZentraleWerkzeugeExcludesMandantenEigene(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral B")); err != nil {
|
||||
t.Fatalf("CreateWerkzeug (zentral): %v", err)
|
||||
}
|
||||
eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes B"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug (eigenes): %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListZentraleWerkzeuge(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListZentraleWerkzeuge: %v", err)
|
||||
}
|
||||
for _, w := range list {
|
||||
if w.ID == eigenes.ID {
|
||||
t.Fatal("expected ListZentraleWerkzeuge to exclude mandantenspezifische Eintraege")
|
||||
}
|
||||
}
|
||||
}
|
||||
108
internal/web/admin_handlers.go
Normal file
108
internal/web/admin_handlers.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen für GENAU EINEN
|
||||
// Mandanten, siehe CLAUDE.md, Fünf Ebenen. Aktuell nur Nutzerverwaltung
|
||||
// (Abteilungen, Anmeldeverfahren, Werkzeug-Freigaben, Rechnungsdaten
|
||||
// sind spätere Ausbaustufen). Es gibt noch keinen Einladungslink/CSV-
|
||||
// Import/SSO (siehe CLAUDE.md, Onboarding) — ein Admin setzt hier
|
||||
// direkt ein Initialpasswort, wie schon bei der Firma-Registrierung.
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen
|
||||
// Mandanten vergeben darf — "betreiber" ist Ebene 5 (Plattform) und
|
||||
// kann von keinem Mandanten-Admin vergeben werden.
|
||||
var mandantenRollen = []string{"mitarbeiter", "verantwortlicher", "pruefer", "admin"}
|
||||
|
||||
func istMandantenRolle(role string) bool {
|
||||
for _, r := range mandantenRollen {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type nutzerListItem struct {
|
||||
Email string
|
||||
Role string
|
||||
}
|
||||
|
||||
type nutzerListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Nutzer []nutzerListItem
|
||||
}
|
||||
|
||||
// handleNutzerListe zeigt alle Logins des eigenen Mandanten.
|
||||
func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.store.ListUsersForAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)}
|
||||
for _, u := range users {
|
||||
data.Nutzer = append(data.Nutzer, nutzerListItem{Email: u.Email, Role: u.Role})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type nutzerNeuData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Rollen []string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleNutzerNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNutzerCreate legt einen weiteren Login im eigenen Mandanten an.
|
||||
// Es gibt keine Einladungsmail (siehe CLAUDE.md, Onboarding — noch
|
||||
// nicht gebaut) — der Admin setzt das Initialpasswort direkt und gibt
|
||||
// es dem/der neuen Nutzer:in auf einem anderen Weg weiter.
|
||||
func (s *Server) handleNutzerCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
role := r.FormValue("role")
|
||||
if email == "" || password == "" || !istMandantenRolle(role) {
|
||||
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: "E-Mail, Passwort und eine gültige Rolle sind Pflicht"}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.store.CreateUser(r.Context(), currentUser(r).AccountID, email, passwordHash, role); err != nil {
|
||||
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||
}
|
||||
75
internal/web/admin_handlers_test.go
Normal file
75
internal/web/admin_handlers_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMitarbeiterCannotAccessNutzerverwaltung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter on /verwaltung/nutzer", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanCreateUserWithMandantenRolle(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"}, "role": {"verantwortlicher"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
u, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if u.Role != "verantwortlicher" {
|
||||
t.Errorf("Role = %q, want verantwortlicher", u.Role)
|
||||
}
|
||||
|
||||
list := getWithCookie(t, s, adminCookie, "/verwaltung/nutzer")
|
||||
if !strings.Contains(list.Body.String(), "neu@example.com") {
|
||||
t.Errorf("expected the new user in the Nutzerliste, got: %s", list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotCreateBetreiberUser(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||
"email": {"versuch@example.com"}, "password": {"ein-langes-passwort"}, "role": {"betreiber"},
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (Formular mit Fehler) for role betreiber", resp.Code)
|
||||
}
|
||||
if _, err := fs.GetUserByEmail(context.Background(), "versuch@example.com"); err == nil {
|
||||
t.Error("expected no user to be created for an invalid role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCreateUserRejectsDuplicateEmail(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||
"email": {"admin@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (Formular mit Fehler) for duplicate email", resp.Code)
|
||||
}
|
||||
}
|
||||
355
internal/web/antrag_handlers.go
Normal file
355
internal/web/antrag_handlers.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// antwortenB/C/D-Feldnamen sind bewusst identisch zu den Fakten-
|
||||
// Schlüsseln in rules/datenklasse.yaml (b1..b7) und
|
||||
// rules/kivo_einstufung.yaml (c1..c5, c2_folge, c3_art) — Schritt 3
|
||||
// (Ableitungen) liest dieselben Schlüssel direkt aus antrag.antworten,
|
||||
// ohne sie erst umbenennen zu müssen.
|
||||
var datenfragen = []struct {
|
||||
Key string
|
||||
Label string
|
||||
}{
|
||||
{"b1", "Namen, E-Mail-Adressen oder andere Angaben zu Personen"},
|
||||
{"b2", "Angaben zu Gesundheit, Religion, Herkunft, Gewerkschaft, Sexualleben"},
|
||||
{"b3", "Daten von Patienten, Mandanten oder Versicherten"},
|
||||
{"b4", "Bewerbungs-, Personal- oder Leistungsdaten von Beschäftigten"},
|
||||
{"b5", "Daten, die uns ein Kunde anvertraut hat"},
|
||||
{"b6", "Konstruktions-, Kalkulations- oder Vertragsunterlagen"},
|
||||
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
|
||||
}
|
||||
|
||||
type antragFormData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Abteilungen []store.Abteilung
|
||||
Datenfragen []struct {
|
||||
Key string
|
||||
Label string
|
||||
}
|
||||
Error string
|
||||
}
|
||||
|
||||
// handleAntragNewForm zeigt den Fragebogen (Ebene 2, "Antrag stellen").
|
||||
// Bewusst eine einzelne Seite statt eines mehrseitigen Assistenten —
|
||||
// jede zusätzliche Seite kostet Zeit, und "dauert der Antrag länger als
|
||||
// fünf Minuten, wird er umgangen" (siehe CLAUDE.md). Adaptive
|
||||
// Folgefragen (C2/C3) werden rein clientseitig per CSS ein-/ausgeblendet,
|
||||
// ohne zusätzlichen Server-Roundtrip.
|
||||
func (s *Server) handleAntragNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r).Role == "betreiber" {
|
||||
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := antragFormData{Title: "Antrag stellen", Nav: navFor(r), Abteilungen: abteilungen, Datenfragen: datenfragen}
|
||||
if err := s.templates.ExecuteTemplate(w, "antrag-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// antworten ist die JSON-Struktur, die in antrag.antworten gespeichert
|
||||
// wird — ein flaches Objekt aus den Fragebogen-Fakten-Schlüsseln.
|
||||
// "Unsicher" wird für B unverändert als "unsicher" gespeichert, NICHT
|
||||
// zu "ja" normalisiert: diese Normalisierung ("Unsicher zählt wie Ja")
|
||||
// ist eine Auswertungsregel (Schritt 3), keine Speicherregel — was der
|
||||
// Mitarbeiter tatsächlich geantwortet hat, bleibt im Antrag unverändert
|
||||
// erhalten.
|
||||
func antwortenFromForm(r *http.Request) []byte {
|
||||
m := map[string]any{}
|
||||
for _, f := range datenfragen {
|
||||
v := r.FormValue(f.Key)
|
||||
if v == "" {
|
||||
v = "nein"
|
||||
}
|
||||
m[f.Key] = v
|
||||
}
|
||||
m["c1"] = r.FormValue("c1") == "ja"
|
||||
m["c2"] = r.FormValue("c2") == "ja"
|
||||
m["c2_folge"] = r.FormValue("c2_folge")
|
||||
m["c3"] = r.FormValue("c3") == "ja"
|
||||
m["c3_art"] = r.FormValue("c3_art")
|
||||
m["c4"] = r.FormValue("c4") == "ja"
|
||||
m["c5"] = r.FormValue("c5") == "ja"
|
||||
m["d_werkzeug_freitext"] = r.FormValue("d_werkzeug_freitext")
|
||||
m["d_zugang"] = r.FormValue("d_zugang")
|
||||
m["d_geraet"] = r.FormValue("d_geraet")
|
||||
|
||||
data, _ := json.Marshal(m) // map[string]any mit nur primitiven Werten kann nicht scheitern
|
||||
return data
|
||||
}
|
||||
|
||||
// handleAntragCreate legt einen Antrag an und reicht ihn direkt ein
|
||||
// (entwurf -> eingereicht in einem Schritt) — der Fragebogen ist eine
|
||||
// einzelne Seite, ein Zwischenspeichern als Entwurf ist für v1 nicht
|
||||
// vorgesehen.
|
||||
func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r).Role == "betreiber" {
|
||||
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
titel := r.FormValue("titel")
|
||||
beschreibung := r.FormValue("beschreibung")
|
||||
ergebnis := r.FormValue("ergebnis")
|
||||
haeufigkeit := r.FormValue("haeufigkeit")
|
||||
if titel == "" || beschreibung == "" || ergebnis == "" || haeufigkeit == "" {
|
||||
http.Error(w, "Titel, Beschreibung, Ergebnis und Häufigkeit sind Pflichtfelder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var abteilungID *string
|
||||
if v := r.FormValue("abteilung_id"); v != "" {
|
||||
abteilungID = &v
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
user := currentUser(r)
|
||||
|
||||
antworten := antwortenFromForm(r)
|
||||
|
||||
antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel)
|
||||
if err != nil {
|
||||
http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antworten); err != nil {
|
||||
http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetAntragStatus(ctx, antrag.ID, "eingereicht"); err != nil {
|
||||
http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.bewerteAntrag(ctx, antrag.ID, user.AccountID, antworten); err != nil {
|
||||
http.Error(w, "Bewertung konnte nicht berechnet werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// bewerteAntrag berechnet die Bewertung für einen eingereichten Antrag
|
||||
// (Schritt 3: Ableitungen und harte Filter) und speichert sie. Bei
|
||||
// "verboten" erfolgt die K.-o.-Prüfung — keine Werkzeugsuche, siehe
|
||||
// CLAUDE.md, Bewertungslogik.
|
||||
func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string, antwortenJSON []byte) error {
|
||||
antworten, err := rules.ParseAntworten(antwortenJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
datenklasse := rules.EvaluateDatenklasse(s.regelwerk.Datenklasse, antworten)
|
||||
einstufung, err := rules.EvaluateEinstufung(s.regelwerk.Einstufung, antworten)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verboten := rules.IstVerboten(einstufung.ID)
|
||||
|
||||
in := store.BewertungInput{
|
||||
AntragID: antragID, Datenklasse: datenklasse.ID, DatenklasseHerleitung: datenklasse.Herleitung,
|
||||
Einstufung: einstufung.ID, EinstufungHerleitung: einstufungHerleitungText(einstufung), Verboten: verboten,
|
||||
KatalogVersion: "n/a (K.-o.-Prüfung, keine Werkzeugsuche)",
|
||||
}
|
||||
|
||||
if !verboten {
|
||||
anforderungen := rules.DeriveAnforderungen(s.regelwerk.Anforderungen, datenklasse.ID, einstufung.ID)
|
||||
anforderungIDs := make([]string, len(anforderungen))
|
||||
for i, a := range anforderungen {
|
||||
in.Anforderungen = append(in.Anforderungen, store.BewertungAnforderung{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||
anforderungIDs[i] = a.ID
|
||||
}
|
||||
|
||||
werkzeuge, err := s.store.ListWerkzeugeForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||
for i, w := range werkzeuge {
|
||||
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
|
||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
||||
}}
|
||||
}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
||||
in.ZulaessigeWerkzeuge = zulaessig
|
||||
for _, a := range ausgeschlossen {
|
||||
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
||||
}
|
||||
|
||||
katalogVersion, err := s.store.CurrentKatalogVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.KatalogVersion = katalogVersion
|
||||
}
|
||||
in.RegelwerkVersion = rules.RegelwerkVersion(s.regelwerk.Datenklasse, s.regelwerk.Einstufung, s.regelwerk.Anforderungen)
|
||||
|
||||
_, err = s.store.CreateBewertung(ctx, in)
|
||||
return err
|
||||
}
|
||||
|
||||
// einstufungHerleitungText fügt die Quelle (z. B. "Art. 5 KI-VO") mit
|
||||
// an, falls das Regelwerk eine nennt.
|
||||
func einstufungHerleitungText(e rules.EinstufungErgebnis) string {
|
||||
if e.Quelle == "" {
|
||||
return e.Herleitung
|
||||
}
|
||||
return e.Herleitung + " (" + e.Quelle + ")"
|
||||
}
|
||||
|
||||
type antragListItem struct {
|
||||
ID string
|
||||
Titel string
|
||||
Status string
|
||||
Haeufigkeit string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type antragListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Antraege []antragListItem
|
||||
}
|
||||
|
||||
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
|
||||
// Anträge einsehen, Status verfolgen"), nicht die des ganzen Mandanten.
|
||||
func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r).Role == "betreiber" {
|
||||
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListAntraegeForUser(r.Context(), currentUser(r).ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := antragListData{Title: "Meine Anträge", Nav: navFor(r)}
|
||||
for _, a := range list {
|
||||
data.Antraege = append(data.Antraege, antragListItem{
|
||||
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
|
||||
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "antraege-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type anforderungView struct {
|
||||
ID string
|
||||
Beschreibung string
|
||||
Herleitung string
|
||||
}
|
||||
|
||||
type werkzeugAusschlussView struct {
|
||||
Name string
|
||||
NichtErfuellt []string
|
||||
}
|
||||
|
||||
type bewertungView struct {
|
||||
Datenklasse string
|
||||
DatenklasseHerleitung string
|
||||
Einstufung string
|
||||
EinstufungHerleitung string
|
||||
Verboten bool
|
||||
Anforderungen []anforderungView
|
||||
ZulaessigeWerkzeuge []string // Namen, nicht IDs
|
||||
AusgeschlosseneWerkzeuge []werkzeugAusschlussView
|
||||
RegelwerkVersion string
|
||||
KatalogVersion string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type antragDetailData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
ID string
|
||||
Titel string
|
||||
Beschreibung string
|
||||
Ergebnis string
|
||||
Haeufigkeit string
|
||||
Status string
|
||||
CreatedAt string
|
||||
Antworten map[string]any
|
||||
Bewertung *bewertungView
|
||||
}
|
||||
|
||||
// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten.
|
||||
// Ein Antrag eines fremden Nutzers wird wie ein nicht existierender
|
||||
// behandelt (404), nie mit einer expliziten 403 bestätigt — dasselbe
|
||||
// Muster wie schon beim alten Produkt für fremde Mandanten.
|
||||
func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
|
||||
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
||||
if err != nil || antrag.ErstellerUserID != currentUser(r).ID {
|
||||
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var antworten map[string]any
|
||||
if len(antrag.Antworten) > 0 {
|
||||
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
||||
}
|
||||
|
||||
data := antragDetailData{
|
||||
Title: "Antrag", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
||||
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
||||
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antworten,
|
||||
}
|
||||
|
||||
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||
if err == nil {
|
||||
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
|
||||
}
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// toBewertungView löst die in der Bewertung nur als ID gespeicherten
|
||||
// Werkzeuge zu Anzeigenamen auf. Ein Werkzeug, das seit der Berechnung
|
||||
// gelöscht wurde, wird mit seiner ID statt eines Namens angezeigt,
|
||||
// statt den ganzen Antrag unanzeigbar zu machen.
|
||||
func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewertungView {
|
||||
v := &bewertungView{
|
||||
Datenklasse: b.Datenklasse, DatenklasseHerleitung: b.DatenklasseHerleitung,
|
||||
Einstufung: b.Einstufung, EinstufungHerleitung: b.EinstufungHerleitung, Verboten: b.Verboten,
|
||||
RegelwerkVersion: b.RegelwerkVersion, KatalogVersion: b.KatalogVersion,
|
||||
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
|
||||
}
|
||||
for _, a := range b.Anforderungen {
|
||||
v.Anforderungen = append(v.Anforderungen, anforderungView{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||
}
|
||||
for _, id := range b.ZulaessigeWerkzeuge {
|
||||
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
|
||||
}
|
||||
for _, a := range b.AusgeschlosseneWerkzeuge {
|
||||
v.AusgeschlosseneWerkzeuge = append(v.AusgeschlosseneWerkzeuge, werkzeugAusschlussView{
|
||||
Name: s.werkzeugName(ctx, a.WerkzeugID), NichtErfuellt: a.NichtErfuellt,
|
||||
})
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) werkzeugName(ctx context.Context, id string) string {
|
||||
w, err := s.store.GetWerkzeug(ctx, id)
|
||||
if err != nil {
|
||||
return id
|
||||
}
|
||||
return w.Name
|
||||
}
|
||||
255
internal/web/antrag_handlers_test.go
Normal file
255
internal/web/antrag_handlers_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func fullAntragForm() url.Values {
|
||||
return url.Values{
|
||||
"titel": {"Angebotstexte generieren"}, "beschreibung": {"KI schreibt Angebotstexte"},
|
||||
"ergebnis": {"Fertiger Text zur Freigabe"}, "haeufigkeit": {"gelegentlich"},
|
||||
"b1": {"ja"}, "b2": {"nein"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"},
|
||||
"c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"},
|
||||
"c4": {"nein"}, "c5": {"ja"},
|
||||
"d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragNewFormRendersFragebogen(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/antraege/neu")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
for _, want := range []string{`name="titel"`, `name="b1"`, `name="c2_folge"`, `name="d_zugang"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("expected %q in the Fragebogen, got: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
|
||||
s, fs, cookie := newAuthedTestServer(t)
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
loc := resp.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/antraege/") {
|
||||
t.Fatalf("Location = %q, want a redirect to the new antrag", loc)
|
||||
}
|
||||
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAntrag: %v", err)
|
||||
}
|
||||
if antrag.Status != "eingereicht" {
|
||||
t.Fatalf("Status = %q, want eingereicht", antrag.Status)
|
||||
}
|
||||
var antworten map[string]any
|
||||
if err := json.Unmarshal(antrag.Antworten, &antworten); err != nil {
|
||||
t.Fatalf("Antworten unmarshal: %v", err)
|
||||
}
|
||||
if antworten["b1"] != "ja" {
|
||||
t.Errorf("b1 = %v, want ja", antworten["b1"])
|
||||
}
|
||||
if antworten["c5"] != true {
|
||||
t.Errorf("c5 = %v, want true", antworten["c5"])
|
||||
}
|
||||
|
||||
detailResp := getWithCookie(t, s, cookie, loc)
|
||||
if detailResp.Code != http.StatusOK {
|
||||
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
|
||||
}
|
||||
if !strings.Contains(detailResp.Body.String(), "Angebotstexte generieren") {
|
||||
t.Errorf("expected the antrag title on the detail page, got: %s", detailResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragCreateRejectsMissingFields(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", url.Values{"titel": {"Nur Titel"}})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 for missing required fields", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragListShowsOnlyOwnAntraege(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
|
||||
if resp := postForm(t, s, cookieA, "/antraege", fullAntragForm()); resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create (A) status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
listA := getWithCookie(t, s, cookieA, "/antraege")
|
||||
if !strings.Contains(listA.Body.String(), "Angebotstexte generieren") {
|
||||
t.Errorf("expected Mandant A to see their own antrag, got: %s", listA.Body.String())
|
||||
}
|
||||
listB := getWithCookie(t, s, cookieB, "/antraege")
|
||||
if strings.Contains(listB.Body.String(), "Angebotstexte generieren") {
|
||||
t.Errorf("expected Mandant B to see no antrag, got: %s", listB.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragDetailRejectsForeignUser(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
|
||||
createResp := postForm(t, s, cookieA, "/antraege", fullAntragForm())
|
||||
antragURL := createResp.Header().Get("Location")
|
||||
|
||||
resp := getWithCookie(t, s, cookieB, antragURL)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a foreign user's antrag", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// fullAntragForm liefert b1=ja (Datenklasse personenbezogen) und
|
||||
// c1..c3=nein (keine der verboten/hochrisiko/transparenzpflicht-
|
||||
// Varianten trifft zu) — die Einstufung fällt damit auf die
|
||||
// Auffangregel "minimal" zurück (siehe rules/kivo_einstufung.yaml).
|
||||
func TestAntragCreateComputesBewertung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if b.Datenklasse != "personenbezogen" {
|
||||
t.Errorf("Datenklasse = %q, want personenbezogen", b.Datenklasse)
|
||||
}
|
||||
if b.Einstufung != "minimal" {
|
||||
t.Errorf("Einstufung = %q, want minimal", b.Einstufung)
|
||||
}
|
||||
if b.Verboten {
|
||||
t.Error("Verboten = true, want false")
|
||||
}
|
||||
|
||||
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
|
||||
if !strings.Contains(detail.Body.String(), "Das System entscheidet nicht") {
|
||||
t.Errorf("expected the mandatory disclaimer on the detail page, got: %s", detail.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAntragCreateVerbotenSkipsWerkzeugSearch prüft die K.-o.-Prüfung:
|
||||
// c3=ja mit c3_art=social_scoring löst "verboten" aus (Art. 5 KI-VO) —
|
||||
// dann darf keine Werkzeugsuche stattfinden (siehe CLAUDE.md,
|
||||
// Bewertungslogik).
|
||||
func TestAntragCreateVerbotenSkipsWerkzeugSearch(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||
|
||||
form := fullAntragForm()
|
||||
form.Set("c3", "ja")
|
||||
form.Set("c3_art", "social_scoring")
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", form)
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if !b.Verboten || b.Einstufung != "verboten" {
|
||||
t.Fatalf("Bewertung = %+v, want Verboten=true, Einstufung=verboten", b)
|
||||
}
|
||||
if len(b.ZulaessigeWerkzeuge) != 0 || len(b.AusgeschlosseneWerkzeuge) != 0 {
|
||||
t.Errorf("expected no werkzeug search for a verboten Bewertung, got %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAntragCreateFiltersWerkzeugeByAnforderungen prüft den harten
|
||||
// Filter: personenbezogen verlangt avv_erforderlich — ein Werkzeug ohne
|
||||
// AVV wird ausgeschlossen und im Ergebnis mit Begründung genannt statt
|
||||
// stillschweigend wegzufallen, ein Werkzeug mit AVV bleibt zulässig.
|
||||
func TestAntragCreateFiltersWerkzeugeByAnforderungen(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||
|
||||
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{
|
||||
ID: "werkzeug-ok", Name: "ChatGPT Enterprise",
|
||||
AVVVerfuegbar: true, TrainingStandard: true,
|
||||
}
|
||||
fs.werkzeuge["werkzeug-ohne-avv"] = store.Werkzeug{
|
||||
ID: "werkzeug-ohne-avv", Name: "Freies KI-Tool",
|
||||
AVVVerfuegbar: false, TrainingStandard: true,
|
||||
}
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "werkzeug-ok" {
|
||||
t.Errorf("ZulaessigeWerkzeuge = %v, want [werkzeug-ok]", b.ZulaessigeWerkzeuge)
|
||||
}
|
||||
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-ohne-avv" {
|
||||
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want [werkzeug-ohne-avv]", b.AusgeschlosseneWerkzeuge)
|
||||
}
|
||||
if got := b.AusgeschlosseneWerkzeuge[0].NichtErfuellt; len(got) != 1 || got[0] != "avv_erforderlich" {
|
||||
t.Errorf("NichtErfuellt = %v, want [avv_erforderlich]", got)
|
||||
}
|
||||
|
||||
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
|
||||
body := detail.Body.String()
|
||||
if !strings.Contains(body, "ChatGPT Enterprise") || !strings.Contains(body, "Freies KI-Tool") {
|
||||
t.Errorf("expected both werkzeuge (by name) on the detail page, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
for _, path := range []string{"/antraege", "/antraege/neu"} {
|
||||
resp := getWithCookie(t, s, betreiberCookie, path)
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Errorf("GET %s status = %d, want 303 redirect for a betreiber", path, resp.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
145
internal/web/auth_handlers.go
Normal file
145
internal/web/auth_handlers.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
type authPageData struct {
|
||||
Title string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
|
||||
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren"})
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
|
||||
}
|
||||
|
||||
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
|
||||
// Spezifikation): legt einen neuen Mandanten (Account) und den ersten
|
||||
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
|
||||
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
|
||||
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
|
||||
// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink
|
||||
// (spätere Ausbaustufe), nicht über dieses Formular.
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
|
||||
return
|
||||
}
|
||||
|
||||
accountName := r.FormValue("account_name")
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
if accountName == "" || email == "" || password == "" {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||
if err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
|
||||
return
|
||||
}
|
||||
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
|
||||
if err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "ungültiges Formular"})
|
||||
return
|
||||
}
|
||||
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
|
||||
ctx := r.Context()
|
||||
user, err := s.store.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
// Absichtlich dieselbe Meldung wie bei falschem Passwort — sonst
|
||||
// verrät die Fehlermeldung, ob eine E-Mail-Adresse existiert.
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
||||
return
|
||||
}
|
||||
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
|
||||
return
|
||||
}
|
||||
// Ein Betreiber-Login hat keinen eigenen Antrag zu stellen — der
|
||||
// Plattform-Bereich ist der sinnvolle Einstieg (Ebene 5, technisch
|
||||
// getrennt vom Mandantenbereich).
|
||||
if user.Role == "betreiber" {
|
||||
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||
_ = s.store.DeleteSession(r.Context(), cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// startSession erzeugt eine neue Sitzung und setzt das Session-Cookie.
|
||||
// Secure ist an genau dann, wenn die Anfrage selbst über TLS kam — ein
|
||||
// fest verdrahtetes Secure=true würde lokale Entwicklung ohne TLS
|
||||
// (http://localhost) brechen, da Browser ein Secure-Cookie über Klartext-
|
||||
// HTTP schlicht nicht speichern.
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||
token, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: expiresAt,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
139
internal/web/betreiber_handlers.go
Normal file
139
internal/web/betreiber_handlers.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type betreiberDashboardData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
AccountCount int
|
||||
RecentAuditCount int
|
||||
}
|
||||
|
||||
// handleBetreiberDashboard zeigt eine kurze Übersicht als Einstieg in
|
||||
// den Plattform-Bereich (Ebene 5 — nur Betreiber, technisch getrennt
|
||||
// vom Mandantenbereich).
|
||||
func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
accounts, err := s.store.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auditLog, err := s.store.ListAuditLog(ctx, 5)
|
||||
if err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := betreiberDashboardData{
|
||||
Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts), RecentAuditCount: len(auditLog),
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type betreiberAccountListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type betreiberAccountListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Accounts []betreiberAccountListItem
|
||||
}
|
||||
|
||||
// handleBetreiberAccountList listet alle Mandanten der Plattform.
|
||||
func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Request) {
|
||||
accounts, err := s.store.ListAccounts(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r)}
|
||||
for _, a := range accounts {
|
||||
data.Accounts = append(data.Accounts, betreiberAccountListItem{
|
||||
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-accounts", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type betreiberUserView struct {
|
||||
Email string
|
||||
Role string
|
||||
}
|
||||
|
||||
type betreiberAccountDetailData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
AccountID string
|
||||
Name string
|
||||
Users []betreiberUserView
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// handleBetreiberAccountDetail zeigt einen Mandanten mit seinen Logins.
|
||||
func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := betreiberAccountDetailData{
|
||||
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name,
|
||||
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
||||
}
|
||||
for _, u := range users {
|
||||
data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type betreiberAuditEntryView struct {
|
||||
CreatedAt string
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Details string
|
||||
}
|
||||
|
||||
type betreiberAuditLogData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Entries []betreiberAuditEntryView
|
||||
}
|
||||
|
||||
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
|
||||
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := s.store.ListAuditLog(r.Context(), 200)
|
||||
if err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
|
||||
for _, e := range entries {
|
||||
data.Entries = append(data.Entries, betreiberAuditEntryView{
|
||||
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
||||
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-audit-log", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
236
internal/web/fachebene_handlers.go
Normal file
236
internal/web/fachebene_handlers.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Ebene 3 (Fachebene) — Posteingang offener Anträge und Entscheiden.
|
||||
// Rollen "verantwortlicher" (Entscheidungsrecht) und "pruefer"
|
||||
// (identische Sicht, kein Entscheidungsrecht) sehen dieselben Seiten;
|
||||
// siehe requireFachebene in middleware.go und CLAUDE.md, Fünf Ebenen.
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
type fallListItem struct {
|
||||
ID string
|
||||
Titel string
|
||||
Haeufigkeit string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type fallListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Faelle []fallListItem
|
||||
}
|
||||
|
||||
// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status
|
||||
// "eingereicht") Anträge des ganzen Mandanten — im Unterschied zu
|
||||
// GET /antraege, das nur die eigenen Anträge eines Nutzers zeigt.
|
||||
func (s *Server) handleFaelleListe(w http.ResponseWriter, r *http.Request) {
|
||||
antraege, err := s.store.ListAntraegeForAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := fallListData{Title: "Posteingang", Nav: navFor(r)}
|
||||
for _, a := range antraege {
|
||||
if a.Status != "eingereicht" {
|
||||
continue
|
||||
}
|
||||
data.Faelle = append(data.Faelle, fallListItem{
|
||||
ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit,
|
||||
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type entscheidungView struct {
|
||||
Entscheidung string
|
||||
Begruendung string
|
||||
WerkzeugName string
|
||||
GueltigBis string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type werkzeugOption struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type fallDetailData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
ID string
|
||||
Titel string
|
||||
Beschreibung string
|
||||
Ergebnis string
|
||||
Haeufigkeit string
|
||||
Status string
|
||||
CreatedAt string
|
||||
Bewertung *bewertungView
|
||||
Entscheidung *entscheidungView
|
||||
CanEntscheiden bool
|
||||
WerkzeugOptionen []werkzeugOption
|
||||
Error string
|
||||
}
|
||||
|
||||
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
|
||||
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
|
||||
// eine tatsächliche Entscheidung davon abweicht (dann ist die
|
||||
// Begründung Pflicht, siehe CLAUDE.md, Grundregel).
|
||||
func vorschlagFuer(b store.Bewertung) string {
|
||||
if b.Verboten {
|
||||
return "abgelehnt"
|
||||
}
|
||||
if len(b.ZulaessigeWerkzeuge) > 0 {
|
||||
return "genehmigt"
|
||||
}
|
||||
return "rueckfrage"
|
||||
}
|
||||
|
||||
// handleFallDetail zeigt einen Antrag mit Bewertung für die Fachebene
|
||||
// — anders als GET /antraege/{id} (nur der eigene Antrag) ist hier
|
||||
// jeder Antrag des Mandanten sichtbar, unabhängig vom Ersteller.
|
||||
func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
|
||||
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
||||
if err != nil || antrag.AccountID != currentUser(r).AccountID {
|
||||
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := fallDetailData{
|
||||
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
||||
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
||||
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"),
|
||||
}
|
||||
|
||||
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||
if err == nil {
|
||||
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
|
||||
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
||||
data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)})
|
||||
}
|
||||
}
|
||||
data.CanEntscheiden = currentUser(r).Role == "verantwortlicher" && antrag.Status == "eingereicht" && data.Bewertung != nil
|
||||
|
||||
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
|
||||
if err == nil {
|
||||
v := &entscheidungView{
|
||||
Entscheidung: entscheidung.Entscheidung, Begruendung: entscheidung.Begruendung,
|
||||
CreatedAt: entscheidung.CreatedAt.Format("02.01.2006 15:04"),
|
||||
}
|
||||
if entscheidung.WerkzeugSnapshot != nil {
|
||||
v.WerkzeugName = entscheidung.WerkzeugSnapshot.Name
|
||||
}
|
||||
if entscheidung.GueltigBis != nil {
|
||||
v.GueltigBis = entscheidung.GueltigBis.Format("02.01.2006")
|
||||
}
|
||||
data.Entscheidung = v
|
||||
}
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// gueltigkeitFuer liefert das Ablaufdatum einer Genehmigung — 12 Monate,
|
||||
// bei hochrisiko 6 (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage).
|
||||
func gueltigkeitFuer(einstufung string) time.Time {
|
||||
if einstufung == "hochrisiko" {
|
||||
return time.Now().AddDate(0, 6, 0)
|
||||
}
|
||||
return time.Now().AddDate(1, 0, 0)
|
||||
}
|
||||
|
||||
// handleFallEntscheiden speichert die Entscheidung eines/einer
|
||||
// Verantwortlichen. Prüfer haben dieselbe Sicht, aber kein
|
||||
// Entscheidungsrecht — anders als bei requireFachebene (404, weil die
|
||||
// Seite für sie gar nicht existieren soll) ist das hier ein 403: ein
|
||||
// Prüfer darf wissen, dass der Fall existiert, nur nicht entscheiden.
|
||||
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user.Role != "verantwortlicher" {
|
||||
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
||||
if err != nil || antrag.AccountID != user.AccountID {
|
||||
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if antrag.Status != "eingereicht" {
|
||||
http.Error(w, "Antrag ist bereits entschieden", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Bewertung nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
entscheidungTyp := r.FormValue("entscheidung")
|
||||
begruendung := r.FormValue("begruendung")
|
||||
werkzeugID := r.FormValue("werkzeug_id")
|
||||
|
||||
switch entscheidungTyp {
|
||||
case "genehmigt", "genehmigt_mit_auflagen", "abgelehnt", "rueckfrage":
|
||||
default:
|
||||
http.Error(w, "ungültige Entscheidung", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if vorschlagFuer(bewertung) != entscheidungTyp && begruendung == "" {
|
||||
http.Error(w, "Begründung erforderlich, wenn die Entscheidung vom Vorschlag abweicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
in := store.EntscheidungInput{
|
||||
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
||||
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
||||
}
|
||||
if entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen" {
|
||||
zulaessig := false
|
||||
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
||||
if id == werkzeugID {
|
||||
zulaessig = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if werkzeugID == "" || !zulaessig {
|
||||
http.Error(w, "bei Genehmigung muss ein zulässiges Werkzeug gewählt werden", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
werkzeug, err := s.store.GetWerkzeug(r.Context(), werkzeugID)
|
||||
if err != nil {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
in.WerkzeugID = &werkzeugID
|
||||
in.WerkzeugSnapshot = &werkzeug
|
||||
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
|
||||
in.GueltigBis = &gueltigBis
|
||||
}
|
||||
|
||||
if _, err := s.store.CreateEntscheidung(r.Context(), in); err != nil {
|
||||
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
||||
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "antrag_entschieden", "antrag", antrag.ID, entscheidungTyp); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
||||
}
|
||||
216
internal/web/fachebene_handlers_test.go
Normal file
216
internal/web/fachebene_handlers_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
// seedFallImAccount legt einen Mandanten mit einem Mitarbeiter an, der
|
||||
// einen Antrag einreicht (mit personenbezogener Datenklasse, Einstufung
|
||||
// minimal — siehe fullAntragForm), und liefert dessen ID sowie eine
|
||||
// zweite Cookie für einen weiteren Nutzer der Rolle role im selben
|
||||
// Mandanten (verantwortlicher/pruefer).
|
||||
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
|
||||
resp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("Antrag anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
for id := range fs.antraege {
|
||||
if fs.antraege[id].AccountID == acc.ID {
|
||||
antragID = id
|
||||
}
|
||||
}
|
||||
fachebeneCookie = seedUserInAccount(t, fs, acc.ID, role+"@example.com", role)
|
||||
return antragID, fachebeneCookie
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessFaelle(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "mitarbeiter@example.com")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/faelle")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter on /faelle", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
|
||||
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
// Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen.
|
||||
otherAcc, err := fs.CreateAccount(context.Background(), "Anderer Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "other-verantwortlicher@example.com", "verantwortlicher")
|
||||
otherResp := getWithCookie(t, s, otherCookie, "/faelle")
|
||||
if strings.Contains(otherResp.Body.String(), "Angebotstexte generieren") {
|
||||
t.Errorf("expected no cross-tenant antrag in the Posteingang, got: %s", otherResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallDetailShowsDecideFormOnlyForVerantwortlicher(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
|
||||
t.Errorf("expected the Entscheiden form for verantwortlicher, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer")
|
||||
|
||||
resp := getWithCookie(t, s, prueferCookie, "/faelle/"+antragID)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), `name="entscheidung"`) {
|
||||
t.Errorf("expected no Entscheiden form for pruefer, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallEntscheidenRejectsPruefer(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer")
|
||||
|
||||
resp := postForm(t, s, prueferCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 for pruefer", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallEntscheidenRequiresBegruendungOnAbweichung: fullAntragForm
|
||||
// liefert keine passenden Werkzeuge im (leeren) Katalog, der Vorschlag
|
||||
// ist also "rueckfrage" (siehe vorschlagFuer). "abgelehnt" weicht davon
|
||||
// ab und braucht eine Begründung.
|
||||
func TestFallEntscheidenRequiresBegruendungOnAbweichung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||
|
||||
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"abgelehnt"}})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 ohne Begründung bei Abweichung, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"abgelehnt"}, "begruendung": {"Kein Werkzeug im Katalog verfügbar, trotzdem vertretbar"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303 mit Begründung, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||
|
||||
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303 (rueckfrage entspricht dem Vorschlag bei leerem Katalog), body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||
if !strings.Contains(detail.Body.String(), "rueckfrage") {
|
||||
t.Errorf("expected the Entscheidung on the fall-detail page, got: %s", detail.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
acc, err := fs.CreateAccount(context.Background(), "Mit Katalog")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
|
||||
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true}
|
||||
s := newServer(t, fs)
|
||||
createResp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
|
||||
if createResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("Antrag anlegen: status = %d, body: %s", createResp.Code, createResp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
if fs.antraege[id].AccountID == acc.ID {
|
||||
antragID = id
|
||||
}
|
||||
}
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, acc.ID, "verantwortlicher@example.com", "verantwortlicher")
|
||||
|
||||
// Ohne werkzeug_id: abgelehnt.
|
||||
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 ohne Werkzeug bei Genehmigung", resp.Code)
|
||||
}
|
||||
|
||||
// Mit zulässigem Werkzeug: erfolgreich, Antrag wird "entschieden".
|
||||
resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAntrag: %v", err)
|
||||
}
|
||||
if antrag.Status != "entschieden" {
|
||||
t.Errorf("Status = %q, want entschieden", antrag.Status)
|
||||
}
|
||||
|
||||
detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||
if !strings.Contains(detail.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the chosen werkzeug name on the fall-detail page, got: %s", detail.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallDetailRejectsForeignAccount(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||
|
||||
otherAcc, err := fs.CreateAccount(context.Background(), "Fremder Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "fremd@example.com", "verantwortlicher")
|
||||
|
||||
resp := getWithCookie(t, s, otherCookie, "/faelle/"+antragID)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a foreign account's fall", resp.Code)
|
||||
}
|
||||
}
|
||||
26
internal/web/handlers.go
Normal file
26
internal/web/handlers.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"ok":true}`)
|
||||
}
|
||||
|
||||
type indexData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
}
|
||||
|
||||
// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für
|
||||
// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte
|
||||
// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge.
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
data := indexData{Title: "Start", Nav: navFor(r)}
|
||||
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
165
internal/web/middleware.go
Normal file
165
internal/web/middleware.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
const sessionCookieName = "deklarix_session"
|
||||
|
||||
type contextKey int
|
||||
|
||||
const userContextKey contextKey = iota
|
||||
|
||||
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
||||
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
|
||||
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
||||
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
||||
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
|
||||
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return store.User{}, false
|
||||
}
|
||||
|
||||
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
return store.User{}, false
|
||||
}
|
||||
if time.Now().After(sess.ExpiresAt) {
|
||||
return store.User{}, false
|
||||
}
|
||||
|
||||
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
||||
if err != nil {
|
||||
return store.User{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
||||
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
||||
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
}
|
||||
}
|
||||
|
||||
// requireAPI schützt htmx-Endpunkte (Formular-Posts, Downloads) — diese
|
||||
// werden nur aus einer bereits authentifizierten Seite heraus
|
||||
// aufgerufen, ein Fehlschlag hier ist der Ausnahmefall (z. B. Sitzung
|
||||
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
||||
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
}
|
||||
}
|
||||
|
||||
// requireBetreiber schützt den Plattform-Bereich (Ebene 5 — "nur
|
||||
// Betreiber", technisch getrennt vom Mandantenbereich, siehe CLAUDE.md).
|
||||
// Ohne Sitzung geht es wie bei requirePage zu /login; mit einer
|
||||
// Sitzung, aber ohne Betreiber-Rolle, gibt es 404 statt 403 — sonst
|
||||
// würde eine 403 einem angemeldeten, aber unprivilegierten Nutzer
|
||||
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
||||
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if user.Role != "betreiber" {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
}
|
||||
}
|
||||
|
||||
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
|
||||
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
|
||||
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der
|
||||
// Seite tatsächlich entschieden werden darf, prüft der einzelne
|
||||
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei
|
||||
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
|
||||
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
|
||||
// nicht erfährt, dass es die Seite überhaupt gibt.
|
||||
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if user.Role != "verantwortlicher" && user.Role != "pruefer" {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
}
|
||||
}
|
||||
|
||||
// requireAdmin schützt die Mandanten-Verwaltung (Ebene 4 — Nutzer/
|
||||
// Rollen für GENAU EINEN Mandanten, siehe CLAUDE.md). Wie bei
|
||||
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
}
|
||||
}
|
||||
|
||||
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
|
||||
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
|
||||
// currentUser zu geben — die Navigation braucht nur, ob ein
|
||||
// Plattform-Link gezeigt werden soll.
|
||||
type navData struct {
|
||||
IsBetreiber bool
|
||||
IsFachebene bool
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
||||
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
|
||||
// currentUser).
|
||||
func navFor(r *http.Request) navData {
|
||||
role := currentUser(r).Role
|
||||
return navData{
|
||||
IsBetreiber: role == "betreiber",
|
||||
IsFachebene: role == "verantwortlicher" || role == "pruefer",
|
||||
IsAdmin: role == "admin",
|
||||
}
|
||||
}
|
||||
|
||||
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
||||
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
|
||||
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,
|
||||
// kein Laufzeitfall, den man abfangen sollte.
|
||||
func currentUser(r *http.Request) store.User {
|
||||
user, ok := r.Context().Value(userContextKey).(store.User)
|
||||
if !ok {
|
||||
panic("web: currentUser aufgerufen ohne requirePage/requireAPI")
|
||||
}
|
||||
return user
|
||||
}
|
||||
125
internal/web/server.go
Normal file
125
internal/web/server.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
|
||||
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
|
||||
// Stack). Jeder Antrag gehört einem Account (Mandant); Auth-Cookies
|
||||
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
|
||||
// Account — siehe middleware.go.
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
|
||||
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
|
||||
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
||||
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||
type Store interface {
|
||||
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||
ListAccounts(ctx context.Context) ([]store.Account, error)
|
||||
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (store.User, error)
|
||||
GetUser(ctx context.Context, id string) (store.User, error)
|
||||
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||
DeleteSession(ctx context.Context, token string) error
|
||||
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
||||
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
||||
|
||||
ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error)
|
||||
CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error)
|
||||
GetAntrag(ctx context.Context, id string) (store.Antrag, error)
|
||||
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
|
||||
SetAntragStatus(ctx context.Context, id, status string) error
|
||||
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
|
||||
ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error)
|
||||
|
||||
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
|
||||
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
|
||||
CurrentKatalogVersion(ctx context.Context) (string, error)
|
||||
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
|
||||
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
|
||||
|
||||
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
|
||||
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error)
|
||||
}
|
||||
|
||||
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
||||
// Bewertung eines Antrags braucht (siehe internal/rules). Ein eigener
|
||||
// Typ statt drei einzelne Server-Felder, damit main.go sie an einer
|
||||
// Stelle zusammenstellt.
|
||||
type Regelwerk struct {
|
||||
Datenklasse rules.DatenklasseRegelwerk
|
||||
Einstufung rules.EinstufungRegelwerk
|
||||
Anforderungen rules.AnforderungsRegelwerk
|
||||
}
|
||||
|
||||
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
||||
type Server struct {
|
||||
mux *http.ServeMux
|
||||
store Store
|
||||
regelwerk Regelwerk
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
||||
// wird einmal beim Start geladen, nicht pro Request.
|
||||
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
store: st,
|
||||
regelwerk: regelwerk,
|
||||
templates: tmpl,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /register", s.handleRegisterForm)
|
||||
mux.HandleFunc("POST /register", s.handleRegister)
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLogin)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
||||
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
|
||||
mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm))
|
||||
mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate))
|
||||
mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail))
|
||||
mux.HandleFunc("GET /faelle", s.requireFachebene(s.handleFaelleListe))
|
||||
mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail))
|
||||
mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden))
|
||||
mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe))
|
||||
mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm))
|
||||
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))
|
||||
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
||||
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||
s.mux = mux
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ServeHTTP macht Server zu einem http.Handler.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
757
internal/web/server_test.go
Normal file
757
internal/web/server_test.go
Normal file
@@ -0,0 +1,757 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
const testSessionCookie = "deklarix_session"
|
||||
|
||||
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
|
||||
// Handler-Tests keine echte Postgres-Instanz brauchen.
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
accounts map[string]store.Account
|
||||
users map[string]store.User
|
||||
usersByEmail map[string]string // email -> user id
|
||||
sessions map[string]store.Session
|
||||
auditLog []store.AuditEntry
|
||||
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
|
||||
antraege map[string]store.Antrag
|
||||
werkzeuge map[string]store.Werkzeug
|
||||
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
|
||||
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{
|
||||
accounts: map[string]store.Account{},
|
||||
users: map[string]store.User{},
|
||||
usersByEmail: map[string]string{},
|
||||
sessions: map[string]store.Session{},
|
||||
abteilungen: map[string][]store.Abteilung{},
|
||||
antraege: map[string]store.Antrag{},
|
||||
werkzeuge: map[string]store.Werkzeug{},
|
||||
bewertungen: map[string][]store.Bewertung{},
|
||||
entscheidungen: map[string][]store.Entscheidung{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeStore) newID() string {
|
||||
f.nextID++
|
||||
return fmt.Sprintf("id-%d", f.nextID)
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()}
|
||||
f.accounts[acc.ID] = acc
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
acc, ok := f.accounts[id]
|
||||
if !ok {
|
||||
return store.Account{}, store.ErrNotFound
|
||||
}
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListAccounts(ctx context.Context) ([]store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.Account
|
||||
for _, acc := range f.accounts {
|
||||
out = append(out, acc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, exists := f.usersByEmail[email]; exists {
|
||||
return store.User{}, fmt.Errorf("fakeStore: email %s bereits vergeben", email)
|
||||
}
|
||||
u := store.User{
|
||||
ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash,
|
||||
Role: role, CreatedAt: time.Now(),
|
||||
}
|
||||
f.users[u.ID] = u
|
||||
f.usersByEmail[email] = u.ID
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetUserByEmail(ctx context.Context, email string) (store.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
id, ok := f.usersByEmail[email]
|
||||
if !ok {
|
||||
return store.User{}, store.ErrNotFound
|
||||
}
|
||||
return f.users[id], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
u, ok := f.users[id]
|
||||
if !ok {
|
||||
return store.User{}, store.ErrNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.User
|
||||
for _, u := range f.users {
|
||||
if u.AccountID == accountID {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
sess := store.Session{Token: token, UserID: userID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
|
||||
f.sessions[token] = sess
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
sess, ok := f.sessions[token]
|
||||
if !ok {
|
||||
return store.Session{}, store.ErrNotFound
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteSession(ctx context.Context, token string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.sessions, token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
e := store.AuditEntry{
|
||||
ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType,
|
||||
TargetID: targetID, Details: details, CreatedAt: time.Now(),
|
||||
}
|
||||
f.auditLog = append(f.auditLog, e)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]store.AuditEntry, len(f.auditLog))
|
||||
for i, e := range f.auditLog {
|
||||
out[len(f.auditLog)-1-i] = e
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.abteilungen[accountID], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
a := store.Antrag{
|
||||
ID: f.newID(), AccountID: accountID, ErstellerUserID: erstellerUserID, AbteilungID: abteilungID,
|
||||
Titel: titel, Status: "entwurf", Antworten: []byte(`{}`), CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
f.antraege[a.ID] = a
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAntrag(ctx context.Context, id string) (store.Antrag, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
a, ok := f.antraege[id]
|
||||
if !ok {
|
||||
return store.Antrag{}, store.ErrNotFound
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
a, ok := f.antraege[id]
|
||||
if !ok {
|
||||
return store.Antrag{}, store.ErrNotFound
|
||||
}
|
||||
a.Titel, a.Beschreibung, a.Ergebnis, a.Haeufigkeit, a.Antworten = titel, beschreibung, ergebnis, haeufigkeit, antworten
|
||||
a.UpdatedAt = time.Now()
|
||||
f.antraege[id] = a
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetAntragStatus(ctx context.Context, id, status string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
a, ok := f.antraege[id]
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
a.Status = status
|
||||
f.antraege[id] = a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.Antrag
|
||||
for _, a := range f.antraege {
|
||||
if a.ErstellerUserID == erstellerUserID {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.Antrag
|
||||
for _, a := range f.antraege {
|
||||
if a.AccountID == accountID {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.Werkzeug
|
||||
for _, w := range f.werkzeuge {
|
||||
if w.AccountID == nil || *w.AccountID == accountID {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
w, ok := f.werkzeuge[id]
|
||||
if !ok {
|
||||
return store.Werkzeug{}, store.ErrNotFound
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return fmt.Sprintf("fake-%d", len(f.werkzeuge)), nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
b := store.Bewertung{
|
||||
ID: f.newID(), AntragID: in.AntragID,
|
||||
Datenklasse: in.Datenklasse, DatenklasseHerleitung: in.DatenklasseHerleitung,
|
||||
Einstufung: in.Einstufung, EinstufungHerleitung: in.EinstufungHerleitung,
|
||||
Verboten: in.Verboten, Anforderungen: in.Anforderungen,
|
||||
ZulaessigeWerkzeuge: in.ZulaessigeWerkzeuge, AusgeschlosseneWerkzeuge: in.AusgeschlosseneWerkzeuge,
|
||||
RegelwerkVersion: in.RegelwerkVersion, KatalogVersion: in.KatalogVersion,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
f.bewertungen[in.AntragID] = append(f.bewertungen[in.AntragID], b)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
bs := f.bewertungen[antragID]
|
||||
if len(bs) == 0 {
|
||||
return store.Bewertung{}, store.ErrNotFound
|
||||
}
|
||||
return bs[len(bs)-1], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
e := store.Entscheidung{
|
||||
ID: f.newID(), AntragID: in.AntragID, BewertungID: in.BewertungID, EntscheiderUserID: in.EntscheiderUserID,
|
||||
Entscheidung: in.Entscheidung, WerkzeugID: in.WerkzeugID, WerkzeugSnapshot: in.WerkzeugSnapshot,
|
||||
Begruendung: in.Begruendung, GueltigBis: in.GueltigBis, CreatedAt: time.Now(),
|
||||
}
|
||||
f.entscheidungen[in.AntragID] = append(f.entscheidungen[in.AntragID], e)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
es := f.entscheidungen[antragID]
|
||||
if len(es) == 0 {
|
||||
return store.Entscheidung{}, store.ErrNotFound
|
||||
}
|
||||
return es[len(es)-1], nil
|
||||
}
|
||||
|
||||
// ─── Test-Setup ───────────────────────────────────────────────────────
|
||||
|
||||
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
|
||||
// die auch main.go beim Start lädt. Handler-Tests laufen so gegen das
|
||||
// tatsächliche Regelwerk statt gegen ein Test-Fixture, das getrennt
|
||||
// von rules/ gepflegt werden müsste.
|
||||
func loadTestRegelwerk(t *testing.T) web.Regelwerk {
|
||||
t.Helper()
|
||||
fsys := os.DirFS("../../rules")
|
||||
dk, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDatenklasse: %v", err)
|
||||
}
|
||||
ei, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEinstufung: %v", err)
|
||||
}
|
||||
an, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAnforderungen: %v", err)
|
||||
}
|
||||
return web.Regelwerk{Datenklasse: dk, Einstufung: ei, Anforderungen: an}
|
||||
}
|
||||
|
||||
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
||||
t.Helper()
|
||||
s, err := web.NewServer(fs, loadTestRegelwerk(t))
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
|
||||
// Account, einen Nutzer und eine gültige Sitzung an und liefert das
|
||||
// Session-Cookie.
|
||||
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
acc, err := fs.CreateAccount(ctx, accountName)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
hash, err := auth.HashPassword("test-passwort-123")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
user, err := fs.CreateUser(ctx, acc.ID, email, hash, role)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
token, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionToken: %v", err)
|
||||
}
|
||||
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
return &http.Cookie{Name: testSessionCookie, Value: token}
|
||||
}
|
||||
|
||||
// seedUserInAccount legt einen weiteren Nutzer in einem bereits
|
||||
// existierenden Account an — für Tests, die mehrere Rollen im selben
|
||||
// Mandanten brauchen (z. B. mitarbeiter stellt einen Antrag,
|
||||
// verantwortlicher entscheidet darüber).
|
||||
func seedUserInAccount(t *testing.T, fs *fakeStore, accountID, email, role string) *http.Cookie {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
hash, err := auth.HashPassword("test-passwort-123")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
user, err := fs.CreateUser(ctx, accountID, email, hash, role)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
token, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionToken: %v", err)
|
||||
}
|
||||
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
return &http.Cookie{Name: testSessionCookie, Value: token}
|
||||
}
|
||||
|
||||
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
|
||||
t.Helper()
|
||||
return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter")
|
||||
}
|
||||
|
||||
func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) {
|
||||
t.Helper()
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||
return s, fs, cookie
|
||||
}
|
||||
|
||||
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// ─── Tests: Health, statische Assets (kein Auth nötig) ────────────────
|
||||
|
||||
func TestHandleHealth(t *testing.T) {
|
||||
s, _, _ := newAuthedTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if w.Body.String() != `{"ok":true}` {
|
||||
t.Fatalf("body = %q, want {\"ok\":true}", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStaticServesHTMX(t *testing.T) {
|
||||
s, _, _ := newAuthedTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if w.Body.Len() < 1000 {
|
||||
t.Fatalf("htmx.min.js suspiciously small: %d bytes", w.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests: Auth (Registrierung, Login, Logout, Zugriffsschutz) ───────
|
||||
|
||||
func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
regResp := postForm(t, s, nil, "/register", url.Values{
|
||||
"account_name": {"Meine Firma"}, "email": {"neu@example.com"},
|
||||
"password": {"ein-sicheres-passwort"},
|
||||
})
|
||||
if regResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
||||
}
|
||||
cookies := regResp.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("expected a session cookie to be set after registration")
|
||||
}
|
||||
|
||||
indexResp := getWithCookie(t, s, cookies[0], "/")
|
||||
if indexResp.Code != http.StatusOK {
|
||||
t.Fatalf("index status with fresh session = %d, want 200", indexResp.Code)
|
||||
}
|
||||
|
||||
var created store.User
|
||||
for _, u := range fs.users {
|
||||
created = u
|
||||
}
|
||||
if created.Role != "admin" {
|
||||
t.Fatalf("expected the first user of a new company to have role admin, got %q", created.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
form := url.Values{
|
||||
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
||||
"password": {"ein-sicheres-passwort"},
|
||||
}
|
||||
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("first register status = %d, want 303", resp.Code)
|
||||
}
|
||||
resp := postForm(t, s, nil, "/register", form)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("second register status = %d, want 200 (re-rendered form with error)", resp.Code)
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "schon vergeben") {
|
||||
t.Errorf("expected a duplicate-email error, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWithCorrectPassword(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
hash, err := auth.HashPassword("richtiges-passwort")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "mitarbeiter"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, nil, "/login", url.Values{"email": {"bestehend@example.com"}, "password": {"richtiges-passwort"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if len(resp.Result().Cookies()) == 0 {
|
||||
t.Fatal("expected a session cookie after successful login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
hash, err := auth.HashPassword("admin-passwort")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
acc, err := fs.CreateAccount(context.Background(), "Deklarix Betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if _, err := fs.CreateUser(context.Background(), acc.ID, "betreiber@example.com", hash, "betreiber"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, nil, "/login", url.Values{"email": {"betreiber@example.com"}, "password": {"admin-passwort"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if loc := resp.Header().Get("Location"); loc != "/betreiber" {
|
||||
t.Fatalf("Location = %q, want /betreiber", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsWrongPassword(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
hash, err := auth.HashPassword("richtiges-passwort")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
acc, _ := fs.CreateAccount(context.Background(), "X")
|
||||
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, nil, "/login", url.Values{"email": {"x@example.com"}, "password": {"falsch"}})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (re-rendered login form)", resp.Code)
|
||||
}
|
||||
if len(resp.Result().Cookies()) != 0 {
|
||||
t.Fatal("expected no session cookie for a failed login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
resp := postForm(t, s, nil, "/login", url.Values{"email": {"gibtsnicht@example.com"}, "password": {"irgendwas"}})
|
||||
if !strings.Contains(resp.Body.String(), "E-Mail oder Passwort falsch") {
|
||||
t.Errorf("expected the generic invalid-credentials message, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexRedirectsToLoginWithoutSession(t *testing.T) {
|
||||
s, _, _ := newAuthedTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/login" {
|
||||
t.Fatalf("Location = %q, want /login", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutClearsSession(t *testing.T) {
|
||||
s, fs, cookie := newAuthedTestServer(t)
|
||||
|
||||
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusOK {
|
||||
t.Fatalf("index before logout = %d, want 200", resp.Code)
|
||||
}
|
||||
|
||||
logoutResp := postForm(t, s, cookie, "/logout", url.Values{})
|
||||
if logoutResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("logout status = %d, want 303", logoutResp.Code)
|
||||
}
|
||||
if _, err := fs.GetSession(context.Background(), cookie.Value); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatal("expected the session to be deleted from the store after logout")
|
||||
}
|
||||
|
||||
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("index after logout = %d, want 303 redirect (session no longer valid)", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleIndexRendersForMitarbeiter(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests: Plattform-Bereich (nur Betreiber) ─────────────────────────
|
||||
|
||||
func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
|
||||
for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log"} {
|
||||
resp := getWithCookie(t, s, cookie, path)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s status = %d, want 404 for a non-betreiber user", path, resp.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberRoutesRedirectToLoginWithoutSession(t *testing.T) {
|
||||
s, _, _ := newAuthedTestServer(t)
|
||||
|
||||
resp := getWithCookie(t, s, nil, "/betreiber")
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303 redirect to /login", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberDashboardAccessibleForBetreiber(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/betreiber")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberAccountListShowsAllAccountsAcrossTenants(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Betreiber"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("expected %q in the betreiber account list, got: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberAccountDetailShowsUsers(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
seedAccount(t, fs, "Mandant A", "mitarbeiter-a@example.com")
|
||||
|
||||
var accID string
|
||||
for id, acc := range fs.accounts {
|
||||
if acc.Name == "Mandant A" {
|
||||
accID = id
|
||||
}
|
||||
}
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+accID)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "mitarbeiter-a@example.com") {
|
||||
t.Errorf("expected the user's email on the account detail page, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberAuditLogShowsEntries(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if _, err := fs.CreateAuditEntry(context.Background(), betreiber.ID, "werkzeug.aktualisiert", "werkzeug", "w1", "manuell geprüft"); err != nil {
|
||||
t.Fatalf("CreateAuditEntry: %v", err)
|
||||
}
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/audit-log")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "werkzeug.aktualisiert") {
|
||||
t.Errorf("expected the audit entry on the audit log page, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
437
internal/web/static/app.css
Normal file
437
internal/web/static/app.css
Normal file
@@ -0,0 +1,437 @@
|
||||
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
|
||||
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
|
||||
Marke (Primärblau #1677ff, Inter, Radius-Skala), aber ohne dessen
|
||||
Ant-Design-/Desktop-Sidebar-Layout, das für Deklarix nicht passt.
|
||||
Mobile-first: Basis-Stile gelten fürs Telefon, @media (min-width)
|
||||
erweitert für größere Bildschirme. */
|
||||
|
||||
@import url('/static/inter.css');
|
||||
|
||||
:root {
|
||||
--branding-primary: #1677ff;
|
||||
--radius: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 10px;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
|
||||
--color-bg: #f8fafc;
|
||||
--color-text: #334155;
|
||||
--color-heading: #0f172a;
|
||||
--color-border: #e2e8f0;
|
||||
--color-muted: #64748b;
|
||||
|
||||
--color-hoch: #b91c1c;
|
||||
--color-hoch-bg: #fef2f2;
|
||||
--color-mittel: #b45309;
|
||||
--color-mittel-bg: #fffbeb;
|
||||
--color-niedrig: #166534;
|
||||
--color-niedrig-bg: #f0fdf4;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
color: var(--color-heading);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--branding-primary);
|
||||
}
|
||||
|
||||
/* Container: volle Breite + Innenabstand auf dem Handy, zentriert mit
|
||||
fester Breite ab Tablet-Größe aufwärts. */
|
||||
.page {
|
||||
padding: 16px;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
|
||||
dem Handy. */
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-heading);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
input, select, textarea, button {
|
||||
font: inherit;
|
||||
font-size: 16px; /* verhindert Auto-Zoom beim Fokussieren auf iOS */
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: 2px solid var(--branding-primary);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--branding-primary);
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 16px;
|
||||
padding: 12px 20px;
|
||||
min-height: 44px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--branding-primary);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
|
||||
button:active {
|
||||
filter: brightness(0.88);
|
||||
}
|
||||
|
||||
nav button {
|
||||
margin-top: 0;
|
||||
background: transparent;
|
||||
color: var(--color-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
min-height: 36px;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.hinweis {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.fehler {
|
||||
background: var(--color-hoch-bg);
|
||||
color: var(--color-hoch);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.rueckfrage {
|
||||
background: var(--color-mittel-bg);
|
||||
color: var(--color-mittel);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.keine-findings {
|
||||
background: var(--color-niedrig-bg);
|
||||
color: var(--color-niedrig);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.archiviert {
|
||||
background: var(--color-niedrig-bg);
|
||||
color: var(--color-niedrig);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.findings {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.finding {
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.finding p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.finding-hoch {
|
||||
background: var(--color-hoch-bg);
|
||||
border-left: 4px solid var(--color-hoch);
|
||||
}
|
||||
|
||||
.finding-mittel {
|
||||
background: var(--color-mittel-bg);
|
||||
border-left: 4px solid var(--color-mittel);
|
||||
}
|
||||
|
||||
.finding-niedrig {
|
||||
background: var(--color-niedrig-bg);
|
||||
border-left: 4px solid var(--color-niedrig);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-border);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.status-published {
|
||||
background: var(--color-niedrig-bg);
|
||||
color: var(--color-niedrig);
|
||||
}
|
||||
|
||||
.status-checked,
|
||||
.status-mittel {
|
||||
background: var(--color-mittel-bg);
|
||||
color: var(--color-mittel);
|
||||
}
|
||||
|
||||
.beitraege-liste {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.beitraege-liste li a {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.beteiligte {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.beteiligter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.beteiligter-kopf {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.beteiligter .rolle {
|
||||
color: var(--color-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.beteiligter-form {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.beteiligter-form label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.beteiligter-form input[type="checkbox"] {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.beteiligter form:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button.entfernen {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--color-hoch);
|
||||
border: 1px solid var(--color-hoch-bg);
|
||||
box-shadow: none;
|
||||
min-height: 36px;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-kacheln {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-kacheln a {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kanzlei-eintrag {
|
||||
display: block;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.fragebogen fieldset {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.fragebogen legend {
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.fragebogen fieldset label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 400;
|
||||
margin: 4px 12px 4px 0;
|
||||
}
|
||||
|
||||
.fragebogen fieldset input[type="radio"] {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Adaptive Folgefragen: rein clientseitig per :has() ein-/ausgeblendet,
|
||||
kein JavaScript nötig. Siehe templates/antrag_neu.html — die "Ja"-
|
||||
Antwort trägt eine eigene Klasse (c2-ja/c3-ja), damit sich die
|
||||
jeweilige Folgefrage gezielt zuordnen lässt. */
|
||||
.mit-folgefrage .folgefrage {
|
||||
display: none;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mit-folgefrage:has(.c2-ja input:checked) .folgefrage,
|
||||
.mit-folgefrage:has(.c3-ja input:checked) .folgefrage {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
|
||||
Rand statt volle Breite, sonst bleibt alles identisch. */
|
||||
@media (min-width: 640px) {
|
||||
.page {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
BIN
internal/web/static/fonts/inter-latin.woff2
Normal file
BIN
internal/web/static/fonts/inter-latin.woff2
Normal file
Binary file not shown.
1
internal/web/static/htmx.min.js
vendored
Normal file
1
internal/web/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
internal/web/static/inter.css
Normal file
13
internal/web/static/inter.css
Normal file
@@ -0,0 +1,13 @@
|
||||
/* Inter, selbst gehostet (aus enconf übernommen) — nur der "latin"-Subset
|
||||
(U+0000-00FF u.a.), das deckt deutsche Umlaute und ß bereits ab, ohne
|
||||
Zeichensätze für Kyrillisch/Griechisch/Vietnamesisch mitzuladen, die
|
||||
Deklarix nicht braucht. Eine Datei für alle Schriftschnitte (variable
|
||||
Font), daher font-weight als Bereich. */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(/static/fonts/inter-latin.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
26
internal/web/templates/antraege_liste.html
Normal file
26
internal/web/templates/antraege_liste.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{{define "antraege-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Meine Anträge</h1>
|
||||
{{if not .Antraege}}
|
||||
<p class="hinweis">Noch keine Anträge gestellt.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Antraege}}
|
||||
<li>
|
||||
<a href="/antraege/{{.ID}}">
|
||||
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · {{.CreatedAt}}
|
||||
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
24
internal/web/templates/antrag_detail.html
Normal file
24
internal/web/templates/antrag_detail.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{{define "antrag-detail"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/antraege">← Meine Anträge</a></p>
|
||||
<h1>{{.Titel}}</h1>
|
||||
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
|
||||
|
||||
<h2>Vorhaben</h2>
|
||||
<p><strong>Was soll die KI tun?</strong><br>{{.Beschreibung}}</p>
|
||||
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
||||
|
||||
{{if .Bewertung}}
|
||||
{{template "bewertung-block" .Bewertung}}
|
||||
{{else}}
|
||||
<p class="hinweis">Die Bewertung wird berechnet, sobald der Antrag eingereicht ist.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
122
internal/web/templates/antrag_neu.html
Normal file
122
internal/web/templates/antrag_neu.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{{define "antrag-neu"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Antrag stellen</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
|
||||
<form method="post" action="/antraege" class="fragebogen">
|
||||
<h2>Vorhaben</h2>
|
||||
<label for="titel">Kurzbezeichnung</label>
|
||||
<input type="text" id="titel" name="titel" required>
|
||||
|
||||
<label for="beschreibung">Was soll die KI tun?</label>
|
||||
<textarea id="beschreibung" name="beschreibung" rows="3" required></textarea>
|
||||
|
||||
<label for="ergebnis">Was soll herauskommen?</label>
|
||||
<textarea id="ergebnis" name="ergebnis" rows="2" required></textarea>
|
||||
|
||||
<label for="haeufigkeit">Häufigkeit</label>
|
||||
<select id="haeufigkeit" name="haeufigkeit" required>
|
||||
<option value="einmalig">einmalig</option>
|
||||
<option value="gelegentlich">gelegentlich</option>
|
||||
<option value="taeglich">täglich</option>
|
||||
<option value="automatisiert">automatisiert</option>
|
||||
</select>
|
||||
|
||||
{{if .Abteilungen}}
|
||||
<label for="abteilung_id">Abteilung</label>
|
||||
<select id="abteilung_id" name="abteilung_id">
|
||||
<option value="">— keine Angabe —</option>
|
||||
{{range .Abteilungen}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{end}}
|
||||
|
||||
<h2>Daten</h2>
|
||||
<p class="hinweis">Bei Unsicherheit "Unsicher" wählen — das wird wie "Ja" behandelt.</p>
|
||||
{{range .Datenfragen}}
|
||||
<fieldset class="datenfrage">
|
||||
<legend>{{.Label}}</legend>
|
||||
<label><input type="radio" name="{{.Key}}" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="{{.Key}}" value="nein"> Nein</label>
|
||||
<label><input type="radio" name="{{.Key}}" value="unsicher"> Unsicher</label>
|
||||
</fieldset>
|
||||
{{end}}
|
||||
|
||||
<h2>Verwendung des Ergebnisses</h2>
|
||||
<fieldset>
|
||||
<legend>Wird das Ergebnis unverändert nach außen gegeben?</legend>
|
||||
<label><input type="radio" name="c1" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="c1" value="nein"> Nein</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mit-folgefrage">
|
||||
<legend>Beeinflusst es eine Entscheidung über einen Menschen?</legend>
|
||||
<label class="c2-ja"><input type="radio" name="c2" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="c2" value="nein"> Nein</label>
|
||||
<div class="folgefrage">
|
||||
<label for="c2_folge">Welche Art von Entscheidung?</label>
|
||||
<select id="c2_folge" name="c2_folge">
|
||||
<option value="beschaeftigung">Einstellung, Beförderung, Kündigung</option>
|
||||
<option value="kreditwuerdigkeit">Kreditwürdigkeit</option>
|
||||
<option value="leistungsbewilligung">Leistungsbewilligung</option>
|
||||
<option value="bildung">Zugang zu Bildung</option>
|
||||
<option value="gesundheit">medizinische Beurteilung</option>
|
||||
<option value="keine">keins davon</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mit-folgefrage">
|
||||
<legend>Erkennt oder bewertet es Emotionen, Verhalten oder biometrische Merkmale?</legend>
|
||||
<label class="c3-ja"><input type="radio" name="c3" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="c3" value="nein"> Nein</label>
|
||||
<div class="folgefrage">
|
||||
<label for="c3_art">Welche Art?</label>
|
||||
<select id="c3_art" name="c3_art">
|
||||
<option value="emotionserkennung_arbeitsplatz">Emotionserkennung am Arbeitsplatz</option>
|
||||
<option value="social_scoring">Social Scoring</option>
|
||||
<option value="biometrische_kategorisierung">Biometrische Kategorisierung</option>
|
||||
<option value="keine">etwas anderes</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Läuft es ohne dass ein Mensch das Ergebnis prüft?</legend>
|
||||
<label><input type="radio" name="c4" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="c4" value="nein"> Nein</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Merkt der Empfänger, dass ein Ergebnis von einer KI stammt?</legend>
|
||||
<label><input type="radio" name="c5" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="c5" value="nein"> Nein</label>
|
||||
</fieldset>
|
||||
|
||||
<h2>Werkzeug</h2>
|
||||
<label for="d_werkzeug_freitext">Haben Sie schon ein Werkzeug im Sinn? (optional)</label>
|
||||
<input type="text" id="d_werkzeug_freitext" name="d_werkzeug_freitext">
|
||||
|
||||
<fieldset>
|
||||
<legend>Firmenkonto oder privater Zugang?</legend>
|
||||
<label><input type="radio" name="d_zugang" value="firma" required> Firmenkonto</label>
|
||||
<label><input type="radio" name="d_zugang" value="privat"> Privater Zugang</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Auf Firmengerät oder Privatgerät?</legend>
|
||||
<label><input type="radio" name="d_geraet" value="firma" required> Firmengerät</label>
|
||||
<label><input type="radio" name="d_geraet" value="privat"> Privatgerät</label>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Antrag einreichen</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
22
internal/web/templates/betreiber_account_detail.html
Normal file
22
internal/web/templates/betreiber_account_detail.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{{define "betreiber-account-detail"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/betreiber/accounts">← Alle Accounts</a></p>
|
||||
<h1>{{.Name}}</h1>
|
||||
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
||||
|
||||
<h2>Nutzer</h2>
|
||||
<ul class="beteiligte">
|
||||
{{range .Users}}
|
||||
<li class="beteiligter">
|
||||
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
25
internal/web/templates/betreiber_accounts.html
Normal file
25
internal/web/templates/betreiber_accounts.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{{define "betreiber-accounts"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<h1>Accounts</h1>
|
||||
{{if not .Accounts}}
|
||||
<p class="hinweis">Noch keine Accounts.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Accounts}}
|
||||
<li>
|
||||
<a href="/betreiber/accounts/{{.ID}}">
|
||||
<strong>{{.Name}}</strong> · {{.CreatedAt}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
24
internal/web/templates/betreiber_audit_log.html
Normal file
24
internal/web/templates/betreiber_audit_log.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{{define "betreiber-audit-log"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<h1>Audit-Log</h1>
|
||||
{{if not .Entries}}
|
||||
<p class="hinweis">Noch keine Einträge.</p>
|
||||
{{else}}
|
||||
<ul class="beteiligte">
|
||||
{{range .Entries}}
|
||||
<li class="beteiligter">
|
||||
<div class="beteiligter-kopf">{{.CreatedAt}} — <strong>{{.Action}}</strong> ({{.TargetType}} {{.TargetID}})</div>
|
||||
{{if .Details}}<p>{{.Details}}</p>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
15
internal/web/templates/betreiber_dashboard.html
Normal file
15
internal/web/templates/betreiber_dashboard.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{{define "betreiber-dashboard"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Plattform</h1>
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/betreiber/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
|
||||
<li><a href="/betreiber/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
24
internal/web/templates/faelle_liste.html
Normal file
24
internal/web/templates/faelle_liste.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{{define "faelle-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Posteingang</h1>
|
||||
{{if not .Faelle}}
|
||||
<p class="keine-findings">Keine offenen Anträge — alles bearbeitet.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Faelle}}
|
||||
<li>
|
||||
<a href="/faelle/{{.ID}}">
|
||||
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · eingereicht am {{.CreatedAt}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
60
internal/web/templates/fall_detail.html
Normal file
60
internal/web/templates/fall_detail.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{{define "fall-detail"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/faelle">← Posteingang</a></p>
|
||||
<h1>{{.Titel}}</h1>
|
||||
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
|
||||
|
||||
<h2>Vorhaben</h2>
|
||||
<p><strong>Was soll die KI tun?</strong><br>{{.Beschreibung}}</p>
|
||||
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
||||
|
||||
{{if .Bewertung}}
|
||||
{{template "bewertung-block" .Bewertung}}
|
||||
{{else}}
|
||||
<p class="hinweis">Für diesen Antrag liegt noch keine Bewertung vor.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Entscheidung}}
|
||||
<h2>Entscheidung</h2>
|
||||
<p><strong>{{.Entscheidung.Entscheidung}}</strong>{{if .Entscheidung.WerkzeugName}} — {{.Entscheidung.WerkzeugName}}{{end}}<br>
|
||||
<span class="hinweis">am {{.Entscheidung.CreatedAt}}{{if .Entscheidung.GueltigBis}}, gültig bis {{.Entscheidung.GueltigBis}}{{end}}</span></p>
|
||||
{{if .Entscheidung.Begruendung}}<p>{{.Entscheidung.Begruendung}}</p>{{end}}
|
||||
{{else if .CanEntscheiden}}
|
||||
<h2>Entscheiden</h2>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/faelle/{{.ID}}/entscheiden">
|
||||
<fieldset>
|
||||
<legend>Entscheidung</legend>
|
||||
<label><input type="radio" name="entscheidung" value="genehmigt" required> Genehmigt</label>
|
||||
<label><input type="radio" name="entscheidung" value="genehmigt_mit_auflagen"> Genehmigt mit Auflagen</label>
|
||||
<label><input type="radio" name="entscheidung" value="abgelehnt"> Abgelehnt</label>
|
||||
<label><input type="radio" name="entscheidung" value="rueckfrage"> Rückfrage</label>
|
||||
</fieldset>
|
||||
|
||||
{{if .WerkzeugOptionen}}
|
||||
<label>Werkzeug (bei Genehmigung erforderlich)
|
||||
<select name="werkzeug_id">
|
||||
<option value="">— auswählen —</option>
|
||||
{{range .WerkzeugOptionen}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
{{end}}
|
||||
|
||||
<label>Begründung (Pflicht bei Abweichung vom Vorschlag)
|
||||
<textarea name="begruendung" rows="3"></textarea>
|
||||
</label>
|
||||
|
||||
<button type="submit">Entscheidung speichern</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="hinweis">Noch keine Entscheidung getroffen. Nur die Rolle "Verantwortlicher" kann entscheiden.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
15
internal/web/templates/index.html
Normal file
15
internal/web/templates/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{{define "index"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Willkommen bei Deklarix</h1>
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
||||
<li><a href="/antraege">Meine Anträge</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
82
internal/web/templates/layout.html
Normal file
82
internal/web/templates/layout.html
Normal file
@@ -0,0 +1,82 @@
|
||||
{{define "head"}}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} — Deklarix</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
{{end}}
|
||||
|
||||
{{define "nav"}}
|
||||
<nav>
|
||||
<a href="/">Start</a>
|
||||
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
|
||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
<button type="submit">Abmelden</button>
|
||||
</form>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
||||
Einstufung, Anforderungen, Werkzeuge, Pflicht-Disclaimer) — gemeinsam
|
||||
genutzt von antrag_detail.html (Ebene 2, nur Ansicht) und
|
||||
fall_detail.html (Ebene 3, mit Entscheiden-Formular). Erwartet eine
|
||||
*bewertungView als . */}}
|
||||
{{define "bewertung-block"}}
|
||||
<h2>Bewertung</h2>
|
||||
|
||||
{{if .Verboten}}
|
||||
<p class="fehler"><strong>Verboten.</strong> Diese Nutzung fällt laut Einstufung unter ein verbotenes KI-Praktiken-Muster der KI-VO ({{.Einstufung}}). {{.EinstufungHerleitung}}</p>
|
||||
{{end}}
|
||||
|
||||
<p><strong>Datenklasse:</strong> {{.Datenklasse}}<br>
|
||||
<span class="hinweis">{{.DatenklasseHerleitung}}</span></p>
|
||||
|
||||
<p><strong>KI-VO-Einstufung:</strong> {{.Einstufung}}<br>
|
||||
<span class="hinweis">{{.EinstufungHerleitung}}</span></p>
|
||||
|
||||
{{if .Anforderungen}}
|
||||
<h3>Anforderungen</h3>
|
||||
<ul class="findings">
|
||||
{{range .Anforderungen}}
|
||||
<li class="finding finding-mittel">
|
||||
<strong>{{.Beschreibung}}</strong>
|
||||
<p>{{.Herleitung}}</p>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
{{if not .Verboten}}
|
||||
<h3>Zulässige Werkzeuge</h3>
|
||||
{{if .ZulaessigeWerkzeuge}}
|
||||
<ul>
|
||||
{{range .ZulaessigeWerkzeuge}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="rueckfrage">Kein Werkzeug im Katalog erfüllt aktuell alle Anforderungen dieses Antrags.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .AusgeschlosseneWerkzeuge}}
|
||||
<h3>Ausgeschlossene Werkzeuge</h3>
|
||||
<ul class="findings">
|
||||
{{range .AusgeschlosseneWerkzeuge}}
|
||||
<li class="finding finding-niedrig">
|
||||
<strong>{{.Name}}</strong>
|
||||
<p>Nicht erfüllt: {{range $i, $a := .NichtErfuellt}}{{if $i}}, {{end}}{{$a}}{{end}}</p>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<p class="disclaimer">
|
||||
Das System entscheidet nicht. Es bereitet vor. Diese Einstufung ist ein
|
||||
Vorschlag zur Herleitung von Datenklasse, KI-VO-Einstufung und
|
||||
Anforderungen (Regelwerk {{.RegelwerkVersion}}, Katalog
|
||||
{{.KatalogVersion}}, berechnet am {{.CreatedAt}}) —
|
||||
die Entscheidung trifft ein Mensch.
|
||||
</p>
|
||||
{{end}}
|
||||
21
internal/web/templates/login.html
Normal file
21
internal/web/templates/login.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{{define "login"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Anmelden</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
17
internal/web/templates/nutzer_liste.html
Normal file
17
internal/web/templates/nutzer_liste.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{{define "nutzer-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Nutzerverwaltung</h1>
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Nutzer}}
|
||||
<li>{{.Email}} <span class="status">{{.Role}}</span></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
33
internal/web/templates/nutzer_neu.html
Normal file
33
internal/web/templates/nutzer_neu.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{{define "nutzer-neu"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/verwaltung/nutzer">← Nutzerverwaltung</a></p>
|
||||
<h1>Nutzer anlegen</h1>
|
||||
<p class="hinweis">
|
||||
Es gibt noch keine Einladungsmail — das hier gesetzte Passwort muss
|
||||
auf einem anderen Weg an die neue Person weitergegeben werden.
|
||||
</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/verwaltung/nutzer">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
|
||||
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" minlength="8" required>
|
||||
|
||||
<fieldset>
|
||||
<legend>Rolle</legend>
|
||||
{{range .Rollen}}
|
||||
<label><input type="radio" name="role" value="{{.}}" required> {{.}}</label>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Nutzer anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
28
internal/web/templates/register.html
Normal file
28
internal/web/templates/register.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{{define "register"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Firma registrieren</h1>
|
||||
<p class="hinweis">
|
||||
Testzugang ist sofort aktiv. Der Bezahlbetrieb wird nach Prüfung durch
|
||||
den Betreiber freigeschaltet.
|
||||
</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/register">
|
||||
<label for="account_name">Firmenname</label>
|
||||
<input type="text" id="account_name" name="account_name" required>
|
||||
|
||||
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
|
||||
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" minlength="8" required>
|
||||
|
||||
<button type="submit">Konto anlegen</button>
|
||||
</form>
|
||||
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
1
packaging/DEBIAN/conffiles
Normal file
1
packaging/DEBIAN/conffiles
Normal file
@@ -0,0 +1 @@
|
||||
/etc/deklarix/deklarix.env.example
|
||||
@@ -2,6 +2,10 @@ Package: deklarix
|
||||
Version: VERSION
|
||||
Architecture: ARCH
|
||||
Maintainer: NetCell IT <team@netcell-it.de>
|
||||
Description: Deklarix — deklarix.de / deklarix.com
|
||||
Deklarix Software Package.
|
||||
Depends:
|
||||
Homepage: https://deklarix.de
|
||||
Section: web
|
||||
Priority: optional
|
||||
Depends: ca-certificates
|
||||
Description: Deklarix — Prüfung und Nachweis für Werbe-Content
|
||||
Pre-Publish-Kennzeichnungsprüfung und revisionssichere Archivierung
|
||||
veröffentlichter Beiträge für Creator, Agenturen, Marken und Kanzleien.
|
||||
|
||||
79
packaging/DEBIAN/postinst
Executable file
79
packaging/DEBIAN/postinst
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# Deklarix — postinst
|
||||
set -e
|
||||
|
||||
SERVICE_USER="deklarix"
|
||||
CONFIG_DIR="/etc/deklarix"
|
||||
DATA_DIR="/var/lib/deklarix"
|
||||
LOG_DIR="/var/log/deklarix"
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
# ─── apt-Quelle einrichten/aktualisieren ────────────────────
|
||||
# Gleiches Muster wie enconf: Gitea-Debian-Registry, Suite =
|
||||
# OS-Codename, Komponente = Kanal (aktuell nur "testing"). Ein
|
||||
# bereits gewählter Kanal bleibt über Upgrades hinweg erhalten.
|
||||
mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL "https://git.netcell-it.de/api/packages/projekte/debian/repository.key" \
|
||||
-o /etc/apt/keyrings/deklarix-gitea.asc 2>/dev/null || true
|
||||
if [ -f /etc/apt/keyrings/deklarix-gitea.asc ]; then
|
||||
DX_CODENAME="$(. /etc/os-release 2>/dev/null; echo "${VERSION_CODENAME:-}")"
|
||||
case "$DX_CODENAME" in bookworm|trixie) ;; *) DX_CODENAME="trixie" ;; esac
|
||||
DX_CHANNEL="$(grep -oE 'projekte/debian[[:space:]]+[a-z]+[[:space:]]+(stable|testing)' \
|
||||
/etc/apt/sources.list.d/deklarix.list 2>/dev/null | awk '{print $NF}' | head -1)"
|
||||
case "$DX_CHANNEL" in stable|testing) ;; *) DX_CHANNEL="testing" ;; esac
|
||||
echo "deb [signed-by=/etc/apt/keyrings/deklarix-gitea.asc] https://git.netcell-it.de/api/packages/projekte/debian $DX_CODENAME $DX_CHANNEL" \
|
||||
> /etc/apt/sources.list.d/deklarix.list
|
||||
fi
|
||||
|
||||
# ─── System-User + Verzeichnisse ────────────────────────────
|
||||
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
useradd -r -s /usr/sbin/nologin -d "$DATA_DIR" "$SERVICE_USER"
|
||||
fi
|
||||
mkdir -p "$DATA_DIR" "$LOG_DIR" "$CONFIG_DIR"
|
||||
chown "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
|
||||
|
||||
# ─── Konfiguration ───────────────────────────────────────────
|
||||
# Erstinstall: Vorlage kopieren, nicht scharf schalten (Secrets
|
||||
# fehlen noch). Upgrade: bestehende deklarix.env bleibt unberührt.
|
||||
if [ ! -f "$CONFIG_DIR/deklarix.env" ]; then
|
||||
cp "$CONFIG_DIR/deklarix.env.example" "$CONFIG_DIR/deklarix.env"
|
||||
chmod 640 "$CONFIG_DIR/deklarix.env"
|
||||
chown "root:$SERVICE_USER" "$CONFIG_DIR/deklarix.env"
|
||||
fi
|
||||
|
||||
# ─── Healing: fehlende Variablen aus älteren Installationen ───
|
||||
# deklarix.env wird bei Upgrades nie überschrieben (Secrets/
|
||||
# Anpassungen bleiben erhalten) — Variablen, die erst nach einer
|
||||
# Erstinstallation dazugekommen sind, fehlen dort sonst schlicht
|
||||
# und lassen den Dienst mit einem für den Admin überraschenden
|
||||
# Fehler abbrechen (z. B. RULES_DIR fehlt -> main.go sucht Regeln
|
||||
# im falschen Verzeichnis, obwohl DATABASE_URL längst gesetzt
|
||||
# war). Bekannte Variablen mit unkritischem Default werden hier
|
||||
# ergänzt, falls sie fehlen — nie überschrieben, nur angehängt.
|
||||
if [ -f "$CONFIG_DIR/deklarix.env" ]; then
|
||||
grep -q '^RULES_DIR=' "$CONFIG_DIR/deklarix.env" || \
|
||||
echo "RULES_DIR=/usr/share/deklarix/rules" >> "$CONFIG_DIR/deklarix.env"
|
||||
fi
|
||||
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
systemctl enable deklarix.service >/dev/null 2>&1 || true
|
||||
|
||||
# Nicht blind starten — ohne gesetzte DATABASE_URL würde der Dienst
|
||||
# nur in eine Restart-Schleife laufen (main.go bricht sonst bewusst
|
||||
# mit log.Fatal ab, siehe CLAUDE.md "keine stillen Fallbacks"). Die
|
||||
# Vorlage liefert sie auskommentiert aus — ein Treffer hier bedeutet
|
||||
# also wirklich "vom Admin gesetzt", nicht den Platzhalter.
|
||||
if grep -qE '^DATABASE_URL=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null; then
|
||||
systemctl restart deklarix.service
|
||||
else
|
||||
echo ""
|
||||
echo " → Deklarix installiert, aber noch nicht gestartet."
|
||||
echo " DATABASE_URL in $CONFIG_DIR/deklarix.env setzen, dann:"
|
||||
echo " systemctl start deklarix"
|
||||
echo ""
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
11
packaging/DEBIAN/prerm
Executable file
11
packaging/DEBIAN/prerm
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
# Deklarix — prerm
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
systemctl stop deklarix.service 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
17
packaging/etc/deklarix/deklarix.env.example
Normal file
17
packaging/etc/deklarix/deklarix.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Deklarix — Konfiguration
|
||||
# Wird bei der Erstinstallation nach /etc/deklarix/deklarix.env kopiert.
|
||||
# Diese Datei selbst wird von apt-Updates verwaltet — Anpassungen gehören
|
||||
# in deklarix.env, nicht hierhin.
|
||||
|
||||
PORT=8080
|
||||
|
||||
# Wo internal/rules das Bewertungsregelwerk (Datenklasse, KI-VO-
|
||||
# Einstufung, Anforderungsprofil) findet — vom .deb-Paket nach
|
||||
# /usr/share/deklarix/rules installiert (siehe scripts/build.sh).
|
||||
RULES_DIR=/usr/share/deklarix/rules
|
||||
|
||||
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
|
||||
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
|
||||
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
|
||||
# Schleife gegen einen Platzhalter laufen zu lassen.
|
||||
#DATABASE_URL=postgres://user:password@host:5432/deklarix?sslmode=require
|
||||
23
packaging/etc/systemd/system/deklarix.service
Normal file
23
packaging/etc/systemd/system/deklarix.service
Normal file
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Deklarix
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=deklarix
|
||||
Group=deklarix
|
||||
EnvironmentFile=/etc/deklarix/deklarix.env
|
||||
ExecStart=/usr/bin/deklarix
|
||||
WorkingDirectory=/var/lib/deklarix
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/lib/deklarix /var/log/deklarix
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
85
rules/OPEN.md
Normal file
85
rules/OPEN.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Offene fachliche Fragen zum Regelwerk
|
||||
|
||||
Diese Punkte wurden beim Anlegen der Regelwerk-YAML-Dateien (Phase 1)
|
||||
bewusst nicht geraten, sondern hier notiert — vor Phase 3 (Ableitungen
|
||||
und harte Filter) mit dem fachlich Verantwortlichen (z. B. der/dem
|
||||
betrieblichen Datenschutzbeauftragten) klären.
|
||||
|
||||
## 1. Rangfolge der Datenklassen
|
||||
|
||||
Die Spezifikation sagt "höchste zutreffende Stufe gewinnt", gibt aber
|
||||
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt aktuell
|
||||
diese Reihenfolge an (niedrigster zu höchstem Rang):
|
||||
|
||||
1. `oeffentlich`
|
||||
2. `intern`
|
||||
3. `auftragsdaten`
|
||||
4. `personenbezogen`
|
||||
5. `berufsgeheimnis`
|
||||
6. `besondere_kategorie`
|
||||
|
||||
Begründung der Annahme: DSGVO Art. 9 (besondere Kategorien) gilt
|
||||
allgemein als striktester Datenschutz-Tatbestand, § 203 StGB
|
||||
(Berufsgeheimnis) hat eigene strafrechtliche Relevanz — beide vor
|
||||
"normalen" personenbezogenen Daten eingeordnet. `auftragsdaten` unter
|
||||
`personenbezogen` einsortiert, weil vertragliche Geheimhaltung in der
|
||||
Regel schwächer sanktioniert ist als DSGVO-Bußgelder. **Nicht
|
||||
bestätigt.**
|
||||
|
||||
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten`
|
||||
|
||||
Die Spezifikation formuliert "aus intern, personenbezogen und höher" —
|
||||
das lässt offen, ob `auftragsdaten` (zwischen `intern` und
|
||||
`personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll.
|
||||
`rules/anforderungen.yaml` schließt `auftragsdaten` aktuell explizit
|
||||
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse
|
||||
nicht zum Training verwendet werden) — **Annahme, nicht bestätigt.**
|
||||
|
||||
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT
|
||||
|
||||
~~Fragebogen-Abschnitt C stellt nur eine einzelne Frage...~~ Mit Phase 2
|
||||
(Fragebogen, `internal/web/templates/antrag_neu.html`) gelöst: C3 hat
|
||||
jetzt eine Folgefrage `c3_art` mit den Werten
|
||||
`emotionserkennung_arbeitsplatz`, `social_scoring`,
|
||||
`biometrische_kategorisierung` und `keine` — exakt die Werte, die
|
||||
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
|
||||
final, nicht mehr Platzhalter. **Weiterhin offen:** ob diese drei
|
||||
Varianten fachlich vollständig sind (deckt das wirklich alle "verboten"-
|
||||
Fälle aus Art. 5 KI-VO ab?) — das war nie Teil dieser Klärung.
|
||||
|
||||
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
||||
|
||||
Die Spezifikation nennt `loeschfrist_max_tage` als Anforderung "aus
|
||||
Datenklasse", ohne die konkreten Tageswerte pro Stufe zu nennen.
|
||||
`rules/anforderungen.yaml` listet die Anforderung nur als grundsätzlich
|
||||
anwendbar (welche Datenklassen sie überhaupt auslösen), ohne
|
||||
Zahlenwerte — die eigentlichen Fristen (z. B. "personenbezogen: 90
|
||||
Tage") müssen vor Phase 3 fachlich festgelegt werden. **Auswirkung auf
|
||||
Schritt 3 (`internal/rules.ErfuelltAnforderung`):** ohne Zahlenwerte
|
||||
wird diese Anforderung aktuell NICHT hart gegen Werkzeuge gefiltert
|
||||
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
|
||||
muss die Filterfunktion entsprechend erweitert werden.
|
||||
|
||||
## 5. Fallback, wenn keine Datenklasse zutrifft
|
||||
|
||||
Wenn ein Antrag bei allen B-Fragen "nein" beantwortet — auch bei B7
|
||||
("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle
|
||||
keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine
|
||||
Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse`
|
||||
nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im
|
||||
Zweifel mehr Schutzanforderungen, nicht weniger. **Nicht fachlich
|
||||
bestätigt**, nur eine sichere Standardannahme.
|
||||
|
||||
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?
|
||||
|
||||
`internal/rules.ErfuelltAnforderung` filtert aktuell hart nur
|
||||
`avv_erforderlich`, `eu_verarbeitung` und `kein_training_auf_eingabe` —
|
||||
das sind technische Eigenschaften eines Werkzeugs
|
||||
(`werkzeug.avv_verfuegbar`, `verarbeitungsort`, `training_standard`).
|
||||
`menschliche_aufsicht`, `kennzeichnungspflicht` und `dsfa_erforderlich`
|
||||
betreffen dagegen einen Prozess rund um die Nutzung, nicht das Werkzeug
|
||||
selbst — ein Werkzeug wird deswegen nicht aussortiert, das wird
|
||||
stattdessen als Auflage vermerkt (geplant für Schritt 4/5). **Diese
|
||||
Aufteilung ist eine Annahme von Schritt 3, nicht ausdrücklich in der
|
||||
Spezifikation so festgelegt** — vor Schritt 4 gegenprüfen, ob das der
|
||||
gewünschten Produktlogik entspricht.
|
||||
32
rules/anforderungen.yaml
Normal file
32
rules/anforderungen.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# Anforderungsprofil: aus Datenklasse und/oder KI-VO-Einstufung
|
||||
# abgeleitete Menge von Anforderungen, die ein Werkzeug erfüllen muss.
|
||||
#
|
||||
# ACHTUNG: "kein_training_auf_eingabe aus intern, personenbezogen und
|
||||
# höher" in der Produktspezifikation lässt offen, ob auftragsdaten
|
||||
# (rang 3, zwischen intern und personenbezogen) eingeschlossen sein
|
||||
# soll — hier bewusst explizit aufgeführt (siehe rules/OPEN.md), damit
|
||||
# nichts über eine implizite Rangfolge erraten wird.
|
||||
version: 1
|
||||
anforderungen:
|
||||
- id: avv_erforderlich
|
||||
aus_datenklassen: [personenbezogen, besondere_kategorie, auftragsdaten]
|
||||
beschreibung: "Auftragsverarbeitungsvertrag mit dem Werkzeug-Anbieter erforderlich"
|
||||
- id: eu_verarbeitung
|
||||
aus_datenklassen: [berufsgeheimnis, besondere_kategorie]
|
||||
beschreibung: "Verarbeitung muss innerhalb der EU stattfinden"
|
||||
- id: kein_training_auf_eingabe
|
||||
aus_datenklassen: [intern, auftragsdaten, personenbezogen, berufsgeheimnis, besondere_kategorie]
|
||||
beschreibung: "Eingaben dürfen nicht zum Training des Werkzeugs verwendet werden"
|
||||
- id: loeschfrist_max_tage
|
||||
aus_datenklassen: [oeffentlich, intern, auftragsdaten, personenbezogen, berufsgeheimnis, besondere_kategorie]
|
||||
beschreibung: "maximale Aufbewahrungsdauer richtet sich nach der Datenklasse"
|
||||
- id: menschliche_aufsicht
|
||||
aus_einstufungen: [hochrisiko]
|
||||
beschreibung: "ein Mensch muss das Ergebnis vor Wirksamkeit prüfen"
|
||||
- id: kennzeichnungspflicht
|
||||
aus_einstufungen: [transparenzpflicht]
|
||||
beschreibung: "das Ergebnis muss als KI-generiert gekennzeichnet werden"
|
||||
- id: dsfa_erforderlich
|
||||
aus_datenklassen: [besondere_kategorie]
|
||||
aus_einstufungen: [hochrisiko]
|
||||
beschreibung: "Datenschutz-Folgenabschätzung erforderlich"
|
||||
35
rules/datenklasse.yaml
Normal file
35
rules/datenklasse.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
# Datenklasse-Ableitung aus Fragebogen-Abschnitt B. "Höchste zutreffende
|
||||
# Stufe gewinnt" — rang entscheidet, welche Stufe gewinnt, wenn mehrere
|
||||
# Auslöser gleichzeitig zutreffen. "Unsicher" zählt wie "Ja" (siehe
|
||||
# Fragebogen-Spezifikation) — das wird in der Auswertung (Phase 3)
|
||||
# umgesetzt, nicht hier.
|
||||
#
|
||||
# ACHTUNG: Die Rangfolge unten ist eine Annahme (nicht explizit in der
|
||||
# Produktspezifikation vorgegeben), siehe rules/OPEN.md — vor Phase 3
|
||||
# mit dem fachlich Verantwortlichen bestätigen.
|
||||
version: 1
|
||||
stufen:
|
||||
- id: oeffentlich
|
||||
rang: 1
|
||||
ausloeser: [b7]
|
||||
folge: "keine besonderen Anforderungen"
|
||||
- id: intern
|
||||
rang: 2
|
||||
ausloeser: [b6]
|
||||
folge: "Geschäftsgeheimnis-Schutz"
|
||||
- id: auftragsdaten
|
||||
rang: 3
|
||||
ausloeser: [b5]
|
||||
folge: "Weitergabe nur wenn Haupt-AVV es zulässt"
|
||||
- id: personenbezogen
|
||||
rang: 4
|
||||
ausloeser: [b1, b4]
|
||||
folge: "AVV, EU-Verarbeitung, Löschfrist"
|
||||
- id: berufsgeheimnis
|
||||
rang: 5
|
||||
ausloeser: [b3]
|
||||
folge: "§ 203 StGB — externe Verarbeitung nur mit Verpflichtung"
|
||||
- id: besondere_kategorie
|
||||
rang: 6
|
||||
ausloeser: [b2]
|
||||
folge: "zusätzlich DSFA prüfen, Art. 9 DSGVO"
|
||||
30
rules/kivo_einstufung.yaml
Normal file
30
rules/kivo_einstufung.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
# KI-VO-Einstufung aus Fragebogen-Abschnitt C. Reihenfolge ist
|
||||
# Prüfreihenfolge (erste zutreffende Stufe gewinnt) — "verboten" muss
|
||||
# zuerst geprüft werden (K.-o.-Prüfung, siehe Bewertungslogik). Jede
|
||||
# Variante ist eine UND-Verknüpfung von Fakten; mehrere Varianten pro
|
||||
# Stufe sind ODER-verknüpft.
|
||||
#
|
||||
# ACHTUNG: c3_art (social_scoring / biometrische_kategorisierung /
|
||||
# emotionserkennung_arbeitsplatz) und c2_folge sind Platzhalter-
|
||||
# Feldnamen, bis Phase 2 (Fragebogen) die genauen Fakten-Schlüssel
|
||||
# festlegt — siehe rules/OPEN.md.
|
||||
version: 1
|
||||
stufen:
|
||||
- id: verboten
|
||||
quelle: "Art. 5 KI-VO"
|
||||
varianten:
|
||||
- {c3: true, c3_art: emotionserkennung_arbeitsplatz}
|
||||
- {c3: true, c3_art: social_scoring}
|
||||
- {c3: true, c3_art: biometrische_kategorisierung}
|
||||
- id: hochrisiko
|
||||
varianten:
|
||||
- {c2: true, c2_folge: beschaeftigung}
|
||||
- {c2: true, c2_folge: kreditwuerdigkeit}
|
||||
- {c2: true, c2_folge: leistungsbewilligung}
|
||||
- {c2: true, c2_folge: bildung}
|
||||
- {c2: true, c2_folge: gesundheit}
|
||||
- id: transparenzpflicht
|
||||
varianten:
|
||||
- {c1: true, c5: false}
|
||||
- id: minimal
|
||||
varianten: []
|
||||
@@ -1,23 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deklarix — Build-Skript (amd64 + arm64)
|
||||
# Deklarix — .deb-Pakete bauen (amd64 + arm64)
|
||||
#
|
||||
# Verwendung:
|
||||
# ./scripts/build.sh [version] # Beide Architekturen
|
||||
# ./scripts/build.sh [version] amd64 # Nur amd64
|
||||
# ./scripts/build.sh [version] arm64 # Nur arm64
|
||||
#
|
||||
# Ausgabe: dist/deklarix_<version>_<arch>
|
||||
#
|
||||
# Release-Prozess:
|
||||
# 1. VERSION erhöhen (Semantic Versioning: MAJOR.MINOR.PATCH)
|
||||
# 2. ./scripts/build.sh <version>
|
||||
# 3. ./scripts/test.sh
|
||||
# 4. git tag v<version> && git push origin v<version>
|
||||
# Ausgabe: dist/deklarix_<version>_<arch>.deb
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PACKAGING_DIR="$REPO_DIR/packaging"
|
||||
BUILD_DIR="$REPO_DIR/dist/build"
|
||||
VERSION="${1:-0.0.1}"
|
||||
ARCH_FILTER="${2:-both}"
|
||||
|
||||
@@ -26,9 +22,8 @@ log() { echo -e "${GRN}[build]${NC} $*"; }
|
||||
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
command -v go >/dev/null || { echo "Go nicht gefunden — PATH: $PATH"; exit 1; }
|
||||
|
||||
mkdir -p "$REPO_DIR/dist"
|
||||
command -v go >/dev/null || { echo "Go nicht gefunden — PATH: $PATH"; exit 1; }
|
||||
command -v dpkg-deb >/dev/null || { echo "dpkg-deb nicht gefunden"; exit 1; }
|
||||
|
||||
log "Version: $VERSION | Go: $(go version)"
|
||||
|
||||
@@ -39,14 +34,42 @@ case "$ARCH_FILTER" in
|
||||
*) ARCHS=("amd64" "arm64") ;;
|
||||
esac
|
||||
|
||||
rm -rf "$BUILD_DIR"
|
||||
mkdir -p "$REPO_DIR/dist"
|
||||
|
||||
for ARCH in "${ARCHS[@]}"; do
|
||||
OUT="$REPO_DIR/dist/deklarix_${VERSION}_${ARCH}"
|
||||
log "Baue $ARCH → $OUT"
|
||||
GOARCH=$ARCH GOOS=linux go build \
|
||||
-ldflags="-X main.Version=${VERSION} -s -w" \
|
||||
-o "$OUT" \
|
||||
./cmd/server/
|
||||
log "$ARCH fertig: $(du -sh "$OUT" | cut -f1)"
|
||||
DEB_NAME="deklarix_${VERSION}_${ARCH}"
|
||||
DEB_DIR="$BUILD_DIR/$DEB_NAME"
|
||||
|
||||
log "Baue Binary ($ARCH)..."
|
||||
mkdir -p "$DEB_DIR/DEBIAN" \
|
||||
"$DEB_DIR/usr/bin" \
|
||||
"$DEB_DIR/usr/share/deklarix/rules" \
|
||||
"$DEB_DIR/etc/systemd/system" \
|
||||
"$DEB_DIR/etc/deklarix"
|
||||
CGO_ENABLED=0 GOARCH=$ARCH GOOS=linux go build \
|
||||
-trimpath -ldflags="-X main.Version=${VERSION} -s -w" \
|
||||
-o "$DEB_DIR/usr/bin/deklarix" \
|
||||
./cmd/deklarix/
|
||||
chmod 755 "$DEB_DIR/usr/bin/deklarix"
|
||||
|
||||
log "Stelle Paket zusammen ($ARCH)..."
|
||||
sed "s/^Version: VERSION/Version: $VERSION/; s/^Architecture: ARCH/Architecture: $ARCH/" \
|
||||
"$PACKAGING_DIR/DEBIAN/control.tmpl" > "$DEB_DIR/DEBIAN/control"
|
||||
cp "$PACKAGING_DIR/DEBIAN/postinst" "$DEB_DIR/DEBIAN/postinst"
|
||||
cp "$PACKAGING_DIR/DEBIAN/prerm" "$DEB_DIR/DEBIAN/prerm"
|
||||
cp "$PACKAGING_DIR/DEBIAN/conffiles" "$DEB_DIR/DEBIAN/conffiles"
|
||||
chmod 755 "$DEB_DIR/DEBIAN/postinst" "$DEB_DIR/DEBIAN/prerm"
|
||||
|
||||
cp "$PACKAGING_DIR/etc/systemd/system/deklarix.service" "$DEB_DIR/etc/systemd/system/"
|
||||
cp "$PACKAGING_DIR/etc/deklarix/deklarix.env.example" "$DEB_DIR/etc/deklarix/"
|
||||
cp "$REPO_DIR"/rules/*.yaml "$DEB_DIR/usr/share/deklarix/rules/"
|
||||
|
||||
dpkg-deb --build --root-owner-group "$DEB_DIR" "$REPO_DIR/dist/$DEB_NAME.deb"
|
||||
|
||||
log "$ARCH fertig: dist/$DEB_NAME.deb ($(du -sh "$REPO_DIR/dist/$DEB_NAME.deb" | cut -f1))"
|
||||
done
|
||||
|
||||
rm -rf "$BUILD_DIR"
|
||||
|
||||
log "Build abgeschlossen ✓"
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
#
|
||||
# Was passiert:
|
||||
# 1. Tests laufen
|
||||
# 2. Build für amd64 + arm64
|
||||
# 3. git tag v<version> wird gesetzt
|
||||
# 4. git push origin main + git push origin v<version>
|
||||
# 2. .deb-Pakete bauen (amd64 + arm64)
|
||||
# 3. Pakete in Giteas Debian-Paketregistrierung hochladen (Kanal "testing")
|
||||
# 4. git tag v<version> wird gesetzt
|
||||
# 5. git push origin main + git push origin v<version>
|
||||
#
|
||||
# Voraussetzungen:
|
||||
# - Sauberer Git-Status (keine uncommitted changes)
|
||||
# - Alle Tests grün
|
||||
# - Alle Tests grün (inkl. DATABASE_URL für die Store-Integrationstests)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -24,8 +25,9 @@ if [[ -z "$VERSION" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GRN='\033[0;32m'; BLD='\033[1m'; NC='\033[0m'
|
||||
log() { echo -e "${GRN}[release]${NC} $*"; }
|
||||
GRN='\033[0;32m'; YLW='\033[0;33m'; BLD='\033[1m'; NC='\033[0m'
|
||||
log() { echo -e "${GRN}[release]${NC} $*"; }
|
||||
warn() { echo -e "${YLW}[release]${NC} $*"; }
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
@@ -43,9 +45,46 @@ log "Tests laufen..."
|
||||
bash "$REPO_DIR/scripts/test.sh"
|
||||
|
||||
# Build
|
||||
log "Build für alle Architekturen..."
|
||||
log "Pakete bauen..."
|
||||
bash "$REPO_DIR/scripts/build.sh" "$VERSION"
|
||||
|
||||
# ─── apt-Kanal-Modell ────────────────────────────────────────────────────────
|
||||
# Suite = OS-Codename, Komponente = Kanal. Deklarix hat aktuell nur einen
|
||||
# Kanal (testing) — es gibt noch keine Kunden, für die "stable" vom
|
||||
# aktuellen Entwicklungsstand getrennt werden müsste. Sobald das relevant
|
||||
# wird: gleiches Promote-Modell wie enconf-webpanel (release.sh stable).
|
||||
GITEA_URL="https://git.netcell-it.de"
|
||||
GITEA_ORG="projekte"
|
||||
GITEA_CHANNEL="testing"
|
||||
APT_SUITES=(bookworm trixie)
|
||||
|
||||
GITEA_TOKEN="$(git -C "$REPO_DIR" remote get-url origin | grep -oP '://[^:]+:\K[^@]+' 2>/dev/null || echo '')"
|
||||
if [ -z "$GITEA_TOKEN" ]; then
|
||||
echo "FEHLER: Gitea-Token nicht im Git-Remote gefunden."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
upload_deb() {
|
||||
curl -sk -w "%{http_code}" -o /dev/null -X PUT \
|
||||
"$GITEA_URL/api/packages/$GITEA_ORG/debian/pool/$2/$3/upload" \
|
||||
-H "Authorization: token $GITEA_TOKEN" --upload-file "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
log "Pakete → Gitea-Debian-Registry (${APT_SUITES[*]} / $GITEA_CHANNEL)..."
|
||||
for deb in "$REPO_DIR"/dist/deklarix_"${VERSION}"_*.deb; do
|
||||
[ -f "$deb" ] || { echo "FEHLER: kein .deb für Version $VERSION in dist/ gefunden"; exit 1; }
|
||||
b="$(basename "$deb")"
|
||||
for suite in "${APT_SUITES[@]}"; do
|
||||
code=$(upload_deb "$deb" "$suite" "$GITEA_CHANNEL")
|
||||
if [ "$code" = "201" ] || [ "$code" = "409" ]; then
|
||||
log " ✅ $b → $suite/$GITEA_CHANNEL"
|
||||
else
|
||||
echo "FEHLER: Upload $b → $suite/$GITEA_CHANNEL fehlgeschlagen (HTTP $code)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Git Tag
|
||||
log "Tag v${VERSION} setzen..."
|
||||
git tag -a "v${VERSION}" -m "Release v${VERSION}"
|
||||
@@ -57,4 +96,5 @@ git push origin "v${VERSION}"
|
||||
|
||||
log ""
|
||||
log "Release v${VERSION} fertig ✓"
|
||||
log "Binaries: dist/deklarix_${VERSION}_amd64 + _arm64"
|
||||
log "Pakete: dist/deklarix_${VERSION}_amd64.deb + _arm64.deb"
|
||||
log "Installation/Update auf Zielservern: apt install/upgrade deklarix"
|
||||
|
||||
@@ -15,6 +15,17 @@ fail() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; }
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
# internal/store hat Integrationstests gegen echtes Postgres (u. a. die
|
||||
# Append-only-Garantie auf finding/extraction/evidence_package). Ohne
|
||||
# DATABASE_URL überspringt Go diese Tests still — das darf im
|
||||
# Release-Pfad (release.sh ruft dieses Skript auf) nicht passieren.
|
||||
# Postgres wird hier bewusst NICHT selbst hochgezogen — das ist Aufgabe
|
||||
# des Testsystems, nicht des lokalen Dev-Rechners. DATABASE_URL muss dort
|
||||
# vorkonfiguriert sein.
|
||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||
fail "DATABASE_URL ist nicht gesetzt — Store-Integrationstests (Append-only-Garantie) würden sonst still übersprungen. Auf dem Testsystem konfigurieren; für schnelle lokale Iteration ohne Postgres stattdessen gezielt 'go test ./internal/...' o. Ä. nutzen."
|
||||
fi
|
||||
|
||||
log "go vet ..."
|
||||
go vet ./... || fail "go vet fehlgeschlagen"
|
||||
|
||||
@@ -22,6 +33,6 @@ log "go test ./... "
|
||||
go test -race -count=1 ./... || fail "Tests fehlgeschlagen"
|
||||
|
||||
log "Build-Check (amd64) ..."
|
||||
GOARCH=amd64 GOOS=linux go build -o /dev/null ./cmd/server/ || fail "Build fehlgeschlagen"
|
||||
GOARCH=amd64 GOOS=linux go build -o /dev/null ./cmd/deklarix/ || fail "Build fehlgeschlagen"
|
||||
|
||||
log "Alle Tests bestanden ✓"
|
||||
|
||||
Reference in New Issue
Block a user