Compare commits

..

6 Commits

Author SHA1 Message Date
noroot
36af1bf288 feat: responsives Frontend — Seitenleiste ab Desktop, Tabellen-Styling
Nav wird jetzt für alle Seiten aus einer einzigen Stelle
(layout.html-Block) gerendert: auf dem Handy ein per Checkbox-Hack
aufklappbares Menü (kein JavaScript), ab 960px Breite per CSS in eine
permanente, dunkle Seitenleiste im enconf-Look verwandelt — dieselbe
Markup-Struktur bedient beide Layouts, kein separates Desktop-Template.
<details>/<summary> wurde bewusst NICHT verwendet: aktuelle
Chromium-Versionen steuern dessen Auf-/Zuklapp-Zustand über eine
CSS-resistente interne Animation (auch mit !important nicht
überschreibbar), per Headless-Screenshot verifiziert.

Zusätzlich: Tabellen (vorher komplett ungestylt, betraf v. a. das
Register) haben jetzt echte Gestaltung inkl. horizontal scrollbarem
Wrapper für schmale Bildschirme, Listenkarten ohne Link-Wrapper
(Nutzer-/Abteilungsliste) bekommen dieselbe Kartenoptik wie
verlinkte Einträge, und destruktive Aktionen (Löschen/Deaktivieren)
sind jetzt visuell von primären Aktionen unterschieden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 15:06:14 +02:00
noroot
5f6502bb41 feat: Wiedervorlage und Katalog-Benachrichtigung (Schritt 7 der Baureihenfolge)
GET /wiedervorlage (Ebene 3) zeigt aktive Genehmigungen, die erneut
geprüft werden sollten: Ablaufdatum erreicht oder innerhalb von 30
Tagen, das zugesagte Werkzeug wurde aus dem Katalog entfernt, oder der
aktuelle Katalogeintrag weicht bei AVV/Training-Standard/
Verarbeitungsort vom zum Entscheidungszeitpunkt eingefrorenen Snapshot
ab. Keine E-Mail-Infrastruktur vorhanden — Benachrichtigung ist bewusst
eine In-App-Liste. Damit ist die ursprüngliche Baureihenfolge
(Schritt 1-7) vollständig umgesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 14:12:44 +02:00
noroot
22f748d66c feat: Ebene 4 vollständig steuerbar machen — Abteilungen, Werkzeug-Sperrungen, Nutzer-Deaktivierung
Deklarix soll ein buchbarer Service werden — dafür muss jede Entität im
Datenmodell über das Frontend steuerbar sein, nicht nur einsehbar.
Schließt drei konkrete Lücken:

- Abteilungen (GET /verwaltung/abteilungen, anlegen/löschen) — ohne
  diese Seite blieb die Abteilung-Auswahl im Antrag-Fragebogen leer
  und unbenutzbar, das war ein Funktionsdefizit, kein Komfortfehler.
- Eigene Werkzeug-Sperrungen (GET /verwaltung/werkzeuge) — ein Mandant
  kann einen zentralen Katalogeintrag jetzt für sich sperren/entsperren,
  ohne den zentralen Katalog selbst zu verändern.
- Nutzer-Deaktivierung (POST /verwaltung/nutzer/{id}/deaktivieren bzw.
  .../aktivieren, neue Spalte app_user.active, Migration 0012). Nutzer
  werden nicht gelöscht (Fremdschlüssel auf antrag/entscheidung/
  audit_log würden das verhindern und die Historie zerstören) —
  deaktivierte Logins können sich nicht mehr anmelden und verlieren
  eine laufende Sitzung sofort. Ein Admin kann sich nicht selbst
  deaktivieren.

Zusätzlich: store.ListAktiveGenehmigungenForAccount als Grundlage für
die Wiedervorlage (Schritt 7, Web-Layer folgt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 14:07:47 +02:00
noroot
d52b325424 feat: Registereintrag und Export (Schritt 6 der Baureihenfolge)
Jede Genehmigung (genehmigt/genehmigt_mit_auflagen) erzeugt jetzt
automatisch einen append-only registereintrag mit Zweck, Abteilung,
Werkzeug, Datenklasse, Einstufung, Auflagen, Verantwortliche/r und
Gültigkeit. Ebene 3 (verantwortlicher/pruefer) kann das Register unter
GET /registereintraege einsehen und als CSV oder PDF exportieren. Der
PDF-Export nutzt das bereits vorhandene, bisher ungenutzte go-pdf/fpdf
(keine neue Abhängigkeit) mit UnicodeTranslatorFromDescriptor für
deutsche Umlaute. Abgelehnte Entscheidungen und Rückfragen erzeugen
bewusst keinen Registereintrag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 12:55:24 +02:00
noroot
cff8c9eadf feat: Werkzeugkatalog-Pflege für die Plattform (Ebene 5)
Betreiber können den zentralen Werkzeugkatalog jetzt über die UI
pflegen (GET /betreiber/werkzeuge, GET/POST .../neu, GET/POST
.../{id}, POST .../{id}/loeschen) statt nur per SQL. Bearbeitet
ausschließlich zentrale (account_id IS NULL) Einträge — ein
mandantenspezifischer Katalogeintrag bleibt über diese Seiten
unerreichbar (404), das ist weiterhin Sache des jeweiligen Mandanten.
Ohne befüllten Katalog konnte bisher keine Bewertung tatsächlich zu
"genehmigt" mit einem echten Werkzeug führen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 12:42:48 +02:00
noroot
aad517ce23 feat: Nutzerverwaltung für Ebene 4 (Mandanten-Verwaltung)
Ein admin kann jetzt weitere Logins im eigenen Mandanten anlegen
(GET /verwaltung/nutzer, GET/POST /verwaltung/nutzer/neu) mit einer
der vier Mandanten-Rollen — betreiber bleibt Ebene 5 vorbehalten und
kann von keinem Mandanten-Admin vergeben werden. Schließt die Lücke,
dass die Fachebene (verantwortlicher/pruefer) bisher nur per
manuellem SQL-Insert erreichbar war, weil die Firma-Registrierung
ausschließlich einen admin-Nutzer erzeugt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 12:34:04 +02:00
40 changed files with 2877 additions and 88 deletions

167
CLAUDE.md
View File

@@ -72,6 +72,47 @@ ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
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** (`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`).
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, eigene
Werkzeug-EINTRÄGE eines Mandanten (nur Sperrungen zentraler Einträge
sind umgesetzt, `account_id`-gesetzte eigene Katalogeinträge fehlen
noch), Rechnungsdaten/Abrechnung (kein Abo-System, bewusst "Nicht bauen
v1"), Account-Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts
nur lesend an — Bearbeiten/Sperren hängt an der noch nicht getroffenen
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
@@ -205,7 +246,7 @@ mehrfacher Neubewertung eines Antrags erhalten bleibt.
---
## Entscheidung, Register, Wiedervorlage (Schritt 5 erledigt, Schritt 6-7 geplant)
## 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 —
@@ -236,14 +277,33 @@ erzeugt zusätzlich einen `audit_log`-Eintrag (Action
`antrag_entschieden`).
Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
`hochrisiko` 6 — `gueltigkeitFuer`). **Noch nicht gebaut (Schritt 7):**
die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf
der eine aktive Genehmigung beruht (Wiedervorlage).
`hochrisiko` 6 — `gueltigkeitFuer`).
**Noch nicht gebaut (Schritt 6):** ein `registereintrag` wird bislang
NICHT automatisch aus einer Genehmigung erzeugt, und es gibt keinen
PDF-/CSV-Export. Geplant: Zweck, Abteilung, Werkzeug, Datenklasse,
Einstufung, Auflagen, Verantwortliche/r, Datum, Gültigkeit.
**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`).
---
@@ -272,6 +332,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
```
@@ -286,7 +389,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
@@ -331,12 +437,13 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
- `entscheidung` — die Entscheidung eines/einer Verantwortlichen über
einen Antrag, mit Snapshot des gewählten Werkzeugs (`werkzeug_snapshot`,
JSONB) und Ablaufdatum (`gueltig_bis`) bei Genehmigung (Schritt 5).
- `registereintrag`**noch nicht gebaut**, geplant für Schritt 6.
- `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), ebenso `bewertung` und
`entscheidung`. `registereintrag` wird bei seiner Einführung ebenfalls
append-only.
wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
`entscheidung` und `registereintrag`.
---
@@ -376,8 +483,24 @@ append-only.
vollständigen Werkzeugdatensatz ein und braucht ein zulässiges
Werkzeug aus der Bewertung, `entscheidung` ist append-only, jede
Entscheidung erzeugt einen `audit_log`-Eintrag.)
6. Registereintrag und Export
7. Wiedervorlage und Katalog-Benachrichtigung
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
@@ -610,7 +733,15 @@ journalctl -u deklarix -f
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.
- **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.

View File

@@ -52,6 +52,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) {

View File

@@ -109,3 +109,38 @@ func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID str
}
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
}

View File

@@ -113,3 +113,41 @@ func TestEntscheidungIsAppendOnly(t *testing.T) {
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)
}
}

View File

@@ -0,0 +1 @@
DROP TABLE registereintrag;

View 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();

View File

@@ -0,0 +1 @@
ALTER TABLE app_user DROP COLUMN active;

View 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;

View 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
}

View 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")
}
}

View File

@@ -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
}

View 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")
}
}

View 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)
}

View 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)
}
}

View File

@@ -93,6 +93,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"})

View File

@@ -8,6 +8,7 @@ type betreiberDashboardData struct {
Title string
Nav navData
AccountCount int
WerkzeugCount int
RecentAuditCount int
}
@@ -21,6 +22,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 +34,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)

View File

@@ -0,0 +1,250 @@
// 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)
}
}
type werkzeugFormData struct {
Title string
Nav navData
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), 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 immer nil — diese Seiten pflegen ausschließlich den
// zentralen Katalog.
func werkzeugInputFromForm(r *http.Request) (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: nil,
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, id, errMsg string) werkzeugFormData {
return werkzeugFormData{
Title: title, Nav: navFor(r), 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)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "", 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", "", "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), 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)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", 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", 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)
}

View 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)
}
}

View File

@@ -192,11 +192,13 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
return
}
istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen"
in := store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
Entscheidung: entscheidungTyp, Begruendung: begruendung,
}
if entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen" {
var werkzeugName string
if istGenehmigung {
zulaessig := false
for _, id := range bewertung.ZulaessigeWerkzeuge {
if id == werkzeugID {
@@ -213,13 +215,15 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
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
}
if _, err := s.store.CreateEntscheidung(r.Context(), in); err != nil {
entscheidung, err := s.store.CreateEntscheidung(r.Context(), in)
if err != nil {
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -232,5 +236,30 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
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)
}

View File

@@ -0,0 +1,75 @@
// 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).
package web
import "net/http"
type mandantWerkzeugItem struct {
ID string
Name string
Anbieter string
Gesperrt bool
}
type mandantWerkzeugListeData struct {
Title string
Nav navData
Werkzeuge []mandantWerkzeugItem
}
// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag.
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
}
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],
})
}
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// 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)
}

View 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)
}
}

View File

@@ -34,7 +34,7 @@ func (s *Server) authenticate(r *http.Request) (store.User, bool) {
}
user, err := s.store.GetUser(r.Context(), sess.UserID)
if err != nil {
if err != nil || !user.Active {
return store.User{}, false
}
return user, true
@@ -112,6 +112,24 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
}
}
// requireAdmin schützt die Mandanten-Verwaltung (Ebene 4 — Nutzer/
// Rollen für GENAU EINEN Mandanten, siehe CLAUDE.md). Wie bei
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
// currentUser zu geben — die Navigation braucht nur, ob ein
@@ -119,6 +137,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
type navData struct {
IsBetreiber bool
IsFachebene bool
IsAdmin bool
}
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
@@ -126,7 +145,11 @@ type navData struct {
// currentUser).
func navFor(r *http.Request) navData {
role := currentUser(r).Role
return navData{IsBetreiber: role == "betreiber", IsFachebene: role == "verantwortlicher" || role == "pruefer"}
return navData{
IsBetreiber: role == "betreiber",
IsFachebene: role == "verantwortlicher" || role == "pruefer",
IsAdmin: role == "admin",
}
}
// currentUser liest den Nutzer, den requirePage/requireAPI in den

View 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)
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -35,6 +35,7 @@ type Store interface {
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)
GetSession(ctx context.Context, token string) (store.Session, error)
DeleteSession(ctx context.Context, token string) error
@@ -42,6 +43,8 @@ type Store interface {
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)
@@ -50,13 +53,25 @@ type Store interface {
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
@@ -106,10 +121,31 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
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/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 /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
mux.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

View File

@@ -24,31 +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
werkzeuge map[string]store.Werkzeug
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
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{},
werkzeuge: map[string]store.Werkzeug{},
bewertungen: map[string][]store.Bewertung{},
entscheidungen: map[string][]store.Entscheidung{},
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{},
}
}
@@ -93,7 +97,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
@@ -120,6 +124,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()
@@ -187,6 +203,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()
@@ -269,6 +320,85 @@ func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID strin
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()
@@ -333,6 +463,46 @@ func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID
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,
@@ -666,7 +836,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)

View File

@@ -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
@@ -275,7 +369,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 +387,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 +532,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 +581,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%;
}
}

View 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}}

View File

@@ -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>

View 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="/betreiber/werkzeuge">&larr; 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}}/betreiber/werkzeuge/{{.ID}}{{else}}/betreiber/werkzeuge{{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}}

View 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">&larr; 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}}

View File

@@ -6,16 +6,36 @@
<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 .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout" style="display:inline">
<button type="submit">Abmelden</button>
</form>
</nav>
<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/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>
{{end}}
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-

View File

@@ -0,0 +1,38 @@
{{define "mandant-werkzeuge"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Werkzeugkatalog</h1>
<p class="hinweis">
Der zentrale Katalog 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}}
</div>
</body>
</html>
{{end}}

View 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></p>
</div>
</body>
</html>
{{end}}

View 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">&larr; 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}}

View 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}}

View 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}}

View 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)
}
}

View 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)
}
}