Compare commits

..

7 Commits

Author SHA1 Message Date
noroot
9d2cb79424 fix: Startseite zeigt echte Übersicht statt Phase-1-Platzhalter
handleIndex war seit dem Produktwechsel unverändert ein Platzhalter aus
Schritt 1 ("Antrag stellen"/"Meine Anträge" — beides bereits in der
Seitenleiste vorhanden, die Startseite selbst zeigte nichts Eigenes).
Zeigt jetzt die Zahl eigener offener Anträge, und für die Fachebene
zusätzlich die Zahl offener Fälle im Posteingang; Betreiber sehen
einen Link zur Plattform statt leerer Antrags-Kacheln, die für sie
ohnehin nicht gelten. Bewusst ohne Wiedervorlage-Zahl — deren
Berechnung vergleicht jeden Genehmigungs-Snapshot gegen den aktuellen
Katalogstand und wäre für eine bei jedem Login geladene Startseite zu
teuer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:10:42 +02:00
noroot
4dd9bd88cb docs: fachliche Annahmen im Regelwerk bestätigt
Rangfolge der Datenklassen, auftragsdaten bei kein_training_auf_eingabe,
Vollständigkeit der drei "verboten"-Varianten und der intern-Fallback
wurden vom Produktverantwortlichen bestätigt (rules/OPEN.md). Weiterhin
offen: konkrete Löschfristen in Tagen, harte Filterung von
menschliche_aufsicht/kennzeichnungspflicht/dsfa_erforderlich.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:47:07 +02:00
noroot
96ac5d6b59 refactor: unbenutzte View-Felder entfernen (ID auf drei Templates nie referenziert)
Ein systematischer Audit nach demselben Muster wie beim Fragebogen-
Antworten-Bug fand drei Felder, die vom Handler befüllt, aber im
zugehörigen Template nie referenziert werden: antragDetailData.ID
(kein Formular auf der Seite braucht sie), anforderungView.ID (die
Beschreibung reicht als lesbare Anforderung, die interne Regel-ID ist
kein Nutzerinhalt), betreiberAccountDetailData.AccountID (die
Account-Detailseite ist rein lesend, kein Formular zeigt darauf).
Anders als der Fragebogen-Antworten-Bug ist hier keine für Nutzer
relevante Information verloren gegangen — die Felder waren schlicht
totes Gewicht, deshalb entfernt statt eine Anzeige dafür zu erfinden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 21:07:00 +02:00
noroot
29961f5657 fix: Fragebogen-Antworten auf der Antrag-/Fall-Detailseite anzeigen
Die tatsächlich gegebenen Fragebogen-Antworten (B-Fragen zur Datenklasse,
C-Fragen zur KI-VO-Einstufung, D-Werkzeugangaben) wurden bisher nirgends
angezeigt — weder dem antragstellenden Mitarbeiter noch der Fachebene
beim Entscheiden, obwohl der Code-Kommentar an handleAntragDetail schon
"zeigt einen Antrag mit allen Antworten" versprach. Nur die daraus
abgeleitete Bewertung war sichtbar, nicht die Grundlage, auf der sie
beruht — im Audit nicht nachvollziehbar (siehe CLAUDE.md, Grundregel)
und für die Fachebene beim Entscheiden nicht praktikabel.

antwortenAnzeige() baut aus den rohen JSON-Antworten eine lesbare Liste
mit denselben Fragen-Labels wie im Fragebogen selbst — jetzt sowohl auf
GET /antraege/{id} (Ebene 2) als auch GET /faelle/{id} (Ebene 3)
sichtbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 18:11:25 +02:00
noroot
3ec57f2786 fix: admin bekommt Fachebene-Rechte (Posteingang, Entscheiden, Register, Wiedervorlage)
Löst einen Blocker im Kernablauf: eine frisch registrierte Firma hat
nur einen admin-Login und konnte damit bislang keinen einzigen
eingereichten Antrag sehen oder entscheiden, weil requireFachebene nur
verantwortlicher/pruefer durchließ. Die Spezifikation will admin+
KI-Verantwortlicher ohnehin auf derselben Person (siehe CLAUDE.md,
Offene Punkte) — statt eines Datenmodell-Umbaus (roles-Array oder
zwei app_user-Zeilen pro Person) bekommt admin jetzt pragmatisch
dieselben Fachebene-Rechte wie verantwortlicher (hatEntscheidungsrecht
in fachebene_handlers.go). pruefer bleibt unverändert nur lesend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:58:06 +02:00
noroot
6884f28109 feat: CSV-Import für Mitarbeiter
Schließt den zweiten Punkt der geplanten Onboarding-Reihenfolge. Admin
lädt eine CSV mit Spalten email (Pflicht) und role (optional, Standard
mitarbeiter) unter GET /verwaltung/nutzer/import hoch. Wie beim
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
ein Einmalpasswort erzeugt und nur auf der Ergebnisseite direkt nach
dem Import angezeigt, danach nirgendwo mehr abrufbar. Kein
Alles-oder-nichts-Import: eine ungültige Zeile blockiert nicht die
übrigen, jede Zeile bekommt ihr eigenes Ergebnis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:52:02 +02:00
noroot
b91a58ccba feat: Einladungslink für die Mitarbeiter-Selbstanmeldung
Schließt den ersten Punkt der geplanten Onboarding-Reihenfolge (siehe
CLAUDE.md): ein Admin sieht unter GET /verwaltung/einladung einen
Sammellink (account.einladung_token, Migration 0013), den beliebig
viele Mitarbeitende nutzen können, um sich selbst mit eigenem Passwort
anzumelden (GET/POST /einladung/{token}, öffentlich, Rolle
mitarbeiter). Kein Ablaufdatum, aber per Admin jederzeit erneuerbar —
das macht den alten Link sofort ungültig, falls er versehentlich
außerhalb des Unternehmens geteilt wurde. Keine E-Mail-Bestätigung
(bewusst konsistent mit dem Rest des Produkts, das noch keine
E-Mail-Infrastruktur hat).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:41:36 +02:00
33 changed files with 1412 additions and 92 deletions

View File

@@ -67,7 +67,10 @@ Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5,
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber` dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
ist der Nachfolger dessen, was früher (vor dem Produktwechsel) ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
`admin` hieß. `admin` hieß. **Praktische Umsetzung dieser Doppelrolle:** `admin`
bekommt zusätzlich dieselben Fachebene-Rechte wie `verantwortlicher`
(siehe `requireFachebene`/`hatEntscheidungsrecht`) — pragmatisch gelöst
ohne Datenmodell-Umbau, siehe Offene Punkte weiter unten.
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
je Handler), nicht als grob unterschiedene Seitenbereiche. je Handler), nicht als grob unterschiedene Seitenbereiche.
@@ -325,12 +328,28 @@ leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis (noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
Ebene 5/Betreiber-Pflege gebaut ist). Ebene 5/Betreiber-Pflege gebaut ist).
**Mitarbeiter, geplante Reihenfolge:** (1) Einladungslink **Mitarbeiter, geplante Reihenfolge:** (1) ~~Einladungslink~~
(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV- **erledigt** (`internal/web/einladung_handlers.go`: `account.einladung_token`,
Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne Migration 0013, ein Sammellink pro Mandant statt Einmal-Token pro
vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto Person, kein Ablaufdatum, per Admin unter `GET /verwaltung/einladung`
entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma- jederzeit erneuerbar [macht den alten Link sofort ungültig].
Registrierung. `GET/POST /einladung/{token}` — öffentlich, Ebene 1 — legt einen Login
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
Einladung.), (2) ~~CSV-Import~~**erledigt** (`internal/web/csv_import_handlers.go`,
`GET /verwaltung/nutzer/import`: CSV mit Kopfzeile, Pflichtspalte
"email", optionale Spalte "role" [Standard `mitarbeiter`]. Wie beim
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
ein Einmalpasswort erzeugt [wiederverwendet `auth.NewSessionToken()`],
das NUR auf der Ergebnisseite direkt nach dem Import angezeigt wird und
danach nirgendwo mehr abrufbar ist. Kein Alles-oder-nichts-Import:
gültige Zeilen werden angelegt, eine ungültige Rolle oder bereits
vergebene E-Mail in einer Zeile blockiert nicht die übrigen Zeilen —
jede Zeile bekommt ihr eigenes Ergebnis [Passwort oder Fehlermeldung].),
(3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne vorheriges
Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto entsteht
nebenbei). (3)-(4) sind weiterhin nicht gebaut.
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht** **Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
@@ -715,16 +734,24 @@ journalctl -u deklarix -f
## Offene Punkte ## Offene Punkte
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil** - ~~Rangfolge der Datenklassen~~ (`rules/OPEN.md`, Punkt 1) — **am
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich 2026-08-29 vom Produktverantwortlichen bestätigt**, keine Änderung
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt nötig.
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten - ~~Anforderung `kein_training_auf_eingabe` und `auftragsdaten`~~
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu (`rules/OPEN.md`, Punkt 2) — **bestätigt**, `auftragsdaten` bleibt
bleiben, aber die Ableitung kann sich noch ändern. eingeschlossen.
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ — - ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. **erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. Ob
Weiterhin offen: ob die drei Varianten fachlich vollständig sind die drei Varianten fachlich vollständig sind, wurde am 2026-08-29
(siehe `rules/OPEN.md`, Punkt 3). ebenfalls **bestätigt** (siehe `rules/OPEN.md`, Punkt 3).
- ~~Fallback ohne zutreffende Datenklasse~~ (`rules/OPEN.md`, Punkt 5)
**bestätigt**, `intern` bleibt die konservative Standardannahme.
- **Welche Anforderungen hart gegen ein Werkzeug gefiltert werden**
(`rules/OPEN.md`, Punkt 6 — `avv_erforderlich`/`eu_verarbeitung`/
`kein_training_auf_eingabe` hart, `menschliche_aufsicht`/
`kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
— weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
Rückmeldung dazu bisher eingeholt.
- **Löschfristen je Datenklasse** (konkrete Tageswerte für - **Löschfristen je Datenklasse** (konkrete Tageswerte für
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt. `loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)** - **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
@@ -734,11 +761,20 @@ journalctl -u deklarix -f
ist ein bewusst noch nicht getroffener Architektur-Entscheid — ist ein bewusst noch nicht getroffener Architektur-Entscheid —
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
entstehen, die sonst nachträglich migriert werden müssten. entstehen, die sonst nachträglich migriert werden müssten.
- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding**die - ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~**pragmatisch
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder Stattdessen bekommt die Rolle `admin` in `requireFachebene` (Ebene 3)
zwei `app_user`-Zeilen für dieselbe Person? dieselben Rechte wie `verantwortlicher` — sieht Posteingang/Register/
Wiedervorlage und darf entscheiden (`hatEntscheidungsrecht` in
`fachebene_handlers.go`). Grund: ohne das könnte eine frisch
registrierte Firma mit nur einem `admin`-Login keinen einzigen
eingereichten Antrag sehen oder bearbeiten — der Kernablauf wäre für
Einzelpersonen-/Kleinfirmen-Onboarding komplett blockiert. `pruefer`
bleibt unverändert nur lesend, ohne Entscheidungsrecht. Kompromiss statt
"sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher
bewusst auf zwei verschiedene Personen verteilen will, funktioniert das
weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die - **Zentraler Werkzeugkatalog muss noch befüllt werden** — die
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu` /betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`

View File

@@ -10,20 +10,35 @@ import (
) )
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung // Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
// nutzt). Jeder Antrag gehört genau einem Account. // nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
type Account struct { type Account struct {
ID string ID string
Name string Name string
EinladungToken string
CreatedAt time.Time CreatedAt time.Time
} }
// CreateAccount legt einen neuen Mandanten an. const accountColumns = `id, name, einladung_token, created_at`
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
func scanAccount(row interface {
Scan(dest ...any) error
}) (Account, error) {
var a Account var a Account
err := s.Pool.QueryRow(ctx, ` err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
return a, err
}
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
row := s.Pool.QueryRow(ctx, `
INSERT INTO account (name) VALUES ($1) INSERT INTO account (name) VALUES ($1)
RETURNING id, name, created_at RETURNING `+accountColumns,
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt) name,
)
a, err := scanAccount(row)
if err != nil { if err != nil {
return Account{}, fmt.Errorf("store: create account: %w", err) return Account{}, fmt.Errorf("store: create account: %w", err)
} }
@@ -32,10 +47,8 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
// GetAccount liest einen Mandanten anhand seiner ID. // GetAccount liest einen Mandanten anhand seiner ID.
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
var a Account row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
err := s.Pool.QueryRow(ctx, ` a, err := scanAccount(row)
SELECT id, name, created_at FROM account WHERE id = $1
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound return Account{}, ErrNotFound
} }
@@ -45,12 +58,40 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
return a, nil return a, nil
} }
// GetAccountByEinladungToken liest den Mandanten zu einem
// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
// oder bereits erneuertem (damit ungültig gewordenem) Token.
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: get account by einladung token: %w", err)
}
return a, nil
}
// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
if err != nil {
return fmt.Errorf("store: regenerate einladung token: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListAccounts liefert alle Mandanten, neueste zuerst — für den // ListAccounts liefert alle Mandanten, neueste zuerst — für den
// Admin-Bereich (Accounts-Verwaltung). // Admin-Bereich (Accounts-Verwaltung).
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.Pool.Query(ctx, ` rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
SELECT id, name, created_at FROM account ORDER BY created_at DESC
`)
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err) return nil, fmt.Errorf("store: list accounts: %w", err)
} }
@@ -58,8 +99,8 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
var out []Account var out []Account
for rows.Next() { for rows.Next() {
var a Account a, err := scanAccount(rows)
if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil { if err != nil {
return nil, fmt.Errorf("store: scan account: %w", err) return nil, fmt.Errorf("store: scan account: %w", err)
} }
out = append(out, a) out = append(out, a)

View File

@@ -25,6 +25,56 @@ func TestAccountCRUD(t *testing.T) {
if got.Name != "Beispiel Agentur GmbH" { if got.Name != "Beispiel Agentur GmbH" {
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name) t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
} }
if got.EinladungToken == "" {
t.Error("expected a newly created account to have a non-empty EinladungToken")
}
}
func TestGetAccountByEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, acc.EinladungToken)
if err != nil {
t.Fatalf("GetAccountByEinladungToken: %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken returned a different account")
}
_, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err)
}
}
func TestRegenerateEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
alterToken := acc.EinladungToken
if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil {
t.Fatalf("RegenerateEinladungToken: %v", err)
}
if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("expected the old token to be invalid, got err = %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token")
if err != nil {
t.Fatalf("GetAccountByEinladungToken (neu): %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken (neu) returned a different account")
}
} }
func TestUserCRUD(t *testing.T) { func TestUserCRUD(t *testing.T) {

View File

@@ -0,0 +1 @@
ALTER TABLE account DROP COLUMN einladung_token;

View File

@@ -0,0 +1,8 @@
-- Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, "Einladung
-- annehmen" — siehe CLAUDE.md, Onboarding: "Einladungslink [Sammellink,
-- Selbstanmeldung, Abteilung beim ersten Antrag]"). Ein Token pro
-- Account, kein Ablaufdatum, per Admin erneuerbar (macht den alten
-- Link ungültig). DEFAULT gen_random_uuid()::text befüllt bestehende
-- Zeilen automatisch und liefert künftigen INSERTs einen Wert, ohne
-- dass store.CreateAccount etwas ändern muss.
ALTER TABLE account ADD COLUMN einladung_token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;

View File

@@ -0,0 +1,72 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin
// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md,
// Offene Punkte / requireFachebene): ein admin sieht den Posteingang
// und kann entscheiden, genau wie ein verantwortlicher.
func TestAdminSiehtPosteingang(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
_, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := getWithCookie(t, s, adminCookie, "/faelle")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
}
}
func TestAdminSiehtEntscheidenFormular(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := getWithCookie(t, s, adminCookie, "/faelle/"+antragID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
t.Errorf("expected the Entscheiden form for admin, got: %s", resp.Body.String())
}
}
func TestAdminKannEntscheiden(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
antrag, err := fs.GetAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetAntrag: %v", err)
}
if antrag.Status != "entschieden" {
t.Errorf("Status = %q, want entschieden", antrag.Status)
}
}
func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
for _, path := range []string{"/registereintraege", "/wiedervorlage"} {
resp := getWithCookie(t, s, adminCookie, path)
if resp.Code != http.StatusOK {
t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code)
}
}
}

View File

@@ -27,6 +27,115 @@ var datenfragen = []struct {
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"}, {"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
} }
var c2FolgeLabels = map[string]string{
"beschaeftigung": "Einstellung, Beförderung, Kündigung",
"kreditwuerdigkeit": "Kreditwürdigkeit",
"leistungsbewilligung": "Leistungsbewilligung",
"bildung": "Zugang zu Bildung",
"gesundheit": "medizinische Beurteilung",
}
var c3ArtLabels = map[string]string{
"emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz",
"social_scoring": "Social Scoring",
"biometrische_kategorisierung": "Biometrische Kategorisierung",
}
// antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige —
// siehe antwortenAnzeige.
type antwortZeile struct {
Frage string
Antwort string
}
func antwortLabel(v string) string {
switch v {
case "ja":
return "Ja"
case "nein":
return "Nein"
case "unsicher":
return "Unsicher"
default:
return v
}
}
func jaNein(v bool) string {
if v {
return "Ja"
}
return "Nein"
}
// antwortenAnzeige baut aus den rohen Fragebogen-Antworten
// (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels
// wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der
// antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden
// nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen
// Antworten, auf denen sie beruht — im Audit ist das nicht
// nachvollziehbar (siehe CLAUDE.md, Grundregel).
func antwortenAnzeige(antworten map[string]any) []antwortZeile {
var out []antwortZeile
for _, f := range datenfragen {
v, _ := antworten[f.Key].(string)
if v == "" {
v = "nein"
}
out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)})
}
c1, _ := antworten["c1"].(bool)
out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)})
c2, _ := antworten["c2"].(bool)
c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)}
if c2 {
if folge, _ := antworten["c2_folge"].(string); folge != "" {
if label, ok := c2FolgeLabels[folge]; ok {
c2Zeile.Antwort += " — " + label
}
}
}
out = append(out, c2Zeile)
c3, _ := antworten["c3"].(bool)
c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)}
if c3 {
if art, _ := antworten["c3_art"].(string); art != "" {
if label, ok := c3ArtLabels[art]; ok {
c3Zeile.Antwort += " — " + label
}
}
}
out = append(out, c3Zeile)
c4, _ := antworten["c4"].(bool)
out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)})
c5, _ := antworten["c5"].(bool)
out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)})
if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" {
out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug})
}
if zugang, _ := antworten["d_zugang"].(string); zugang != "" {
label := "Privater Zugang"
if zugang == "firma" {
label = "Firmenkonto"
}
out = append(out, antwortZeile{Frage: "Zugang", Antwort: label})
}
if geraet, _ := antworten["d_geraet"].(string); geraet != "" {
label := "Privatgerät"
if geraet == "firma" {
label = "Firmengerät"
}
out = append(out, antwortZeile{Frage: "Gerät", Antwort: label})
}
return out
}
type antragFormData struct { type antragFormData struct {
Title string Title string
Nav navData Nav navData
@@ -251,7 +360,6 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
} }
type anforderungView struct { type anforderungView struct {
ID string
Beschreibung string Beschreibung string
Herleitung string Herleitung string
} }
@@ -278,14 +386,13 @@ type bewertungView struct {
type antragDetailData struct { type antragDetailData struct {
Title string Title string
Nav navData Nav navData
ID string
Titel string Titel string
Beschreibung string Beschreibung string
Ergebnis string Ergebnis string
Haeufigkeit string Haeufigkeit string
Status string Status string
CreatedAt string CreatedAt string
Antworten map[string]any Antworten []antwortZeile
Bewertung *bewertungView Bewertung *bewertungView
} }
@@ -306,9 +413,9 @@ func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
} }
data := antragDetailData{ data := antragDetailData{
Title: "Antrag", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel, Title: "Antrag", Nav: navFor(r), Titel: antrag.Titel,
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit, Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antworten, Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
} }
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID) bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
@@ -333,7 +440,7 @@ func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewert
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"), CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
} }
for _, a := range b.Anforderungen { for _, a := range b.Anforderungen {
v.Anforderungen = append(v.Anforderungen, anforderungView{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung}) v.Anforderungen = append(v.Anforderungen, anforderungView{Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
} }
for _, id := range b.ZulaessigeWerkzeuge { for _, id := range b.ZulaessigeWerkzeuge {
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id)) v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))

View File

@@ -75,8 +75,15 @@ func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
if detailResp.Code != http.StatusOK { if detailResp.Code != http.StatusOK {
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String()) t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
} }
if !strings.Contains(detailResp.Body.String(), "Angebotstexte generieren") { body := detailResp.Body.String()
t.Errorf("expected the antrag title on the detail page, got: %s", detailResp.Body.String()) if !strings.Contains(body, "Angebotstexte generieren") {
t.Errorf("expected the antrag title on the detail page, got: %s", body)
}
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
t.Errorf("expected the b1 Fragebogen-Frage on the detail page, got: %s", body)
}
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
t.Errorf("expected the c5 Fragebogen-Frage on the detail page, got: %s", body)
} }
} }

View File

@@ -31,8 +31,9 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb // Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows). // wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne // Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink // Mitarbeiter über dieses Formular — die kommen über den
// (spätere Ausbaustufe), nicht über dieses Formular. // Einladungslink (siehe einladung_handlers.go) oder künftig CSV-
// Import/Mandantenlink (weiterhin nicht gebaut).
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"}) s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})

View File

@@ -80,7 +80,6 @@ type betreiberUserView struct {
type betreiberAccountDetailData struct { type betreiberAccountDetailData struct {
Title string Title string
Nav navData Nav navData
AccountID string
Name string Name string
Users []betreiberUserView Users []betreiberUserView
CreatedAt string CreatedAt string
@@ -101,7 +100,7 @@ func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Req
} }
data := betreiberAccountDetailData{ data := betreiberAccountDetailData{
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name, Title: "Account", Nav: navFor(r), Name: acc.Name,
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"), CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
} }
for _, u := range users { for _, u := range users {

View File

@@ -0,0 +1,184 @@
// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten
// Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine
// E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie
// beim einzelnen "Nutzer anlegen" generiert der Import ein
// Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG
// sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern:
// gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet
// (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer
// Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren).
package web
import (
"encoding/csv"
"fmt"
"io"
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/auth"
)
type csvMitarbeiterZeile struct {
Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen
Email string
Role string
}
// parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile.
// Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder
// "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst
// beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine
// einzelne falsche Rolle nicht den ganzen Import verhindert).
func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) {
cr := csv.NewReader(r)
cr.TrimLeadingSpace = true
cr.FieldsPerRecord = -1
header, err := cr.Read()
if err != nil {
return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err)
}
emailCol, roleCol := -1, -1
for i, h := range header {
switch strings.ToLower(strings.TrimSpace(h)) {
case "email", "e-mail":
emailCol = i
case "role", "rolle":
roleCol = i
}
}
if emailCol == -1 {
return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`)
}
var out []csvMitarbeiterZeile
zeile := 1
for {
record, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err)
}
zeile++
if emailCol >= len(record) {
continue
}
email := strings.TrimSpace(record[emailCol])
if email == "" {
continue
}
role := "mitarbeiter"
if roleCol != -1 && roleCol < len(record) {
if v := strings.TrimSpace(record[roleCol]); v != "" {
role = v
}
}
out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role})
}
return out, nil
}
// generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für
// eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt
// einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine
// Länge, die sich noch manuell weitergeben lässt.
func generateEinmalPasswort() (string, error) {
token, err := auth.NewSessionToken()
if err != nil {
return "", err
}
return token[:16], nil
}
type csvImportFormData struct {
Title string
Nav navData
Error string
}
func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) {
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)}
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) {
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg}
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type csvImportErgebnisZeile struct {
Zeile int
Email string
Role string
Passwort string // nur bei Erfolg gesetzt
Fehler string // nur bei Fehler gesetzt
}
type csvImportErgebnisData struct {
Title string
Nav navData
Ergebnisse []csvImportErgebnisZeile
}
// handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt
// pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis
// (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite
// gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden.
func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste
s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error())
return
}
file, _, err := r.FormFile("csv")
if err != nil {
s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen")
return
}
defer file.Close()
zeilen, err := parseCSVMitarbeiter(file)
if err != nil {
s.renderCSVImportForm(w, r, err.Error())
return
}
if len(zeilen) == 0 {
s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen")
return
}
accountID := currentUser(r).AccountID
var ergebnisse []csvImportErgebnisZeile
for _, z := range zeilen {
if !istMandantenRolle(z.Role) {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)})
continue
}
passwort, err := generateEinmalPasswort()
if err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"})
continue
}
hash, err := auth.HashPassword(passwort)
if err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()})
continue
}
if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"})
continue
}
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort})
}
data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse}
if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,149 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/web"
)
// postCSV lädt csvContent als multipart-Formular mit Feldname "csv" hoch
// — reale Browser-Uploads laufen genauso über multipart/form-data.
func postCSV(t *testing.T, s *web.Server, cookie *http.Cookie, path, csvContent string) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("csv", "mitarbeiter.csv")
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := fw.Write([]byte(csvContent)); err != nil {
t.Fatalf("Write: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("mw.Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, path, &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestAdminCanImportMitarbeiterPerCSV(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "lisa@example.com") || !strings.Contains(body, "tom@example.com") {
t.Fatalf("expected both imported emails in the result, got: %s", body)
}
lisa, err := fs.GetUserByEmail(context.Background(), "lisa@example.com")
if err != nil {
t.Fatalf("GetUserByEmail (lisa): %v", err)
}
if lisa.Role != "verantwortlicher" {
t.Errorf("lisa.Role = %q, want verantwortlicher", lisa.Role)
}
tom, err := fs.GetUserByEmail(context.Background(), "tom@example.com")
if err != nil {
t.Fatalf("GetUserByEmail (tom): %v", err)
}
if tom.Role != "mitarbeiter" {
t.Errorf("tom.Role = %q, want default mitarbeiter", tom.Role)
}
}
func TestCSVImportZeigtEinmalpasswortDasFunktioniert(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email\nneu@example.com\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
// Das angezeigte Einmalpasswort aus der <code>-Zelle extrahieren.
body := resp.Body.String()
start := strings.Index(body, "<code>")
end := strings.Index(body, "</code>")
if start == -1 || end == -1 {
t.Fatalf("expected a <code>-Zelle mit dem Einmalpasswort, got: %s", body)
}
passwort := body[start+len("<code>") : end]
loginResp := postForm(t, s, nil, "/login", map[string][]string{
"email": {"neu@example.com"}, "password": {passwort},
})
if loginResp.Code != http.StatusSeeOther {
t.Fatalf("login with the generated password: status = %d, body: %s", loginResp.Code, loginResp.Body.String())
}
}
func TestCSVImportMeldetUngueltigeRolleOhneDieAnderenZuBlockieren(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nboese@example.com,betreiber\ngut@example.com,mitarbeiter\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if _, err := fs.GetUserByEmail(context.Background(), "boese@example.com"); err == nil {
t.Error("expected no user to be created for an invalid role (betreiber)")
}
if _, err := fs.GetUserByEmail(context.Background(), "gut@example.com"); err != nil {
t.Error("expected the valid row to still be imported despite the earlier invalid row")
}
}
func TestCSVImportOhneDateiZeigtFehler(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
var body bytes.Buffer
mw := multipart.NewWriter(&body)
if err := mw.Close(); err != nil {
t.Fatalf("mw.Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/verwaltung/nutzer/import", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(adminCookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "CSV-Datei auswählen") {
t.Errorf("expected an error asking for a file, got: %s", w.Body.String())
}
}
func TestMitarbeiterCannotAccessCSVImport(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer/import")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}

View File

@@ -0,0 +1,64 @@
package web
import (
"strings"
"testing"
)
func TestParseCSVMitarbeiterMitRolle(t *testing.T) {
csv := "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 2 {
t.Fatalf("zeilen = %+v, want 2", zeilen)
}
if zeilen[0].Email != "lisa@example.com" || zeilen[0].Role != "verantwortlicher" {
t.Errorf("zeilen[0] = %+v, unerwartet", zeilen[0])
}
if zeilen[1].Email != "tom@example.com" || zeilen[1].Role != "mitarbeiter" {
t.Errorf("zeilen[1] = %+v, want default role mitarbeiter", zeilen[1])
}
}
func TestParseCSVMitarbeiterOhneRollenspalte(t *testing.T) {
csv := "email\nlisa@example.com\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 1 || zeilen[0].Role != "mitarbeiter" {
t.Fatalf("zeilen = %+v, unerwartet", zeilen)
}
}
func TestParseCSVMitarbeiterUeberspringtLeereEmail(t *testing.T) {
csv := "email,role\n,mitarbeiter\nlisa@example.com,mitarbeiter\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 1 || zeilen[0].Email != "lisa@example.com" {
t.Fatalf("zeilen = %+v, want exactly lisa@example.com", zeilen)
}
}
func TestParseCSVMitarbeiterOhneEmailSpalteSchlaegtFehl(t *testing.T) {
csv := "name,role\nLisa,mitarbeiter\n"
_, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err == nil {
t.Fatal("expected an error when the header has no email column")
}
}
func TestParseCSVMitarbeiterZeilennummernZaehlenKopfzeileAlsEins(t *testing.T) {
csv := "email\nlisa@example.com\ntom@example.com\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 2 || zeilen[0].Zeile != 2 || zeilen[1].Zeile != 3 {
t.Fatalf("zeilen = %+v, want Zeile 2 and 3", zeilen)
}
}

View File

@@ -0,0 +1,140 @@
// Ebene 1 (öffentlich) — Mitarbeiter-Selbstanmeldung über den
// Sammellink eines Mandanten ("Einladung annehmen", siehe CLAUDE.md,
// Onboarding: "Einladungslink [Sammellink, Selbstanmeldung, Abteilung
// beim ersten Antrag]"). Ein Link gilt für beliebig viele Mitarbeiter
// desselben Mandanten, kein Einmal-Token pro Person — die Abteilung
// wählt der/die Mitarbeiter:in erst beim ersten Antrag (Fragebogen
// Abschnitt A), nicht hier.
package web
import (
"net/http"
"github.com/netcell-it/deklarix/internal/auth"
)
type einladungFormData struct {
Title string
Firmenname string
Error string
}
// handleEinladungForm zeigt das Selbstanmelde-Formular für einen
// gültigen Einladungslink. Ein unbekanntes oder erneuertes (damit
// ungültig gewordenes) Token bekommt dieselbe Fehlermeldung wie ein
// abgelaufener Link — es wird nicht unterschieden, ob das Token nie
// existiert hat oder nur nicht mehr gültig ist.
func (s *Server) handleEinladungForm(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
if err != nil {
s.renderEinladungError(w)
return
}
s.renderEinladungForm(w, acc.Name, "")
}
func (s *Server) renderEinladungForm(w http.ResponseWriter, firmenname, errMsg string) {
data := einladungFormData{Title: "Einladung annehmen", Firmenname: firmenname, Error: errMsg}
if err := s.templates.ExecuteTemplate(w, "einladung", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) renderEinladungError(w http.ResponseWriter) {
if err := s.templates.ExecuteTemplate(w, "einladung-ungueltig", authPageData{Title: "Einladung"}); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleEinladungAnnehmen legt einen neuen Login mit Rolle
// "mitarbeiter" im Mandanten des Einladungslinks an und meldet die
// Person direkt an — wie bei der Firma-Registrierung gibt es keine
// E-Mail-Bestätigung (noch nicht gebaut, siehe CLAUDE.md, Onboarding).
func (s *Server) handleEinladungAnnehmen(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
if err != nil {
s.renderEinladungError(w)
return
}
if err := r.ParseForm(); err != nil {
s.renderEinladungForm(w, acc.Name, "ungültiges Formular")
return
}
email := r.FormValue("email")
password := r.FormValue("password")
if email == "" || password == "" {
s.renderEinladungForm(w, acc.Name, "E-Mail und Passwort sind Pflicht")
return
}
passwordHash, err := auth.HashPassword(password)
if err != nil {
s.renderEinladungForm(w, acc.Name, err.Error())
return
}
user, err := s.store.CreateUser(r.Context(), acc.ID, email, passwordHash, "mitarbeiter")
if err != nil {
s.renderEinladungForm(w, acc.Name, "Konto konnte nicht angelegt werden — E-Mail evtl. schon vergeben")
return
}
if err := s.startSession(w, r, user.ID); err != nil {
s.renderEinladungForm(w, acc.Name, "Sitzung konnte nicht gestartet werden")
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
type einladungVerwaltungData struct {
Title string
Nav navData
Link string
}
// einladungBasisURL leitet Schema+Host aus dem Request ab, damit der
// vollständige Link (nicht nur das Token) angezeigt werden kann — hinter
// einem Reverse-Proxy (siehe CLAUDE.md, Testsystem: nginx+TLS) trägt
// r.TLS bei HTTPS-Terminierung am Proxy nichts, deshalb zusätzlich
// X-Forwarded-Proto berücksichtigen.
func einladungBasisURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host
}
// handleEinladungVerwaltung zeigt den aktuellen Sammellink des eigenen
// Mandanten zum Kopieren.
func (s *Server) handleEinladungVerwaltung(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
if err != nil {
http.Error(w, "Account konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := einladungVerwaltungData{
Title: "Einladungslink", Nav: navFor(r),
Link: einladungBasisURL(r) + "/einladung/" + acc.EinladungToken,
}
if err := s.templates.ExecuteTemplate(w, "einladung-verwaltung", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleEinladungErneuern ersetzt den Sammellink des eigenen Mandanten
// — der alte Link wird sofort ungültig (z. B. wenn er versehentlich
// außerhalb des Unternehmens geteilt wurde).
func (s *Server) handleEinladungErneuern(w http.ResponseWriter, r *http.Request) {
newToken, err := auth.NewSessionToken()
if err != nil {
http.Error(w, "Neuer Link konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.RegenerateEinladungToken(r.Context(), currentUser(r).AccountID, newToken); err != nil {
http.Error(w, "Link konnte nicht erneuert werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/einladung", http.StatusSeeOther)
}

View File

@@ -0,0 +1,144 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
func TestEinladungFormZeigtFirmenname(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
resp := getWithCookie(t, s, nil, "/einladung/"+acc.EinladungToken)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Beispiel GmbH") {
t.Errorf("expected the Firmenname on the Einladung page, got: %s", resp.Body.String())
}
}
func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := getWithCookie(t, s, nil, "/einladung/unbekanntes-token")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "ungültig") {
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
}
}
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
resp := postForm(t, s, nil, "/einladung/"+acc.EinladungToken, url.Values{
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if len(resp.Result().Cookies()) == 0 {
t.Fatal("expected a session cookie to be set")
}
user, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if user.Role != "mitarbeiter" || user.AccountID != acc.ID {
t.Errorf("User = %+v, want role mitarbeiter in account %s", user, acc.ID)
}
cookie := resp.Result().Cookies()[0]
protected := getWithCookie(t, s, cookie, "/antraege")
if protected.Code != http.StatusOK {
t.Fatalf("expected the new session to work, status = %d", protected.Code)
}
}
func TestEinladungAnnehmenMitUnbekanntemTokenSchlaegtFehl(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := postForm(t, s, nil, "/einladung/unbekanntes-token", url.Values{
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "ungültig") {
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
}
if len(fs.users) != 0 {
t.Error("expected no user to be created for an invalid token")
}
}
func TestAdminSiehtEinladungslink(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := getWithCookie(t, s, adminCookie, "/verwaltung/einladung")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "/einladung/") {
t.Errorf("expected the Einladungslink on the page, got: %s", resp.Body.String())
}
}
func TestAdminKannEinladungslinkErneuern(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
altesToken := fs.accounts[admin.AccountID].EinladungToken
resp := postForm(t, s, adminCookie, "/verwaltung/einladung/erneuern", url.Values{})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
neuesToken := fs.accounts[admin.AccountID].EinladungToken
if neuesToken == altesToken {
t.Fatal("expected the token to change")
}
oldLinkResp := getWithCookie(t, s, nil, "/einladung/"+altesToken)
if !strings.Contains(oldLinkResp.Body.String(), "ungültig") {
t.Errorf("expected the old link to be invalid, got: %s", oldLinkResp.Body.String())
}
newLinkResp := getWithCookie(t, s, nil, "/einladung/"+neuesToken)
if newLinkResp.Code != http.StatusOK || strings.Contains(newLinkResp.Body.String(), "ungültig") {
t.Errorf("expected the new link to work, status=%d body: %s", newLinkResp.Code, newLinkResp.Body.String())
}
}
func TestMitarbeiterCannotAccessEinladungsverwaltung(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
resp := getWithCookie(t, s, cookie, "/verwaltung/einladung")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}

View File

@@ -5,6 +5,7 @@
package web package web
import ( import (
"encoding/json"
"net/http" "net/http"
"time" "time"
@@ -71,6 +72,7 @@ type fallDetailData struct {
Haeufigkeit string Haeufigkeit string
Status string Status string
CreatedAt string CreatedAt string
Antworten []antwortZeile
Bewertung *bewertungView Bewertung *bewertungView
Entscheidung *entscheidungView Entscheidung *entscheidungView
CanEntscheiden bool CanEntscheiden bool
@@ -78,6 +80,13 @@ type fallDetailData struct {
Error string Error string
} }
// hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe
// requireFachebene: die Spezifikation will admin+verantwortlicher auf
// derselben Person, ohne dass app_user.role zwei Werte tragen kann.
func hatEntscheidungsrecht(role string) bool {
return role == "verantwortlicher" || role == "admin"
}
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das // vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob // System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
// eine tatsächliche Entscheidung davon abweicht (dann ist die // eine tatsächliche Entscheidung davon abweicht (dann ist die
@@ -102,10 +111,15 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
return return
} }
var antworten map[string]any
if len(antrag.Antworten) > 0 {
_ = json.Unmarshal(antrag.Antworten, &antworten)
}
data := fallDetailData{ data := fallDetailData{
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel, Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit, Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
} }
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID) bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
@@ -115,7 +129,7 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)}) data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)})
} }
} }
data.CanEntscheiden = currentUser(r).Role == "verantwortlicher" && antrag.Status == "eingereicht" && data.Bewertung != nil data.CanEntscheiden = hatEntscheidungsrecht(currentUser(r).Role) && antrag.Status == "eingereicht" && data.Bewertung != nil
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID) entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
if err == nil { if err == nil {
@@ -147,13 +161,14 @@ func gueltigkeitFuer(einstufung string) time.Time {
} }
// handleFallEntscheiden speichert die Entscheidung eines/einer // handleFallEntscheiden speichert die Entscheidung eines/einer
// Verantwortlichen. Prüfer haben dieselbe Sicht, aber kein // Verantwortlichen (oder admin, siehe hatEntscheidungsrecht). Prüfer
// Entscheidungsrecht — anders als bei requireFachebene (404, weil die // haben dieselbe Sicht, aber kein Entscheidungsrecht — anders als bei
// Seite für sie gar nicht existieren soll) ist das hier ein 403: ein // requireFachebene (404, weil die Seite für sie gar nicht existieren
// Prüfer darf wissen, dass der Fall existiert, nur nicht entscheiden. // soll) ist das hier ein 403: ein Prüfer darf wissen, dass der Fall
// existiert, nur nicht entscheiden.
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
if user.Role != "verantwortlicher" { if !hatEntscheidungsrecht(user.Role) {
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden) http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
return return
} }

View File

@@ -87,6 +87,28 @@ func TestFallDetailShowsDecideFormOnlyForVerantwortlicher(t *testing.T) {
} }
} }
// TestFallDetailZeigtFragebogenAntworten: die Fachebene muss die
// tatsächlich gegebenen Fragebogen-Antworten sehen, nicht nur die
// daraus abgeleitete Bewertung — sonst lässt sich eine Entscheidung
// nicht nachvollziehbar treffen (siehe CLAUDE.md, Grundregel).
func TestFallDetailZeigtFragebogenAntworten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
t.Errorf("expected the b1 Fragebogen-Frage on the fall-detail page, got: %s", body)
}
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
t.Errorf("expected the c5 Fragebogen-Frage on the fall-detail page, got: %s", body)
}
}
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) { func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
fs := newFakeStore() fs := newFakeStore()
s := newServer(t, fs) s := newServer(t, fs)

View File

@@ -13,13 +13,47 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
type indexData struct { type indexData struct {
Title string Title string
Nav navData Nav navData
OffeneAntraege int
ZeigePosteingang bool
OffenerPosteingang int
} }
// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für // handleIndex ist die Startseite nach der Anmeldung — ein kurzer
// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte // Überblick statt reiner Navigations-Duplikate (die Links stehen
// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge. // eh schon in der Seitenleiste): eigene offene Anträge, und für die
// Fachebene zusätzlich die Zahl offener Fälle im Posteingang. Bewusst
// ohne die Wiedervorlage-Zahl hier — deren Berechnung vergleicht jeden
// aktiven Genehmigungs-Snapshot gegen den aktuellen Katalogstand
// (siehe handleWiedervorlageListe) und wäre für eine Startseite, die
// bei jedem Login geladen wird, zu teuer.
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
data := indexData{Title: "Start", Nav: navFor(r)} user := currentUser(r)
nav := navFor(r)
data := indexData{Title: "Start", Nav: nav}
if !nav.IsBetreiber {
eigene, err := s.store.ListAntraegeForUser(r.Context(), user.ID)
if err == nil {
for _, a := range eigene {
if a.Status == "eingereicht" {
data.OffeneAntraege++
}
}
}
}
if nav.IsFachebene {
data.ZeigePosteingang = true
alle, err := s.store.ListAntraegeForAccount(r.Context(), user.AccountID)
if err == nil {
for _, a := range alle {
if a.Status == "eingereicht" {
data.OffenerPosteingang++
}
}
}
}
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil { if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }

View File

@@ -0,0 +1,63 @@
package web_test
import (
"net/http"
"strings"
"testing"
)
func TestIndexZeigtEigeneOffeneAntraege(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
postForm(t, s, cookie, "/antraege", fullAntragForm())
resp := getWithCookie(t, s, cookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `href="/antraege">Meine offenen Anträge <span class="status">1</span>`) {
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", resp.Body.String())
}
}
func TestIndexZeigtPosteingangNurFuerFachebene(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
resp := getWithCookie(t, s, verantwortlicherCookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `href="/faelle">Posteingang <span class="status">1</span>`) {
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", resp.Body.String())
}
}
func TestIndexZeigtKeinenPosteingangFuerMitarbeiter(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
resp := getWithCookie(t, s, cookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if strings.Contains(resp.Body.String(), "Posteingang") {
t.Errorf("expected no Posteingang tile for mitarbeiter, got: %s", resp.Body.String())
}
}
func TestIndexZeigtPlattformLinkFuerBetreiber(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
resp := getWithCookie(t, s, betreiberCookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `href="/betreiber">Zur Plattform`) {
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
}
if strings.Contains(resp.Body.String(), "Antrag stellen") {
t.Errorf("expected no antrag tiles for betreiber, got: %s", resp.Body.String())
}
}

View File

@@ -91,9 +91,16 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und // requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe // Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der // CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
// Seite tatsächlich entschieden werden darf, prüft der einzelne // will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei // verantwortlicher ist ("Admin und KI-Verantwortlicher" — siehe CLAUDE.md,
// Offene Punkte), app_user.role kennt aber nur einen Wert. Statt eines
// Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person)
// bekommt admin hier pragmatisch dieselben Fachebene-Rechte wie
// verantwortlicher (inkl. Entscheidungsrecht, siehe handleFallEntscheiden)
// — ohne das könnte eine frisch registrierte Firma mit nur einem
// admin-Login keinen einzigen eingereichten Antrag sehen oder
// bearbeiten. pruefer bleibt unverändert nur lesend. Wie bei
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein // requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter") // angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
// nicht erfährt, dass es die Seite überhaupt gibt. // nicht erfährt, dass es die Seite überhaupt gibt.
@@ -104,7 +111,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
} }
if user.Role != "verantwortlicher" && user.Role != "pruefer" { if user.Role != "verantwortlicher" && user.Role != "pruefer" && user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound) http.Error(w, "nicht gefunden", http.StatusNotFound)
return return
} }
@@ -147,7 +154,8 @@ func navFor(r *http.Request) navData {
role := currentUser(r).Role role := currentUser(r).Role
return navData{ return navData{
IsBetreiber: role == "betreiber", IsBetreiber: role == "betreiber",
IsFachebene: role == "verantwortlicher" || role == "pruefer", // admin sieht die Fachebene mit, siehe requireFachebene.
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
IsAdmin: role == "admin", IsAdmin: role == "admin",
} }
} }

View File

@@ -30,6 +30,8 @@ var staticFS embed.FS
type Store interface { type Store interface {
CreateAccount(ctx context.Context, name string) (store.Account, error) CreateAccount(ctx context.Context, name string) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, error) GetAccount(ctx context.Context, id string) (store.Account, error)
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
ListAccounts(ctx context.Context) ([]store.Account, error) ListAccounts(ctx context.Context) ([]store.Account, error)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error) GetUserByEmail(ctx context.Context, email string) (store.User, error)
@@ -110,6 +112,8 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /register", s.handleRegisterForm) mux.HandleFunc("GET /register", s.handleRegisterForm)
mux.HandleFunc("POST /register", s.handleRegister) mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("GET /einladung/{token}", s.handleEinladungForm)
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
mux.HandleFunc("GET /login", s.handleLoginForm) mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("POST /logout", s.handleLogout)
@@ -130,6 +134,10 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate)) 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}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren))
mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren)) mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
mux.HandleFunc("GET /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportForm))
mux.HandleFunc("POST /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportUpload))
mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe)) mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate)) mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate))
mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete)) mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete))

View File

@@ -64,11 +64,35 @@ func (f *fakeStore) newID() string {
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) { func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()} id := f.newID()
acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()}
f.accounts[acc.ID] = acc f.accounts[acc.ID] = acc
return acc, nil return acc, nil
} }
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, acc := range f.accounts {
if acc.EinladungToken == token {
return acc, nil
}
}
return store.Account{}, store.ErrNotFound
}
func (f *fakeStore) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[accountID]
if !ok {
return store.ErrNotFound
}
acc.EinladungToken = newToken
f.accounts[accountID] = acc
return nil
}
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) { func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()

View File

@@ -13,6 +13,15 @@
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p> <p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p> <p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
{{if .Antworten}}
<h2>Antworten im Fragebogen</h2>
<ul class="findings">
{{range .Antworten}}
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
{{end}}
</ul>
{{end}}
{{if .Bewertung}} {{if .Bewertung}}
{{template "bewertung-block" .Bewertung}} {{template "bewertung-block" .Bewertung}}
{{else}} {{else}}

View File

@@ -0,0 +1,22 @@
{{define "einladung"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Einladung annehmen</h1>
<p class="hinweis">Sie treten dem Konto von <strong>{{.Firmenname}}</strong> bei.</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
<label for="password">Passwort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
<button type="submit">Konto anlegen</button>
</form>
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,12 @@
{{define "einladung-ungueltig"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Einladung</h1>
<p class="fehler">Dieser Einladungslink ist ungültig oder wurde erneuert. Bitte einen aktuellen Link anfragen.</p>
<p><a href="/login">Zur Anmeldung</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,20 @@
{{define "einladung-verwaltung"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Einladungslink</h1>
<p class="hinweis">
Ein Sammellink für alle Mitarbeitenden — jede Person, die den Link
öffnet, kann sich selbst mit eigenem Passwort anmelden (Rolle
"mitarbeiter"). Kein Ablaufdatum, aber jederzeit erneuerbar.
</p>
<p><input type="text" readonly value="{{.Link}}" onclick="this.select()"></p>
<form method="post" action="/verwaltung/einladung/erneuern" onsubmit="return confirm('Neuen Link erzeugen? Der alte Link wird dann sofort ungültig.')">
<button type="submit" class="entfernen">Link erneuern</button>
</form>
</div>
</body>
</html>
{{end}}

View File

@@ -13,6 +13,15 @@
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p> <p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p> <p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
{{if .Antworten}}
<h2>Antworten im Fragebogen</h2>
<ul class="findings">
{{range .Antworten}}
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
{{end}}
</ul>
{{end}}
{{if .Bewertung}} {{if .Bewertung}}
{{template "bewertung-block" .Bewertung}} {{template "bewertung-block" .Bewertung}}
{{else}} {{else}}

View File

@@ -5,10 +5,19 @@
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<h1>Willkommen bei Deklarix</h1> <h1>Willkommen bei Deklarix</h1>
{{if .Nav.IsBetreiber}}
<ul class="admin-kacheln">
<li><a href="/betreiber">Zur Plattform</a></li>
</ul>
{{else}}
<ul class="admin-kacheln"> <ul class="admin-kacheln">
<li><a href="/antraege/neu">Antrag stellen</a></li> <li><a href="/antraege/neu">Antrag stellen</a></li>
<li><a href="/antraege">Meine Anträge</a></li> <li><a href="/antraege">Meine offenen Anträge <span class="status">{{.OffeneAntraege}}</span></a></li>
{{if .ZeigePosteingang}}
<li><a href="/faelle">Posteingang <span class="status">{{.OffenerPosteingang}}</span></a></li>
{{end}}
</ul> </ul>
{{end}}
</div> </div>
</body> </body>
</html> </html>

View File

@@ -28,6 +28,7 @@
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}} {{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}} {{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}} {{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}} {{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}} {{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}} {{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}

View File

@@ -0,0 +1,27 @@
{{define "nutzer-import"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/verwaltung/nutzer">&larr; Nutzerverwaltung</a></p>
<h1>Mitarbeiter per CSV importieren</h1>
<p class="hinweis">
CSV mit Kopfzeile, Pflichtspalte "email", optionale Spalte "role"
(mitarbeiter/verantwortlicher/pruefer/admin — Standard: mitarbeiter).
Es gibt keine Einladungsmail: nach dem Import wird pro Zeile ein
Einmalpasswort angezeigt, das nur DIESES EINE MAL sichtbar ist — bitte
auf dieser Seite kopieren und sicher weitergeben, bevor sie
geschlossen wird.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/verwaltung/nutzer/import" enctype="multipart/form-data">
<label for="csv">CSV-Datei</label>
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
<button type="submit">Importieren</button>
</form>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,33 @@
{{define "nutzer-import-ergebnis"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Import-Ergebnis</h1>
<p class="fehler">
Die Einmalpasswörter unten werden nach dieser Seite nirgendwo mehr
angezeigt — jetzt kopieren und sicher weitergeben.
</p>
<div class="table-scroll">
<table>
<thead>
<tr><th>Zeile</th><th>E-Mail</th><th>Rolle</th><th>Ergebnis</th></tr>
</thead>
<tbody>
{{range .Ergebnisse}}
<tr>
<td>{{.Zeile}}</td>
<td>{{.Email}}</td>
<td>{{if .Fehler}}—{{else}}{{.Role}}{{end}}</td>
<td>{{if .Fehler}}<span class="fehler">{{.Fehler}}</span>{{else}}<code>{{.Passwort}}</code>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p><a href="/verwaltung/nutzer">Zur Nutzerverwaltung</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -25,7 +25,7 @@
</li> </li>
{{end}} {{end}}
</ul> </ul>
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a></p> <p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a> · <a href="/verwaltung/nutzer/import">Per CSV importieren</a></p>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -5,11 +5,12 @@ bewusst nicht geraten, sondern hier notiert — vor Phase 3 (Ableitungen
und harte Filter) mit dem fachlich Verantwortlichen (z. B. der/dem und harte Filter) mit dem fachlich Verantwortlichen (z. B. der/dem
betrieblichen Datenschutzbeauftragten) klären. betrieblichen Datenschutzbeauftragten) klären.
## 1. Rangfolge der Datenklassen ## 1. Rangfolge der Datenklassen — BESTÄTIGT (2026-08-29)
Die Spezifikation sagt "höchste zutreffende Stufe gewinnt", gibt aber Die Spezifikation sagt "höchste zutreffende Stufe gewinnt", gibt aber
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt aktuell keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt diese
diese Reihenfolge an (niedrigster zu höchstem Rang): Reihenfolge an (niedrigster zu höchstem Rang) — **vom Produktverantwortlichen
bestätigt, keine Änderung nötig:**
1. `oeffentlich` 1. `oeffentlich`
2. `intern` 2. `intern`
@@ -18,22 +19,22 @@ diese Reihenfolge an (niedrigster zu höchstem Rang):
5. `berufsgeheimnis` 5. `berufsgeheimnis`
6. `besondere_kategorie` 6. `besondere_kategorie`
Begründung der Annahme: DSGVO Art. 9 (besondere Kategorien) gilt Begründung: DSGVO Art. 9 (besondere Kategorien) gilt allgemein als
allgemein als striktester Datenschutz-Tatbestand, § 203 StGB striktester Datenschutz-Tatbestand, § 203 StGB (Berufsgeheimnis) hat
(Berufsgeheimnis) hat eigene strafrechtliche Relevanz — beide vor eigene strafrechtliche Relevanz — beide vor "normalen" personenbezogenen
"normalen" personenbezogenen Daten eingeordnet. `auftragsdaten` unter Daten eingeordnet. `auftragsdaten` unter `personenbezogen` einsortiert,
`personenbezogen` einsortiert, weil vertragliche Geheimhaltung in der weil vertragliche Geheimhaltung in der Regel schwächer sanktioniert ist
Regel schwächer sanktioniert ist als DSGVO-Bußgelder. **Nicht als DSGVO-Bußgelder.
bestätigt.**
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten` ## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten` — BESTÄTIGT (2026-08-29)
Die Spezifikation formuliert "aus intern, personenbezogen und höher" — Die Spezifikation formuliert "aus intern, personenbezogen und höher" —
das lässt offen, ob `auftragsdaten` (zwischen `intern` und das lässt offen, ob `auftragsdaten` (zwischen `intern` und
`personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll. `personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll.
`rules/anforderungen.yaml` schließt `auftragsdaten` aktuell explizit `rules/anforderungen.yaml` schließt `auftragsdaten` ein (Kundendaten
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse sollten aus denselben Gründen wie Geschäftsgeheimnisse nicht zum
nicht zum Training verwendet werden) — **Annahme, nicht bestätigt.** Training verwendet werden) — **vom Produktverantwortlichen bestätigt,
keine Änderung nötig.**
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT ## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT
@@ -43,9 +44,9 @@ jetzt eine Folgefrage `c3_art` mit den Werten
`emotionserkennung_arbeitsplatz`, `social_scoring`, `emotionserkennung_arbeitsplatz`, `social_scoring`,
`biometrische_kategorisierung` und `keine` — exakt die Werte, die `biometrische_kategorisierung` und `keine` — exakt die Werte, die
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit `rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
final, nicht mehr Platzhalter. **Weiterhin offen:** ob diese drei final, nicht mehr Platzhalter. Ob diese drei Varianten fachlich
Varianten fachlich vollständig sind (deckt das wirklich alle "verboten"- vollständig sind, wurde am 2026-08-29 vom Produktverantwortlichen
Fälle aus Art. 5 KI-VO ab?) — das war nie Teil dieser Klärung. **bestätigt (keine Ergänzung nötig).**
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse ## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
@@ -60,15 +61,15 @@ wird diese Anforderung aktuell NICHT hart gegen Werkzeuge gefiltert
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen, (jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
muss die Filterfunktion entsprechend erweitert werden. muss die Filterfunktion entsprechend erweitert werden.
## 5. Fallback, wenn keine Datenklasse zutrifft ## 5. Fallback, wenn keine Datenklasse zutrifft — BESTÄTIGT (2026-08-29)
Wenn ein Antrag bei allen B-Fragen "nein" beantwortet — auch bei B7 Wenn ein Antrag bei allen B-Fragen "nein" beantwortet — auch bei B7
("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle ("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle
keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine
Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse` Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse`
nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im
Zweifel mehr Schutzanforderungen, nicht weniger. **Nicht fachlich Zweifel mehr Schutzanforderungen, nicht weniger. **Vom
bestätigt**, nur eine sichere Standardannahme. Produktverantwortlichen bestätigt, keine Änderung nötig.**
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert? ## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?