Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96ac5d6b59 | ||
|
|
29961f5657 | ||
|
|
3ec57f2786 | ||
|
|
6884f28109 |
41
CLAUDE.md
41
CLAUDE.md
@@ -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.
|
||||||
@@ -334,10 +337,19 @@ jederzeit erneuerbar [macht den alten Link sofort ungültig].
|
|||||||
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
|
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
|
||||||
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
|
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
|
||||||
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
|
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
|
||||||
Einladung.), (2) CSV-Import, (3) SSO (Entra ID/Google Workspace),
|
Einladung.), (2) ~~CSV-Import~~ — **erledigt** (`internal/web/csv_import_handlers.go`,
|
||||||
(4) Antrag ganz ohne vorheriges Konto über Mandantenlink + E-Mail-
|
`GET /verwaltung/nutzer/import`: CSV mit Kopfzeile, Pflichtspalte
|
||||||
Bestätigungscode (Konto entsteht nebenbei). (2)-(4) sind weiterhin
|
"email", optionale Spalte "role" [Standard `mitarbeiter`]. Wie beim
|
||||||
nicht gebaut.
|
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
|
||||||
@@ -741,11 +753,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`
|
||||||
|
|||||||
72
internal/web/admin_fachebene_test.go
Normal file
72
internal/web/admin_fachebene_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin
|
||||||
|
// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md,
|
||||||
|
// Offene Punkte / requireFachebene): ein admin sieht den Posteingang
|
||||||
|
// und kann entscheiden, genau wie ein verantwortlicher.
|
||||||
|
func TestAdminSiehtPosteingang(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
_, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/faelle")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSiehtEntscheidenFormular(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/faelle/"+antragID)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
|
||||||
|
t.Errorf("expected the Entscheiden form for admin, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminKannEntscheiden(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if antrag.Status != "entschieden" {
|
||||||
|
t.Errorf("Status = %q, want entschieden", antrag.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
for _, path := range []string{"/registereintraege", "/wiedervorlage"} {
|
||||||
|
resp := getWithCookie(t, s, adminCookie, path)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
184
internal/web/csv_import_handlers.go
Normal file
184
internal/web/csv_import_handlers.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten
|
||||||
|
// Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine
|
||||||
|
// E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie
|
||||||
|
// beim einzelnen "Nutzer anlegen" generiert der Import ein
|
||||||
|
// Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG
|
||||||
|
// sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern:
|
||||||
|
// gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet
|
||||||
|
// (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer
|
||||||
|
// Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren).
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type csvMitarbeiterZeile struct {
|
||||||
|
Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile.
|
||||||
|
// Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder
|
||||||
|
// "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst
|
||||||
|
// beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine
|
||||||
|
// einzelne falsche Rolle nicht den ganzen Import verhindert).
|
||||||
|
func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) {
|
||||||
|
cr := csv.NewReader(r)
|
||||||
|
cr.TrimLeadingSpace = true
|
||||||
|
cr.FieldsPerRecord = -1
|
||||||
|
|
||||||
|
header, err := cr.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err)
|
||||||
|
}
|
||||||
|
emailCol, roleCol := -1, -1
|
||||||
|
for i, h := range header {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(h)) {
|
||||||
|
case "email", "e-mail":
|
||||||
|
emailCol = i
|
||||||
|
case "role", "rolle":
|
||||||
|
roleCol = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if emailCol == -1 {
|
||||||
|
return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []csvMitarbeiterZeile
|
||||||
|
zeile := 1
|
||||||
|
for {
|
||||||
|
record, err := cr.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err)
|
||||||
|
}
|
||||||
|
zeile++
|
||||||
|
if emailCol >= len(record) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(record[emailCol])
|
||||||
|
if email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role := "mitarbeiter"
|
||||||
|
if roleCol != -1 && roleCol < len(record) {
|
||||||
|
if v := strings.TrimSpace(record[roleCol]); v != "" {
|
||||||
|
role = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für
|
||||||
|
// eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt
|
||||||
|
// einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine
|
||||||
|
// Länge, die sich noch manuell weitergeben lässt.
|
||||||
|
func generateEinmalPasswort() (string, error) {
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token[:16], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvImportFormData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||||
|
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvImportErgebnisZeile struct {
|
||||||
|
Zeile int
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
Passwort string // nur bei Erfolg gesetzt
|
||||||
|
Fehler string // nur bei Fehler gesetzt
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvImportErgebnisData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Ergebnisse []csvImportErgebnisZeile
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt
|
||||||
|
// pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis
|
||||||
|
// (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite
|
||||||
|
// gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden.
|
||||||
|
func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste
|
||||||
|
s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, _, err := r.FormFile("csv")
|
||||||
|
if err != nil {
|
||||||
|
s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
zeilen, err := parseCSVMitarbeiter(file)
|
||||||
|
if err != nil {
|
||||||
|
s.renderCSVImportForm(w, r, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(zeilen) == 0 {
|
||||||
|
s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
var ergebnisse []csvImportErgebnisZeile
|
||||||
|
for _, z := range zeilen {
|
||||||
|
if !istMandantenRolle(z.Role) {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
passwort, err := generateEinmalPasswort()
|
||||||
|
if err != nil {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(passwort)
|
||||||
|
if err != nil {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort})
|
||||||
|
}
|
||||||
|
|
||||||
|
data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
149
internal/web/csv_import_handlers_test.go
Normal file
149
internal/web/csv_import_handlers_test.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// postCSV lädt csvContent als multipart-Formular mit Feldname "csv" hoch
|
||||||
|
// — reale Browser-Uploads laufen genauso über multipart/form-data.
|
||||||
|
func postCSV(t *testing.T, s *web.Server, cookie *http.Cookie, path, csvContent string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
fw, err := mw.CreateFormFile("csv", "mitarbeiter.csv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write([]byte(csvContent)); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatalf("mw.Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCanImportMitarbeiterPerCSV(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, "lisa@example.com") || !strings.Contains(body, "tom@example.com") {
|
||||||
|
t.Fatalf("expected both imported emails in the result, got: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
lisa, err := fs.GetUserByEmail(context.Background(), "lisa@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail (lisa): %v", err)
|
||||||
|
}
|
||||||
|
if lisa.Role != "verantwortlicher" {
|
||||||
|
t.Errorf("lisa.Role = %q, want verantwortlicher", lisa.Role)
|
||||||
|
}
|
||||||
|
tom, err := fs.GetUserByEmail(context.Background(), "tom@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail (tom): %v", err)
|
||||||
|
}
|
||||||
|
if tom.Role != "mitarbeiter" {
|
||||||
|
t.Errorf("tom.Role = %q, want default mitarbeiter", tom.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSVImportZeigtEinmalpasswortDasFunktioniert(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email\nneu@example.com\n")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das angezeigte Einmalpasswort aus der <code>-Zelle extrahieren.
|
||||||
|
body := resp.Body.String()
|
||||||
|
start := strings.Index(body, "<code>")
|
||||||
|
end := strings.Index(body, "</code>")
|
||||||
|
if start == -1 || end == -1 {
|
||||||
|
t.Fatalf("expected a <code>-Zelle mit dem Einmalpasswort, got: %s", body)
|
||||||
|
}
|
||||||
|
passwort := body[start+len("<code>") : end]
|
||||||
|
|
||||||
|
loginResp := postForm(t, s, nil, "/login", map[string][]string{
|
||||||
|
"email": {"neu@example.com"}, "password": {passwort},
|
||||||
|
})
|
||||||
|
if loginResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login with the generated password: status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSVImportMeldetUngueltigeRolleOhneDieAnderenZuBlockieren(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nboese@example.com,betreiber\ngut@example.com,mitarbeiter\n")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := fs.GetUserByEmail(context.Background(), "boese@example.com"); err == nil {
|
||||||
|
t.Error("expected no user to be created for an invalid role (betreiber)")
|
||||||
|
}
|
||||||
|
if _, err := fs.GetUserByEmail(context.Background(), "gut@example.com"); err != nil {
|
||||||
|
t.Error("expected the valid row to still be imported despite the earlier invalid row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSVImportOhneDateiZeigtFehler(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatalf("mw.Close: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/verwaltung/nutzer/import", &body)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.AddCookie(adminCookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "CSV-Datei auswählen") {
|
||||||
|
t.Errorf("expected an error asking for a file, got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessCSVImport(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer/import")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/web/csv_import_parse_test.go
Normal file
64
internal/web/csv_import_parse_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterMitRolle(t *testing.T) {
|
||||||
|
csv := "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 2 {
|
||||||
|
t.Fatalf("zeilen = %+v, want 2", zeilen)
|
||||||
|
}
|
||||||
|
if zeilen[0].Email != "lisa@example.com" || zeilen[0].Role != "verantwortlicher" {
|
||||||
|
t.Errorf("zeilen[0] = %+v, unerwartet", zeilen[0])
|
||||||
|
}
|
||||||
|
if zeilen[1].Email != "tom@example.com" || zeilen[1].Role != "mitarbeiter" {
|
||||||
|
t.Errorf("zeilen[1] = %+v, want default role mitarbeiter", zeilen[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterOhneRollenspalte(t *testing.T) {
|
||||||
|
csv := "email\nlisa@example.com\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 1 || zeilen[0].Role != "mitarbeiter" {
|
||||||
|
t.Fatalf("zeilen = %+v, unerwartet", zeilen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterUeberspringtLeereEmail(t *testing.T) {
|
||||||
|
csv := "email,role\n,mitarbeiter\nlisa@example.com,mitarbeiter\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 1 || zeilen[0].Email != "lisa@example.com" {
|
||||||
|
t.Fatalf("zeilen = %+v, want exactly lisa@example.com", zeilen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterOhneEmailSpalteSchlaegtFehl(t *testing.T) {
|
||||||
|
csv := "name,role\nLisa,mitarbeiter\n"
|
||||||
|
_, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when the header has no email column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterZeilennummernZaehlenKopfzeileAlsEins(t *testing.T) {
|
||||||
|
csv := "email\nlisa@example.com\ntom@example.com\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 2 || zeilen[0].Zeile != 2 || zeilen[1].Zeile != 3 {
|
||||||
|
t.Fatalf("zeilen = %+v, want Zeile 2 and 3", zeilen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ 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("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
|
||||||
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
|
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))
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
27
internal/web/templates/nutzer_import.html
Normal file
27
internal/web/templates/nutzer_import.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "nutzer-import"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<p><a href="/verwaltung/nutzer">← Nutzerverwaltung</a></p>
|
||||||
|
<h1>Mitarbeiter per CSV importieren</h1>
|
||||||
|
<p class="hinweis">
|
||||||
|
CSV mit Kopfzeile, Pflichtspalte "email", optionale Spalte "role"
|
||||||
|
(mitarbeiter/verantwortlicher/pruefer/admin — Standard: mitarbeiter).
|
||||||
|
Es gibt keine Einladungsmail: nach dem Import wird pro Zeile ein
|
||||||
|
Einmalpasswort angezeigt, das nur DIESES EINE MAL sichtbar ist — bitte
|
||||||
|
auf dieser Seite kopieren und sicher weitergeben, bevor sie
|
||||||
|
geschlossen wird.
|
||||||
|
</p>
|
||||||
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<form method="post" action="/verwaltung/nutzer/import" enctype="multipart/form-data">
|
||||||
|
<label for="csv">CSV-Datei</label>
|
||||||
|
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
|
||||||
|
|
||||||
|
<button type="submit">Importieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
33
internal/web/templates/nutzer_import_ergebnis.html
Normal file
33
internal/web/templates/nutzer_import_ergebnis.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "nutzer-import-ergebnis"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<h1>Import-Ergebnis</h1>
|
||||||
|
<p class="fehler">
|
||||||
|
Die Einmalpasswörter unten werden nach dieser Seite nirgendwo mehr
|
||||||
|
angezeigt — jetzt kopieren und sicher weitergeben.
|
||||||
|
</p>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Zeile</th><th>E-Mail</th><th>Rolle</th><th>Ergebnis</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Ergebnisse}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Zeile}}</td>
|
||||||
|
<td>{{.Email}}</td>
|
||||||
|
<td>{{if .Fehler}}—{{else}}{{.Role}}{{end}}</td>
|
||||||
|
<td>{{if .Fehler}}<span class="fehler">{{.Fehler}}</span>{{else}}<code>{{.Passwort}}</code>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p><a href="/verwaltung/nutzer">Zur Nutzerverwaltung</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user