feat: Fragebogen mit adaptiver Logik (Schritt 2 der Baureihenfolge)

Ebene 2 ("Antrag stellen") als einseitiges Formular statt mehrseitigem
Assistenten — jede zusätzliche Seite kostet Zeit, und laut Spezifikation
wird ein Antrag umgangen, wenn er länger als fünf Minuten dauert.

- GET /antraege/neu: Fragebogen-Formular (Abschnitte A-D exakt nach
  Spezifikation). Adaptive Folgefragen (C2: welche Art von Entscheidung,
  C3: welche Art von Erkennung) werden rein per CSS :has() ein-/
  ausgeblendet, kein JavaScript nötig — visuell mit Chromium-Screenshots
  verifiziert (unchecked vs. checked).
- POST /antraege: legt an und reicht direkt ein (entwurf->eingereicht in
  einem Schritt, kein Zwischenspeichern als Entwurf für v1). Die
  antworten-JSON nutzt exakt dieselben Fakten-Schlüssel wie
  rules/*.yaml (b1-b7, c1-c5, c2_folge, c3_art) — Schritt 3 kann sie
  direkt auswerten, ohne Felder umzubenennen. "Unsicher" wird bewusst
  NICHT zu "ja" normalisiert (das ist eine Auswertungsregel für Schritt
  3, keine Speicherregel) — der Antrag hält fest, was der Mitarbeiter
  tatsächlich geantwortet hat.
- GET /antraege: eigene Anträge (Ebene 2 sieht nur eigene, nicht die
  des ganzen Mandanten — dafür neue Store-Methode
  ListAntraegeForUser, getrennt von ListAntraegeForAccount für den
  späteren Ebene-3-Posteingang).
- GET /antraege/{id}: Detail, fremder Antrag liefert 404 (nicht 403,
  gleiches Muster wie überall sonst im Projekt).
- betreiber-Rolle wird von allen Antrags-Routen weggeleitet (Ebene 5
  ist technisch getrennt vom Mandantenbereich).

rules/OPEN.md Punkt 3 (fehlende Fragebogen-Unterscheidung für die
"verboten"-Varianten) damit geklärt: c3_art existiert jetzt mit den
bereits in rules/kivo_einstufung.yaml erwarteten Werten.

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Antrag anlegen, antworten-JSON in
der DB korrekt, Detail-/Listenansicht, Mandantentrennung).
This commit is contained in:
noroot
2026-08-28 23:09:24 +02:00
parent 344a33805a
commit db9c5b7482
14 changed files with 719 additions and 25 deletions

View File

@@ -29,6 +29,8 @@ type fakeStore struct {
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
}
func newFakeStore() *fakeStore {
@@ -37,6 +39,8 @@ func newFakeStore() *fakeStore {
users: map[string]store.User{},
usersByEmail: map[string]string{},
sessions: map[string]store.Session{},
abteilungen: map[string][]store.Abteilung{},
antraege: map[string]store.Antrag{},
}
}
@@ -169,6 +173,70 @@ func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditE
return out, nil
}
func (f *fakeStore) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.abteilungen[accountID], nil
}
func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) {
f.mu.Lock()
defer f.mu.Unlock()
a := store.Antrag{
ID: f.newID(), AccountID: accountID, ErstellerUserID: erstellerUserID, AbteilungID: abteilungID,
Titel: titel, Status: "entwurf", Antworten: []byte(`{}`), CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
f.antraege[a.ID] = a
return a, nil
}
func (f *fakeStore) GetAntrag(ctx context.Context, id string) (store.Antrag, error) {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.antraege[id]
if !ok {
return store.Antrag{}, store.ErrNotFound
}
return a, nil
}
func (f *fakeStore) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error) {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.antraege[id]
if !ok {
return store.Antrag{}, store.ErrNotFound
}
a.Titel, a.Beschreibung, a.Ergebnis, a.Haeufigkeit, a.Antworten = titel, beschreibung, ergebnis, haeufigkeit, antworten
a.UpdatedAt = time.Now()
f.antraege[id] = a
return a, nil
}
func (f *fakeStore) SetAntragStatus(ctx context.Context, id, status string) error {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.antraege[id]
if !ok {
return store.ErrNotFound
}
a.Status = status
f.antraege[id] = a
return nil
}
func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Antrag
for _, a := range f.antraege {
if a.ErstellerUserID == erstellerUserID {
out = append(out, a)
}
}
return out, nil
}
// ─── Test-Setup ───────────────────────────────────────────────────────
func newServer(t *testing.T, fs *fakeStore) *web.Server {