Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7397f70068 | ||
|
|
2736e2c0db | ||
|
|
9d2cb79424 | ||
|
|
4dd9bd88cb | ||
|
|
96ac5d6b59 | ||
|
|
29961f5657 | ||
|
|
3ec57f2786 | ||
|
|
6884f28109 | ||
|
|
b91a58ccba | ||
|
|
eb99891e38 | ||
|
|
36af1bf288 | ||
|
|
5f6502bb41 | ||
|
|
22f748d66c | ||
|
|
d52b325424 | ||
|
|
cff8c9eadf | ||
|
|
aad517ce23 | ||
|
|
d1e80dd19f | ||
|
|
e968cf9761 |
349
CLAUDE.md
349
CLAUDE.md
@@ -67,11 +67,90 @@ 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ß.
|
||||
`admin` hieß. **Praktische Umsetzung dieser Doppelrolle:** `admin`
|
||||
bekommt zusätzlich dieselben Fachebene-Rechte wie `verantwortlicher`
|
||||
(siehe `requireFachebene`/`hatEntscheidungsrecht`) — pragmatisch gelöst
|
||||
ohne Datenmodell-Umbau, siehe Offene Punkte weiter unten.
|
||||
|
||||
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
|
||||
je Handler), nicht als grob unterschiedene Seitenbereiche.
|
||||
|
||||
**Ebene 4 ist inzwischen zu großen Teilen umgesetzt** (Anspruch: alles,
|
||||
was im Datenmodell existiert, muss über das Frontend steuerbar sein,
|
||||
nicht nur einsehbar — Deklarix soll ein buchbarer Service werden, ein
|
||||
reiner Anzeige-Screen reicht nicht):
|
||||
|
||||
- **Nutzerverwaltung** (`internal/web/admin_handlers.go`, Middleware
|
||||
`requireAdmin`): `GET /verwaltung/nutzer` listet alle Logins des
|
||||
eigenen Mandanten mit Status (aktiv/deaktiviert), `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. Nutzer werden **nicht gelöscht**
|
||||
(`app_user` wird von `antrag`/`entscheidung`/`audit_log` per Foreign
|
||||
Key referenziert — ein Hard-Delete würde die Historie zerstören),
|
||||
sondern über `POST /verwaltung/nutzer/{id}/deaktivieren` bzw.
|
||||
`.../aktivieren` (de-)aktiviert (Spalte `app_user.active`, Migration
|
||||
0012). Ein deaktivierter Login kann sich nicht mehr anmelden
|
||||
(`handleLogin` prüft `Active` erst NACH der Passwortprüfung, um keine
|
||||
Kontoexistenz zu verraten) und verliert eine bereits laufende Sitzung
|
||||
sofort (`authenticate`-Middleware prüft `Active` bei jedem Request).
|
||||
Ein Admin kann sich nicht selbst deaktivieren (Aussperr-Schutz).
|
||||
- **Abteilungen** (`GET /verwaltung/abteilungen`, anlegen + löschen):
|
||||
reine Stammdaten für das Fragebogen-Feld "Abteilung" — ohne diese
|
||||
Seite blieb die Abteilung-Auswahl im Fragebogen faktisch leer und
|
||||
unbenutzbar, das war kein Komfort-, sondern ein Funktionsdefizit.
|
||||
- **Eigene Werkzeug-Sperrungen und -Einträge** (`internal/web/mandant_werkzeug_handlers.go`,
|
||||
`GET /verwaltung/werkzeuge`): ein Mandant kann einen zentralen
|
||||
Katalogeintrag für sich sperren/entsperren (`werkzeug_sperre`), ohne
|
||||
den zentralen Katalog selbst zu verändern — das bleibt Sache der
|
||||
Plattform (Ebene 5, `betreiber_werkzeug_handlers.go`). Zusätzlich kann
|
||||
ein Mandant eigene, nur für sich sichtbare Katalogeinträge anlegen/
|
||||
bearbeiten/löschen (`GET/POST /verwaltung/werkzeuge/neu`, `GET/POST
|
||||
/verwaltung/werkzeuge/{id}`, `POST /verwaltung/werkzeuge/{id}/loeschen`
|
||||
— `werkzeug.account_id` = der eigene Account). Nutzt dasselbe Formular
|
||||
wie der zentrale Katalog (`werkzeugFormData`/`betreiber-werkzeug-form.html`,
|
||||
ein `ActionBase`-Feld unterscheidet Betreiber- von Mandanten-Ziel-URL)
|
||||
— ein zentraler oder fremder Eintrag ist über diese Route nicht
|
||||
erreichbar (404).
|
||||
|
||||
**Support-Login (Ebene 5, `betreiber`) ist umgesetzt** — auf
|
||||
`GET /betreiber/accounts/{id}` kann sich der Betreiber über
|
||||
`POST .../nutzer/{userID}/anmelden-als` als ein aktiver Kunden-Nutzer
|
||||
anmelden, ohne dessen Passwort zu kennen (`handleBetreiberLoginAls` in
|
||||
`betreiber_handlers.go`, `store.CreateImpersonatedSession`, Migration
|
||||
0014). Nicht für deaktivierte Nutzer möglich. Die neue Sitzung ersetzt
|
||||
die eigene Betreiber-Sitzung (kein Sitzungs-Stack/"Zurück zum
|
||||
Betreiber" — der Betreiber meldet sich danach mit den eigenen
|
||||
Zugangsdaten neu an). Jede Nutzung erzeugt einen `audit_log`-Eintrag
|
||||
(Action `betreiber_login_als_nutzer`); während der gesamten Sitzung
|
||||
zeigt die Nav einen auffälligen Banner ("Support-Zugriff durch ...",
|
||||
siehe `currentImpersonator` in `middleware.go`), damit nie unklar ist,
|
||||
im Kontext eines fremden Kontos zu handeln.
|
||||
|
||||
**Firmen-CRUD für den Betreiber ist umgesetzt:** `GET/POST
|
||||
/betreiber/accounts/neu` legt eine Firma samt erstem `admin`-Login an
|
||||
(dasselbe Ergebnis wie die öffentliche Registrierung, nur vom Betreiber
|
||||
ausgelöst — z. B. für vertriebsunterstütztes Onboarding oder Testkonten),
|
||||
`POST /betreiber/accounts/{id}/umbenennen` korrigiert den Firmennamen
|
||||
(`store.UpdateAccount`). Jede Firmenanlage erzeugt einen
|
||||
`audit_log`-Eintrag. Bewusst **kein** Löschen — ein Hard-Delete würde
|
||||
gegen die Fremdschlüssel aus `antrag`/`app_user`/`audit_log` laufen und
|
||||
Historie zerstören (dasselbe Muster wie bei Nutzern: deaktivieren statt
|
||||
löschen, aber ein Sperren/Deaktivieren-Zustand für Accounts existiert
|
||||
noch nicht, siehe unten).
|
||||
|
||||
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, ein echtes
|
||||
Abo-System (Preismodell und Zahlungsanbieter mit dem Nutzer am
|
||||
2026-08-30 grundsätzlich geklärt — 3 €/Mitarbeiter/Monat, Mindestabnahme
|
||||
10 Mitarbeiter, 14 Tage Testphase, Stripe mit SEPA-Lastschrift — die
|
||||
eigentliche Umsetzung wartet noch auf einen Stripe-Account/Testmodus-
|
||||
Zugangsdaten), Sperren/Deaktivieren eines Accounts durch den Betreiber
|
||||
(hängt weiter an der Abrechnungs-/Freischaltungs-Architektur, siehe
|
||||
Offene Punkte: `account.verified` wurde beim Produktwechsel sogar
|
||||
entfernt).
|
||||
|
||||
**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
|
||||
@@ -147,10 +226,13 @@ und wertet sie seit Schritt 3 auch aus: `EvaluateDatenklasse`,
|
||||
`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). **Noch
|
||||
nicht verdrahtet:** eine Web-Seite, die das für einen konkreten Antrag
|
||||
tatsächlich aufruft und anzeigt — das ist Schritt 4
|
||||
("Ergebnisdarstellung mit Herleitung").
|
||||
`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."
|
||||
|
||||
---
|
||||
|
||||
@@ -202,23 +284,64 @@ mehrfacher Neubewertung eines Antrags erhalten bleibt.
|
||||
|
||||
---
|
||||
|
||||
## Entscheidung, Register, Wiedervorlage (geplant, Schritt 5-7)
|
||||
## Entscheidung, Register, Wiedervorlage (Schritt 5-7 erledigt)
|
||||
|
||||
**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. Begründungsfeld ist bei Abweichung vom Vorschlag
|
||||
Pflicht. **Snapshot bei Entscheidung:** Regelwerk-Version, Katalog-
|
||||
Version und der vollständige Werkzeugdatensatz werden eingefroren — ein
|
||||
späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf eine
|
||||
Entscheidung beruhte.
|
||||
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 (Vorschlag: 12 Monate, bei
|
||||
`hochrisiko` 6). Ändert sich im Katalog eine Eigenschaft, auf der eine
|
||||
aktive Genehmigung beruht, wird der/die Verantwortliche benachrichtigt
|
||||
(Wiedervorlage).
|
||||
Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
|
||||
`hochrisiko` 6 — `gueltigkeitFuer`).
|
||||
|
||||
Jede Genehmigung erzeugt automatisch einen Registereintrag: Zweck,
|
||||
Abteilung, Werkzeug, Datenklasse, Einstufung, Auflagen, Verantwortliche/
|
||||
r, Datum, Gültigkeit. Export als PDF und CSV.
|
||||
**Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt**
|
||||
(`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene
|
||||
3). Es gibt keine E-Mail-Infrastruktur — "Benachrichtigung" ist bewusst
|
||||
eine In-App-Liste, kein Versand. Eine aktive Genehmigung erscheint dort,
|
||||
wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
|
||||
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
|
||||
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
|
||||
dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag
|
||||
bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsort. Keine
|
||||
gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
|
||||
Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
|
||||
liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
|
||||
eines Mandanten (Join über `antrag.account_id`, da `entscheidung`
|
||||
selbst kein `account_id` trägt).
|
||||
|
||||
**Schritt 6 (Registereintrag, Export) ist umgesetzt.** Jede Genehmigung
|
||||
erzeugt automatisch einen `registereintrag` (append-only, Migration
|
||||
0011): Zweck (`antrag.titel`), Abteilung, Werkzeug, Datenklasse,
|
||||
Einstufung, Auflagen (die Anforderungen der Bewertung), Verantwortliche/r,
|
||||
Entscheidungsdatum, Gültigkeit — siehe `internal/web/register_handlers.go`.
|
||||
`GET /registereintraege` (Ebene 3, `verantwortlicher`/`pruefer`) zeigt
|
||||
sie, `GET /registereintraege/export.{csv,pdf}` exportiert. Abgelehnte
|
||||
Entscheidungen und Rückfragen erzeugen bewusst KEINEN Registereintrag —
|
||||
das Register ist eine Genehmigungsliste, kein vollständiges Antragslog
|
||||
(das leistet bereits `GET /faelle`).
|
||||
|
||||
---
|
||||
|
||||
@@ -234,12 +357,28 @@ 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.
|
||||
**Mitarbeiter, geplante Reihenfolge:** (1) ~~Einladungslink~~ —
|
||||
**erledigt** (`internal/web/einladung_handlers.go`: `account.einladung_token`,
|
||||
Migration 0013, ein Sammellink pro Mandant statt Einmal-Token pro
|
||||
Person, kein Ablaufdatum, per Admin unter `GET /verwaltung/einladung`
|
||||
jederzeit erneuerbar [macht den alten Link sofort ungültig].
|
||||
`GET/POST /einladung/{token}` — öffentlich, Ebene 1 — legt einen Login
|
||||
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
|
||||
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
|
||||
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
|
||||
Einladung.), (2) ~~CSV-Import~~ — **erledigt** (`internal/web/csv_import_handlers.go`,
|
||||
`GET /verwaltung/nutzer/import`: CSV mit Kopfzeile, Pflichtspalte
|
||||
"email", optionale Spalte "role" [Standard `mitarbeiter`]. Wie beim
|
||||
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
|
||||
ein Einmalpasswort erzeugt [wiederverwendet `auth.NewSessionToken()`],
|
||||
das NUR auf der Ergebnisseite direkt nach dem Import angezeigt wird und
|
||||
danach nirgendwo mehr abrufbar ist. Kein Alles-oder-nichts-Import:
|
||||
gültige Zeilen werden angelegt, eine ungültige Rolle oder bereits
|
||||
vergebene E-Mail in einer Zeile blockiert nicht die übrigen Zeilen —
|
||||
jede Zeile bekommt ihr eigenes Ergebnis [Passwort oder Fehlermeldung].),
|
||||
(3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne vorheriges
|
||||
Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto entsteht
|
||||
nebenbei). (3)-(4) sind weiterhin nicht gebaut.
|
||||
|
||||
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
|
||||
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
|
||||
@@ -247,6 +386,49 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
Anspruch (2026-08-29 explizit vom Nutzer bestätigt, da Deklarix ein
|
||||
buchbarer Service werden soll): **für Browser UND Mobile optimiert,
|
||||
und jede Entität im Datenmodell muss über das Frontend steuerbar sein**
|
||||
— eine reine Anzeige-Seite ohne CRUD ist ein Defizit, kein v1-Kompromiss.
|
||||
Stack bleibt `html/template` + htmx, kein React/Vue/SPA (siehe
|
||||
Baureihenfolge/Architektur-Entscheidungen) — die responsive Optik wird
|
||||
rein mit CSS erreicht, ohne das Template-Modell zu ändern.
|
||||
|
||||
**Navigation** (`internal/web/templates/layout.html`, `"nav"`-Block,
|
||||
einmal definiert und von jeder Seite eingebunden — Änderungen hier
|
||||
wirken auf das ganze Produkt): ein Checkbox-Hack (versteckte Checkbox +
|
||||
`<label for=...>` als Klick-Ziel) klappt auf dem Handy ein Menü auf,
|
||||
ganz ohne JavaScript. **Nicht** das naheliegendere `<details>`/`<summary>`
|
||||
verwenden — aktuelle Chromium-Versionen steuern dessen Auf-/Zuklapp-
|
||||
Zustand über eine interne, mit CSS (auch nicht mit `!important`)
|
||||
nicht überschreibbare Animation; ein Versuch, `.app-nav { display:
|
||||
flex !important }` bei geschlossenem `<details>` zu erzwingen, bleibt
|
||||
wirkungslos (mit Chromium-Headless-Screenshot verifiziert). Ab
|
||||
960px Breite (`@media (min-width: 960px)`, `app.css`) wird exakt
|
||||
dieselbe Markup-Struktur per CSS zu einer permanenten, dunklen
|
||||
Seitenleiste (Farb-Tokens aus `design/enterprise.css` übernommen) —
|
||||
kein separates Desktop-Template.
|
||||
|
||||
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
|
||||
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
|
||||
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
|
||||
Scrollen auf dem Handy statt gequetschter Spalten).
|
||||
|
||||
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
|
||||
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
||||
websocket-Python-Modul) — stattdessen: Seite per `curl -b cookies.txt`
|
||||
authentifiziert abrufen, `/static/...`-Pfade im HTML auf eine absolute
|
||||
`http://localhost:<port>/static/...`-URL umschreiben (Assets brauchen
|
||||
kein Auth-Cookie), dann `chromium --headless --disable-gpu --no-sandbox
|
||||
--window-size=<W>,<H> --screenshot=out.png file:///pfad/zur/seite.html`
|
||||
bei mobiler (z. B. 390×844) und Desktop-Breite (z. B. 1440×900)
|
||||
screenshotten. Für einen Menü-Aufklapp-Zustand: `checked`-Attribut
|
||||
manuell ins gespeicherte HTML einfügen vor dem Screenshot.
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
@@ -261,7 +443,10 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
|
||||
│ └── web/ # Handler, Templates
|
||||
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
|
||||
├── design/
|
||||
│ └── enterprise.css # Design-System (enconf-Basis, ungenutzt seit Pivot)
|
||||
│ └── enterprise.css # enconf Ant-Design-Theme — Referenz für Design-Tokens
|
||||
│ (Farben/Radius/Schatten), nicht direkt eingebunden
|
||||
│ (Ant-Design-Klassennamen passen nicht zu Deklarix'
|
||||
│ reinem HTML/htmx-Stack, siehe internal/web/static/app.css)
|
||||
├── packaging/
|
||||
│ └── DEBIAN/
|
||||
│ └── control.tmpl # .deb Package-Control-Template
|
||||
@@ -302,12 +487,17 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
|
||||
NICHT append-only (normale Zustandsänderung `entwurf` →
|
||||
`eingereicht` → `entschieden`, wie `submission` es im alten Produkt
|
||||
war).
|
||||
- `bewertung`, `entscheidung`, `registereintrag` — **noch nicht
|
||||
gebaut**, geplant für Schritt 5/6 der Baureihenfolge.
|
||||
- `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` — automatisch bei jeder Genehmigung erzeugt
|
||||
(Schritt 6), denormalisierter Nachweis (eigene Textspalten statt
|
||||
Joins über bewertung/entscheidung/werkzeug).
|
||||
|
||||
**Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`,
|
||||
wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/
|
||||
`registereintrag` werden bei ihrer Einführung ebenfalls append-only.
|
||||
wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
|
||||
`entscheidung` und `registereintrag`.
|
||||
|
||||
---
|
||||
|
||||
@@ -328,11 +518,43 @@ wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/
|
||||
3. ~~Ableitungen und harte Filter~~ — **erledigt** (`internal/rules`:
|
||||
`EvaluateDatenklasse`/`EvaluateEinstufung`/`DeriveAnforderungen`/
|
||||
`IstVerboten`/`FilterWerkzeuge`, vollständig getestet gegen die
|
||||
echten `rules/*.yaml`-Dateien. Noch nicht ans Web angebunden.)
|
||||
4. Ergebnisdarstellung mit Herleitung
|
||||
5. Entscheidung, Snapshot, Audit-Log
|
||||
6. Registereintrag und Export
|
||||
7. Wiedervorlage und Katalog-Benachrichtigung
|
||||
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~~ — **erledigt** (`internal/web/register_handlers.go`:
|
||||
`GET /registereintraege` zeigt alle Registereinträge des Mandanten,
|
||||
`GET /registereintraege/export.{csv,pdf}` exportiert sie. Jede
|
||||
Genehmigung — `genehmigt` oder `genehmigt_mit_auflagen` — erzeugt in
|
||||
`handleFallEntscheiden` automatisch einen `registereintrag`
|
||||
[append-only, Migration 0011]: Zweck [`antrag.titel`], Abteilung,
|
||||
Werkzeug, Datenklasse, Einstufung, Auflagen [die Anforderungen der
|
||||
Bewertung], Verantwortliche/r, Entscheidungsdatum, Gültigkeit —
|
||||
bewusst denormalisiert [eigene Textspalten statt Joins], damit ein
|
||||
Eintrag nicht von einem späteren Katalog-/Abteilungs-Wandel abhängt.
|
||||
Abgelehnte Entscheidungen und Rückfragen erzeugen keinen Eintrag.
|
||||
Der PDF-Export nutzt das bereits vorhandene, bis dahin ungenutzte
|
||||
`go-pdf/fpdf` [keine neue Abhängigkeit] mit `UnicodeTranslatorFromDescriptor`
|
||||
für deutsche Umlaute.)
|
||||
7. ~~Wiedervorlage und Katalog-Benachrichtigung~~ — **erledigt**
|
||||
(`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und
|
||||
Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat
|
||||
oder entfernt wurde. In-App-Liste, kein E-Mail-Versand.)
|
||||
|
||||
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
|
||||
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
|
||||
@@ -541,16 +763,24 @@ journalctl -u deklarix -f
|
||||
|
||||
## 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.
|
||||
- ~~Rangfolge der Datenklassen~~ (`rules/OPEN.md`, Punkt 1) — **am
|
||||
2026-08-29 vom Produktverantwortlichen bestätigt**, keine Änderung
|
||||
nötig.
|
||||
- ~~Anforderung `kein_training_auf_eingabe` und `auftragsdaten`~~
|
||||
(`rules/OPEN.md`, Punkt 2) — **bestätigt**, `auftragsdaten` bleibt
|
||||
eingeschlossen.
|
||||
- ~~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).
|
||||
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. Ob
|
||||
die drei Varianten fachlich vollständig sind, wurde am 2026-08-29
|
||||
ebenfalls **bestätigt** (siehe `rules/OPEN.md`, Punkt 3).
|
||||
- ~~Fallback ohne zutreffende Datenklasse~~ (`rules/OPEN.md`, Punkt 5)
|
||||
— **bestätigt**, `intern` bleibt die konservative Standardannahme.
|
||||
- **Welche Anforderungen hart gegen ein Werkzeug gefiltert werden**
|
||||
(`rules/OPEN.md`, Punkt 6 — `avv_erforderlich`/`eu_verarbeitung`/
|
||||
`kein_training_auf_eingabe` hart, `menschliche_aufsicht`/
|
||||
`kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
|
||||
— weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
|
||||
Rückmeldung dazu bisher eingeholt.
|
||||
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
||||
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
||||
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||
@@ -560,12 +790,29 @@ journalctl -u deklarix -f
|
||||
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.
|
||||
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch
|
||||
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
|
||||
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
|
||||
Stattdessen bekommt die Rolle `admin` in `requireFachebene` (Ebene 3)
|
||||
dieselben Rechte wie `verantwortlicher` — sieht Posteingang/Register/
|
||||
Wiedervorlage und darf entscheiden (`hatEntscheidungsrecht` in
|
||||
`fachebene_handlers.go`). Grund: ohne das könnte eine frisch
|
||||
registrierte Firma mit nur einem `admin`-Login keinen einzigen
|
||||
eingereichten Antrag sehen oder bearbeiten — der Kernablauf wäre für
|
||||
Einzelpersonen-/Kleinfirmen-Onboarding komplett blockiert. `pruefer`
|
||||
bleibt unverändert nur lesend, ohne Entscheidungsrecht. Kompromiss statt
|
||||
"sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher
|
||||
bewusst auf zwei verschiedene Personen verteilen will, funktioniert das
|
||||
weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).
|
||||
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die
|
||||
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
|
||||
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`
|
||||
anlegen, `GET/POST /betreiber/werkzeuge/{id}` bearbeiten, `POST
|
||||
.../loeschen` löschen — siehe `internal/web/betreiber_werkzeug_handlers.go`.
|
||||
Bearbeitet ausschließlich `account_id IS NULL`-Einträge, ein
|
||||
mandantenspezifischer Eintrag ist über diese Seiten nicht erreichbar
|
||||
[404]). "Werkzeugkatalog aus der Plattform vorbelegt" beim Firma-
|
||||
Onboarding bleibt trotzdem nicht automatisiert — der Katalog ist
|
||||
inhaltlich weiterhin leer, bis der Betreiber tatsächlich Einträge
|
||||
pflegt.
|
||||
- Markenanmeldung Deklarix, sobald erster Kunde zahlt.
|
||||
|
||||
@@ -51,7 +51,9 @@ func main() {
|
||||
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)
|
||||
server, err := web.NewServer(db, web.Regelwerk{
|
||||
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("web server: %v", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package rules
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -33,6 +34,14 @@ func (a Antworten) istJaOderUnsicher(key string) bool {
|
||||
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"
|
||||
@@ -40,24 +49,27 @@ func (a Antworten) istJaOderUnsicher(key string) bool {
|
||||
// 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) string {
|
||||
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) {
|
||||
if gewinner == nil || st.Rang > gewinner.Rang {
|
||||
gewinner = st
|
||||
}
|
||||
break
|
||||
stTreffer = append(stTreffer, ausloeser)
|
||||
}
|
||||
}
|
||||
if len(stTreffer) > 0 && (gewinner == nil || st.Rang > gewinner.Rang) {
|
||||
gewinner = st
|
||||
treffer = stTreffer
|
||||
}
|
||||
}
|
||||
if gewinner == nil {
|
||||
return fallback
|
||||
return DatenklasseErgebnis{ID: fallback, Herleitung: "keine Kategorie aus Abschnitt B traf eindeutig zu (konservativer Standardwert)"}
|
||||
}
|
||||
return gewinner.ID
|
||||
return DatenklasseErgebnis{ID: gewinner.ID, Herleitung: "ausgelöst durch " + strings.Join(treffer, ", ")}
|
||||
}
|
||||
|
||||
// variantePasst prüft, ob alle Schlüssel-Werte-Paare einer Variante zu
|
||||
@@ -86,24 +98,46 @@ func variantePasst(variante EinstufungVariante, antworten Antworten) bool {
|
||||
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) (string, error) {
|
||||
func EvaluateEinstufung(regelwerk EinstufungRegelwerk, antworten Antworten) (EinstufungErgebnis, error) {
|
||||
for _, st := range regelwerk.Stufen {
|
||||
if len(st.Varianten) == 0 {
|
||||
return st.ID, nil // Auffangregel, z. B. "minimal"
|
||||
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 st.ID, nil
|
||||
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: formatVariante(variante)}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("rules: keine einstufung trifft zu und keine auffangregel definiert")
|
||||
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"
|
||||
@@ -112,13 +146,27 @@ 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) []string {
|
||||
var out []string
|
||||
func DeriveAnforderungen(regelwerk AnforderungsRegelwerk, datenklasseID, einstufungID string) []AnforderungErgebnis {
|
||||
var out []AnforderungErgebnis
|
||||
for _, a := range regelwerk.Anforderungen {
|
||||
if containsString(a.AusDatenklassen, datenklasseID) || containsString(a.AusEinstufungen, einstufungID) {
|
||||
out = append(out, a.ID)
|
||||
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
|
||||
@@ -200,8 +248,17 @@ func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zu
|
||||
return zulaessig, ausgeschlossen
|
||||
}
|
||||
|
||||
// String liefert eine lesbare Begründung, z. B. für die spätere
|
||||
// Ergebnisdarstellung (Schritt 4).
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ 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 != "oeffentlich" {
|
||||
t.Fatalf("EvaluateDatenklasse = %q, want oeffentlich", got)
|
||||
if got.ID != "oeffentlich" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want oeffentlich", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ func TestEvaluateDatenklasseHighestWins(t *testing.T) {
|
||||
// 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 != "besondere_kategorie" {
|
||||
t.Fatalf("EvaluateDatenklasse = %q, want besondere_kategorie", got)
|
||||
if got.ID != "besondere_kategorie" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want besondere_kategorie", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ func TestEvaluateDatenklasseUnsicherZaehltWieJa(t *testing.T) {
|
||||
dk, _, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"b3": "unsicher"}
|
||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||
if got != "berufsgeheimnis" {
|
||||
t.Fatalf("EvaluateDatenklasse = %q, want berufsgeheimnis (unsicher zaehlt wie ja)", got)
|
||||
if got.ID != "berufsgeheimnis" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want berufsgeheimnis (unsicher zaehlt wie ja)", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ 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 != "intern" {
|
||||
t.Fatalf("EvaluateDatenklasse = %q, want intern (konservativer Fallback)", got)
|
||||
if got.ID != "intern" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want intern (konservativer Fallback)", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +69,10 @@ func TestEvaluateEinstufungVerboten(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got != "verboten" {
|
||||
t.Fatalf("EvaluateEinstufung = %q, want verboten", got)
|
||||
if got.ID != "verboten" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want verboten", got.ID)
|
||||
}
|
||||
if !rules.IstVerboten(got) {
|
||||
if !rules.IstVerboten(got.ID) {
|
||||
t.Error("IstVerboten sollte true liefern")
|
||||
}
|
||||
}
|
||||
@@ -84,8 +84,8 @@ func TestEvaluateEinstufungHochrisiko(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got != "hochrisiko" {
|
||||
t.Fatalf("EvaluateEinstufung = %q, want hochrisiko", got)
|
||||
if got.ID != "hochrisiko" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want hochrisiko", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,8 @@ func TestEvaluateEinstufungTransparenzpflicht(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got != "transparenzpflicht" {
|
||||
t.Fatalf("EvaluateEinstufung = %q, want transparenzpflicht", got)
|
||||
if got.ID != "transparenzpflicht" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want transparenzpflicht", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +108,10 @@ func TestEvaluateEinstufungMinimalFallback(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got != "minimal" {
|
||||
t.Fatalf("EvaluateEinstufung = %q, want minimal", got)
|
||||
if got.ID != "minimal" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want minimal", got.ID)
|
||||
}
|
||||
if rules.IstVerboten(got) {
|
||||
if rules.IstVerboten(got.ID) {
|
||||
t.Error("IstVerboten sollte fuer minimal false liefern")
|
||||
}
|
||||
}
|
||||
@@ -121,8 +121,11 @@ func TestDeriveAnforderungenPersonenbezogen(t *testing.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 _, id := range got {
|
||||
gotSet[id] = true
|
||||
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] {
|
||||
@@ -138,8 +141,8 @@ func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) {
|
||||
_, _, an := loadRealRegelwerke(t)
|
||||
got := rules.DeriveAnforderungen(an, "oeffentlich", "hochrisiko")
|
||||
gotSet := map[string]bool{}
|
||||
for _, id := range got {
|
||||
gotSet[id] = true
|
||||
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)
|
||||
|
||||
@@ -10,32 +10,59 @@ import (
|
||||
)
|
||||
|
||||
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
|
||||
// nutzt). Jeder Antrag gehört genau einem Account.
|
||||
// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
|
||||
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
|
||||
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
|
||||
type Account struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
Name string
|
||||
EinladungToken string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAccount legt einen neuen Mandanten an.
|
||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||
const accountColumns = `id, name, einladung_token, created_at`
|
||||
|
||||
func scanAccount(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Account, error) {
|
||||
var a Account
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
|
||||
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
|
||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO account (name) VALUES ($1)
|
||||
RETURNING id, name, created_at
|
||||
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||
RETURNING `+accountColumns,
|
||||
name,
|
||||
)
|
||||
a, err := scanAccount(row)
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: create account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
|
||||
// Betreiber-gestützten Anlage korrigieren).
|
||||
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
|
||||
row := s.Pool.QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: update 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)
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
@@ -45,12 +72,40 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAccountByEinladungToken liest den Mandanten zu einem
|
||||
// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung
|
||||
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
|
||||
// oder bereits erneuertem (damit ungültig gewordenem) Token.
|
||||
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: get account by einladung token: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten
|
||||
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
|
||||
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
|
||||
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: regenerate einladung token: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return 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
|
||||
`)
|
||||
rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||
}
|
||||
@@ -58,8 +113,8 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||
|
||||
var out []Account
|
||||
for rows.Next() {
|
||||
var a Account
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil {
|
||||
a, err := scanAccount(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan account: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
|
||||
@@ -25,6 +25,89 @@ func TestAccountCRUD(t *testing.T) {
|
||||
if got.Name != "Beispiel Agentur GmbH" {
|
||||
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
|
||||
}
|
||||
if got.EinladungToken == "" {
|
||||
t.Error("expected a newly created account to have a non-empty EinladungToken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
acc, err := s.CreateAccount(ctx, "Alter Name GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
updated, err := s.UpdateAccount(ctx, acc.ID, "Neuer Name GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAccount: %v", err)
|
||||
}
|
||||
if updated.Name != "Neuer Name GmbH" {
|
||||
t.Fatalf("Name = %q, want Neuer Name GmbH", updated.Name)
|
||||
}
|
||||
|
||||
got, err := s.GetAccount(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount: %v", err)
|
||||
}
|
||||
if got.Name != "Neuer Name GmbH" {
|
||||
t.Fatalf("Name nach erneutem Laden = %q, want Neuer Name GmbH", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAccountNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.UpdateAccount(context.Background(), "00000000-0000-0000-0000-000000000000", "X")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountByEinladungToken(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.GetAccountByEinladungToken(ctx, acc.EinladungToken)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountByEinladungToken: %v", err)
|
||||
}
|
||||
if got.ID != acc.ID {
|
||||
t.Fatalf("GetAccountByEinladungToken returned a different account")
|
||||
}
|
||||
|
||||
_, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateEinladungToken(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)
|
||||
}
|
||||
alterToken := acc.EinladungToken
|
||||
|
||||
if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil {
|
||||
t.Fatalf("RegenerateEinladungToken: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("expected the old token to be invalid, got err = %v", err)
|
||||
}
|
||||
got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountByEinladungToken (neu): %v", err)
|
||||
}
|
||||
if got.ID != acc.ID {
|
||||
t.Fatalf("GetAccountByEinladungToken (neu) returned a different account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
@@ -52,6 +135,49 @@ func TestUserCRUD(t *testing.T) {
|
||||
if byID.Email != "team@example.com" || byID.AccountID != accID {
|
||||
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
|
||||
}
|
||||
if !byID.Active {
|
||||
t.Error("expected a newly created user to be Active by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserActive(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
user, err := s.CreateUser(ctx, accID, "deaktivierbar@example.com", "bcrypt-hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetUserActive(ctx, user.ID, false); err != nil {
|
||||
t.Fatalf("SetUserActive (false): %v", err)
|
||||
}
|
||||
deaktiviert, err := s.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if deaktiviert.Active {
|
||||
t.Fatal("expected the user to be inactive")
|
||||
}
|
||||
|
||||
if err := s.SetUserActive(ctx, user.ID, true); err != nil {
|
||||
t.Fatalf("SetUserActive (true): %v", err)
|
||||
}
|
||||
reaktiviert, err := s.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if !reaktiviert.Active {
|
||||
t.Fatal("expected the user to be active again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserActiveNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
err := s.SetUserActive(context.Background(), "00000000-0000-0000-0000-000000000000", false)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByEmailNotFound(t *testing.T) {
|
||||
@@ -98,6 +224,9 @@ func TestSessionCRUD(t *testing.T) {
|
||||
if got.UserID != user.ID {
|
||||
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
||||
}
|
||||
if got.ImpersonatedByUserID != nil {
|
||||
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
|
||||
}
|
||||
|
||||
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
@@ -114,3 +243,38 @@ func TestGetSessionNotFound(t *testing.T) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateImpersonatedSession(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
kundenAcc := testAccountID(t, s)
|
||||
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser (Kunde): %v", err)
|
||||
}
|
||||
betreiberAcc := testAccountID(t, s)
|
||||
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser (Betreiber): %v", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateImpersonatedSession: %v", err)
|
||||
}
|
||||
if sess.UserID != kundenNutzer.ID {
|
||||
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
|
||||
}
|
||||
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
|
||||
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
|
||||
}
|
||||
|
||||
got, err := s.GetSession(ctx, sess.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
|
||||
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
146
internal/store/entscheidung.go
Normal file
146
internal/store/entscheidung.go
Normal file
@@ -0,0 +1,146 @@
|
||||
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
|
||||
}
|
||||
|
||||
// ListAktiveGenehmigungenForAccount liefert alle Genehmigungen
|
||||
// ("genehmigt"/"genehmigt_mit_auflagen") eines Mandanten — Grundlage
|
||||
// für die Wiedervorlage (Schritt 7): abgelaufene/bald ablaufende
|
||||
// Genehmigungen und solche, deren zugrunde liegendes Werkzeug sich seit
|
||||
// der Entscheidung im Katalog geändert hat, muss der/die Verantwortliche
|
||||
// erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug
|
||||
// zum Mandanten läuft über den zugehörigen antrag.
|
||||
func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung,
|
||||
e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at
|
||||
FROM entscheidung e
|
||||
JOIN antrag a ON a.id = e.antrag_id
|
||||
WHERE a.account_id = $1 AND e.entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen')
|
||||
ORDER BY e.created_at DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Entscheidung
|
||||
for rows.Next() {
|
||||
e, err := scanEntscheidung(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan entscheidung: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
153
internal/store/entscheidung_test.go
Normal file
153
internal/store/entscheidung_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAktiveGenehmigungenForAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
|
||||
antragGenehmigt, b1 := testAntragMitBewertung(t, s, accID, userID)
|
||||
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antragGenehmigt.ID, BewertungID: b1.ID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateEntscheidung (genehmigt): %v", err)
|
||||
}
|
||||
|
||||
antragAbgelehnt, b2 := testAntragMitBewertung(t, s, accID, userID)
|
||||
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antragAbgelehnt.ID, BewertungID: b2.ID, EntscheiderUserID: userID, Entscheidung: "abgelehnt",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateEntscheidung (abgelehnt): %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListAktiveGenehmigungenForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].AntragID != antragGenehmigt.ID {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount = %+v, want exactly the genehmigt entscheidung", list)
|
||||
}
|
||||
|
||||
otherAcc := testAccountID(t, s)
|
||||
otherList, err := s.ListAktiveGenehmigungenForAccount(ctx, otherAcc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount (other): %v", err)
|
||||
}
|
||||
if len(otherList) != 0 {
|
||||
t.Fatalf("expected no genehmigungen for a different tenant, got %+v", otherList)
|
||||
}
|
||||
}
|
||||
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();
|
||||
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE registereintrag;
|
||||
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Registereintrag: wird automatisch bei jeder Genehmigung erzeugt
|
||||
-- (genehmigt / genehmigt_mit_auflagen), siehe CLAUDE.md, Entscheidung/
|
||||
-- Register/Wiedervorlage. Bewusst denormalisiert (eigene Textspalten
|
||||
-- statt Joins über bewertung/entscheidung/werkzeug) — ein Registereintrag
|
||||
-- ist ein für sich lesbarer, exportierbarer Nachweis, der nicht von
|
||||
-- einem späteren Katalog- oder Abteilungs-Wandel abhängen darf.
|
||||
-- Append-only wie bewertung/entscheidung.
|
||||
CREATE TABLE registereintrag (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||
entscheidung_id UUID NOT NULL REFERENCES entscheidung (id),
|
||||
zweck TEXT NOT NULL,
|
||||
abteilung TEXT NOT NULL DEFAULT '',
|
||||
werkzeug TEXT NOT NULL,
|
||||
datenklasse TEXT NOT NULL,
|
||||
einstufung TEXT NOT NULL,
|
||||
auflagen TEXT[] NOT NULL DEFAULT '{}',
|
||||
verantwortlicher TEXT NOT NULL,
|
||||
entschieden_am TIMESTAMPTZ NOT NULL,
|
||||
gueltig_bis TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER registereintrag_append_only
|
||||
BEFORE UPDATE OR DELETE ON registereintrag
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE app_user DROP COLUMN active;
|
||||
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Nutzer können nicht gelöscht werden (app_user wird von antrag,
|
||||
-- session, bewertung [über antrag], entscheidung, audit_log,
|
||||
-- registereintrag [über antrag] per Foreign Key referenziert — ein
|
||||
-- Hard-Delete würde die Historie zerstören). Stattdessen: deaktivieren.
|
||||
-- Ein deaktivierter Nutzer kann sich nicht mehr anmelden, bleibt aber
|
||||
-- als Akteur in Anträgen/Entscheidungen/Audit-Log nachvollziehbar.
|
||||
ALTER TABLE app_user ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE account DROP COLUMN einladung_token;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, "Einladung
|
||||
-- annehmen" — siehe CLAUDE.md, Onboarding: "Einladungslink [Sammellink,
|
||||
-- Selbstanmeldung, Abteilung beim ersten Antrag]"). Ein Token pro
|
||||
-- Account, kein Ablaufdatum, per Admin erneuerbar (macht den alten
|
||||
-- Link ungültig). DEFAULT gen_random_uuid()::text befüllt bestehende
|
||||
-- Zeilen automatisch und liefert künftigen INSERTs einen Wert, ohne
|
||||
-- dass store.CreateAccount etwas ändern muss.
|
||||
ALTER TABLE account ADD COLUMN einladung_token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE session DROP COLUMN impersonated_by_user_id;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Support-Login (Betreiber meldet sich als Kunden-Nutzer an, siehe
|
||||
-- internal/web/betreiber_handlers.go). impersonated_by_user_id ist
|
||||
-- gesetzt, wenn diese Sitzung durch einen Betreiber-Support-Login
|
||||
-- entstanden ist (nicht durch den regulären Login des Nutzers selbst)
|
||||
-- — die authenticate-Middleware liest das, um in der Nav einen
|
||||
-- deutlichen Hinweis-Banner anzuzeigen, damit niemand vergisst, dass
|
||||
-- er/sie gerade im Kontext eines fremden Kontos handelt.
|
||||
ALTER TABLE session ADD COLUMN impersonated_by_user_id UUID REFERENCES app_user (id);
|
||||
106
internal/store/registereintrag.go
Normal file
106
internal/store/registereintrag.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Registereintrag wird automatisch bei jeder Genehmigung erzeugt
|
||||
// (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage). Append-only
|
||||
// — siehe Migration. Bewusst denormalisiert: eigene Textspalten statt
|
||||
// Joins über bewertung/entscheidung/werkzeug, damit ein einmal
|
||||
// erzeugter Eintrag nicht von einem späteren Katalog- oder Abteilungs-
|
||||
// Wandel abhängt.
|
||||
type Registereintrag struct {
|
||||
ID string
|
||||
AccountID string
|
||||
AntragID string
|
||||
EntscheidungID string
|
||||
Zweck string
|
||||
Abteilung string
|
||||
Werkzeug string
|
||||
Datenklasse string
|
||||
Einstufung string
|
||||
Auflagen []string
|
||||
Verantwortlicher string
|
||||
EntschiedenAm time.Time
|
||||
GueltigBis *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const registereintragColumns = `id, account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis, created_at`
|
||||
|
||||
func scanRegistereintrag(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Registereintrag, error) {
|
||||
var e Registereintrag
|
||||
err := row.Scan(
|
||||
&e.ID, &e.AccountID, &e.AntragID, &e.EntscheidungID, &e.Zweck, &e.Abteilung, &e.Werkzeug,
|
||||
&e.Datenklasse, &e.Einstufung, &e.Auflagen, &e.Verantwortlicher, &e.EntschiedenAm, &e.GueltigBis, &e.CreatedAt,
|
||||
)
|
||||
return e, err
|
||||
}
|
||||
|
||||
// RegistereintragInput bündelt die Felder eines neuen Registereintrags.
|
||||
type RegistereintragInput struct {
|
||||
AccountID string
|
||||
AntragID string
|
||||
EntscheidungID string
|
||||
Zweck string
|
||||
Abteilung string
|
||||
Werkzeug string
|
||||
Datenklasse string
|
||||
Einstufung string
|
||||
Auflagen []string
|
||||
Verantwortlicher string
|
||||
EntschiedenAm time.Time
|
||||
GueltigBis *time.Time
|
||||
}
|
||||
|
||||
// CreateRegistereintrag speichert einen Registereintrag.
|
||||
func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInput) (Registereintrag, error) {
|
||||
if in.Auflagen == nil {
|
||||
in.Auflagen = []string{}
|
||||
}
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO registereintrag (
|
||||
account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING `+registereintragColumns,
|
||||
in.AccountID, in.AntragID, in.EntscheidungID, in.Zweck, in.Abteilung, in.Werkzeug,
|
||||
in.Datenklasse, in.Einstufung, in.Auflagen, in.Verantwortlicher, in.EntschiedenAm, in.GueltigBis,
|
||||
)
|
||||
e, err := scanRegistereintrag(row)
|
||||
if err != nil {
|
||||
return Registereintrag{}, fmt.Errorf("store: create registereintrag: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListRegistereintraegeForAccount liefert alle Registereinträge eines
|
||||
// Mandanten, neueste zuerst.
|
||||
func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Registereintrag
|
||||
for rows.Next() {
|
||||
e, err := scanRegistereintrag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan registereintrag: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
100
internal/store/registereintrag_test.go
Normal file
100
internal/store/registereintrag_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testRegistereintragInput(accountID, antragID, entscheidungID string) store.RegistereintragInput {
|
||||
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
return store.RegistereintragInput{
|
||||
AccountID: accountID, AntragID: antragID, EntscheidungID: entscheidungID,
|
||||
Zweck: "Angebotstexte generieren", Abteilung: "Vertrieb", Werkzeug: "ChatGPT Enterprise",
|
||||
Datenklasse: "personenbezogen", Einstufung: "minimal",
|
||||
Auflagen: []string{"AVV erforderlich", "kein Training auf Eingaben"},
|
||||
Verantwortlicher: "verantwortlicher@example.com", EntschiedenAm: time.Now().Truncate(time.Millisecond),
|
||||
GueltigBis: &gueltigBis,
|
||||
}
|
||||
}
|
||||
|
||||
func testEntscheidungID(t *testing.T, s *store.Store, antragID, bewertungID, userID string) string {
|
||||
t.Helper()
|
||||
e, err := s.CreateEntscheidung(context.Background(), store.EntscheidungInput{
|
||||
AntragID: antragID, BewertungID: bewertungID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntscheidung: %v", err)
|
||||
}
|
||||
return e.ID
|
||||
}
|
||||
|
||||
func TestRegistereintragCreateAndList(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||
|
||||
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||
}
|
||||
if e.Werkzeug != "ChatGPT Enterprise" || len(e.Auflagen) != 2 {
|
||||
t.Fatalf("CreateRegistereintrag = %+v, unerwartete Werte", e)
|
||||
}
|
||||
|
||||
list, err := s.ListRegistereintraegeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRegistereintraegeForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != e.ID {
|
||||
t.Fatalf("ListRegistereintraegeForAccount = %+v, want exactly one entry", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistereintragIsolatesTenants(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
userA := testUserID(t, s, accA)
|
||||
antragA, bewertungA := testAntragMitBewertung(t, s, accA, userA)
|
||||
entscheidungA := testEntscheidungID(t, s, antragA.ID, bewertungA.ID, userA)
|
||||
if _, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accA, antragA.ID, entscheidungA)); err != nil {
|
||||
t.Fatalf("CreateRegistereintrag (A): %v", err)
|
||||
}
|
||||
|
||||
accB := testAccountID(t, s)
|
||||
list, err := s.ListRegistereintraegeForAccount(ctx, accB)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRegistereintraegeForAccount (B): %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no registereintraege for a different tenant, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistereintragIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE registereintrag SET werkzeug = 'geaendert' WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on registereintrag to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM registereintrag WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on registereintrag to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
@@ -12,38 +12,70 @@ import (
|
||||
// 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.
|
||||
// ImpersonatedByUserID ist gesetzt, wenn diese Sitzung durch einen
|
||||
// Betreiber-Support-Login entstanden ist (siehe CreateImpersonatedSession)
|
||||
// statt durch den regulären Login des Nutzers selbst.
|
||||
type Session struct {
|
||||
Token string
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
Token string
|
||||
UserID string
|
||||
ImpersonatedByUserID *string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const sessionColumns = `token, user_id, impersonated_by_user_id, expires_at, created_at`
|
||||
|
||||
func scanSession(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Session, error) {
|
||||
var sess Session
|
||||
err := row.Scan(&sess.Token, &sess.UserID, &sess.ImpersonatedByUserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||
return sess, err
|
||||
}
|
||||
|
||||
// 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, `
|
||||
row := 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)
|
||||
RETURNING `+sessionColumns,
|
||||
token, userID, expiresAt,
|
||||
)
|
||||
sess, err := scanSession(row)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: create session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// CreateImpersonatedSession speichert eine Support-Login-Sitzung: ein
|
||||
// Betreiber meldet sich als ein bestimmter Kunden-Nutzer an, ohne
|
||||
// dessen Passwort zu kennen (siehe handleBetreiberLoginAls). Anders als
|
||||
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
|
||||
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
|
||||
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+sessionColumns,
|
||||
token, userID, impersonatedByUserID, expiresAt,
|
||||
)
|
||||
sess, err := scanSession(row)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: create impersonated 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)
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||
sess, err := scanSession(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
|
||||
@@ -15,28 +15,41 @@ import (
|
||||
// Login: falsche E-Mail vs. Datenbankfehler).
|
||||
var ErrNotFound = errors.New("store: nicht gefunden")
|
||||
|
||||
// User ist ein Login innerhalb eines Account (Mandanten).
|
||||
// User ist ein Login innerhalb eines Account (Mandanten). Nutzer werden
|
||||
// nicht gelöscht (app_user wird von antrag/session/entscheidung/
|
||||
// audit_log per Foreign Key referenziert — ein Hard-Delete würde die
|
||||
// Historie zerstören), sondern über Active deaktiviert.
|
||||
type User struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const userColumns = `id, account_id, email, password_hash, role, active, created_at`
|
||||
|
||||
func scanUser(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (User, error) {
|
||||
var u User
|
||||
err := row.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.Active, &u.CreatedAt)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// 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, `
|
||||
row := 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,
|
||||
RETURNING `+userColumns,
|
||||
accountID, email, passwordHash, role,
|
||||
)
|
||||
u, err := scanUser(row)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: create user: %w", err)
|
||||
}
|
||||
@@ -46,11 +59,8 @@ func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash,
|
||||
// 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)
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
||||
u, err := scanUser(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
@@ -62,11 +72,8 @@ func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error)
|
||||
|
||||
// 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)
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
||||
u, err := scanUser(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
@@ -80,8 +87,7 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||
// 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
|
||||
SELECT `+userColumns+` 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)
|
||||
@@ -90,8 +96,8 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
|
||||
|
||||
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 {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan user: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
@@ -101,3 +107,18 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
|
||||
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
|
||||
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
||||
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
|
||||
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set user active: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
96
internal/web/abteilung_handlers_test.go
Normal file
96
internal/web/abteilung_handlers_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminCanCreateAndDeleteAbteilung(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/abteilungen", url.Values{"name": {"Vertrieb"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
list := getWithCookie(t, s, adminCookie, "/verwaltung/abteilungen")
|
||||
if !strings.Contains(list.Body.String(), "Vertrieb") {
|
||||
t.Errorf("expected Vertrieb in the Abteilungsliste, got: %s", list.Body.String())
|
||||
}
|
||||
|
||||
var abtID string
|
||||
for accID, abts := range fs.abteilungen {
|
||||
_ = accID
|
||||
for _, a := range abts {
|
||||
if a.Name == "Vertrieb" {
|
||||
abtID = a.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if abtID == "" {
|
||||
t.Fatal("expected Vertrieb to exist in fakeStore")
|
||||
}
|
||||
|
||||
delResp := postForm(t, s, adminCookie, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{})
|
||||
if delResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete status = %d, body: %s", delResp.Code, delResp.Body.String())
|
||||
}
|
||||
if _, err := fs.GetAbteilung(context.Background(), abtID); err == nil {
|
||||
t.Error("expected the Abteilung to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeueAntragFormZeigtAngelegteAbteilung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
postForm(t, s, adminCookie, "/verwaltung/abteilungen", url.Values{"name": {"Marketing"}})
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/antraege/neu")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Marketing") {
|
||||
t.Errorf("expected the newly created Abteilung in the Fragebogen, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessAbteilungsverwaltung(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/abteilungen")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotDeleteForeignAbteilung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||
postForm(t, s, adminA, "/verwaltung/abteilungen", url.Values{"name": {"Nur A"}})
|
||||
var abtID string
|
||||
for _, abts := range fs.abteilungen {
|
||||
for _, a := range abts {
|
||||
if a.Name == "Nur A" {
|
||||
abtID = a.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||
|
||||
resp := postForm(t, s, adminB, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{})
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a foreign account's Abteilung", resp.Code)
|
||||
}
|
||||
if _, err := fs.GetAbteilung(context.Background(), abtID); err != nil {
|
||||
t.Error("expected the foreign Abteilung to still exist")
|
||||
}
|
||||
}
|
||||
72
internal/web/admin_fachebene_test.go
Normal file
72
internal/web/admin_fachebene_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin
|
||||
// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md,
|
||||
// Offene Punkte / requireFachebene): ein admin sieht den Posteingang
|
||||
// und kann entscheiden, genau wie ein verantwortlicher.
|
||||
func TestAdminSiehtPosteingang(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
_, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSiehtEntscheidenFormular(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/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 admin, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminKannEntscheiden(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
for _, path := range []string{"/registereintraege", "/wiedervorlage"} {
|
||||
resp := getWithCookie(t, s, adminCookie, path)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
218
internal/web/admin_handlers.go
Normal file
218
internal/web/admin_handlers.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen und Abteilungen für
|
||||
// GENAU EINEN Mandanten, siehe CLAUDE.md, Fünf Ebenen. Anmeldeverfahren,
|
||||
// Werkzeug-Freigaben/-Sperrungen und Rechnungsdaten sind weiterhin
|
||||
// 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 {
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
Active bool
|
||||
IstSelbst bool
|
||||
}
|
||||
|
||||
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) {
|
||||
self := currentUser(r)
|
||||
users, err := s.store.ListUsersForAccount(r.Context(), self.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{
|
||||
ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNutzerDeaktivieren sperrt einen Login des eigenen Mandanten —
|
||||
// nicht löschen, siehe store.User (Fremdschlüssel auf antrag/entscheidung/
|
||||
// audit_log würden das verhindern und die Historie zerstören). Ein Admin
|
||||
// kann sich nicht selbst deaktivieren, sonst könnte ein Mandant sich
|
||||
// versehentlich komplett aussperren.
|
||||
func (s *Server) handleNutzerDeaktivieren(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
self := currentUser(r)
|
||||
if id == self.ID {
|
||||
http.Error(w, "Der eigene Zugang kann nicht deaktiviert werden", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
target, err := s.store.GetUser(r.Context(), id)
|
||||
if err != nil || target.AccountID != self.AccountID {
|
||||
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserActive(r.Context(), id, false); err != nil {
|
||||
http.Error(w, "Nutzer konnte nicht deaktiviert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleNutzerAktivieren(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
target, err := s.store.GetUser(r.Context(), id)
|
||||
if err != nil || target.AccountID != currentUser(r).AccountID {
|
||||
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserActive(r.Context(), id, true); err != nil {
|
||||
http.Error(w, "Nutzer konnte nicht aktiviert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
type abteilungListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type abteilungListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Abteilungen []abteilungListItem
|
||||
Error string
|
||||
}
|
||||
|
||||
// handleAbteilungListe zeigt alle Abteilungen des eigenen Mandanten
|
||||
// mit einem Formular zum Anlegen weiterer — bewusst eine Seite statt
|
||||
// Liste+Formular getrennt, da Abteilungen reine Stammdaten ohne
|
||||
// weitere Felder sind (nur `name`).
|
||||
func (s *Server) handleAbteilungListe(w http.ResponseWriter, r *http.Request) {
|
||||
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 := abteilungListeData{Title: "Abteilungen", Nav: navFor(r)}
|
||||
for _, a := range abteilungen {
|
||||
data.Abteilungen = append(data.Abteilungen, abteilungListItem{ID: a.ID, Name: a.Name})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "abteilung-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAbteilungCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAbteilung(r.Context(), currentUser(r).AccountID, name); err != nil {
|
||||
http.Error(w, "Abteilung konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleAbteilungDelete entfernt eine Abteilung des eigenen Mandanten.
|
||||
// Fremde Abteilungen (anderer Mandant) sind über die ID nicht löschbar
|
||||
// — GetAbteilung prüft AccountID gegen den angemeldeten Mandanten,
|
||||
// bevor gelöscht wird.
|
||||
func (s *Server) handleAbteilungDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
abt, err := s.store.GetAbteilung(r.Context(), id)
|
||||
if err != nil || abt.AccountID != currentUser(r).AccountID {
|
||||
http.Error(w, "Abteilung nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteAbteilung(r.Context(), id); err != nil {
|
||||
http.Error(w, "Abteilung konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/abteilungen", 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)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
@@ -25,6 +27,115 @@ var datenfragen = []struct {
|
||||
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
|
||||
}
|
||||
|
||||
var c2FolgeLabels = map[string]string{
|
||||
"beschaeftigung": "Einstellung, Beförderung, Kündigung",
|
||||
"kreditwuerdigkeit": "Kreditwürdigkeit",
|
||||
"leistungsbewilligung": "Leistungsbewilligung",
|
||||
"bildung": "Zugang zu Bildung",
|
||||
"gesundheit": "medizinische Beurteilung",
|
||||
}
|
||||
|
||||
var c3ArtLabels = map[string]string{
|
||||
"emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz",
|
||||
"social_scoring": "Social Scoring",
|
||||
"biometrische_kategorisierung": "Biometrische Kategorisierung",
|
||||
}
|
||||
|
||||
// antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige —
|
||||
// siehe antwortenAnzeige.
|
||||
type antwortZeile struct {
|
||||
Frage string
|
||||
Antwort string
|
||||
}
|
||||
|
||||
func antwortLabel(v string) string {
|
||||
switch v {
|
||||
case "ja":
|
||||
return "Ja"
|
||||
case "nein":
|
||||
return "Nein"
|
||||
case "unsicher":
|
||||
return "Unsicher"
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func jaNein(v bool) string {
|
||||
if v {
|
||||
return "Ja"
|
||||
}
|
||||
return "Nein"
|
||||
}
|
||||
|
||||
// antwortenAnzeige baut aus den rohen Fragebogen-Antworten
|
||||
// (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels
|
||||
// wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der
|
||||
// antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden
|
||||
// nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen
|
||||
// Antworten, auf denen sie beruht — im Audit ist das nicht
|
||||
// nachvollziehbar (siehe CLAUDE.md, Grundregel).
|
||||
func antwortenAnzeige(antworten map[string]any) []antwortZeile {
|
||||
var out []antwortZeile
|
||||
for _, f := range datenfragen {
|
||||
v, _ := antworten[f.Key].(string)
|
||||
if v == "" {
|
||||
v = "nein"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)})
|
||||
}
|
||||
|
||||
c1, _ := antworten["c1"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)})
|
||||
|
||||
c2, _ := antworten["c2"].(bool)
|
||||
c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)}
|
||||
if c2 {
|
||||
if folge, _ := antworten["c2_folge"].(string); folge != "" {
|
||||
if label, ok := c2FolgeLabels[folge]; ok {
|
||||
c2Zeile.Antwort += " — " + label
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, c2Zeile)
|
||||
|
||||
c3, _ := antworten["c3"].(bool)
|
||||
c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)}
|
||||
if c3 {
|
||||
if art, _ := antworten["c3_art"].(string); art != "" {
|
||||
if label, ok := c3ArtLabels[art]; ok {
|
||||
c3Zeile.Antwort += " — " + label
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, c3Zeile)
|
||||
|
||||
c4, _ := antworten["c4"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)})
|
||||
|
||||
c5, _ := antworten["c5"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)})
|
||||
|
||||
if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" {
|
||||
out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug})
|
||||
}
|
||||
if zugang, _ := antworten["d_zugang"].(string); zugang != "" {
|
||||
label := "Privater Zugang"
|
||||
if zugang == "firma" {
|
||||
label = "Firmenkonto"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: "Zugang", Antwort: label})
|
||||
}
|
||||
if geraet, _ := antworten["d_geraet"].(string); geraet != "" {
|
||||
label := "Privatgerät"
|
||||
if geraet == "firma" {
|
||||
label = "Firmengerät"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: "Gerät", Antwort: label})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type antragFormData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
@@ -118,12 +229,14 @@ func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
|
||||
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, antwortenFromForm(r)); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -131,10 +244,83 @@ func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -173,17 +359,41 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
type anforderungView struct {
|
||||
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
|
||||
Antworten []antwortZeile
|
||||
Bewertung *bewertungView
|
||||
}
|
||||
|
||||
// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten.
|
||||
@@ -203,11 +413,50 @@ func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
data := antragDetailData{
|
||||
Title: "Antrag", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
||||
Title: "Antrag", Nav: navFor(r), 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,
|
||||
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(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{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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func fullAntragForm() url.Values {
|
||||
@@ -73,8 +75,15 @@ func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
|
||||
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())
|
||||
body := detailResp.Body.String()
|
||||
if !strings.Contains(body, "Angebotstexte generieren") {
|
||||
t.Errorf("expected the antrag title on the detail page, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
|
||||
t.Errorf("expected the b1 Fragebogen-Frage on the detail page, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
|
||||
t.Errorf("expected the c5 Fragebogen-Frage on the detail page, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +131,123 @@ func TestAntragDetailRejectsForeignUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -31,8 +31,9 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
// 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.
|
||||
// Mitarbeiter über dieses Formular — die kommen über den
|
||||
// Einladungslink (siehe einladung_handlers.go) oder künftig CSV-
|
||||
// Import/Mandantenlink (weiterhin nicht gebaut).
|
||||
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"})
|
||||
@@ -93,6 +94,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
||||
return
|
||||
}
|
||||
// Erst NACH erfolgreicher Passwortprüfung unterscheiden — sonst würde
|
||||
// diese Meldung einem Angreifer ohne gültiges Passwort verraten, dass
|
||||
// die E-Mail-Adresse zu einem (deaktivierten) Konto gehört.
|
||||
if !user.Active {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Dieses Konto ist deaktiviert"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
|
||||
@@ -132,6 +140,14 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
|
||||
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
setSessionCookie(w, r, token, expiresAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von
|
||||
// startSession (regulärer Login) und handleBetreiberLoginAls
|
||||
// (Support-Login).
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
@@ -141,5 +157,4 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: expiresAt,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
98
internal/web/betreiber_account_crud_test.go
Normal file
98
internal/web/betreiber_account_crud_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{
|
||||
"account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
user, err := fs.GetUserByEmail(context.Background(), "admin@neue-firma.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
t.Errorf("Role = %q, want admin", user.Role)
|
||||
}
|
||||
acc, err := fs.GetAccount(context.Background(), user.AccountID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount: %v", err)
|
||||
}
|
||||
if acc.Name != "Neue Firma GmbH" {
|
||||
t.Errorf("Name = %q, want Neue Firma GmbH", acc.Name)
|
||||
}
|
||||
|
||||
// Der neue Login funktioniert tatsächlich.
|
||||
loginResp := postForm(t, s, nil, "/login", url.Values{
|
||||
"email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||
})
|
||||
if loginResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberFirmaAnlegenRejectsMissingFields(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{"account_name": {"Nur Name"}})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code)
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Pflicht") {
|
||||
t.Errorf("expected a validation error, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberKannFirmaUmbenennen(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
acc, err := fs.CreateAccount(context.Background(), "Alter Name")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID+"/umbenennen", url.Values{"name": {"Neuer Name"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
got, err := fs.GetAccount(context.Background(), acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount: %v", err)
|
||||
}
|
||||
if got.Name != "Neuer Name" {
|
||||
t.Errorf("Name = %q, want Neuer Name", got.Name)
|
||||
}
|
||||
|
||||
detail := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID)
|
||||
if !strings.Contains(detail.Body.String(), "Neuer Name") {
|
||||
t.Errorf("expected the new name on the detail page, got: %s", detail.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotCreateFirma(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/betreiber/accounts/neu")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@ package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
type betreiberDashboardData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
AccountCount int
|
||||
WerkzeugCount int
|
||||
RecentAuditCount int
|
||||
}
|
||||
|
||||
@@ -21,6 +25,11 @@ func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request
|
||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
werkzeuge, err := s.store.ListZentraleWerkzeuge(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "Werkzeugkatalog konnte 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)
|
||||
@@ -28,7 +37,8 @@ func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
data := betreiberDashboardData{
|
||||
Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts), RecentAuditCount: len(auditLog),
|
||||
Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts),
|
||||
WerkzeugCount: len(werkzeuge), RecentAuditCount: len(auditLog),
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
@@ -65,9 +75,103 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
type betreiberAccountNeuData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleBetreiberAccountNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r)}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberAccountCreate legt eine Firma samt erstem admin-Login
|
||||
// an — dasselbe Ergebnis wie die öffentliche Firma-Registrierung
|
||||
// (handleRegister), nur vom Betreiber ausgelöst statt von der Firma
|
||||
// selbst (z. B. bei vertriebsunterstütztem Onboarding oder zum Anlegen
|
||||
// eines Testkontos). Der Betreiber setzt das Initialpasswort direkt,
|
||||
// wie überall sonst im Produkt ohne E-Mail-Infrastruktur.
|
||||
func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accountName := r.FormValue("account_name")
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
if accountName == "" || email == "" || password == "" {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder sind Pflicht"}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||
if err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Konto konnte nicht angelegt werden: " + err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin"); err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/betreiber/accounts/"+acc.ID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleBetreiberAccountRename benennt eine Firma um (z. B. Tippfehler
|
||||
// korrigieren) — kein sonstiges Bearbeiten, Löschen bleibt bewusst
|
||||
// ungebaut (siehe CLAUDE.md, Offene Punkte: hängt an der noch nicht
|
||||
// getroffenen Abrechnungs-/Freischaltungs-Architektur, und ein
|
||||
// Hard-Delete würde gegen die Fremdschlüssel aus antrag/app_user/
|
||||
// audit_log laufen).
|
||||
func (s *Server) handleBetreiberAccountRename(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if _, err := s.store.UpdateAccount(r.Context(), id, name); err != nil {
|
||||
http.Error(w, "Firma konnte nicht umbenannt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/accounts/"+id, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type betreiberUserView struct {
|
||||
Email string
|
||||
Role string
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type betreiberAccountDetailData struct {
|
||||
@@ -98,7 +202,7 @@ func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Req
|
||||
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})
|
||||
data.Users = append(data.Users, betreiberUserView{ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
@@ -137,3 +241,46 @@ func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberLoginAls meldet den Betreiber als einen bestimmten
|
||||
// Kunden-Nutzer an (Support-Login) — ohne dessen Passwort zu kennen.
|
||||
// Nur für aktive Nutzer möglich (ein deaktivierter Login soll auch per
|
||||
// Support-Zugriff nicht nutzbar sein). Die neue Sitzung ersetzt die
|
||||
// eigene Betreiber-Sitzung (kein Sitzungs-Stack) — der Betreiber meldet
|
||||
// sich danach mit den eigenen Zugangsdaten wieder an, das ist für die
|
||||
// seltene Nutzung dieser Funktion einfacher als ein "Zurück zum
|
||||
// Betreiber"-Mechanismus. Jede Nutzung wird append-only im Audit-Log
|
||||
// festgehalten (Actor = Betreiber, Target = der Kunden-Nutzer) und die
|
||||
// entstehende Sitzung trägt den Betreiber als ImpersonatedByUserID —
|
||||
// die Nav zeigt während der gesamten Sitzung einen auffälligen Hinweis
|
||||
// (siehe currentImpersonator, layout.html), damit nie unklar ist, dass
|
||||
// gerade im Kontext eines fremden Kontos gehandelt wird.
|
||||
func (s *Server) handleBetreiberLoginAls(w http.ResponseWriter, r *http.Request) {
|
||||
betreiber := currentUser(r)
|
||||
ziel, err := s.store.GetUser(r.Context(), r.PathValue("userID"))
|
||||
if err != nil || ziel.AccountID != r.PathValue("accountID") {
|
||||
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !ziel.Active {
|
||||
http.Error(w, "Nutzer ist deaktiviert", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||
if _, err := s.store.CreateImpersonatedSession(r.Context(), token, ziel.ID, betreiber.ID, expiresAt); err != nil {
|
||||
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(r.Context(), betreiber.ID, "betreiber_login_als_nutzer", "app_user", ziel.ID, ziel.Email); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, token, expiresAt)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
255
internal/web/betreiber_werkzeug_handlers.go
Normal file
255
internal/web/betreiber_werkzeug_handlers.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Ebene 5 (Plattform) — Pflege des zentralen Werkzeugkatalogs
|
||||
// (`werkzeug.account_id IS NULL`). Mandantenspezifische Ergänzungen
|
||||
// (`account_id` gesetzt) sind NICHT Teil dieser Seiten — die bleiben
|
||||
// Sache des jeweiligen Mandanten (Ebene 4, "eigene Werkzeug-
|
||||
// Freigaben/-Sperrungen", noch nicht gebaut), siehe CLAUDE.md.
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
var gueltigeVerarbeitungsorte = []string{"EU", "USA", "gemischt", "on-prem"}
|
||||
|
||||
func istGueltigerVerarbeitungsort(v string) bool {
|
||||
for _, o := range gueltigeVerarbeitungsorte {
|
||||
if o == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitListe zerlegt ein kommasepariertes Formularfeld (Zertifizierungen,
|
||||
// geeignete Zwecke, Einschränkungen) in eine bereinigte Liste — ohne
|
||||
// führende/folgende Leerzeichen und ohne leere Einträge.
|
||||
func splitListe(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type werkzeugListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
LetztePruefung string
|
||||
}
|
||||
|
||||
type werkzeugListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Werkzeuge []werkzeugListItem
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
|
||||
func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||
werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
||||
for _, wz := range werkzeuge {
|
||||
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// werkzeugFormData bedient sowohl das Betreiber-Formular (zentraler
|
||||
// Katalog, ActionBase "/betreiber/werkzeuge") als auch das Mandanten-
|
||||
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
|
||||
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
|
||||
type werkzeugFormData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
ActionBase string
|
||||
ID string // leer bei "neu"
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsorte []string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
Zertifizierungen string
|
||||
GeeigneteZwecke string
|
||||
Einschraenkungen string
|
||||
LetztePruefung string
|
||||
Quelle string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular.
|
||||
// accountID ist nil für den zentralen Katalog (Betreiber) oder gesetzt
|
||||
// für einen mandantenspezifischen Eintrag (Ebene 4).
|
||||
func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugInput, string) {
|
||||
letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung"))
|
||||
if err != nil {
|
||||
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
|
||||
}
|
||||
verarbeitungsort := r.FormValue("verarbeitungsort")
|
||||
if !istGueltigerVerarbeitungsort(verarbeitungsort) {
|
||||
return store.WerkzeugInput{}, "ungültiger Verarbeitungsort"
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
anbieter := r.FormValue("anbieter")
|
||||
quelle := r.FormValue("quelle")
|
||||
if name == "" || anbieter == "" || quelle == "" {
|
||||
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
|
||||
}
|
||||
aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage"))
|
||||
|
||||
return store.WerkzeugInput{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Anbieter: anbieter,
|
||||
Verarbeitungsort: verarbeitungsort,
|
||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
|
||||
AVVURL: r.FormValue("avv_url"),
|
||||
TrainingOptOut: r.FormValue("training_opt_out") == "on",
|
||||
TrainingStandard: r.FormValue("training_standard") == "on",
|
||||
AufbewahrungTage: aufbewahrungTage,
|
||||
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
||||
GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")),
|
||||
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
||||
LetztePruefung: letztePruefung,
|
||||
Quelle: quelle,
|
||||
}, ""
|
||||
}
|
||||
|
||||
// formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei
|
||||
// einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten,
|
||||
// statt das Formular zu leeren.
|
||||
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
|
||||
return werkzeugFormData{
|
||||
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
||||
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
|
||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
|
||||
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
|
||||
Zertifizierungen: r.FormValue("zertifizierungen"), GeeigneteZwecke: r.FormValue("geeignete_zwecke"),
|
||||
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
|
||||
Quelle: r.FormValue("quelle"), Error: errMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugCreate legt einen zentralen Katalogeintrag an.
|
||||
func (s *Server) handleBetreiberWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||
if errMsg != "" {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", errMsg)
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugEditForm zeigt einen zentralen Katalogeintrag
|
||||
// zum Bearbeiten. Ein mandantenspezifischer Eintrag (account_id
|
||||
// gesetzt) ist hier bewusst nicht erreichbar — das ist Sache des
|
||||
// jeweiligen Mandanten, nicht der Plattform.
|
||||
func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
|
||||
if err != nil || wz.AccountID != nil {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := werkzeugFormData{
|
||||
Title: "Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
||||
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
||||
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
|
||||
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
|
||||
Quelle: wz.Quelle,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugUpdate speichert Änderungen an einem zentralen
|
||||
// Katalogeintrag. Werkzeug ist nicht append-only (siehe store.UpdateWerkzeug)
|
||||
// — eine bereits getroffene Entscheidung hält ihren eigenen, zum
|
||||
// Entscheidungszeitpunkt eingefrorenen Snapshot (entscheidung.werkzeug_snapshot).
|
||||
func (s *Server) handleBetreiberWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||
if err != nil || wz.AccountID != nil {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||
if errMsg != "" {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, errMsg)
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugDelete entfernt einen zentralen Katalogeintrag.
|
||||
func (s *Server) handleBetreiberWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||
if err != nil || wz.AccountID != nil {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
|
||||
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
118
internal/web/betreiber_werkzeug_handlers_test.go
Normal file
118
internal/web/betreiber_werkzeug_handlers_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func werkzeugForm() url.Values {
|
||||
return url.Values{
|
||||
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungsort": {"EU"},
|
||||
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
||||
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
|
||||
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},
|
||||
"einschraenkungen": {""}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberCanCreateZentralesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var w store.Werkzeug
|
||||
for _, wz := range fs.werkzeuge {
|
||||
w = wz
|
||||
}
|
||||
if w.Name != "ChatGPT Enterprise" || w.AccountID != nil {
|
||||
t.Fatalf("Werkzeug = %+v, unerwartete Werte", w)
|
||||
}
|
||||
if len(w.Zertifizierungen) != 2 {
|
||||
t.Errorf("Zertifizierungen = %v, want 2 Einträge", w.Zertifizierungen)
|
||||
}
|
||||
|
||||
list := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
|
||||
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the new werkzeug in the Katalogliste, got: %s", list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberWerkzeugCreateRequiresLetztePruefungUndQuelle(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
form := werkzeugForm()
|
||||
form.Set("letzte_pruefung", "")
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", form)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (Formular mit Fehler) ohne letzte_pruefung", resp.Code)
|
||||
}
|
||||
if len(fs.werkzeuge) != 0 {
|
||||
t.Errorf("expected no werkzeug to be created, got %d", len(fs.werkzeuge))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberCanUpdateAndDeleteZentralesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm())
|
||||
var id string
|
||||
for wid := range fs.werkzeuge {
|
||||
id = wid
|
||||
}
|
||||
|
||||
editResp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id)
|
||||
if editResp.Code != http.StatusOK || !strings.Contains(editResp.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Fatalf("edit form status = %d, body: %s", editResp.Code, editResp.Body.String())
|
||||
}
|
||||
|
||||
updateForm := werkzeugForm()
|
||||
updateForm.Set("name", "ChatGPT Enterprise (aktualisiert)")
|
||||
updateResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id, updateForm)
|
||||
if updateResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||
}
|
||||
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (aktualisiert)" {
|
||||
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
|
||||
}
|
||||
|
||||
deleteResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||
if deleteResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||
}
|
||||
if _, ok := fs.werkzeuge[id]; ok {
|
||||
t.Error("expected the werkzeug to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBetreiberCannotEditMandantenEigenesWerkzeug: ein mandantenspezifischer
|
||||
// Katalogeintrag (account_id gesetzt) ist über die Betreiber-Seiten
|
||||
// nicht erreichbar — das bleibt Sache des jeweiligen Mandanten.
|
||||
func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
acc, err := fs.CreateAccount(context.Background(), "Mandant mit eigenem Werkzeug")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
eigenesID := "eigenes-werkzeug"
|
||||
fs.werkzeuge[eigenesID] = store.Werkzeug{ID: eigenesID, AccountID: &acc.ID, Name: "Mandanten-eigenes Tool"}
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+eigenesID)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code)
|
||||
}
|
||||
}
|
||||
184
internal/web/csv_import_handlers.go
Normal file
184
internal/web/csv_import_handlers.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten
|
||||
// Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine
|
||||
// E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie
|
||||
// beim einzelnen "Nutzer anlegen" generiert der Import ein
|
||||
// Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG
|
||||
// sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern:
|
||||
// gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet
|
||||
// (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer
|
||||
// Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren).
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
type csvMitarbeiterZeile struct {
|
||||
Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen
|
||||
Email string
|
||||
Role string
|
||||
}
|
||||
|
||||
// parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile.
|
||||
// Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder
|
||||
// "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst
|
||||
// beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine
|
||||
// einzelne falsche Rolle nicht den ganzen Import verhindert).
|
||||
func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) {
|
||||
cr := csv.NewReader(r)
|
||||
cr.TrimLeadingSpace = true
|
||||
cr.FieldsPerRecord = -1
|
||||
|
||||
header, err := cr.Read()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err)
|
||||
}
|
||||
emailCol, roleCol := -1, -1
|
||||
for i, h := range header {
|
||||
switch strings.ToLower(strings.TrimSpace(h)) {
|
||||
case "email", "e-mail":
|
||||
emailCol = i
|
||||
case "role", "rolle":
|
||||
roleCol = i
|
||||
}
|
||||
}
|
||||
if emailCol == -1 {
|
||||
return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`)
|
||||
}
|
||||
|
||||
var out []csvMitarbeiterZeile
|
||||
zeile := 1
|
||||
for {
|
||||
record, err := cr.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err)
|
||||
}
|
||||
zeile++
|
||||
if emailCol >= len(record) {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(record[emailCol])
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
role := "mitarbeiter"
|
||||
if roleCol != -1 && roleCol < len(record) {
|
||||
if v := strings.TrimSpace(record[roleCol]); v != "" {
|
||||
role = v
|
||||
}
|
||||
}
|
||||
out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für
|
||||
// eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt
|
||||
// einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine
|
||||
// Länge, die sich noch manuell weitergeben lässt.
|
||||
func generateEinmalPasswort() (string, error) {
|
||||
token, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token[:16], nil
|
||||
}
|
||||
|
||||
type csvImportFormData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type csvImportErgebnisZeile struct {
|
||||
Zeile int
|
||||
Email string
|
||||
Role string
|
||||
Passwort string // nur bei Erfolg gesetzt
|
||||
Fehler string // nur bei Fehler gesetzt
|
||||
}
|
||||
|
||||
type csvImportErgebnisData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Ergebnisse []csvImportErgebnisZeile
|
||||
}
|
||||
|
||||
// handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt
|
||||
// pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis
|
||||
// (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite
|
||||
// gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden.
|
||||
func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste
|
||||
s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("csv")
|
||||
if err != nil {
|
||||
s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zeilen, err := parseCSVMitarbeiter(file)
|
||||
if err != nil {
|
||||
s.renderCSVImportForm(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
if len(zeilen) == 0 {
|
||||
s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen")
|
||||
return
|
||||
}
|
||||
|
||||
accountID := currentUser(r).AccountID
|
||||
var ergebnisse []csvImportErgebnisZeile
|
||||
for _, z := range zeilen {
|
||||
if !istMandantenRolle(z.Role) {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)})
|
||||
continue
|
||||
}
|
||||
passwort, err := generateEinmalPasswort()
|
||||
if err != nil {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"})
|
||||
continue
|
||||
}
|
||||
hash, err := auth.HashPassword(passwort)
|
||||
if err != nil {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()})
|
||||
continue
|
||||
}
|
||||
if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"})
|
||||
continue
|
||||
}
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort})
|
||||
}
|
||||
|
||||
data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
149
internal/web/csv_import_handlers_test.go
Normal file
149
internal/web/csv_import_handlers_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
// postCSV lädt csvContent als multipart-Formular mit Feldname "csv" hoch
|
||||
// — reale Browser-Uploads laufen genauso über multipart/form-data.
|
||||
func postCSV(t *testing.T, s *web.Server, cookie *http.Cookie, path, csvContent string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fw, err := mw.CreateFormFile("csv", "mitarbeiter.csv")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile: %v", err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(csvContent)); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatalf("mw.Close: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestAdminCanImportMitarbeiterPerCSV(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "lisa@example.com") || !strings.Contains(body, "tom@example.com") {
|
||||
t.Fatalf("expected both imported emails in the result, got: %s", body)
|
||||
}
|
||||
|
||||
lisa, err := fs.GetUserByEmail(context.Background(), "lisa@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail (lisa): %v", err)
|
||||
}
|
||||
if lisa.Role != "verantwortlicher" {
|
||||
t.Errorf("lisa.Role = %q, want verantwortlicher", lisa.Role)
|
||||
}
|
||||
tom, err := fs.GetUserByEmail(context.Background(), "tom@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail (tom): %v", err)
|
||||
}
|
||||
if tom.Role != "mitarbeiter" {
|
||||
t.Errorf("tom.Role = %q, want default mitarbeiter", tom.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVImportZeigtEinmalpasswortDasFunktioniert(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email\nneu@example.com\n")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// Das angezeigte Einmalpasswort aus der <code>-Zelle extrahieren.
|
||||
body := resp.Body.String()
|
||||
start := strings.Index(body, "<code>")
|
||||
end := strings.Index(body, "</code>")
|
||||
if start == -1 || end == -1 {
|
||||
t.Fatalf("expected a <code>-Zelle mit dem Einmalpasswort, got: %s", body)
|
||||
}
|
||||
passwort := body[start+len("<code>") : end]
|
||||
|
||||
loginResp := postForm(t, s, nil, "/login", map[string][]string{
|
||||
"email": {"neu@example.com"}, "password": {passwort},
|
||||
})
|
||||
if loginResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login with the generated password: status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVImportMeldetUngueltigeRolleOhneDieAnderenZuBlockieren(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nboese@example.com,betreiber\ngut@example.com,mitarbeiter\n")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if _, err := fs.GetUserByEmail(context.Background(), "boese@example.com"); err == nil {
|
||||
t.Error("expected no user to be created for an invalid role (betreiber)")
|
||||
}
|
||||
if _, err := fs.GetUserByEmail(context.Background(), "gut@example.com"); err != nil {
|
||||
t.Error("expected the valid row to still be imported despite the earlier invalid row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVImportOhneDateiZeigtFehler(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatalf("mw.Close: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/verwaltung/nutzer/import", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.AddCookie(adminCookie)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "CSV-Datei auswählen") {
|
||||
t.Errorf("expected an error asking for a file, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessCSVImport(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/import")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
64
internal/web/csv_import_parse_test.go
Normal file
64
internal/web/csv_import_parse_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCSVMitarbeiterMitRolle(t *testing.T) {
|
||||
csv := "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 2 {
|
||||
t.Fatalf("zeilen = %+v, want 2", zeilen)
|
||||
}
|
||||
if zeilen[0].Email != "lisa@example.com" || zeilen[0].Role != "verantwortlicher" {
|
||||
t.Errorf("zeilen[0] = %+v, unerwartet", zeilen[0])
|
||||
}
|
||||
if zeilen[1].Email != "tom@example.com" || zeilen[1].Role != "mitarbeiter" {
|
||||
t.Errorf("zeilen[1] = %+v, want default role mitarbeiter", zeilen[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterOhneRollenspalte(t *testing.T) {
|
||||
csv := "email\nlisa@example.com\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 1 || zeilen[0].Role != "mitarbeiter" {
|
||||
t.Fatalf("zeilen = %+v, unerwartet", zeilen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterUeberspringtLeereEmail(t *testing.T) {
|
||||
csv := "email,role\n,mitarbeiter\nlisa@example.com,mitarbeiter\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 1 || zeilen[0].Email != "lisa@example.com" {
|
||||
t.Fatalf("zeilen = %+v, want exactly lisa@example.com", zeilen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterOhneEmailSpalteSchlaegtFehl(t *testing.T) {
|
||||
csv := "name,role\nLisa,mitarbeiter\n"
|
||||
_, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the header has no email column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterZeilennummernZaehlenKopfzeileAlsEins(t *testing.T) {
|
||||
csv := "email\nlisa@example.com\ntom@example.com\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 2 || zeilen[0].Zeile != 2 || zeilen[1].Zeile != 3 {
|
||||
t.Fatalf("zeilen = %+v, want Zeile 2 and 3", zeilen)
|
||||
}
|
||||
}
|
||||
140
internal/web/einladung_handlers.go
Normal file
140
internal/web/einladung_handlers.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Ebene 1 (öffentlich) — Mitarbeiter-Selbstanmeldung über den
|
||||
// Sammellink eines Mandanten ("Einladung annehmen", siehe CLAUDE.md,
|
||||
// Onboarding: "Einladungslink [Sammellink, Selbstanmeldung, Abteilung
|
||||
// beim ersten Antrag]"). Ein Link gilt für beliebig viele Mitarbeiter
|
||||
// desselben Mandanten, kein Einmal-Token pro Person — die Abteilung
|
||||
// wählt der/die Mitarbeiter:in erst beim ersten Antrag (Fragebogen
|
||||
// Abschnitt A), nicht hier.
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
type einladungFormData struct {
|
||||
Title string
|
||||
Firmenname string
|
||||
Error string
|
||||
}
|
||||
|
||||
// handleEinladungForm zeigt das Selbstanmelde-Formular für einen
|
||||
// gültigen Einladungslink. Ein unbekanntes oder erneuertes (damit
|
||||
// ungültig gewordenes) Token bekommt dieselbe Fehlermeldung wie ein
|
||||
// abgelaufener Link — es wird nicht unterschieden, ob das Token nie
|
||||
// existiert hat oder nur nicht mehr gültig ist.
|
||||
func (s *Server) handleEinladungForm(w http.ResponseWriter, r *http.Request) {
|
||||
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
|
||||
if err != nil {
|
||||
s.renderEinladungError(w)
|
||||
return
|
||||
}
|
||||
s.renderEinladungForm(w, acc.Name, "")
|
||||
}
|
||||
|
||||
func (s *Server) renderEinladungForm(w http.ResponseWriter, firmenname, errMsg string) {
|
||||
data := einladungFormData{Title: "Einladung annehmen", Firmenname: firmenname, Error: errMsg}
|
||||
if err := s.templates.ExecuteTemplate(w, "einladung", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderEinladungError(w http.ResponseWriter) {
|
||||
if err := s.templates.ExecuteTemplate(w, "einladung-ungueltig", authPageData{Title: "Einladung"}); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleEinladungAnnehmen legt einen neuen Login mit Rolle
|
||||
// "mitarbeiter" im Mandanten des Einladungslinks an und meldet die
|
||||
// Person direkt an — wie bei der Firma-Registrierung gibt es keine
|
||||
// E-Mail-Bestätigung (noch nicht gebaut, siehe CLAUDE.md, Onboarding).
|
||||
func (s *Server) handleEinladungAnnehmen(w http.ResponseWriter, r *http.Request) {
|
||||
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
|
||||
if err != nil {
|
||||
s.renderEinladungError(w)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, "ungültiges Formular")
|
||||
return
|
||||
}
|
||||
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
if email == "" || password == "" {
|
||||
s.renderEinladungForm(w, acc.Name, "E-Mail und Passwort sind Pflicht")
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(r.Context(), acc.ID, email, passwordHash, "mitarbeiter")
|
||||
if err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, "Konto konnte nicht angelegt werden — E-Mail evtl. schon vergeben")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, "Sitzung konnte nicht gestartet werden")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type einladungVerwaltungData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Link string
|
||||
}
|
||||
|
||||
// einladungBasisURL leitet Schema+Host aus dem Request ab, damit der
|
||||
// vollständige Link (nicht nur das Token) angezeigt werden kann — hinter
|
||||
// einem Reverse-Proxy (siehe CLAUDE.md, Testsystem: nginx+TLS) trägt
|
||||
// r.TLS bei HTTPS-Terminierung am Proxy nichts, deshalb zusätzlich
|
||||
// X-Forwarded-Proto berücksichtigen.
|
||||
func einladungBasisURL(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
// handleEinladungVerwaltung zeigt den aktuellen Sammellink des eigenen
|
||||
// Mandanten zum Kopieren.
|
||||
func (s *Server) handleEinladungVerwaltung(w http.ResponseWriter, r *http.Request) {
|
||||
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Account konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := einladungVerwaltungData{
|
||||
Title: "Einladungslink", Nav: navFor(r),
|
||||
Link: einladungBasisURL(r) + "/einladung/" + acc.EinladungToken,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "einladung-verwaltung", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleEinladungErneuern ersetzt den Sammellink des eigenen Mandanten
|
||||
// — der alte Link wird sofort ungültig (z. B. wenn er versehentlich
|
||||
// außerhalb des Unternehmens geteilt wurde).
|
||||
func (s *Server) handleEinladungErneuern(w http.ResponseWriter, r *http.Request) {
|
||||
newToken, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Neuer Link konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.RegenerateEinladungToken(r.Context(), currentUser(r).AccountID, newToken); err != nil {
|
||||
http.Error(w, "Link konnte nicht erneuert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/einladung", http.StatusSeeOther)
|
||||
}
|
||||
144
internal/web/einladung_handlers_test.go
Normal file
144
internal/web/einladung_handlers_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEinladungFormZeigtFirmenname(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
resp := getWithCookie(t, s, nil, "/einladung/"+acc.EinladungToken)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Beispiel GmbH") {
|
||||
t.Errorf("expected the Firmenname on the Einladung page, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
resp := getWithCookie(t, s, nil, "/einladung/unbekanntes-token")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, nil, "/einladung/"+acc.EinladungToken, url.Values{
|
||||
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if len(resp.Result().Cookies()) == 0 {
|
||||
t.Fatal("expected a session cookie to be set")
|
||||
}
|
||||
|
||||
user, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if user.Role != "mitarbeiter" || user.AccountID != acc.ID {
|
||||
t.Errorf("User = %+v, want role mitarbeiter in account %s", user, acc.ID)
|
||||
}
|
||||
|
||||
cookie := resp.Result().Cookies()[0]
|
||||
protected := getWithCookie(t, s, cookie, "/antraege")
|
||||
if protected.Code != http.StatusOK {
|
||||
t.Fatalf("expected the new session to work, status = %d", protected.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinladungAnnehmenMitUnbekanntemTokenSchlaegtFehl(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
resp := postForm(t, s, nil, "/einladung/unbekanntes-token", url.Values{
|
||||
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
|
||||
}
|
||||
if len(fs.users) != 0 {
|
||||
t.Error("expected no user to be created for an invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSiehtEinladungslink(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/verwaltung/einladung")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "/einladung/") {
|
||||
t.Errorf("expected the Einladungslink on the page, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminKannEinladungslinkErneuern(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
altesToken := fs.accounts[admin.AccountID].EinladungToken
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/einladung/erneuern", url.Values{})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
neuesToken := fs.accounts[admin.AccountID].EinladungToken
|
||||
if neuesToken == altesToken {
|
||||
t.Fatal("expected the token to change")
|
||||
}
|
||||
|
||||
oldLinkResp := getWithCookie(t, s, nil, "/einladung/"+altesToken)
|
||||
if !strings.Contains(oldLinkResp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected the old link to be invalid, got: %s", oldLinkResp.Body.String())
|
||||
}
|
||||
newLinkResp := getWithCookie(t, s, nil, "/einladung/"+neuesToken)
|
||||
if newLinkResp.Code != http.StatusOK || strings.Contains(newLinkResp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected the new link to work, status=%d body: %s", newLinkResp.Code, newLinkResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessEinladungsverwaltung(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/einladung")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
280
internal/web/fachebene_handlers.go
Normal file
280
internal/web/fachebene_handlers.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"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
|
||||
Antworten []antwortZeile
|
||||
Bewertung *bewertungView
|
||||
Entscheidung *entscheidungView
|
||||
CanEntscheiden bool
|
||||
WerkzeugOptionen []werkzeugOption
|
||||
Error string
|
||||
}
|
||||
|
||||
// hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe
|
||||
// requireFachebene: die Spezifikation will admin+verantwortlicher auf
|
||||
// derselben Person, ohne dass app_user.role zwei Werte tragen kann.
|
||||
func hatEntscheidungsrecht(role string) bool {
|
||||
return role == "verantwortlicher" || role == "admin"
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
var antworten map[string]any
|
||||
if len(antrag.Antworten) > 0 {
|
||||
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
||||
}
|
||||
|
||||
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"), Antworten: antwortenAnzeige(antworten),
|
||||
}
|
||||
|
||||
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 = hatEntscheidungsrecht(currentUser(r).Role) && 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 (oder admin, siehe hatEntscheidungsrecht). 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 !hatEntscheidungsrecht(user.Role) {
|
||||
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
|
||||
}
|
||||
|
||||
istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen"
|
||||
in := store.EntscheidungInput{
|
||||
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
||||
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
||||
}
|
||||
var werkzeugName string
|
||||
if istGenehmigung {
|
||||
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
|
||||
}
|
||||
werkzeugName = werkzeug.Name
|
||||
in.WerkzeugID = &werkzeugID
|
||||
in.WerkzeugSnapshot = &werkzeug
|
||||
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
|
||||
in.GueltigBis = &gueltigBis
|
||||
}
|
||||
|
||||
entscheidung, err := s.store.CreateEntscheidung(r.Context(), in)
|
||||
if 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
|
||||
}
|
||||
|
||||
// Jede Genehmigung erzeugt automatisch einen Registereintrag (siehe
|
||||
// CLAUDE.md) — bei "abgelehnt"/"rueckfrage" gibt es nichts
|
||||
// Genehmigtes, das im Register stehen müsste.
|
||||
if istGenehmigung {
|
||||
abteilungName := ""
|
||||
if antrag.AbteilungID != nil {
|
||||
if abt, err := s.store.GetAbteilung(r.Context(), *antrag.AbteilungID); err == nil {
|
||||
abteilungName = abt.Name
|
||||
}
|
||||
}
|
||||
var auflagen []string
|
||||
for _, a := range bewertung.Anforderungen {
|
||||
auflagen = append(auflagen, a.Beschreibung)
|
||||
}
|
||||
if _, err := s.store.CreateRegistereintrag(r.Context(), store.RegistereintragInput{
|
||||
AccountID: user.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
|
||||
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
|
||||
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
|
||||
Verantwortlicher: user.Email, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
|
||||
}); err != nil {
|
||||
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
||||
}
|
||||
238
internal/web/fachebene_handlers_test.go
Normal file
238
internal/web/fachebene_handlers_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallDetailZeigtFragebogenAntworten: die Fachebene muss die
|
||||
// tatsächlich gegebenen Fragebogen-Antworten sehen, nicht nur die
|
||||
// daraus abgeleitete Bewertung — sonst lässt sich eine Entscheidung
|
||||
// nicht nachvollziehbar treffen (siehe CLAUDE.md, Grundregel).
|
||||
func TestFallDetailZeigtFragebogenAntworten(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())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
|
||||
t.Errorf("expected the b1 Fragebogen-Frage on the fall-detail page, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
|
||||
t.Errorf("expected the c5 Fragebogen-Frage on the fall-detail page, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,49 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type indexData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Title string
|
||||
Nav navData
|
||||
OffeneAntraege int
|
||||
ZeigePosteingang bool
|
||||
OffenerPosteingang int
|
||||
}
|
||||
|
||||
// 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.
|
||||
// handleIndex ist die Startseite nach der Anmeldung — ein kurzer
|
||||
// Überblick statt reiner Navigations-Duplikate (die Links stehen
|
||||
// eh schon in der Seitenleiste): eigene offene Anträge, und für die
|
||||
// Fachebene zusätzlich die Zahl offener Fälle im Posteingang. Bewusst
|
||||
// ohne die Wiedervorlage-Zahl hier — deren Berechnung vergleicht jeden
|
||||
// aktiven Genehmigungs-Snapshot gegen den aktuellen Katalogstand
|
||||
// (siehe handleWiedervorlageListe) und wäre für eine Startseite, die
|
||||
// bei jedem Login geladen wird, zu teuer.
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
data := indexData{Title: "Start", Nav: navFor(r)}
|
||||
user := currentUser(r)
|
||||
nav := navFor(r)
|
||||
data := indexData{Title: "Start", Nav: nav}
|
||||
|
||||
if !nav.IsBetreiber {
|
||||
eigene, err := s.store.ListAntraegeForUser(r.Context(), user.ID)
|
||||
if err == nil {
|
||||
for _, a := range eigene {
|
||||
if a.Status == "eingereicht" {
|
||||
data.OffeneAntraege++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if nav.IsFachebene {
|
||||
data.ZeigePosteingang = true
|
||||
alle, err := s.store.ListAntraegeForAccount(r.Context(), user.AccountID)
|
||||
if err == nil {
|
||||
for _, a := range alle {
|
||||
if a.Status == "eingereicht" {
|
||||
data.OffenerPosteingang++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
63
internal/web/handlers_test.go
Normal file
63
internal/web/handlers_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndexZeigtEigeneOffeneAntraege(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/antraege">Meine offenen Anträge <span class="status">1</span>`) {
|
||||
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexZeigtPosteingangNurFuerFachebene(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/faelle">Posteingang <span class="status">1</span>`) {
|
||||
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexZeigtKeinenPosteingangFuerMitarbeiter(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "Posteingang") {
|
||||
t.Errorf("expected no Posteingang tile for mitarbeiter, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexZeigtPlattformLinkFuerBetreiber(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/betreiber">Zur Plattform`) {
|
||||
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "Antrag stellen") {
|
||||
t.Errorf("expected no antrag tiles for betreiber, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
120
internal/web/impersonation_test.go
Normal file
120
internal/web/impersonation_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBetreiberKannAlsNutzerAnmelden(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
cookies := resp.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("expected a new session cookie")
|
||||
}
|
||||
neueCookie := cookies[0]
|
||||
|
||||
// Die neue Sitzung ist tatsächlich der Kunden-Login.
|
||||
protected := getWithCookie(t, s, neueCookie, "/antraege")
|
||||
if protected.Code != http.StatusOK {
|
||||
t.Fatalf("expected the impersonated session to work, status = %d", protected.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberLoginAlsZeigtBanner(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
loginResp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||
neueCookie := loginResp.Result().Cookies()[0]
|
||||
|
||||
resp := getWithCookie(t, s, neueCookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Support-Zugriff durch") || !strings.Contains(resp.Body.String(), "betreiber@example.com") {
|
||||
t.Errorf("expected the impersonation banner naming the betreiber, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberLoginAlsProtokolliertAuditEintrag(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||
|
||||
var gefunden bool
|
||||
for _, e := range fs.auditLog {
|
||||
if e.Action == "betreiber_login_als_nutzer" && e.ActorUserID == betreiber.ID && e.TargetID == kunde.ID {
|
||||
gefunden = true
|
||||
}
|
||||
}
|
||||
if !gefunden {
|
||||
t.Errorf("expected an audit log entry for the impersonation, got: %+v", fs.auditLog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberCannotLoginAlsDeaktiviertenNutzer(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
adminCookie := seedAccountWithRole(t, fs, "Kunde GmbH", "kunde-admin@example.com", "admin")
|
||||
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||
})
|
||||
weg, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+weg.ID+"/deaktivieren", url.Values{})
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+weg.AccountID+"/nutzer/"+weg.ID+"/anmelden-als", url.Values{})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 for a deactivated user", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotLoginAls(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
self, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, cookie, "/betreiber/accounts/"+self.AccountID+"/nutzer/"+self.ID+"/anmelden-als", url.Values{})
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
103
internal/web/mandant_eigenes_werkzeug_test.go
Normal file
103
internal/web/mandant_eigenes_werkzeug_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestAdminCanCreateEditAndDeleteEigenesWerkzeug(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/werkzeuge", werkzeugForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var id string
|
||||
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
for wid, w := range fs.werkzeuge {
|
||||
if w.AccountID != nil && *w.AccountID == admin.AccountID {
|
||||
id = wid
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("expected an eigenes werkzeug to exist")
|
||||
}
|
||||
|
||||
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the eigenes werkzeug in the list, got: %s", list.Body.String())
|
||||
}
|
||||
|
||||
editForm := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/"+id)
|
||||
if editForm.Code != http.StatusOK || !strings.Contains(editForm.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Fatalf("edit form status = %d, body: %s", editForm.Code, editForm.Body.String())
|
||||
}
|
||||
|
||||
updateForm := werkzeugForm()
|
||||
updateForm.Set("name", "ChatGPT Enterprise (eigene Version)")
|
||||
updateResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id, updateForm)
|
||||
if updateResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||
}
|
||||
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (eigene Version)" {
|
||||
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
|
||||
}
|
||||
|
||||
deleteResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||
if deleteResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||
}
|
||||
if _, ok := fs.werkzeuge[id]; ok {
|
||||
t.Error("expected the eigenes werkzeug to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotEditZentralesWerkzeugUeberMandantenRoute(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a central werkzeug via the tenant route", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotEditFremdesEigenesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||
postForm(t, s, adminA, "/verwaltung/werkzeuge", werkzeugForm())
|
||||
adminAUser, err := fs.GetUserByEmail(context.Background(), "admin-a@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
var id string
|
||||
for wid, w := range fs.werkzeuge {
|
||||
if w.AccountID != nil && *w.AccountID == adminAUser.AccountID {
|
||||
id = wid
|
||||
}
|
||||
}
|
||||
|
||||
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||
resp := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge/"+id)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a foreign account's eigenes werkzeug", resp.Code)
|
||||
}
|
||||
deleteResp := postForm(t, s, adminB, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||
if deleteResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("delete status = %d, want 404 for a foreign account's eigenes werkzeug", deleteResp.Code)
|
||||
}
|
||||
}
|
||||
209
internal/web/mandant_werkzeug_handlers.go
Normal file
209
internal/web/mandant_werkzeug_handlers.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Ebene 4 — "eigene Werkzeug-Freigaben/-Sperrungen" (siehe CLAUDE.md,
|
||||
// Fünf Ebenen): ein Mandant kann einen zentralen Katalogeintrag für
|
||||
// sich sperren, ohne den zentralen Katalog selbst zu verändern (das
|
||||
// bleibt Sache der Plattform, siehe betreiber_werkzeug_handlers.go),
|
||||
// UND eigene, mandantenspezifische Katalogeinträge pflegen
|
||||
// (`werkzeug.account_id` = der eigene Account) — dieselbe
|
||||
// Formular-Vorlage wie der Betreiber nutzt (werkzeugFormData,
|
||||
// betreiber-werkzeug-form.html, ActionBase unterscheidet die Ziel-URL).
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type mandantWerkzeugItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Gesperrt bool
|
||||
}
|
||||
|
||||
type eigenesWerkzeugItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
}
|
||||
|
||||
type mandantWerkzeugListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Werkzeuge []mandantWerkzeugItem
|
||||
EigeneWerkzeuge []eigenesWerkzeugItem
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des
|
||||
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag, sowie
|
||||
// die eigenen (mandantenspezifischen) Katalogeinträge mit Bearbeiten/
|
||||
// Löschen.
|
||||
func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
zentral, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sperren, err := s.store.ListWerkzeugSperrenForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Sperrungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
eigene, err := s.store.ListWerkzeugeForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Werkzeuge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
gesperrt := make(map[string]bool, len(sperren))
|
||||
for _, sp := range sperren {
|
||||
gesperrt[sp.WerkzeugID] = true
|
||||
}
|
||||
|
||||
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
||||
for _, wz := range zentral {
|
||||
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Gesperrt: gesperrt[wz.ID],
|
||||
})
|
||||
}
|
||||
for _, wz := range eigene {
|
||||
if wz.AccountID == nil {
|
||||
continue // zentral, oben schon gelistet
|
||||
}
|
||||
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := werkzeugFormData{Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugCreate legt einen mandantenspezifischen
|
||||
// Katalogeintrag an (`werkzeug.account_id` = eigener Account) — sichtbar
|
||||
// nur für diesen Mandanten, verändert den zentralen Katalog nicht.
|
||||
func (s *Server) handleMandantWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accountID := currentUser(r).AccountID
|
||||
in, errMsg := werkzeugInputFromForm(r, &accountID)
|
||||
if errMsg != "" {
|
||||
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", errMsg)
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
||||
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugEditForm zeigt einen eigenen Katalogeintrag zum
|
||||
// Bearbeiten. Ein zentraler oder fremder Eintrag ist hier nicht
|
||||
// erreichbar (404) — das bleibt Sache der Plattform bzw. des jeweils
|
||||
// anderen Mandanten.
|
||||
func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
|
||||
accountID := currentUser(r).AccountID
|
||||
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := werkzeugFormData{
|
||||
Title: "Eigenes Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
||||
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
||||
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
|
||||
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
|
||||
Quelle: wz.Quelle,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugUpdate speichert Änderungen an einem eigenen
|
||||
// Katalogeintrag.
|
||||
func (s *Server) handleMandantWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
accountID := currentUser(r).AccountID
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
in, errMsg := werkzeugInputFromForm(r, &accountID)
|
||||
if errMsg != "" {
|
||||
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, errMsg)
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
||||
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugDelete entfernt einen eigenen Katalogeintrag.
|
||||
func (s *Server) handleMandantWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
accountID := currentUser(r).AccountID
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
|
||||
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugSperren sperrt einen zentralen Katalogeintrag
|
||||
// für den eigenen Mandanten — der zentrale Eintrag selbst bleibt
|
||||
// unverändert, er verschwindet nur aus ListWerkzeugeForAccount für
|
||||
// diesen einen Mandanten.
|
||||
func (s *Server) handleMandantWerkzeugSperren(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
grund := r.FormValue("grund")
|
||||
if _, err := s.store.CreateWerkzeugSperre(r.Context(), currentUser(r).AccountID, r.PathValue("id"), grund); err != nil {
|
||||
http.Error(w, "Werkzeug konnte nicht gesperrt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleMandantWerkzeugEntsperren(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteWerkzeugSperre(r.Context(), currentUser(r).AccountID, r.PathValue("id")); err != nil {
|
||||
http.Error(w, "Sperrung konnte nicht aufgehoben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
66
internal/web/mandant_werkzeug_handlers_test.go
Normal file
66
internal/web/mandant_werkzeug_handlers_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestAdminCanSperrenUndEntsperrenZentralesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Fatalf("expected the zentrale werkzeug in the list, got: %s", list.Body.String())
|
||||
}
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central/sperren", url.Values{"grund": {"zu teuer"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("sperren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
afterSperren := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||
if !strings.Contains(afterSperren.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||
t.Errorf("expected the werkzeug to show as gesperrt, got: %s", afterSperren.Body.String())
|
||||
}
|
||||
|
||||
resp = postForm(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central/entsperren", url.Values{})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("entsperren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
afterEntsperren := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||
if strings.Contains(afterEntsperren.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||
t.Errorf("expected the werkzeug to no longer show as gesperrt, got: %s", afterEntsperren.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSperreIstProMandantIsoliert(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||
|
||||
postForm(t, s, adminA, "/verwaltung/werkzeuge/werkzeug-central/sperren", url.Values{})
|
||||
|
||||
respB := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge")
|
||||
if strings.Contains(respB.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||
t.Errorf("expected Mandant B to see the werkzeug as unsperrt, got: %s", respB.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessWerkzeugverwaltung(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/werkzeuge")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -12,44 +12,67 @@ const sessionCookieName = "deklarix_session"
|
||||
|
||||
type contextKey int
|
||||
|
||||
const userContextKey contextKey = iota
|
||||
const (
|
||||
userContextKey contextKey = iota
|
||||
impersonatorContextKey
|
||||
)
|
||||
|
||||
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
||||
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
|
||||
// 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) {
|
||||
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der
|
||||
// zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung
|
||||
// durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession),
|
||||
// sonst leer.
|
||||
func (s *Server) authenticate(r *http.Request) (store.User, string, bool) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return store.User{}, false
|
||||
return store.User{}, "", false
|
||||
}
|
||||
|
||||
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
return store.User{}, false
|
||||
return store.User{}, "", false
|
||||
}
|
||||
if time.Now().After(sess.ExpiresAt) {
|
||||
return store.User{}, false
|
||||
return store.User{}, "", false
|
||||
}
|
||||
|
||||
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
||||
if err != nil {
|
||||
return store.User{}, false
|
||||
if err != nil || !user.Active {
|
||||
return store.User{}, "", false
|
||||
}
|
||||
return user, true
|
||||
|
||||
impersonator := ""
|
||||
if sess.ImpersonatedByUserID != nil {
|
||||
if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil {
|
||||
impersonator = betreiber.Email
|
||||
}
|
||||
}
|
||||
return user, impersonator, true
|
||||
}
|
||||
|
||||
// withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den
|
||||
// Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares.
|
||||
func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request {
|
||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||
if impersonator != "" {
|
||||
ctx = context.WithValue(ctx, impersonatorContextKey, impersonator)
|
||||
}
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
// 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)
|
||||
user, impersonator, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,12 +82,12 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||
// 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)
|
||||
user, impersonator, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +99,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||
// 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)
|
||||
user, impersonator, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
@@ -85,7 +108,55 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
}
|
||||
}
|
||||
|
||||
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
|
||||
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
|
||||
// CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
|
||||
// will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
|
||||
// verantwortlicher ist ("Admin und KI-Verantwortlicher" — siehe CLAUDE.md,
|
||||
// Offene Punkte), app_user.role kennt aber nur einen Wert. Statt eines
|
||||
// Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person)
|
||||
// bekommt admin hier pragmatisch dieselben Fachebene-Rechte wie
|
||||
// verantwortlicher (inkl. Entscheidungsrecht, siehe handleFallEntscheiden)
|
||||
// — ohne das könnte eine frisch registrierte Firma mit nur einem
|
||||
// admin-Login keinen einzigen eingereichten Antrag sehen oder
|
||||
// bearbeiten. pruefer bleibt unverändert nur lesend. 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, impersonator, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if user.Role != "verantwortlicher" && user.Role != "pruefer" && user.Role != "admin" {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
}
|
||||
}
|
||||
|
||||
// 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, impersonator, 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, withAuthContext(r, user, impersonator))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,14 +165,24 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||
// currentUser zu geben — die Navigation braucht nur, ob ein
|
||||
// Plattform-Link gezeigt werden soll.
|
||||
type navData struct {
|
||||
IsBetreiber bool
|
||||
IsBetreiber bool
|
||||
IsFachebene bool
|
||||
IsAdmin bool
|
||||
Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return navData{IsBetreiber: currentUser(r).Role == "betreiber"}
|
||||
role := currentUser(r).Role
|
||||
return navData{
|
||||
IsBetreiber: role == "betreiber",
|
||||
// admin sieht die Fachebene mit, siehe requireFachebene.
|
||||
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
|
||||
IsAdmin: role == "admin",
|
||||
Impersonator: currentImpersonator(r),
|
||||
}
|
||||
}
|
||||
|
||||
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
||||
@@ -115,3 +196,10 @@ func currentUser(r *http.Request) store.User {
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// currentImpersonator liefert die E-Mail des Betreibers, falls die
|
||||
// aktuelle Sitzung ein Support-Login ist, sonst einen leeren String.
|
||||
func currentImpersonator(r *http.Request) string {
|
||||
v, _ := r.Context().Value(impersonatorContextKey).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
102
internal/web/nutzer_aktivierung_test.go
Normal file
102
internal/web/nutzer_aktivierung_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminKannNutzerDeaktivierenUndReaktivieren(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||
})
|
||||
target, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/deaktivieren", url.Values{})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("deaktivieren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
deaktiviert, err := fs.GetUser(context.Background(), target.ID)
|
||||
if err != nil || deaktiviert.Active {
|
||||
t.Fatalf("expected user to be inactive, got %+v (err %v)", deaktiviert, err)
|
||||
}
|
||||
|
||||
loginResp := postForm(t, s, nil, "/login", url.Values{"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}})
|
||||
if !strings.Contains(loginResp.Body.String(), "deaktiviert") {
|
||||
t.Errorf("expected a deactivation message on login, got: %s", loginResp.Body.String())
|
||||
}
|
||||
|
||||
reaktivierenResp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/aktivieren", url.Values{})
|
||||
if reaktivierenResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("aktivieren status = %d, body: %s", reaktivierenResp.Code, reaktivierenResp.Body.String())
|
||||
}
|
||||
reaktiviert, err := fs.GetUser(context.Background(), target.ID)
|
||||
if err != nil || !reaktiviert.Active {
|
||||
t.Fatalf("expected user to be active again, got %+v (err %v)", reaktiviert, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminKannSichNichtSelbstDeaktivieren(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
self, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+self.ID+"/deaktivieren", url.Values{})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 for self-deactivation", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeaktivierterNutzerVerliertLaufendeSitzungSofort(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
mitarbeiterCookie := seedUserInAccount(t, fs, admin.AccountID, "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
if resp := getWithCookie(t, s, mitarbeiterCookie, "/antraege"); resp.Code != http.StatusOK {
|
||||
t.Fatalf("expected active session to work, status = %d", resp.Code)
|
||||
}
|
||||
|
||||
target, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/deaktivieren", url.Values{})
|
||||
|
||||
resp := getWithCookie(t, s, mitarbeiterCookie, "/antraege")
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected the deactivated user's session to be rejected, status = %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotDeactivateForeignAccountUser(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||
seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||
targetB, err := fs.GetUserByEmail(context.Background(), "admin-b@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, adminA, "/verwaltung/nutzer/"+targetB.ID+"/deaktivieren", url.Values{})
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a foreign account's user", resp.Code)
|
||||
}
|
||||
}
|
||||
133
internal/web/register_handlers.go
Normal file
133
internal/web/register_handlers.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Ebene 3 — Register und Export (Schritt 6 der Baureihenfolge, siehe
|
||||
// CLAUDE.md). Jede Genehmigung erzeugt automatisch einen Registereintrag
|
||||
// (siehe handleFallEntscheiden); diese Seiten zeigen und exportieren
|
||||
// sie. Reuse des bereits vorhandenen (bisher ungenutzten) go-pdf/fpdf-
|
||||
// Dependency statt einer neuen Bibliothek für den PDF-Export.
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-pdf/fpdf"
|
||||
)
|
||||
|
||||
type registerEintragView struct {
|
||||
Zweck string
|
||||
Abteilung string
|
||||
Werkzeug string
|
||||
Datenklasse string
|
||||
Einstufung string
|
||||
Auflagen string
|
||||
Verantwortlicher string
|
||||
EntschiedenAm string
|
||||
GueltigBis string
|
||||
}
|
||||
|
||||
type registerListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Eintraege []registerEintragView
|
||||
}
|
||||
|
||||
func (s *Server) registerEintraegeFuerAccount(r *http.Request) ([]registerEintragView, error) {
|
||||
eintraege, err := s.store.ListRegistereintraegeForAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views := make([]registerEintragView, len(eintraege))
|
||||
for i, e := range eintraege {
|
||||
gueltigBis := ""
|
||||
if e.GueltigBis != nil {
|
||||
gueltigBis = e.GueltigBis.Format("02.01.2006")
|
||||
}
|
||||
views[i] = registerEintragView{
|
||||
Zweck: e.Zweck, Abteilung: e.Abteilung, Werkzeug: e.Werkzeug,
|
||||
Datenklasse: e.Datenklasse, Einstufung: e.Einstufung, Auflagen: strings.Join(e.Auflagen, "; "),
|
||||
Verantwortlicher: e.Verantwortlicher, EntschiedenAm: e.EntschiedenAm.Format("02.01.2006 15:04"), GueltigBis: gueltigBis,
|
||||
}
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// handleRegisterListe zeigt das Register (alle Genehmigungen des
|
||||
// Mandanten) — Ebene 3, für verantwortlicher und pruefer gleichermaßen
|
||||
// lesbar.
|
||||
func (s *Server) handleRegisterListe(w http.ResponseWriter, r *http.Request) {
|
||||
views, err := s.registerEintraegeFuerAccount(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := registerListeData{Title: "Register", Nav: navFor(r), Eintraege: views}
|
||||
if err := s.templates.ExecuteTemplate(w, "register-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
var registerCSVHeader = []string{"Zweck", "Abteilung", "Werkzeug", "Datenklasse", "Einstufung", "Auflagen", "Verantwortliche/r", "Entschieden am", "Gültig bis"}
|
||||
|
||||
func registerCSVRow(e registerEintragView) []string {
|
||||
return []string{e.Zweck, e.Abteilung, e.Werkzeug, e.Datenklasse, e.Einstufung, e.Auflagen, e.Verantwortlicher, e.EntschiedenAm, e.GueltigBis}
|
||||
}
|
||||
|
||||
// handleRegisterExportCSV exportiert das Register als CSV.
|
||||
func (s *Server) handleRegisterExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
views, err := s.registerEintraegeFuerAccount(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="register.csv"`)
|
||||
cw := csv.NewWriter(w)
|
||||
_ = cw.Write(registerCSVHeader)
|
||||
for _, e := range views {
|
||||
_ = cw.Write(registerCSVRow(e))
|
||||
}
|
||||
cw.Flush()
|
||||
}
|
||||
|
||||
// handleRegisterExportPDF exportiert das Register als PDF — eine
|
||||
// einfache Tabelle, keine Gestaltung über den Design-System-Anspruch
|
||||
// des restlichen Produkts hinaus.
|
||||
func (s *Server) handleRegisterExportPDF(w http.ResponseWriter, r *http.Request) {
|
||||
views, err := s.registerEintraegeFuerAccount(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pdf := fpdf.New("L", "mm", "A4", "")
|
||||
// cp1252 deckt deutsche Umlaute/ß ab — die eingebauten Helvetica-
|
||||
// Fonts von fpdf können sonst kein UTF-8 darstellen.
|
||||
tr := pdf.UnicodeTranslatorFromDescriptor("")
|
||||
pdf.AddPage()
|
||||
pdf.SetFont("Helvetica", "B", 14)
|
||||
pdf.CellFormat(0, 10, tr("Register"), "", 1, "L", false, 0, "")
|
||||
pdf.Ln(2)
|
||||
|
||||
colWidths := []float64{45, 25, 35, 30, 30, 55, 40, 30, 25}
|
||||
pdf.SetFont("Helvetica", "B", 8)
|
||||
for i, h := range registerCSVHeader {
|
||||
pdf.CellFormat(colWidths[i], 8, tr(h), "1", 0, "L", false, 0, "")
|
||||
}
|
||||
pdf.Ln(-1)
|
||||
|
||||
pdf.SetFont("Helvetica", "", 8)
|
||||
for _, e := range views {
|
||||
row := registerCSVRow(e)
|
||||
for i, cell := range row {
|
||||
pdf.CellFormat(colWidths[i], 8, tr(cell), "1", 0, "L", false, 0, "")
|
||||
}
|
||||
pdf.Ln(-1)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="register.pdf"`)
|
||||
if err := pdf.Output(w); err != nil {
|
||||
http.Error(w, fmt.Sprintf("PDF konnte nicht erzeugt werden: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
142
internal/web/register_handlers_test.go
Normal file
142
internal/web/register_handlers_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// genehmigeFall reicht einen Antrag ein und genehmigt ihn mit einem
|
||||
// echten, zulässigen Werkzeug — Vorbereitung für Register-Tests, die
|
||||
// einen tatsächlichen Registereintrag brauchen.
|
||||
func genehmigeFall(t *testing.T, fs *fakeStore, s *web.Server) (accountID string) {
|
||||
t.Helper()
|
||||
acc, err := fs.CreateAccount(context.Background(), "Register-Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true}
|
||||
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
|
||||
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")
|
||||
entscheidenResp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"},
|
||||
})
|
||||
if entscheidenResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("Entscheiden: status = %d, body: %s", entscheidenResp.Code, entscheidenResp.Body.String())
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
func TestGenehmigungErzeugtRegistereintrag(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
|
||||
list := fs.registereintraege[accountID]
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("registereintraege = %+v, want exactly one entry", list)
|
||||
}
|
||||
e := list[0]
|
||||
if e.Werkzeug != "ChatGPT Enterprise" || e.Zweck != "Angebotstexte generieren" || e.Datenklasse != "personenbezogen" {
|
||||
t.Errorf("Registereintrag = %+v, unerwartete Werte", e)
|
||||
}
|
||||
if e.Verantwortlicher != "verantwortlicher@example.com" {
|
||||
t.Errorf("Verantwortlicher = %q, want verantwortlicher@example.com", e.Verantwortlicher)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbgelehnteEntscheidungErzeugtKeinenRegistereintrag(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"}, "begruendung": {"Kein Werkzeug verfügbar"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
for _, list := range fs.registereintraege {
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no registereintrag for an abgelehnt Entscheidung, got %+v", list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterListeZeigtGenehmigung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the genehmigtes werkzeug in the register, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExportCSVEnthaeltEintrag(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.csv")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if ct := resp.Header().Get("Content-Type"); !strings.Contains(ct, "text/csv") {
|
||||
t.Errorf("Content-Type = %q, want text/csv", ct)
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the werkzeug in the CSV export, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExportPDFReturnsPDF(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.pdf")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if ct := resp.Header().Get("Content-Type"); ct != "application/pdf" {
|
||||
t.Errorf("Content-Type = %q, want application/pdf", ct)
|
||||
}
|
||||
if !strings.HasPrefix(resp.Body.String(), "%PDF") {
|
||||
t.Error("expected the response body to start with the PDF magic bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessRegister(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/registereintraege")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter on /registereintraege", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
@@ -28,35 +29,76 @@ var staticFS embed.FS
|
||||
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||
type Store interface {
|
||||
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
|
||||
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
||||
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) 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)
|
||||
SetUserActive(ctx context.Context, id string, active bool) error
|
||||
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID 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)
|
||||
CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error)
|
||||
DeleteAbteilung(ctx context.Context, id string) 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)
|
||||
ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error)
|
||||
CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error)
|
||||
DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error
|
||||
ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error)
|
||||
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
|
||||
CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error)
|
||||
UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error)
|
||||
DeleteWerkzeug(ctx context.Context, id string) 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)
|
||||
ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error)
|
||||
|
||||
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
|
||||
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
|
||||
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, 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.
|
||||
func NewServer(st Store) (*Server, error) {
|
||||
// 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)
|
||||
@@ -64,6 +106,7 @@ func NewServer(st Store) (*Server, error) {
|
||||
|
||||
s := &Server{
|
||||
store: st,
|
||||
regelwerk: regelwerk,
|
||||
templates: tmpl,
|
||||
}
|
||||
|
||||
@@ -71,6 +114,8 @@ func NewServer(st Store) (*Server, error) {
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /register", s.handleRegisterForm)
|
||||
mux.HandleFunc("POST /register", s.handleRegister)
|
||||
mux.HandleFunc("GET /einladung/{token}", s.handleEinladungForm)
|
||||
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLogin)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
@@ -79,10 +124,47 @@ func NewServer(st Store) (*Server, error) {
|
||||
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 /registereintraege", s.requireFachebene(s.handleRegisterListe))
|
||||
mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV))
|
||||
mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF))
|
||||
mux.HandleFunc("GET /wiedervorlage", s.requireFachebene(s.handleWiedervorlageListe))
|
||||
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("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren))
|
||||
mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
|
||||
mux.HandleFunc("GET /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportForm))
|
||||
mux.HandleFunc("POST /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportUpload))
|
||||
mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
|
||||
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
|
||||
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
|
||||
mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate))
|
||||
mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete))
|
||||
mux.HandleFunc("GET /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugListe))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/sperren", s.requireAdmin(s.handleMandantWerkzeugSperren))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren))
|
||||
mux.HandleFunc("GET /verwaltung/werkzeuge/neu", s.requireAdmin(s.handleMandantWerkzeugNeuForm))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugCreate))
|
||||
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
||||
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))
|
||||
mux.HandleFunc("POST /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountCreate))
|
||||
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||
mux.HandleFunc("POST /betreiber/accounts/{id}/umbenennen", s.requireBetreiber(s.handleBetreiberAccountRename))
|
||||
mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls))
|
||||
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
||||
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
||||
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
||||
mux.HandleFunc("POST /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugCreate))
|
||||
mux.HandleFunc("GET /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugEditForm))
|
||||
mux.HandleFunc("POST /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugUpdate))
|
||||
mux.HandleFunc("POST /betreiber/werkzeuge/{id}/loeschen", s.requireBetreiber(s.handleBetreiberWerkzeugDelete))
|
||||
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||
s.mux = mux
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -22,25 +24,35 @@ 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
|
||||
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
|
||||
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
|
||||
werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen
|
||||
}
|
||||
|
||||
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{},
|
||||
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{},
|
||||
registereintraege: map[string][]store.Registereintrag{},
|
||||
werkzeugSperren: map[string][]store.WerkzeugSperre{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +64,47 @@ func (f *fakeStore) newID() string {
|
||||
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()}
|
||||
id := f.newID()
|
||||
acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()}
|
||||
f.accounts[acc.ID] = acc
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
acc, ok := f.accounts[id]
|
||||
if !ok {
|
||||
return store.Account{}, store.ErrNotFound
|
||||
}
|
||||
acc.Name = name
|
||||
f.accounts[id] = acc
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, acc := range f.accounts {
|
||||
if acc.EinladungToken == token {
|
||||
return acc, nil
|
||||
}
|
||||
}
|
||||
return store.Account{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
acc, ok := f.accounts[accountID]
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
acc.EinladungToken = newToken
|
||||
f.accounts[accountID] = acc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -85,7 +133,7 @@ func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHa
|
||||
}
|
||||
u := store.User{
|
||||
ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash,
|
||||
Role: role, CreatedAt: time.Now(),
|
||||
Role: role, Active: true, CreatedAt: time.Now(),
|
||||
}
|
||||
f.users[u.ID] = u
|
||||
f.usersByEmail[email] = u.ID
|
||||
@@ -112,6 +160,18 @@ func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetUserActive(ctx context.Context, id string, active bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
u, ok := f.users[id]
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
u.Active = active
|
||||
f.users[id] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -132,6 +192,14 @@ func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, exp
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
sess := store.Session{Token: token, UserID: userID, ImpersonatedByUserID: &impersonatedByUserID, 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()
|
||||
@@ -179,6 +247,41 @@ func (f *fakeStore) ListAbteilungenForAccount(ctx context.Context, accountID str
|
||||
return f.abteilungen[accountID], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAbteilung(ctx context.Context, id string) (store.Abteilung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, list := range f.abteilungen {
|
||||
for _, a := range list {
|
||||
if a.ID == id {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return store.Abteilung{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
a := store.Abteilung{ID: f.newID(), AccountID: accountID, Name: name, CreatedAt: time.Now()}
|
||||
f.abteilungen[accountID] = append(f.abteilungen[accountID], a)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteAbteilung(ctx context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for accID, list := range f.abteilungen {
|
||||
for i, a := range list {
|
||||
if a.ID == id {
|
||||
f.abteilungen[accID] = append(list[:i], list[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return store.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -237,11 +340,240 @@ func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID str
|
||||
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) ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.Werkzeug
|
||||
for _, w := range f.werkzeuge {
|
||||
if w.AccountID == nil {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
sp := store.WerkzeugSperre{ID: f.newID(), AccountID: accountID, WerkzeugID: werkzeugID, Grund: grund, GesperrtAm: time.Now()}
|
||||
f.werkzeugSperren[accountID] = append(f.werkzeugSperren[accountID], sp)
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
list := f.werkzeugSperren[accountID]
|
||||
for i, sp := range list {
|
||||
if sp.WerkzeugID == werkzeugID {
|
||||
f.werkzeugSperren[accountID] = append(list[:i], list[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return store.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.werkzeugSperren[accountID], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
w := store.Werkzeug{
|
||||
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
|
||||
Verarbeitungsort: in.Verarbeitungsort, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
|
||||
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, AufbewahrungTage: in.AufbewahrungTage,
|
||||
Zertifizierungen: in.Zertifizierungen, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
|
||||
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
f.werkzeuge[w.ID] = w
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
w, ok := f.werkzeuge[id]
|
||||
if !ok {
|
||||
return store.Werkzeug{}, store.ErrNotFound
|
||||
}
|
||||
w.Name, w.Anbieter, w.Verarbeitungsort = in.Name, in.Anbieter, in.Verarbeitungsort
|
||||
w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL
|
||||
w.TrainingOptOut, w.TrainingStandard, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage
|
||||
w.Zertifizierungen, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.GeeigneteZwecke, in.Einschraenkungen
|
||||
w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now()
|
||||
f.werkzeuge[id] = w
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteWerkzeug(ctx context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.werkzeuge[id]; !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
delete(f.werkzeuge, id)
|
||||
return 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
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.Entscheidung
|
||||
for antragID, a := range f.antraege {
|
||||
if a.AccountID != accountID {
|
||||
continue
|
||||
}
|
||||
es := f.entscheidungen[antragID]
|
||||
if len(es) == 0 {
|
||||
continue
|
||||
}
|
||||
latest := es[len(es)-1]
|
||||
if latest.Entscheidung == "genehmigt" || latest.Entscheidung == "genehmigt_mit_auflagen" {
|
||||
out = append(out, latest)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
e := store.Registereintrag{
|
||||
ID: f.newID(), AccountID: in.AccountID, AntragID: in.AntragID, EntscheidungID: in.EntscheidungID,
|
||||
Zweck: in.Zweck, Abteilung: in.Abteilung, Werkzeug: in.Werkzeug,
|
||||
Datenklasse: in.Datenklasse, Einstufung: in.Einstufung, Auflagen: in.Auflagen,
|
||||
Verantwortlicher: in.Verantwortlicher, EntschiedenAm: in.EntschiedenAm, GueltigBis: in.GueltigBis,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
f.registereintraege[in.AccountID] = append(f.registereintraege[in.AccountID], e)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.registereintraege[accountID], 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)
|
||||
s, err := web.NewServer(fs, loadTestRegelwerk(t))
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
@@ -276,6 +608,31 @@ func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role s
|
||||
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")
|
||||
@@ -523,7 +880,7 @@ func TestHandleIndexRendersForMitarbeiter(t *testing.T) {
|
||||
func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
|
||||
for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log"} {
|
||||
for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log", "/betreiber/werkzeuge"} {
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* 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. */
|
||||
Marke (Primärblau #1677ff, Inter, Radius-Skala). Mobile-first:
|
||||
Basis-Stile gelten fürs Telefon (Nav als aufklappbares <details>-Menü
|
||||
ohne JavaScript), @media (min-width: 960px) verwandelt dieselbe
|
||||
Nav-Markup-Struktur per CSS in eine feste Seitenleiste (siehe
|
||||
.topbar/.app-nav) — kein separates Desktop-Template nötig. */
|
||||
|
||||
@import url('/static/inter.css');
|
||||
|
||||
@@ -14,6 +15,7 @@
|
||||
--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);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
|
||||
--color-bg: #f8fafc;
|
||||
--color-text: #334155;
|
||||
@@ -27,6 +29,12 @@
|
||||
--color-mittel-bg: #fffbeb;
|
||||
--color-niedrig: #166534;
|
||||
--color-niedrig-bg: #f0fdf4;
|
||||
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-bg: linear-gradient(180deg, #0b1426 0%, #101d33 50%, #0d1829 100%);
|
||||
--sidebar-text: #b6c2d9;
|
||||
--sidebar-text-hover: #ffffff;
|
||||
--sidebar-hover-bg: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -74,23 +82,109 @@ a {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
nav {
|
||||
/* ── Navigation ──────────────────────────────────────────────────────
|
||||
Mobil: .topbar ist eine schmale, fixierte Kopfleiste mit einem
|
||||
aufklappbaren <details>-Menü (siehe layout.html) — kein JavaScript,
|
||||
das <details>-Element liefert Auf-/Zuklappen nativ. Ab Desktop-Breite
|
||||
(siehe Media Query unten) wird dieselbe Markup-Struktur per CSS in
|
||||
eine permanent sichtbare, dunkle Seitenleiste verwandelt. */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: var(--color-muted);
|
||||
.brand {
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Checkbox-Hack: eine unsichtbare Checkbox plus ein <label for=...>
|
||||
als Klick-/Tap-Ziel steuern per CSS-Geschwister-Selektor, ob .app-nav
|
||||
sichtbar ist — funktioniert ohne JavaScript und ohne die Falle von
|
||||
<details> (siehe Kommentar in layout.html). */
|
||||
.nav-toggle {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-toggle-label {
|
||||
cursor: pointer;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.nav-toggle:focus-visible ~ .nav-toggle-label {
|
||||
outline: 2px solid var(--branding-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.nav-toggle:checked ~ .nav-toggle-label {
|
||||
border-color: var(--branding-primary);
|
||||
color: var(--branding-primary);
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 16px;
|
||||
z-index: 20;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 220px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.nav-toggle:checked ~ .app-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-nav a {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
color: var(--color-text);
|
||||
.app-nav a:hover {
|
||||
background: var(--color-bg);
|
||||
color: var(--branding-primary);
|
||||
}
|
||||
|
||||
.app-nav form {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.app-nav button {
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
background: transparent;
|
||||
color: var(--color-hoch);
|
||||
border: 1px solid var(--color-hoch-bg);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
|
||||
@@ -181,6 +275,38 @@ nav button {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* Support-Login-Hinweis (siehe currentImpersonator in middleware.go) —
|
||||
bewusst auffällig (nicht im normalen .hinweis-Grauton), damit niemand
|
||||
vergisst, gerade im Kontext eines fremden Kontos zu handeln. */
|
||||
.impersonation-banner {
|
||||
background: var(--color-mittel-bg);
|
||||
color: var(--color-mittel);
|
||||
padding: 10px 16px;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--color-mittel);
|
||||
}
|
||||
|
||||
.impersonation-banner form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.impersonation-banner button {
|
||||
margin-top: 0;
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.impersonation-banner {
|
||||
margin-left: var(--sidebar-width);
|
||||
}
|
||||
}
|
||||
|
||||
.fehler {
|
||||
background: var(--color-hoch-bg);
|
||||
color: var(--color-hoch);
|
||||
@@ -275,7 +401,12 @@ nav button {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.beitraege-liste li a {
|
||||
.beitraege-liste li a,
|
||||
/* Einträge ohne umschließenden Link (Zeilen mit Badge/Aktions-Buttons
|
||||
statt Navigation, z. B. Nutzer-/Abteilungsliste) bekommen dieselbe
|
||||
Karte direkt auf dem <li> — :has() unterscheidet beide Fälle, damit
|
||||
ein verlinkter Eintrag nicht doppelt (li UND a) gerahmt wird. */
|
||||
.beitraege-liste > li:not(:has(> a)) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -288,6 +419,15 @@ nav button {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.beitraege-liste > li:not(:has(> a)) form {
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.beitraege-liste > li:not(:has(> a)) button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.beteiligte {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
@@ -424,7 +564,45 @@ button.entfernen {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
|
||||
/* Tabellen (z. B. Register-Export-Ansicht) — bewusst horizontal
|
||||
scrollbar in einem eigenen Wrapper statt schrumpfender Spalten: bei
|
||||
neun Spalten wie im Register würde Schrumpfen nur unlesbaren Text
|
||||
erzeugen, siehe .table-scroll in register_liste.html. */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-heading);
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
|
||||
Rand statt volle Breite, sonst bleibt alles identisch. */
|
||||
@media (min-width: 640px) {
|
||||
.page {
|
||||
@@ -435,3 +613,74 @@ button.entfernen {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ab Desktop-Breite: dieselbe .topbar/.app-nav-Markup wird zu einer
|
||||
permanent sichtbaren, dunklen Seitenleiste — kein separates
|
||||
Desktop-Template, nur CSS. Der `details`/`summary`-Aufklapp-
|
||||
Mechanismus wird hier überschrieben (Inhalt immer sichtbar,
|
||||
summary versteckt), das Handy-Verhalten bleibt darunter unverändert. */
|
||||
@media (min-width: 960px) {
|
||||
.topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 20px 16px;
|
||||
background: var(--sidebar-bg);
|
||||
border-bottom: none;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: #fff;
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.nav-toggle-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
position: static;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-nav a {
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
|
||||
.app-nav a:hover {
|
||||
background: var(--sidebar-hover-bg);
|
||||
color: var(--sidebar-text-hover);
|
||||
}
|
||||
|
||||
.app-nav button {
|
||||
color: var(--sidebar-text);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.app-nav button:hover {
|
||||
background: var(--sidebar-hover-bg);
|
||||
color: var(--sidebar-text-hover);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.page {
|
||||
margin-left: var(--sidebar-width);
|
||||
max-width: 1100px;
|
||||
padding: 32px 40px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
32
internal/web/templates/abteilung_liste.html
Normal file
32
internal/web/templates/abteilung_liste.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{define "abteilung-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Abteilungen</h1>
|
||||
<p class="hinweis">Stammdaten für den Fragebogen (Feld "Abteilung").</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if not .Abteilungen}}
|
||||
<p class="hinweis">Noch keine Abteilungen.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Abteilungen}}
|
||||
<li>
|
||||
{{.Name}}
|
||||
<form method="post" action="/verwaltung/abteilungen/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?')">
|
||||
<button type="submit" class="entfernen">Löschen</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<form method="post" action="/verwaltung/abteilungen">
|
||||
<label for="name">Neue Abteilung</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -13,11 +13,20 @@
|
||||
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
||||
|
||||
<p class="hinweis">
|
||||
Die Bewertung (Datenklasse, KI-VO-Einstufung, Werkzeugvorschlag) folgt
|
||||
in einer späteren Ausbaustufe — dieser Antrag ist gespeichert und
|
||||
eingereicht.
|
||||
</p>
|
||||
{{if .Antworten}}
|
||||
<h2>Antworten im Fragebogen</h2>
|
||||
<ul class="findings">
|
||||
{{range .Antworten}}
|
||||
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
{{if .Bewertung}}
|
||||
{{template "bewertung-block" .Bewertung}}
|
||||
{{else}}
|
||||
<p class="hinweis">Die Bewertung wird berechnet, sobald der Antrag eingereicht ist.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,11 +8,25 @@
|
||||
<h1>{{.Name}}</h1>
|
||||
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
||||
|
||||
<form method="post" action="/betreiber/accounts/{{.AccountID}}/umbenennen">
|
||||
<label for="name">Firmenname</label>
|
||||
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
||||
<button type="submit">Umbenennen</button>
|
||||
</form>
|
||||
|
||||
<h2>Nutzer</h2>
|
||||
<ul class="beteiligte">
|
||||
{{range .Users}}
|
||||
<li class="beteiligter">
|
||||
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
|
||||
<div class="beteiligter-kopf">
|
||||
{{.Email}} <span class="rolle">({{.Role}})</span>
|
||||
{{if not .Active}}<span class="status">deaktiviert</span>{{end}}
|
||||
</div>
|
||||
{{if .Active}}
|
||||
<form method="post" action="/betreiber/accounts/{{$.AccountID}}/nutzer/{{.ID}}/anmelden-als" onsubmit="return confirm('Als {{.Email}} anmelden? Ihre eigene Sitzung wird dabei ersetzt.')">
|
||||
<button type="submit">Als Nutzer anmelden</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
29
internal/web/templates/betreiber_account_neu.html
Normal file
29
internal/web/templates/betreiber_account_neu.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{{define "betreiber-account-neu"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/betreiber/accounts">← Accounts</a></p>
|
||||
<h1>Firma anlegen</h1>
|
||||
<p class="hinweis">
|
||||
Legt eine Firma mit dem ersten Login (Rolle admin) an — dasselbe
|
||||
Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
|
||||
</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/betreiber/accounts">
|
||||
<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">Initialpasswort (mind. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" minlength="8" required>
|
||||
|
||||
<button type="submit">Firma anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -19,6 +19,7 @@
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p><a href="/betreiber/accounts/neu">Firma anlegen</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<h1>Plattform</h1>
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/betreiber/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
|
||||
<li><a href="/betreiber/werkzeuge">Werkzeugkatalog <span class="status">{{.WerkzeugCount}}</span></a></li>
|
||||
<li><a href="/betreiber/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
65
internal/web/templates/betreiber_werkzeug_form.html
Normal file
65
internal/web/templates/betreiber_werkzeug_form.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{{define "betreiber-werkzeug-form"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="{{.ActionBase}}">← Werkzeugkatalog</a></p>
|
||||
<h1>{{.Title}}</h1>
|
||||
<p class="hinweis">
|
||||
Jede Zusicherung hier (AVV, Verarbeitungsort, Training-Opt-out,
|
||||
Zertifizierungen) muss belegbar sein — Quelle und letzte Prüfung sind
|
||||
Pflicht.
|
||||
</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
||||
|
||||
<label for="anbieter">Anbieter</label>
|
||||
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
|
||||
|
||||
<fieldset>
|
||||
<legend>Verarbeitungsort</legend>
|
||||
{{$gewaehlt := .Verarbeitungsort}}
|
||||
{{range .Verarbeitungsorte}}
|
||||
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
|
||||
<label for="avv_url">AVV-URL</label>
|
||||
<input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}">
|
||||
|
||||
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
|
||||
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
|
||||
|
||||
<label for="aufbewahrung_tage">Aufbewahrung (Tage)</label>
|
||||
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
|
||||
|
||||
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
|
||||
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
|
||||
|
||||
<label for="geeignete_zwecke">Geeignete Zwecke (kommagetrennt)</label>
|
||||
<input type="text" id="geeignete_zwecke" name="geeignete_zwecke" value="{{.GeeigneteZwecke}}">
|
||||
|
||||
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
|
||||
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
|
||||
|
||||
<label for="letzte_pruefung">Letzte Prüfung</label>
|
||||
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
|
||||
|
||||
<label for="quelle">Quelle (Beleg)</label>
|
||||
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
|
||||
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
{{if .ID}}
|
||||
<form method="post" action="/betreiber/werkzeuge/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
|
||||
<button type="submit" class="entfernen">Werkzeug löschen</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
26
internal/web/templates/betreiber_werkzeuge.html
Normal file
26
internal/web/templates/betreiber_werkzeuge.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{{define "betreiber-werkzeuge"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<h1>Werkzeugkatalog</h1>
|
||||
{{if not .Werkzeuge}}
|
||||
<p class="hinweis">Noch keine zentralen Katalogeinträge.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Werkzeuge}}
|
||||
<li>
|
||||
<a href="/betreiber/werkzeuge/{{.ID}}">
|
||||
<strong>{{.Name}}</strong> · {{.Anbieter}} · {{.Verarbeitungsort}} · zuletzt geprüft {{.LetztePruefung}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
22
internal/web/templates/einladung.html
Normal file
22
internal/web/templates/einladung.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{{define "einladung"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Einladung annehmen</h1>
|
||||
<p class="hinweis">Sie treten dem Konto von <strong>{{.Firmenname}}</strong> bei.</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post">
|
||||
<label for="email">E-Mail</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}}
|
||||
12
internal/web/templates/einladung_ungueltig.html
Normal file
12
internal/web/templates/einladung_ungueltig.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{{define "einladung-ungueltig"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Einladung</h1>
|
||||
<p class="fehler">Dieser Einladungslink ist ungültig oder wurde erneuert. Bitte einen aktuellen Link anfragen.</p>
|
||||
<p><a href="/login">Zur Anmeldung</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
20
internal/web/templates/einladung_verwaltung.html
Normal file
20
internal/web/templates/einladung_verwaltung.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{{define "einladung-verwaltung"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Einladungslink</h1>
|
||||
<p class="hinweis">
|
||||
Ein Sammellink für alle Mitarbeitenden — jede Person, die den Link
|
||||
öffnet, kann sich selbst mit eigenem Passwort anmelden (Rolle
|
||||
"mitarbeiter"). Kein Ablaufdatum, aber jederzeit erneuerbar.
|
||||
</p>
|
||||
<p><input type="text" readonly value="{{.Link}}" onclick="this.select()"></p>
|
||||
<form method="post" action="/verwaltung/einladung/erneuern" onsubmit="return confirm('Neuen Link erzeugen? Der alte Link wird dann sofort ungültig.')">
|
||||
<button type="submit" class="entfernen">Link erneuern</button>
|
||||
</form>
|
||||
</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}}
|
||||
69
internal/web/templates/fall_detail.html
Normal file
69
internal/web/templates/fall_detail.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{{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 .Antworten}}
|
||||
<h2>Antworten im Fragebogen</h2>
|
||||
<ul class="findings">
|
||||
{{range .Antworten}}
|
||||
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
{{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}}
|
||||
@@ -5,10 +5,19 @@
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Willkommen bei Deklarix</h1>
|
||||
{{if .Nav.IsBetreiber}}
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/betreiber">Zur Plattform</a></li>
|
||||
</ul>
|
||||
{{else}}
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
||||
<li><a href="/antraege">Meine Anträge</a></li>
|
||||
<li><a href="/antraege">Meine offenen Anträge <span class="status">{{.OffeneAntraege}}</span></a></li>
|
||||
{{if .ZeigePosteingang}}
|
||||
<li><a href="/faelle">Posteingang <span class="status">{{.OffenerPosteingang}}</span></a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,13 +6,105 @@
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
{{end}}
|
||||
|
||||
{{/* Eine einzige Nav-Definition für alle Seiten (siehe navData in
|
||||
middleware.go) — auf dem Handy ein aufklappbares Menü (Checkbox-
|
||||
Hack, kein JavaScript: das <details>-Element scheitert daran, dass
|
||||
aktuelle Chromium-Versionen dessen Auf-/Zuklapp-Zustand über eine
|
||||
interne, nicht per CSS überschreibbare Animation steuern), ab
|
||||
Desktop-Breite per CSS in eine permanent sichtbare Seitenleiste
|
||||
verwandelt. Dieselbe Markup-Struktur bedient beide Layouts, siehe
|
||||
app.css .app-nav. Die Checkbox ist bei jedem Seitenaufruf frisch
|
||||
unchecked (serverseitig gerendert, kein Zustand über Navigation
|
||||
hinweg nötig). */}}
|
||||
{{define "nav"}}
|
||||
<nav>
|
||||
<a href="/">Start</a>
|
||||
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">Deklarix</a>
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen">Menü</label>
|
||||
<nav class="app-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 .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Abmelden</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
{{if .Impersonator}}
|
||||
<div class="impersonation-banner">
|
||||
Support-Zugriff durch <strong>{{.Impersonator}}</strong> — dies ist nicht Ihre eigene Sitzung.
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
<button type="submit">Abmelden</button>
|
||||
<button type="submit">Sitzung beenden</button>
|
||||
</form>
|
||||
</nav>
|
||||
</div>
|
||||
{{end}}
|
||||
{{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}}
|
||||
|
||||
58
internal/web/templates/mandant_werkzeuge.html
Normal file
58
internal/web/templates/mandant_werkzeuge.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{{define "mandant-werkzeuge"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Werkzeugkatalog</h1>
|
||||
|
||||
<h2>Zentraler Katalog</h2>
|
||||
<p class="hinweis">
|
||||
Wird von der Plattform gepflegt. Ein gesperrtes Werkzeug bleibt im
|
||||
zentralen Katalog unverändert — es wird nur für diesen Mandanten aus
|
||||
der Werkzeugsuche ausgeblendet.
|
||||
</p>
|
||||
{{if not .Werkzeuge}}
|
||||
<p class="hinweis">Der zentrale Katalog ist noch leer.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Werkzeuge}}
|
||||
<li>
|
||||
{{.Name}} · {{.Anbieter}}
|
||||
{{if .Gesperrt}}
|
||||
<span class="status">gesperrt</span>
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
||||
<button type="submit">Entsperren</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline">
|
||||
<input type="text" name="grund" placeholder="Grund (optional)">
|
||||
<button type="submit">Sperren</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<h2>Eigene Werkzeuge</h2>
|
||||
<p class="hinweis">
|
||||
Zusätzliche Katalogeinträge nur für diesen Mandanten — sichtbar nur
|
||||
hier, nicht im zentralen Katalog der Plattform.
|
||||
</p>
|
||||
{{if not .EigeneWerkzeuge}}
|
||||
<p class="hinweis">Noch keine eigenen Werkzeuge.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .EigeneWerkzeuge}}
|
||||
<li>
|
||||
<a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}} · {{.Anbieter}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
27
internal/web/templates/nutzer_import.html
Normal file
27
internal/web/templates/nutzer_import.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "nutzer-import"}}<!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>Mitarbeiter per CSV importieren</h1>
|
||||
<p class="hinweis">
|
||||
CSV mit Kopfzeile, Pflichtspalte "email", optionale Spalte "role"
|
||||
(mitarbeiter/verantwortlicher/pruefer/admin — Standard: mitarbeiter).
|
||||
Es gibt keine Einladungsmail: nach dem Import wird pro Zeile ein
|
||||
Einmalpasswort angezeigt, das nur DIESES EINE MAL sichtbar ist — bitte
|
||||
auf dieser Seite kopieren und sicher weitergeben, bevor sie
|
||||
geschlossen wird.
|
||||
</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/verwaltung/nutzer/import" enctype="multipart/form-data">
|
||||
<label for="csv">CSV-Datei</label>
|
||||
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
|
||||
|
||||
<button type="submit">Importieren</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
33
internal/web/templates/nutzer_import_ergebnis.html
Normal file
33
internal/web/templates/nutzer_import_ergebnis.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{{define "nutzer-import-ergebnis"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Import-Ergebnis</h1>
|
||||
<p class="fehler">
|
||||
Die Einmalpasswörter unten werden nach dieser Seite nirgendwo mehr
|
||||
angezeigt — jetzt kopieren und sicher weitergeben.
|
||||
</p>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Zeile</th><th>E-Mail</th><th>Rolle</th><th>Ergebnis</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Ergebnisse}}
|
||||
<tr>
|
||||
<td>{{.Zeile}}</td>
|
||||
<td>{{.Email}}</td>
|
||||
<td>{{if .Fehler}}—{{else}}{{.Role}}{{end}}</td>
|
||||
<td>{{if .Fehler}}<span class="fehler">{{.Fehler}}</span>{{else}}<code>{{.Passwort}}</code>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p><a href="/verwaltung/nutzer">Zur Nutzerverwaltung</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
32
internal/web/templates/nutzer_liste.html
Normal file
32
internal/web/templates/nutzer_liste.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{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>
|
||||
{{if .Active}}
|
||||
<span class="status status-published">aktiv</span>
|
||||
{{if not .IstSelbst}}
|
||||
<form method="post" action="/verwaltung/nutzer/{{.ID}}/deaktivieren" style="display:inline">
|
||||
<button type="submit" class="entfernen">Deaktivieren</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="status">deaktiviert</span>
|
||||
<form method="post" action="/verwaltung/nutzer/{{.ID}}/aktivieren" style="display:inline">
|
||||
<button type="submit">Aktivieren</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a> · <a href="/verwaltung/nutzer/import">Per CSV importieren</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}}
|
||||
40
internal/web/templates/register_liste.html
Normal file
40
internal/web/templates/register_liste.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{{define "register-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Register</h1>
|
||||
<p class="hinweis">
|
||||
Jede Genehmigung erzeugt automatisch einen Registereintrag —
|
||||
abgelehnte Anträge und Rückfragen stehen nicht im Register.
|
||||
</p>
|
||||
<p><a href="/registereintraege/export.csv">CSV-Export</a> · <a href="/registereintraege/export.pdf">PDF-Export</a></p>
|
||||
{{if not .Eintraege}}
|
||||
<p class="hinweis">Noch keine Genehmigungen.</p>
|
||||
{{else}}
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zweck</th><th>Abteilung</th><th>Werkzeug</th><th>Datenklasse</th>
|
||||
<th>Einstufung</th><th>Auflagen</th><th>Verantwortliche/r</th>
|
||||
<th>Entschieden am</th><th>Gültig bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Eintraege}}
|
||||
<tr>
|
||||
<td>{{.Zweck}}</td><td>{{.Abteilung}}</td><td>{{.Werkzeug}}</td><td>{{.Datenklasse}}</td>
|
||||
<td>{{.Einstufung}}</td><td>{{.Auflagen}}</td><td>{{.Verantwortlicher}}</td>
|
||||
<td>{{.EntschiedenAm}}</td><td>{{.GueltigBis}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
29
internal/web/templates/wiedervorlage_liste.html
Normal file
29
internal/web/templates/wiedervorlage_liste.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{{define "wiedervorlage-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Wiedervorlage</h1>
|
||||
<p class="hinweis">
|
||||
Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren
|
||||
Werkzeug sich seit der Entscheidung im Katalog geändert hat.
|
||||
</p>
|
||||
{{if not .Eintraege}}
|
||||
<p class="keine-findings">Nichts zur Wiedervorlage.</p>
|
||||
{{else}}
|
||||
<ul class="findings">
|
||||
{{range .Eintraege}}
|
||||
<li class="finding finding-mittel">
|
||||
<strong><a href="/faelle/{{.AntragID}}">{{.Titel}}</a></strong>{{if .Werkzeug}} — {{.Werkzeug}}{{end}}
|
||||
<ul>
|
||||
{{range .Gruende}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
104
internal/web/wiedervorlage_handlers.go
Normal file
104
internal/web/wiedervorlage_handlers.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Ebene 3 — Wiedervorlage (Schritt 7 der Baureihenfolge, siehe
|
||||
// CLAUDE.md). Eine aktive Genehmigung muss erneut geprüft werden, wenn
|
||||
// entweder ihr Ablaufdatum erreicht/nah ist, oder sich im Katalog eine
|
||||
// Eigenschaft geändert hat, auf der sie beruhte. Es gibt keine E-Mail-
|
||||
// Infrastruktur — "Benachrichtigung" ist v1 eine In-App-Liste, kein
|
||||
// Versand.
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// wiedervorlageSchwelle: eine Genehmigung erscheint schon vor ihrem
|
||||
// Ablauf in der Wiedervorlage, damit der/die Verantwortliche Zeit zum
|
||||
// Reagieren hat.
|
||||
const wiedervorlageSchwelle = 30 * 24 * time.Hour
|
||||
|
||||
// werkzeugDiff vergleicht den zum Entscheidungszeitpunkt eingefrorenen
|
||||
// Werkzeug-Snapshot mit dem aktuellen Katalogstand und benennt jede für
|
||||
// die Bewertung relevante Eigenschaft, die sich seitdem geändert hat.
|
||||
func werkzeugDiff(snapshot, current store.Werkzeug) []string {
|
||||
var diffs []string
|
||||
if snapshot.AVVVerfuegbar != current.AVVVerfuegbar {
|
||||
diffs = append(diffs, fmt.Sprintf("AVV verfügbar: %v → %v", snapshot.AVVVerfuegbar, current.AVVVerfuegbar))
|
||||
}
|
||||
if snapshot.TrainingStandard != current.TrainingStandard {
|
||||
diffs = append(diffs, fmt.Sprintf("Training standardmäßig aus: %v → %v", snapshot.TrainingStandard, current.TrainingStandard))
|
||||
}
|
||||
if snapshot.Verarbeitungsort != current.Verarbeitungsort {
|
||||
diffs = append(diffs, fmt.Sprintf("Verarbeitungsort: %s → %s", snapshot.Verarbeitungsort, current.Verarbeitungsort))
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
type wiedervorlageItem struct {
|
||||
AntragID string
|
||||
Titel string
|
||||
Werkzeug string
|
||||
Gruende []string
|
||||
}
|
||||
|
||||
type wiedervorlageListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Eintraege []wiedervorlageItem
|
||||
}
|
||||
|
||||
// handleWiedervorlageListe zeigt alle aktiven Genehmigungen des
|
||||
// Mandanten, die erneut geprüft werden sollten — abgelaufen/bald
|
||||
// ablaufend oder mit einem seither im Katalog geänderten Werkzeug.
|
||||
// Genehmigungen ohne Grund zur Wiedervorlage werden nicht gezeigt.
|
||||
func (s *Server) handleWiedervorlageListe(w http.ResponseWriter, r *http.Request) {
|
||||
entscheidungen, err := s.store.ListAktiveGenehmigungenForAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Genehmigungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var data wiedervorlageListeData
|
||||
data.Title, data.Nav = "Wiedervorlage", navFor(r)
|
||||
for _, e := range entscheidungen {
|
||||
var gruende []string
|
||||
|
||||
if e.GueltigBis != nil {
|
||||
if time.Now().After(*e.GueltigBis) {
|
||||
gruende = append(gruende, "Genehmigung ist abgelaufen (seit "+e.GueltigBis.Format("02.01.2006")+")")
|
||||
} else if time.Until(*e.GueltigBis) < wiedervorlageSchwelle {
|
||||
gruende = append(gruende, "Genehmigung läuft am "+e.GueltigBis.Format("02.01.2006")+" ab")
|
||||
}
|
||||
}
|
||||
|
||||
werkzeugName := ""
|
||||
if e.WerkzeugID != nil {
|
||||
current, err := s.store.GetWerkzeug(r.Context(), *e.WerkzeugID)
|
||||
if err != nil {
|
||||
gruende = append(gruende, "Werkzeug wurde aus dem Katalog entfernt")
|
||||
} else {
|
||||
werkzeugName = current.Name
|
||||
if e.WerkzeugSnapshot != nil {
|
||||
gruende = append(gruende, werkzeugDiff(*e.WerkzeugSnapshot, current)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(gruende) == 0 {
|
||||
continue
|
||||
}
|
||||
antrag, err := s.store.GetAntrag(r.Context(), e.AntragID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data.Eintraege = append(data.Eintraege, wiedervorlageItem{
|
||||
AntragID: antrag.ID, Titel: antrag.Titel, Werkzeug: werkzeugName, Gruende: gruende,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, "wiedervorlage-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
95
internal/web/wiedervorlage_handlers_test.go
Normal file
95
internal/web/wiedervorlage_handlers_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWiedervorlageZeigtAbgelaufeneGenehmigung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
|
||||
var antragID string
|
||||
for id, a := range fs.antraege {
|
||||
if a.AccountID == accountID {
|
||||
antragID = id
|
||||
}
|
||||
}
|
||||
es := fs.entscheidungen[antragID]
|
||||
abgelaufen := time.Now().Add(-24 * time.Hour)
|
||||
es[len(es)-1].GueltigBis = &abgelaufen
|
||||
fs.entscheidungen[antragID] = es
|
||||
|
||||
prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "abgelaufen") {
|
||||
t.Errorf("expected the abgelaufene Genehmigung to appear, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWiedervorlageZeigtGeaenderteWerkzeugeigenschaft(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
|
||||
wz := fs.werkzeuge["werkzeug-ok"]
|
||||
wz.AVVVerfuegbar = false // Katalog hat sich seit der Genehmigung geändert
|
||||
fs.werkzeuge["werkzeug-ok"] = wz
|
||||
|
||||
prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "AVV verfügbar") {
|
||||
t.Errorf("expected the changed AVV property to be flagged, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWiedervorlageZeigtEntferntesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
delete(fs.werkzeuge, "werkzeug-ok")
|
||||
|
||||
prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "aus dem Katalog entfernt") {
|
||||
t.Errorf("expected the removed werkzeug to be flagged, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWiedervorlageIstLeerOhneAenderung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
|
||||
prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Nichts zur Wiedervorlage") {
|
||||
t.Errorf("expected no entries when nothing changed, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessWiedervorlage(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/wiedervorlage")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,12 @@ 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
|
||||
## 1. Rangfolge der Datenklassen — BESTÄTIGT (2026-08-29)
|
||||
|
||||
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):
|
||||
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt diese
|
||||
Reihenfolge an (niedrigster zu höchstem Rang) — **vom Produktverantwortlichen
|
||||
bestätigt, keine Änderung nötig:**
|
||||
|
||||
1. `oeffentlich`
|
||||
2. `intern`
|
||||
@@ -18,22 +19,22 @@ diese Reihenfolge an (niedrigster zu höchstem Rang):
|
||||
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.**
|
||||
Begründung: 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.
|
||||
|
||||
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten`
|
||||
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten` — BESTÄTIGT (2026-08-29)
|
||||
|
||||
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.**
|
||||
`rules/anforderungen.yaml` schließt `auftragsdaten` ein (Kundendaten
|
||||
sollten aus denselben Gründen wie Geschäftsgeheimnisse nicht zum
|
||||
Training verwendet werden) — **vom Produktverantwortlichen bestätigt,
|
||||
keine Änderung nötig.**
|
||||
|
||||
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT
|
||||
|
||||
@@ -43,9 +44,9 @@ 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.
|
||||
final, nicht mehr Platzhalter. Ob diese drei Varianten fachlich
|
||||
vollständig sind, wurde am 2026-08-29 vom Produktverantwortlichen
|
||||
**bestätigt (keine Ergänzung nötig).**
|
||||
|
||||
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
||||
|
||||
@@ -60,15 +61,15 @@ 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
|
||||
## 5. Fallback, wenn keine Datenklasse zutrifft — BESTÄTIGT (2026-08-29)
|
||||
|
||||
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.
|
||||
Zweifel mehr Schutzanforderungen, nicht weniger. **Vom
|
||||
Produktverantwortlichen bestätigt, keine Änderung nötig.**
|
||||
|
||||
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user