Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe68b16c9 | ||
|
|
7db4707bc5 | ||
|
|
fa5e68c892 | ||
|
|
d3b171f721 | ||
|
|
b0d6b00045 | ||
|
|
fe28278615 | ||
|
|
0fe29f8c80 | ||
|
|
eccf02050b | ||
|
|
8a8295dacd | ||
|
|
690660b655 | ||
|
|
f729e5ae48 | ||
|
|
9e2c7f92ff | ||
|
|
4c60603055 | ||
|
|
bc99858c94 | ||
|
|
3aecbb446c | ||
|
|
08c7721165 | ||
|
|
59e1bcc8f7 | ||
|
|
2e459f1dd1 | ||
|
|
d77b19f61f | ||
|
|
dbeaa45644 | ||
|
|
4358dbaa0d | ||
|
|
7397f70068 | ||
|
|
2736e2c0db | ||
|
|
9d2cb79424 | ||
|
|
4dd9bd88cb | ||
|
|
96ac5d6b59 | ||
|
|
29961f5657 | ||
|
|
3ec57f2786 | ||
|
|
6884f28109 | ||
|
|
b91a58ccba | ||
|
|
eb99891e38 | ||
|
|
36af1bf288 | ||
|
|
5f6502bb41 | ||
|
|
22f748d66c | ||
|
|
d52b325424 | ||
|
|
cff8c9eadf |
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/mail"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
@@ -21,7 +22,19 @@ func main() {
|
|||||||
log.Fatalf("migrate: %v", err)
|
log.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := store.Open(context.Background(), databaseURL)
|
// Migrationen laufen immer über DATABASE_URL (braucht DDL-Rechte,
|
||||||
|
// z. B. CREATE TABLE/ALTER TABLE). Die laufende Anwendung verbindet
|
||||||
|
// sich dagegen möglichst über die eingeschränkte Row-Level-Security-
|
||||||
|
// Rolle "deklarix_app" (siehe Migration 0021) — DATABASE_URL_APP,
|
||||||
|
// falls gesetzt. Ohne DATABASE_URL_APP fällt sie auf DATABASE_URL
|
||||||
|
// zurück (bisheriges Verhalten, Postgres-Superuser umgeht RLS-
|
||||||
|
// Policies dann vollständig — kein Fehler, aber auch keine
|
||||||
|
// zusätzliche Isolation auf DB-Ebene, siehe CLAUDE.md).
|
||||||
|
appDatabaseURL := os.Getenv("DATABASE_URL_APP")
|
||||||
|
if appDatabaseURL == "" {
|
||||||
|
appDatabaseURL = databaseURL
|
||||||
|
}
|
||||||
|
db, err := store.Open(context.Background(), appDatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open store: %v", err)
|
log.Fatalf("open store: %v", err)
|
||||||
}
|
}
|
||||||
@@ -51,9 +64,17 @@ func main() {
|
|||||||
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
||||||
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
||||||
|
|
||||||
|
mailer := mail.NewSMTPMailer(mail.Config{
|
||||||
|
Host: os.Getenv("SMTP_HOST"),
|
||||||
|
Port: os.Getenv("SMTP_PORT"),
|
||||||
|
User: os.Getenv("SMTP_USER"),
|
||||||
|
Password: os.Getenv("SMTP_PASSWORD"),
|
||||||
|
From: os.Getenv("SMTP_FROM"),
|
||||||
|
})
|
||||||
|
|
||||||
server, err := web.NewServer(db, web.Regelwerk{
|
server, err := web.NewServer(db, web.Regelwerk{
|
||||||
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||||
})
|
}, mailer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("web server: %v", err)
|
log.Fatalf("web server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
78
internal/mail/mail.go
Normal file
78
internal/mail/mail.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Package mail versendet E-Mails (aktuell: Passwort-Zurücksetzen-Links)
|
||||||
|
// über einen konfigurierten SMTP-Server. Reine Versandlogik, kein
|
||||||
|
// Template-Rendering — der Aufrufer (internal/web) baut Betreff/Text
|
||||||
|
// selbst zusammen, damit dieses Paket unabhängig vom Web-Layer bleibt.
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mailer ist die Schnittstelle, die internal/web nutzt — austauschbar
|
||||||
|
// gegen eine Test-Doppel (siehe FakeMailer), damit Tests keinen echten
|
||||||
|
// SMTP-Server brauchen.
|
||||||
|
type Mailer interface {
|
||||||
|
Send(to, subject, body string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config sind die SMTP-Zugangsdaten, aus Umgebungsvariablen gelesen
|
||||||
|
// (siehe cmd/deklarix/main.go) — kein neues Geheimnis im Code.
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
From string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTPMailer versendet über einen echten SMTP-Server (STARTTLS, falls
|
||||||
|
// vom Server angeboten — das deckt den Standard-Submission-Port 587 ab;
|
||||||
|
// implizites TLS auf Port 465 wird von net/smtp nicht unterstützt).
|
||||||
|
type SMTPMailer struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSMTPMailer(cfg Config) *SMTPMailer {
|
||||||
|
return &SMTPMailer{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send verschickt eine reine Text-E-Mail. Liefert einen Fehler, wenn
|
||||||
|
// kein SMTP-Host konfiguriert ist — bewusst kein stiller No-op, ein
|
||||||
|
// fehlgeschlagener Versand (z. B. Passwort-Reset) muss sichtbar
|
||||||
|
// scheitern statt eine E-Mail vorzutäuschen, die nie ankommt.
|
||||||
|
func (m *SMTPMailer) Send(to, subject, body string) error {
|
||||||
|
if m.cfg.Host == "" {
|
||||||
|
return fmt.Errorf("mail: SMTP nicht konfiguriert (SMTP_HOST fehlt)")
|
||||||
|
}
|
||||||
|
addr := m.cfg.Host + ":" + m.cfg.Port
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||||
|
m.cfg.From, to, subject, body,
|
||||||
|
)
|
||||||
|
|
||||||
|
var auth smtp.Auth
|
||||||
|
if m.cfg.User != "" {
|
||||||
|
auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host)
|
||||||
|
}
|
||||||
|
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, []byte(msg)); err != nil {
|
||||||
|
return fmt.Errorf("mail: senden an %s: %w", to, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SentMail protokolliert einen Versand für Tests.
|
||||||
|
type SentMail struct {
|
||||||
|
To, Subject, Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FakeMailer ist das Test-Doppel für Mailer — sammelt gesendete Mails
|
||||||
|
// statt sie zu verschicken.
|
||||||
|
type FakeMailer struct {
|
||||||
|
Sent []SentMail
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeMailer) Send(to, subject, body string) error {
|
||||||
|
f.Sent = append(f.Sent, SentMail{To: to, Subject: subject, Body: body})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -186,11 +186,50 @@ func containsString(list []string, v string) bool {
|
|||||||
// statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so
|
// statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so
|
||||||
// unabhängig von internal/store und für sich allein testbar.
|
// unabhängig von internal/store und für sich allein testbar.
|
||||||
type WerkzeugEigenschaften struct {
|
type WerkzeugEigenschaften struct {
|
||||||
AVVVerfuegbar bool
|
AVVVerfuegbar bool
|
||||||
Verarbeitungsort string
|
// Verarbeitungslaender nennt die tatsächlichen Länder statt eines
|
||||||
TrainingOptOut bool
|
// groben "EU"/"USA"-Eimers (siehe CLAUDE.md, Werkzeugkatalog) — die
|
||||||
TrainingStandard bool
|
// eu_verarbeitung-Anforderung gilt nur als erfüllt, wenn ALLE
|
||||||
AufbewahrungTage int
|
// genannten Länder EU/EWR-Mitgliedstaaten sind.
|
||||||
|
Verarbeitungslaender []string
|
||||||
|
TrainingOptOut bool
|
||||||
|
TrainingStandard bool
|
||||||
|
// AufbewahrungTage ist nil, wenn der Anbieter keine Aufbewahrungsdauer
|
||||||
|
// beziffert — das erfüllt eine gesetzte Löschfrist-Anforderung NICHT
|
||||||
|
// (fail closed), analog zur leeren Länderliste bei eu_verarbeitung.
|
||||||
|
AufbewahrungTage *int
|
||||||
|
}
|
||||||
|
|
||||||
|
// euEwrLaender sind die Staaten, für die eine Verarbeitung nicht als
|
||||||
|
// Drittlandtransfer gilt (Art. 44 ff. DSGVO) — die 27 EU-Mitgliedstaaten
|
||||||
|
// plus die drei über den EWR-Vertrag gleichgestellten Staaten. Die USA
|
||||||
|
// gehören bewusst NICHT dazu — sie sind ein Drittland wie jedes andere,
|
||||||
|
// unabhängig vom EU-US Data Privacy Framework (das mildert die
|
||||||
|
// Transfer-Grundlage, macht die USA aber nicht zum EU/EWR-Gebiet).
|
||||||
|
var euEwrLaender = map[string]bool{
|
||||||
|
"Belgien": true, "Bulgarien": true, "Dänemark": true, "Deutschland": true,
|
||||||
|
"Estland": true, "Finnland": true, "Frankreich": true, "Griechenland": true,
|
||||||
|
"Irland": true, "Italien": true, "Kroatien": true, "Lettland": true,
|
||||||
|
"Litauen": true, "Luxemburg": true, "Malta": true, "Niederlande": true,
|
||||||
|
"Österreich": true, "Polen": true, "Portugal": true, "Rumänien": true,
|
||||||
|
"Schweden": true, "Slowakei": true, "Slowenien": true, "Spanien": true,
|
||||||
|
"Tschechien": true, "Ungarn": true, "Zypern": true,
|
||||||
|
"Island": true, "Liechtenstein": true, "Norwegen": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// alleLaenderInEUEWR prüft, ob jedes genannte Land EU/EWR ist. Eine
|
||||||
|
// leere Liste (kein Land benannt) gilt als NICHT erfüllt — "wissen wir
|
||||||
|
// nicht" darf nie stillschweigend als "ist okay" durchgehen.
|
||||||
|
func alleLaenderInEUEWR(laender []string) bool {
|
||||||
|
if len(laender) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, l := range laender {
|
||||||
|
if !euEwrLaender[l] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
|
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
|
||||||
@@ -198,17 +237,30 @@ type WerkzeugEigenschaften struct {
|
|||||||
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
||||||
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
||||||
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
||||||
// Werkzeug selbst. loeschfrist_max_tage hat noch keine fachlich
|
// Werkzeug selbst.
|
||||||
// festgelegten Tageswerte (rules/OPEN.md, Punkt 4) und wird deshalb
|
//
|
||||||
// ebenfalls noch nicht hart gefiltert.
|
// loeschfristMaxTage ist die vom Mandanten für die aktuelle Datenklasse
|
||||||
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
// konfigurierte Frist (siehe store.LoeschfristEinstellung) — nil
|
||||||
|
// bedeutet "für diesen Mandanten/diese Datenklasse nicht konfiguriert"
|
||||||
|
// und wird NICHT hart gefiltert (Rückwärtskompatibilität, siehe
|
||||||
|
// rules/OPEN.md, Punkt 4); ist eine Frist gesetzt, erfüllt ein Werkzeug
|
||||||
|
// ohne bezifferte Aufbewahrungsdauer sie NICHT (fail closed).
|
||||||
|
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften, loeschfristMaxTage *int) bool {
|
||||||
switch anforderungID {
|
switch anforderungID {
|
||||||
case "avv_erforderlich":
|
case "avv_erforderlich":
|
||||||
return w.AVVVerfuegbar
|
return w.AVVVerfuegbar
|
||||||
case "eu_verarbeitung":
|
case "eu_verarbeitung":
|
||||||
return w.Verarbeitungsort == "EU"
|
return alleLaenderInEUEWR(w.Verarbeitungslaender)
|
||||||
case "kein_training_auf_eingabe":
|
case "kein_training_auf_eingabe":
|
||||||
return w.TrainingStandard
|
return w.TrainingStandard
|
||||||
|
case "loeschfrist_max_tage":
|
||||||
|
if loeschfristMaxTage == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if w.AufbewahrungTage == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return *w.AufbewahrungTage <= *loeschfristMaxTage
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -230,12 +282,13 @@ type WerkzeugKandidat struct {
|
|||||||
|
|
||||||
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
||||||
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
||||||
// Werkzeug fest, welche Anforderungen fehlten.
|
// Werkzeug fest, welche Anforderungen fehlten. loeschfristMaxTage siehe
|
||||||
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
// ErfuelltAnforderung.
|
||||||
|
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string, loeschfristMaxTage *int) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||||
for _, k := range kandidaten {
|
for _, k := range kandidaten {
|
||||||
var fehlend []string
|
var fehlend []string
|
||||||
for _, reqID := range anforderungIDs {
|
for _, reqID := range anforderungIDs {
|
||||||
if !ErfuelltAnforderung(reqID, k.Eigenschaften) {
|
if !ErfuelltAnforderung(reqID, k.Eigenschaften, loeschfristMaxTage) {
|
||||||
fehlend = append(fehlend, reqID)
|
fehlend = append(fehlend, reqID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,26 +150,62 @@ func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestErfuelltAnforderungAVV(t *testing.T) {
|
func TestErfuelltAnforderungAVV(t *testing.T) {
|
||||||
if rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: false}) {
|
if rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: false}, nil) {
|
||||||
t.Error("erwartet: nicht erfuellt ohne AVV")
|
t.Error("erwartet: nicht erfuellt ohne AVV")
|
||||||
}
|
}
|
||||||
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}) {
|
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}, nil) {
|
||||||
t.Error("erwartet: erfuellt mit AVV")
|
t.Error("erwartet: erfuellt mit AVV")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungEUVerarbeitung(t *testing.T) {
|
||||||
|
if !rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland"}}, nil) {
|
||||||
|
t.Error("erwartet: erfuellt bei einem einzelnen EU/EWR-Land")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"USA"}}, nil) {
|
||||||
|
t.Error("erwartet: nicht erfuellt bei USA - die USA sind ein Drittland wie jedes andere")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland", "USA"}}, nil) {
|
||||||
|
t.Error("erwartet: nicht erfuellt, sobald auch nur ein Land nicht EU/EWR ist")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{}, nil) {
|
||||||
|
t.Error("erwartet: nicht erfuellt ohne benanntes Land - 'wissen wir nicht' darf nicht als 'ist okay' durchgehen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungLoeschfrist(t *testing.T) {
|
||||||
|
dreissig := 30
|
||||||
|
neunzig := 90
|
||||||
|
// Keine Frist konfiguriert -> nicht hart gefiltert (Rückwärtskompatibilität).
|
||||||
|
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, nil) {
|
||||||
|
t.Error("erwartet: erfuellt ohne konfigurierte Frist")
|
||||||
|
}
|
||||||
|
// Frist gesetzt, Werkzeug bleibt innerhalb -> erfuellt.
|
||||||
|
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &dreissig}, &neunzig) {
|
||||||
|
t.Error("erwartet: erfuellt, wenn Aufbewahrung <= Frist")
|
||||||
|
}
|
||||||
|
// Frist gesetzt, Werkzeug überschreitet -> nicht erfuellt.
|
||||||
|
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &neunzig}, &dreissig) {
|
||||||
|
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrung > Frist")
|
||||||
|
}
|
||||||
|
// Frist gesetzt, Werkzeug beziffert Aufbewahrung nicht -> fail closed.
|
||||||
|
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, &dreissig) {
|
||||||
|
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrungsdauer unbekannt ist ('wissen wir nicht' darf nicht als 'ist okay' durchgehen)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||||
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}, nil) {
|
||||||
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||||
kandidaten := []rules.WerkzeugKandidat{
|
kandidaten := []rules.WerkzeugKandidat{
|
||||||
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungsort: "EU", TrainingStandard: true}},
|
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||||
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungsort: "EU", TrainingStandard: true}},
|
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||||
}
|
}
|
||||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"})
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"}, nil)
|
||||||
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
||||||
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
|
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
|
||||||
}
|
}
|
||||||
@@ -183,7 +219,7 @@ func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
|||||||
|
|
||||||
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
|
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
|
||||||
kandidaten := []rules.WerkzeugKandidat{{ID: "x"}, {ID: "y"}}
|
kandidaten := []rules.WerkzeugKandidat{{ID: "x"}, {ID: "y"}}
|
||||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, nil)
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, nil, nil)
|
||||||
if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
|
if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
|
||||||
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
|
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type Abteilung struct {
|
|||||||
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
|
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
|
||||||
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
|
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
|
||||||
var a Abteilung
|
var a Abteilung
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
|
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
|
||||||
RETURNING id, account_id, name, created_at
|
RETURNING id, account_id, name, created_at
|
||||||
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||||
@@ -33,7 +33,7 @@ func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Ab
|
|||||||
// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
|
// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
|
||||||
// alphabetisch — als Auswahlliste für den Fragebogen.
|
// alphabetisch — als Auswahlliste für den Fragebogen.
|
||||||
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) {
|
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT id, account_id, name, created_at FROM abteilung
|
SELECT id, account_id, name, created_at FROM abteilung
|
||||||
WHERE account_id = $1 ORDER BY name
|
WHERE account_id = $1 ORDER BY name
|
||||||
`, accountID)
|
`, accountID)
|
||||||
@@ -59,7 +59,7 @@ func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string)
|
|||||||
// GetAbteilung liest eine Abteilung anhand ihrer ID.
|
// GetAbteilung liest eine Abteilung anhand ihrer ID.
|
||||||
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
|
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
|
||||||
var a Abteilung
|
var a Abteilung
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
|
SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
|
||||||
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -74,7 +74,7 @@ func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error)
|
|||||||
// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
|
// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
|
||||||
// angelegt).
|
// angelegt).
|
||||||
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error {
|
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
|
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: delete abteilung: %w", err)
|
return fmt.Errorf("store: delete abteilung: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,32 +10,106 @@ 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.
|
||||||
|
// Strasse/PLZ/Ort/Land/UStID/Rechnungsemail sind die Firmen- und
|
||||||
|
// Abrechnungsdaten (Migration 0023) — bei bestehenden, vor dieser
|
||||||
|
// Migration angelegten Accounts können sie leer sein, siehe dort.
|
||||||
type Account struct {
|
type Account struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
CreatedAt time.Time
|
EinladungToken string
|
||||||
|
Strasse string
|
||||||
|
PLZ string
|
||||||
|
Ort string
|
||||||
|
Land string
|
||||||
|
UStID string
|
||||||
|
Rechnungsemail string
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAccount legt einen neuen Mandanten an.
|
// AccountInput bündelt die Firmendaten für CreateAccount/
|
||||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
// UpdateAccountDetails. Name ist die einzige Pflichtangabe auf
|
||||||
|
// Store-Ebene — welche der übrigen Felder ein Formular tatsächlich
|
||||||
|
// verlangt (z. B. Adresse bei Neuanlage), entscheidet die Web-Schicht,
|
||||||
|
// nicht der Store (Tests legen Accounts oft ohne vollständige
|
||||||
|
// Firmendaten an, das ist auf Store-Ebene kein Fehler).
|
||||||
|
type AccountInput struct {
|
||||||
|
Name string
|
||||||
|
Strasse string
|
||||||
|
PLZ string
|
||||||
|
Ort string
|
||||||
|
Land string
|
||||||
|
UStID string
|
||||||
|
Rechnungsemail string
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountColumns = `id, name, einladung_token, strasse, plz, ort, land, ust_id, rechnungsemail, created_at`
|
||||||
|
|
||||||
|
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.Strasse, &a.PLZ, &a.Ort, &a.Land, &a.UStID, &a.Rechnungsemail, &a.CreatedAt)
|
||||||
INSERT INTO account (name) VALUES ($1)
|
return a, err
|
||||||
RETURNING id, name, created_at
|
}
|
||||||
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
|
||||||
|
// 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, in AccountInput) (Account, error) {
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO account (name, strasse, plz, ort, land, ust_id, rechnungsemail)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING `+accountColumns,
|
||||||
|
in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
|
||||||
|
)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
|
||||||
|
// Betreiber-gestützten Anlage korrigieren) — ändert bewusst nur den
|
||||||
|
// Namen, nicht die übrigen Firmendaten, siehe UpdateAccountDetails.
|
||||||
|
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
|
||||||
|
a, err := scanAccount(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Account{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, fmt.Errorf("store: update account: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAccountDetails aktualisiert Name und Firmen-/Abrechnungsdaten
|
||||||
|
// gemeinsam — genutzt von der Firmendaten-Seite (Ebene 4, admin), auf
|
||||||
|
// der ein Mandant seine eigenen Angaben pflegt/nachträgt.
|
||||||
|
func (s *Store) UpdateAccountDetails(ctx context.Context, id string, in AccountInput) (Account, error) {
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
UPDATE account SET name = $2, strasse = $3, plz = $4, ort = $5, land = $6, ust_id = $7, rechnungsemail = $8
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING `+accountColumns,
|
||||||
|
id, in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
|
||||||
|
)
|
||||||
|
a, err := scanAccount(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Account{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, fmt.Errorf("store: update account details: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
// 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.db(ctx).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 +119,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.db(ctx).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.db(ctx).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.db(ctx).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 +160,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)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package store_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
|
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
|
||||||
@@ -37,7 +39,7 @@ func TestListAccounts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListAccounts: %v", err)
|
t.Fatalf("ListAccounts: %v", err)
|
||||||
}
|
}
|
||||||
acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts")
|
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Neuer Mandant fuer ListAccounts"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func scanAntrag(row interface {
|
|||||||
|
|
||||||
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
|
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
|
||||||
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
|
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
|
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING `+antragColumns,
|
RETURNING `+antragColumns,
|
||||||
@@ -62,7 +62,7 @@ func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID str
|
|||||||
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
|
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
|
||||||
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
|
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
|
||||||
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
|
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
|
||||||
a, err := scanAntrag(row)
|
a, err := scanAntrag(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Antrag{}, ErrNotFound
|
return Antrag{}, ErrNotFound
|
||||||
@@ -78,7 +78,7 @@ func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
|
|||||||
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
|
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
|
||||||
// Status hier nicht).
|
// Status hier nicht).
|
||||||
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
|
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
UPDATE antrag SET
|
UPDATE antrag SET
|
||||||
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
|
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@@ -99,7 +99,7 @@ func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung,
|
|||||||
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
|
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
|
||||||
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
|
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
|
||||||
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: set antrag status: %w", err)
|
return fmt.Errorf("store: set antrag status: %w", err)
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
|||||||
// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle
|
// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle
|
||||||
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
|
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
|
||||||
func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) {
|
func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC
|
SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC
|
||||||
`, erstellerUserID)
|
`, erstellerUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,7 +139,7 @@ func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string)
|
|||||||
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
||||||
// zuerst.
|
// zuerst.
|
||||||
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
|
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
|
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
|
||||||
`, accountID)
|
`, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type AuditEntry struct {
|
|||||||
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
||||||
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
||||||
var e AuditEntry
|
var e AuditEntry
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
|
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
|
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
|
||||||
@@ -37,7 +37,7 @@ func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targe
|
|||||||
|
|
||||||
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
|
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
|
||||||
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
|
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
|
||||||
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
||||||
`, limit)
|
`, limit)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func TestAccountCRUD(t *testing.T) {
|
|||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,142 @@ 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 TestCreateAccountMitFirmendaten(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, store.AccountInput{
|
||||||
|
Name: "Vollstaendig GmbH", Strasse: "Musterstraße 1", PLZ: "12345", Ort: "Musterstadt",
|
||||||
|
Land: "Deutschland", UStID: "DE123456789", Rechnungsemail: "rechnung@vollstaendig.example.com",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if acc.Strasse != "Musterstraße 1" || acc.PLZ != "12345" || acc.Ort != "Musterstadt" ||
|
||||||
|
acc.Land != "Deutschland" || acc.UStID != "DE123456789" || acc.Rechnungsemail != "rechnung@vollstaendig.example.com" {
|
||||||
|
t.Fatalf("Account = %+v, Firmendaten unvollständig gespeichert", acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAccount(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Rechnungsemail != acc.Rechnungsemail {
|
||||||
|
t.Fatalf("Rechnungsemail nach GetAccount = %q, want %q", got.Rechnungsemail, acc.Rechnungsemail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccountDetails(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alte Firma", Ort: "Altstadt"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.UpdateAccountDetails(ctx, acc.ID, store.AccountInput{
|
||||||
|
Name: "Neue Firma", Strasse: "Neue Straße 2", PLZ: "54321", Ort: "Neustadt",
|
||||||
|
Land: "Österreich", UStID: "", Rechnungsemail: "buchhaltung@neue-firma.example.com",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateAccountDetails: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Name != "Neue Firma" || updated.Ort != "Neustadt" || updated.Land != "Österreich" {
|
||||||
|
t.Fatalf("Account nach Update = %+v, nicht wie erwartet aktualisiert", updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccountDetailsNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := s.UpdateAccountDetails(ctx, "00000000-0000-0000-0000-000000000000", store.AccountInput{Name: "X"})
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccount(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alter Name GmbH"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.UpdateAccount(ctx, acc.ID, "Neuer Name GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Name != "Neuer Name GmbH" {
|
||||||
|
t.Fatalf("Name = %q, want Neuer Name GmbH", updated.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAccount(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Neuer Name GmbH" {
|
||||||
|
t.Fatalf("Name nach erneutem Laden = %q, want Neuer Name GmbH", got.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccountNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, err := s.UpdateAccount(context.Background(), "00000000-0000-0000-0000-000000000000", "X")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAccountByEinladungToken(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "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, store.AccountInput{Name: "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) {
|
||||||
@@ -52,6 +188,49 @@ func TestUserCRUD(t *testing.T) {
|
|||||||
if byID.Email != "team@example.com" || byID.AccountID != accID {
|
if byID.Email != "team@example.com" || byID.AccountID != accID {
|
||||||
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
|
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
|
||||||
}
|
}
|
||||||
|
if !byID.Active {
|
||||||
|
t.Error("expected a newly created user to be Active by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetUserActive(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
user, err := s.CreateUser(ctx, accID, "deaktivierbar@example.com", "bcrypt-hash", "mitarbeiter")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetUserActive(ctx, user.ID, false); err != nil {
|
||||||
|
t.Fatalf("SetUserActive (false): %v", err)
|
||||||
|
}
|
||||||
|
deaktiviert, err := s.GetUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUser: %v", err)
|
||||||
|
}
|
||||||
|
if deaktiviert.Active {
|
||||||
|
t.Fatal("expected the user to be inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetUserActive(ctx, user.ID, true); err != nil {
|
||||||
|
t.Fatalf("SetUserActive (true): %v", err)
|
||||||
|
}
|
||||||
|
reaktiviert, err := s.GetUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUser: %v", err)
|
||||||
|
}
|
||||||
|
if !reaktiviert.Active {
|
||||||
|
t.Fatal("expected the user to be active again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetUserActiveNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
err := s.SetUserActive(context.Background(), "00000000-0000-0000-0000-000000000000", false)
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetUserByEmailNotFound(t *testing.T) {
|
func TestGetUserByEmailNotFound(t *testing.T) {
|
||||||
@@ -98,6 +277,9 @@ func TestSessionCRUD(t *testing.T) {
|
|||||||
if got.UserID != user.ID {
|
if got.UserID != user.ID {
|
||||||
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
||||||
}
|
}
|
||||||
|
if got.ImpersonatedByUserID != nil {
|
||||||
|
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
||||||
t.Fatalf("DeleteSession: %v", err)
|
t.Fatalf("DeleteSession: %v", err)
|
||||||
@@ -114,3 +296,38 @@ func TestGetSessionNotFound(t *testing.T) {
|
|||||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateImpersonatedSession(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
kundenAcc := testAccountID(t, s)
|
||||||
|
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser (Kunde): %v", err)
|
||||||
|
}
|
||||||
|
betreiberAcc := testAccountID(t, s)
|
||||||
|
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser (Betreiber): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||||
|
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateImpersonatedSession: %v", err)
|
||||||
|
}
|
||||||
|
if sess.UserID != kundenNutzer.ID {
|
||||||
|
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
|
||||||
|
}
|
||||||
|
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
|
||||||
|
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetSession(ctx, sess.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSession: %v", err)
|
||||||
|
}
|
||||||
|
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
|
||||||
|
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertu
|
|||||||
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err)
|
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO bewertung (
|
INSERT INTO bewertung (
|
||||||
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||||
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version
|
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version
|
||||||
@@ -124,7 +124,7 @@ func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertu
|
|||||||
// eines Antrags. Liefert ErrNotFound, wenn noch keine Bewertung
|
// eines Antrags. Liefert ErrNotFound, wenn noch keine Bewertung
|
||||||
// existiert.
|
// existiert.
|
||||||
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
|
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
SELECT `+bewertungColumns+`
|
SELECT `+bewertungColumns+`
|
||||||
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||||
`, antragID)
|
`, antragID)
|
||||||
|
|||||||
116
internal/store/email_vorlage.go
Normal file
116
internal/store/email_vorlage.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
|
||||||
|
// AccountID nil = plattformweiter Standard (Betreiber), gesetzt =
|
||||||
|
// mandantenspezifische Übersteuerung. ResolveEmailVorlage löst beides
|
||||||
|
// auf: eigene Vorlage, falls vorhanden, sonst der Plattform-Standard.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailVorlage struct {
|
||||||
|
ID string
|
||||||
|
AccountID *string
|
||||||
|
Typ string
|
||||||
|
Betreff string
|
||||||
|
Text string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertEmailVorlage legt eine Vorlage an oder aktualisiert sie —
|
||||||
|
// accountID nil schreibt den plattformweiten Standard (nur für den
|
||||||
|
// Betreiber sinnvoll, RLS erzwingt das zusätzlich auf DB-Ebene).
|
||||||
|
//
|
||||||
|
// Zwei unterschiedliche ON-CONFLICT-Ziele, weil SQL NULL nie als gleich
|
||||||
|
// zu NULL behandelt: ein einzelner Unique-Index über (account_id, typ)
|
||||||
|
// hätte beliebig viele Plattform-Standard-Zeilen (account_id IS NULL)
|
||||||
|
// je typ zugelassen, siehe Migration 0022 und den dort dokumentierten
|
||||||
|
// Bug (ohne diese Aufteilung erzeugte jedes Speichern des Plattform-
|
||||||
|
// Standards eine neue Zeile statt die bestehende zu aktualisieren).
|
||||||
|
func (s *Store) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (EmailVorlage, error) {
|
||||||
|
var v EmailVorlage
|
||||||
|
var err error
|
||||||
|
if accountID == nil {
|
||||||
|
err = s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO email_vorlage (account_id, typ, betreff, text)
|
||||||
|
VALUES (NULL, $1, $2, $3)
|
||||||
|
ON CONFLICT (typ) WHERE account_id IS NULL
|
||||||
|
DO UPDATE SET betreff = $2, text = $3, updated_at = now()
|
||||||
|
RETURNING id, account_id, typ, betreff, text, updated_at
|
||||||
|
`, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||||
|
} else {
|
||||||
|
err = s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO email_vorlage (account_id, typ, betreff, text)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (account_id, typ) WHERE account_id IS NOT NULL
|
||||||
|
DO UPDATE SET betreff = $3, text = $4, updated_at = now()
|
||||||
|
RETURNING id, account_id, typ, betreff, text, updated_at
|
||||||
|
`, accountID, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return EmailVorlage{}, fmt.Errorf("store: upsert email vorlage: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEmailVorlage liest eine Vorlage exakt (keine Fallback-Auflösung) —
|
||||||
|
// accountID nil sucht den plattformweiten Standard.
|
||||||
|
func (s *Store) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (EmailVorlage, error) {
|
||||||
|
var v EmailVorlage
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
|
||||||
|
WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
|
||||||
|
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return EmailVorlage{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return EmailVorlage{}, fmt.Errorf("store: get email vorlage: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveEmailVorlage liefert die für einen Mandanten tatsächlich
|
||||||
|
// wirksame Vorlage: die eigene Übersteuerung, falls vorhanden, sonst
|
||||||
|
// den plattformweiten Standard (account_id IS NULL). ErrNotFound nur,
|
||||||
|
// wenn keins von beidem existiert (sollte praktisch nie vorkommen, der
|
||||||
|
// Plattform-Standard wird per Migration angelegt).
|
||||||
|
func (s *Store) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (EmailVorlage, error) {
|
||||||
|
var v EmailVorlage
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
|
||||||
|
WHERE typ = $2 AND (account_id = $1 OR account_id IS NULL)
|
||||||
|
ORDER BY account_id NULLS LAST
|
||||||
|
LIMIT 1
|
||||||
|
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return EmailVorlage{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return EmailVorlage{}, fmt.Errorf("store: resolve email vorlage: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEmailVorlage entfernt eine Vorlage — bei einem Mandanten "auf
|
||||||
|
// Plattform-Standard zurücksetzen" (ResolveEmailVorlage greift danach
|
||||||
|
// wieder auf den plattformweiten Standard zurück), beim Betreiber
|
||||||
|
// bewusst nicht vorgesehen (der Standard muss immer existieren, siehe
|
||||||
|
// Migration 0022 — kein Lösch-Button auf der Betreiber-Seite).
|
||||||
|
func (s *Store) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `
|
||||||
|
DELETE FROM email_vorlage WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
|
||||||
|
`, accountID, typ)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete email vorlage: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
117
internal/store/email_vorlage_test.go
Normal file
117
internal/store/email_vorlage_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmailVorlagePlattformStandardExistiertNachMigration(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
v, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEmailVorlage (Plattform): %v", err)
|
||||||
|
}
|
||||||
|
if v.AccountID != nil {
|
||||||
|
t.Fatalf("AccountID = %v, want nil (Plattform-Standard)", v.AccountID)
|
||||||
|
}
|
||||||
|
if v.Betreff == "" || v.Text == "" {
|
||||||
|
t.Fatalf("Plattform-Standard unvollständig: %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveEmailVorlageFaelltAufPlattformStandardZurueck(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
// Ohne eigene Übersteuerung liefert Resolve den Plattform-Standard.
|
||||||
|
resolved, err := s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveEmailVorlage (kein eigener Override): %v", err)
|
||||||
|
}
|
||||||
|
if resolved.AccountID != nil {
|
||||||
|
t.Fatalf("AccountID = %v, want nil (geerbt vom Plattform-Standard)", resolved.AccountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eigene Übersteuerung anlegen — Resolve muss jetzt die eigene liefern.
|
||||||
|
eigene, err := s.UpsertEmailVorlage(ctx, &accID, "passwort_zuruecksetzen", "Eigener Betreff", "Eigener Text {{link}}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertEmailVorlage: %v", err)
|
||||||
|
}
|
||||||
|
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveEmailVorlage (mit Override): %v", err)
|
||||||
|
}
|
||||||
|
if resolved.ID != eigene.ID || resolved.Betreff != "Eigener Betreff" {
|
||||||
|
t.Fatalf("resolved = %+v, want eigene Vorlage", resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zurücksetzen entfernt die Übersteuerung, Resolve fällt wieder zurück.
|
||||||
|
if err := s.DeleteEmailVorlage(ctx, &accID, "passwort_zuruecksetzen"); err != nil {
|
||||||
|
t.Fatalf("DeleteEmailVorlage: %v", err)
|
||||||
|
}
|
||||||
|
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveEmailVorlage (nach Reset): %v", err)
|
||||||
|
}
|
||||||
|
if resolved.AccountID != nil {
|
||||||
|
t.Fatalf("AccountID nach Reset = %v, want nil (wieder Plattform-Standard)", resolved.AccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertPlattformStandardAktualisiertStattZuDuplizieren(t *testing.T) {
|
||||||
|
// Regressionstest: SQL behandelt NULL nie als gleich zu NULL, ein
|
||||||
|
// naiver UNIQUE(account_id, typ)-Constraint hätte beliebig viele
|
||||||
|
// Plattform-Standard-Zeilen je typ zugelassen — jedes erneute
|
||||||
|
// Speichern hätte eine neue Zeile erzeugt statt die bestehende zu
|
||||||
|
// aktualisieren (genau das ist beim Live-Verifizieren passiert).
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Erster Betreff", "Erster Text {{link}}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertEmailVorlage (1): %v", err)
|
||||||
|
}
|
||||||
|
second, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Zweiter Betreff", "Zweiter Text {{link}}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertEmailVorlage (2): %v", err)
|
||||||
|
}
|
||||||
|
if first.ID != second.ID {
|
||||||
|
t.Fatalf("zweites Upsert erzeugte eine neue Zeile (ID %s statt %s) statt zu aktualisieren", second.ID, first.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEmailVorlage: %v", err)
|
||||||
|
}
|
||||||
|
if got.Betreff != "Zweiter Betreff" {
|
||||||
|
t.Fatalf("Betreff = %q, want %q (aktueller Stand)", got.Betreff, "Zweiter Betreff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailVorlageZweierMandantenIsoliert(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accA := testAccountID(t, s)
|
||||||
|
accB := testAccountID(t, s)
|
||||||
|
|
||||||
|
if _, err := s.UpsertEmailVorlage(ctx, &accA, "passwort_zuruecksetzen", "Vorlage A", "Text A {{link}}"); err != nil {
|
||||||
|
t.Fatalf("UpsertEmailVorlage A: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedB, err := s.ResolveEmailVorlage(ctx, accB, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveEmailVorlage B: %v", err)
|
||||||
|
}
|
||||||
|
if resolvedB.Betreff == "Vorlage A" {
|
||||||
|
t.Fatal("Mandant B sieht die Vorlage von Mandant A — Isolation defekt")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.GetEmailVorlage(ctx, &accB, "passwort_zuruecksetzen"); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("GetEmailVorlage B (kein eigener Override) err=%v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (E
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO entscheidung (
|
INSERT INTO entscheidung (
|
||||||
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
|
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
@@ -96,7 +96,7 @@ func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (E
|
|||||||
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
|
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
|
||||||
// Entscheidung existiert.
|
// Entscheidung existiert.
|
||||||
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
|
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
SELECT `+entscheidungColumns+`
|
SELECT `+entscheidungColumns+`
|
||||||
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||||
`, antragID)
|
`, antragID)
|
||||||
@@ -109,3 +109,38 @@ func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID str
|
|||||||
}
|
}
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAktiveGenehmigungenForAccount liefert alle Genehmigungen
|
||||||
|
// ("genehmigt"/"genehmigt_mit_auflagen") eines Mandanten — Grundlage
|
||||||
|
// für die Wiedervorlage (Schritt 7): abgelaufene/bald ablaufende
|
||||||
|
// Genehmigungen und solche, deren zugrunde liegendes Werkzeug sich seit
|
||||||
|
// der Entscheidung im Katalog geändert hat, muss der/die Verantwortliche
|
||||||
|
// erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug
|
||||||
|
// zum Mandanten läuft über den zugehörigen antrag.
|
||||||
|
func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung,
|
||||||
|
e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at
|
||||||
|
FROM entscheidung e
|
||||||
|
JOIN antrag a ON a.id = e.antrag_id
|
||||||
|
WHERE a.account_id = $1 AND e.entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen')
|
||||||
|
ORDER BY e.created_at DESC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Entscheidung
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanEntscheidung(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan entscheidung: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,3 +113,41 @@ func TestEntscheidungIsAppendOnly(t *testing.T) {
|
|||||||
t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger")
|
t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListAktiveGenehmigungenForAccount(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
|
||||||
|
antragGenehmigt, b1 := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||||
|
AntragID: antragGenehmigt.ID, BewertungID: b1.ID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung (genehmigt): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
antragAbgelehnt, b2 := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||||
|
AntragID: antragAbgelehnt.ID, BewertungID: b2.ID, EntscheiderUserID: userID, Entscheidung: "abgelehnt",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung (abgelehnt): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListAktiveGenehmigungenForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAktiveGenehmigungenForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].AntragID != antragGenehmigt.ID {
|
||||||
|
t.Fatalf("ListAktiveGenehmigungenForAccount = %+v, want exactly the genehmigt entscheidung", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherAcc := testAccountID(t, s)
|
||||||
|
otherList, err := s.ListAktiveGenehmigungenForAccount(ctx, otherAcc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAktiveGenehmigungenForAccount (other): %v", err)
|
||||||
|
}
|
||||||
|
if len(otherList) != 0 {
|
||||||
|
t.Fatalf("expected no genehmigungen for a different tenant, got %+v", otherList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
388
internal/store/freigabe.go
Normal file
388
internal/store/freigabe.go
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
|
||||||
|
// und CLAUDE.md. GenehmigerRolle ist eine reine Freigabe-Funktion
|
||||||
|
// (z. B. "Datenschutzbeauftragter", "Geschäftsführung"), unabhängig von
|
||||||
|
// app_user.role (Zugriffskontrolle) — eine Person kann beides zugleich
|
||||||
|
// sein oder keines von beidem.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenehmigerRolle struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
Name string
|
||||||
|
Beschreibung string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen
|
||||||
|
// Mandanten an. Beschreibung ist reiner Freitext zur Orientierung des
|
||||||
|
// Admins (z. B. "Prüft den Antrag aus Sicherheitssicht") — bindet keine
|
||||||
|
// Bedingung, das bleibt Sache von freigabe_regel.
|
||||||
|
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, account_id, name, beschreibung, created_at
|
||||||
|
`, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
|
||||||
|
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1
|
||||||
|
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return GenehmigerRolle{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return GenehmigerRolle{}, fmt.Errorf("store: get genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
|
||||||
|
// Mandanten.
|
||||||
|
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle
|
||||||
|
WHERE account_id = $1 ORDER BY name
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list genehmiger rollen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []GenehmigerRolle
|
||||||
|
for rows.Next() {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGenehmigerRolle entfernt eine Genehmiger-Rolle. Zuordnungen
|
||||||
|
// (nutzer_genehmiger_rolle) und darauf verweisende Freigabe-Regeln
|
||||||
|
// hängen per Fremdschlüssel daran — der Aufrufer muss sie vorher
|
||||||
|
// entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein
|
||||||
|
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
|
||||||
|
func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
|
||||||
|
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||||
|
_, err := s.db(ctx).Exec(ctx, `
|
||||||
|
INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
|
||||||
|
`, userID, genehmigerRolleID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: add nutzer genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
|
||||||
|
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||||
|
_, err := s.db(ctx).Exec(ctx, `
|
||||||
|
DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2
|
||||||
|
`, userID, genehmigerRolleID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: remove nutzer genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
|
||||||
|
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
|
||||||
|
func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT gr.id, gr.account_id, gr.name, gr.created_at
|
||||||
|
FROM genehmiger_rolle gr
|
||||||
|
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id
|
||||||
|
WHERE ngr.app_user_id = $1
|
||||||
|
ORDER BY gr.name
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list genehmiger rollen for user: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []GenehmigerRolle
|
||||||
|
for rows.Next() {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenehmigerMitglied ist ein Nutzer, der eine bestimmte Genehmiger-Rolle
|
||||||
|
// innehat — für die Anzeige "wer hat diese Rolle" auf der Verwaltungsseite.
|
||||||
|
type GenehmigerMitglied struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
|
||||||
|
// bestimmten Genehmiger-Rolle.
|
||||||
|
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT u.id, u.email
|
||||||
|
FROM app_user u
|
||||||
|
JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id
|
||||||
|
WHERE ngr.genehmiger_rolle_id = $1
|
||||||
|
ORDER BY u.email
|
||||||
|
`, genehmigerRolleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list nutzer for genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []GenehmigerMitglied
|
||||||
|
for rows.Next() {
|
||||||
|
var m GenehmigerMitglied
|
||||||
|
if err := rows.Scan(&m.UserID, &m.Email); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan genehmiger mitglied: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreigabeRegel: "wenn Bedingung X zutrifft, ist zusätzlich eine
|
||||||
|
// Freigabe durch GenehmigerRolleID nötig." BedingungTyp ist eine von
|
||||||
|
// "anforderung"/"einstufung"/"datenklasse", BedingungWert die jeweilige
|
||||||
|
// ID aus dem Regelwerk (z. B. "dsfa_erforderlich", "hochrisiko").
|
||||||
|
type FreigabeRegel struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
BedingungTyp string
|
||||||
|
BedingungWert string
|
||||||
|
GenehmigerRolleID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
|
||||||
|
var f FreigabeRegel
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||||
|
`, accountID, bedingungTyp, bedingungWert, genehmigerRolleID).Scan(
|
||||||
|
&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return FreigabeRegel{}, fmt.Errorf("store: create freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||||
|
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list freigabe regeln: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []FreigabeRegel
|
||||||
|
for rows.Next() {
|
||||||
|
var f FreigabeRegel
|
||||||
|
if err := rows.Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFreigabeRegel liest eine einzelne Freigabe-Regel — genutzt, um
|
||||||
|
// beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen.
|
||||||
|
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
|
||||||
|
var f FreigabeRegel
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||||
|
FROM freigabe_regel WHERE id = $1
|
||||||
|
`, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return FreigabeRegel{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return FreigabeRegel{}, fmt.Errorf("store: get freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freigabeschritt ist eine einzelne, durch eine FreigabeRegel
|
||||||
|
// ausgelöste Freigabe-Aufgabe für einen konkreten Antrag. Nicht
|
||||||
|
// append-only — ein Schritt geht von "ausstehend" in genau einen
|
||||||
|
// Endzustand über, keine Historie mehrerer Entscheidungen zum selben
|
||||||
|
// Schritt.
|
||||||
|
type Freigabeschritt struct {
|
||||||
|
ID string
|
||||||
|
AntragID string
|
||||||
|
GenehmigerRolleID string
|
||||||
|
Status string // ausstehend/genehmigt/abgelehnt
|
||||||
|
EntschiedenVon *string
|
||||||
|
EntschiedenAm *time.Time
|
||||||
|
Kommentar string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
|
||||||
|
var f Freigabeschritt
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||||
|
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
`, antragID, genehmigerRolleID).Scan(
|
||||||
|
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Freigabeschritt{}, fmt.Errorf("store: create freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
|
||||||
|
var f Freigabeschritt
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
FROM freigabeschritt WHERE id = $1
|
||||||
|
`, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Freigabeschritt{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Freigabeschritt{}, fmt.Errorf("store: get freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at
|
||||||
|
`, antragID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list freigabeschritte for antrag: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Freigabeschritt
|
||||||
|
for rows.Next() {
|
||||||
|
var f Freigabeschritt
|
||||||
|
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAusstehendeFreigabeschritteForUser liefert alle offenen
|
||||||
|
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
|
||||||
|
// innehat — Grundlage für "Meine Freigaben".
|
||||||
|
func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at
|
||||||
|
FROM freigabeschritt f
|
||||||
|
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id
|
||||||
|
WHERE ngr.app_user_id = $1 AND f.status = 'ausstehend'
|
||||||
|
ORDER BY f.created_at
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list ausstehende freigabeschritte: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Freigabeschritt
|
||||||
|
for rows.Next() {
|
||||||
|
var f Freigabeschritt
|
||||||
|
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntscheideFreigabeschritt trägt die Entscheidung einer Person zu
|
||||||
|
// einem einzelnen Freigabeschritt ein. Nur von "ausstehend" aus
|
||||||
|
// erlaubt — der Aufrufer prüft das vorher (ErrNotFound bei bereits
|
||||||
|
// entschiedenen Schritten wäre irreführend, deshalb hier ein
|
||||||
|
// generischer Fehler statt eines Sentinels).
|
||||||
|
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
|
||||||
|
var f Freigabeschritt
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
UPDATE freigabeschritt
|
||||||
|
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
|
||||||
|
WHERE id = $1 AND status = 'ausstehend'
|
||||||
|
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
`, id, status, entschiedenVon, kommentar).Scan(
|
||||||
|
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Freigabeschritt{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Freigabeschritt{}, fmt.Errorf("store: entscheide freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KaskadiereAblehnung lehnt alle noch ausstehenden Freigabeschritte
|
||||||
|
// eines Antrags automatisch ab, nachdem ein anderer Schritt desselben
|
||||||
|
// Antrags bereits abgelehnt wurde — eine einzelne Ablehnung kippt den
|
||||||
|
// gesamten Antrag, die übrigen offenen Freigaben werden dadurch
|
||||||
|
// gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen
|
||||||
|
// hängen zu bleiben.
|
||||||
|
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||||
|
_, err := s.db(ctx).Exec(ctx, `
|
||||||
|
UPDATE freigabeschritt
|
||||||
|
SET status = 'abgelehnt', entschieden_am = now(),
|
||||||
|
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
|
||||||
|
WHERE antrag_id = $1 AND status = 'ausstehend' AND id != $2
|
||||||
|
`, antragID, ausloesenderSchrittID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: kaskadiere ablehnung: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
174
internal/store/freigabe_test.go
Normal file
174
internal/store/freigabe_test.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenehmigerRolleCRUDAndMitgliedschaft(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
if g.AccountID != accID || g.Name != "Datenschutzbeauftragter" {
|
||||||
|
t.Fatalf("GenehmigerRolle = %+v, unerwartete Werte", g)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetGenehmigerRolle(ctx, g.ID)
|
||||||
|
if err != nil || got.Name != "Datenschutzbeauftragter" {
|
||||||
|
t.Fatalf("GetGenehmigerRolle = %+v, err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
liste, err := s.ListGenehmigerRollenForAccount(ctx, accID)
|
||||||
|
if err != nil || len(liste) != 1 {
|
||||||
|
t.Fatalf("ListGenehmigerRollenForAccount = %+v, err=%v", liste, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := testUserID(t, s, accID)
|
||||||
|
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||||
|
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
// Erneutes Hinzufügen derselben Zuordnung ist ein No-op (ON CONFLICT),
|
||||||
|
// kein Fehler.
|
||||||
|
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||||
|
t.Fatalf("AddNutzerGenehmigerRolle (erneut): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rollenDesNutzers, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||||
|
if err != nil || len(rollenDesNutzers) != 1 || rollenDesNutzers[0].ID != g.ID {
|
||||||
|
t.Fatalf("ListGenehmigerRollenForUser = %+v, err=%v", rollenDesNutzers, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mitglieder, err := s.ListNutzerForGenehmigerRolle(ctx, g.ID)
|
||||||
|
if err != nil || len(mitglieder) != 1 || mitglieder[0].UserID != user {
|
||||||
|
t.Fatalf("ListNutzerForGenehmigerRolle = %+v, err=%v", mitglieder, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.RemoveNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||||
|
t.Fatalf("RemoveNutzerGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
rollenNachEntfernen, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||||
|
if err != nil || len(rollenNachEntfernen) != 0 {
|
||||||
|
t.Fatalf("ListGenehmigerRollenForUser nach Entfernen = %+v, err=%v", rollenNachEntfernen, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteGenehmigerRolle(ctx, g.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetGenehmigerRolle(ctx, g.ID); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeRegelCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
regel, err := s.CreateFreigabeRegel(ctx, accID, "einstufung", "hochrisiko", g.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFreigabeRegel: %v", err)
|
||||||
|
}
|
||||||
|
if regel.BedingungTyp != "einstufung" || regel.BedingungWert != "hochrisiko" {
|
||||||
|
t.Fatalf("FreigabeRegel = %+v, unerwartete Werte", regel)
|
||||||
|
}
|
||||||
|
|
||||||
|
liste, err := s.ListFreigabeRegelnForAccount(ctx, accID)
|
||||||
|
if err != nil || len(liste) != 1 {
|
||||||
|
t.Fatalf("ListFreigabeRegelnForAccount = %+v, err=%v", liste, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetFreigabeRegel(ctx, regel.ID)
|
||||||
|
if err != nil || got.ID != regel.ID {
|
||||||
|
t.Fatalf("GetFreigabeRegel = %+v, err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteFreigabeRegel(ctx, regel.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteFreigabeRegel: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetFreigabeRegel(ctx, regel.ID); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeschrittLifecycleUndKaskade(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
user := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, user, nil, "Test-Antrag für Freigabeschritte")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
antragID := antrag.ID
|
||||||
|
|
||||||
|
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||||
|
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||||
|
|
||||||
|
schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
if schrittDSB.Status != "ausstehend" {
|
||||||
|
t.Fatalf("Status = %q, want ausstehend", schrittDSB.Status)
|
||||||
|
}
|
||||||
|
schrittGF, err := s.CreateFreigabeschritt(ctx, antragID, gf.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alle, err := s.ListFreigabeschritteForAntrag(ctx, antragID)
|
||||||
|
if err != nil || len(alle) != 2 {
|
||||||
|
t.Fatalf("ListFreigabeschritteForAntrag = %+v, err=%v", alle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ausstehend, err := s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAusstehendeFreigabeschritteForUser: %v", err)
|
||||||
|
}
|
||||||
|
if len(ausstehend) != 0 {
|
||||||
|
t.Fatalf("erwartet 0 ausstehende Freigaben ohne Genehmiger-Rollen-Zuordnung, got %d", len(ausstehend))
|
||||||
|
}
|
||||||
|
if err := s.AddNutzerGenehmigerRolle(ctx, user, dsb.ID); err != nil {
|
||||||
|
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
ausstehend, err = s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||||
|
if err != nil || len(ausstehend) != 1 || ausstehend[0].ID != schrittDSB.ID {
|
||||||
|
t.Fatalf("ListAusstehendeFreigabeschritteForUser = %+v, err=%v", ausstehend, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entschieden, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "abgelehnt", user, "nicht ausreichend")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntscheideFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
if entschieden.Status != "abgelehnt" || entschieden.EntschiedenVon == nil || *entschieden.EntschiedenVon != user {
|
||||||
|
t.Fatalf("Freigabeschritt nach Entscheidung = %+v, unerwartete Werte", entschieden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein bereits entschiedener Schritt kann nicht erneut entschieden werden.
|
||||||
|
if _, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "genehmigt", user, ""); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("erneute Entscheidung err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.KaskadiereAblehnung(ctx, antragID, schrittDSB.ID); err != nil {
|
||||||
|
t.Fatalf("KaskadiereAblehnung: %v", err)
|
||||||
|
}
|
||||||
|
nachKaskade, err := s.GetFreigabeschritt(ctx, schrittGF.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
if nachKaskade.Status != "abgelehnt" {
|
||||||
|
t.Fatalf("Status nach Kaskade = %q, want abgelehnt", nachKaskade.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
internal/store/loeschfrist.go
Normal file
69
internal/store/loeschfrist.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Löschfristen je Datenklasse (Migration 0020) — schließt die in
|
||||||
|
// rules/OPEN.md, Punkt 4 dokumentierte Lücke: loeschfrist_max_tage war
|
||||||
|
// bisher nur als Anforderung "vorhanden", ohne Tageswerte, und wurde
|
||||||
|
// deshalb nicht hart gefiltert. Die konkreten Tageswerte sind KEINE
|
||||||
|
// gesetzliche Vorgabe (die DSGVO nennt keine festen Fristen, nur den
|
||||||
|
// Grundsatz "so lange wie für den Zweck nötig", Art. 5 Abs. 1 lit. e) —
|
||||||
|
// deshalb pro Mandant einstellbar statt hartkodiert, vom
|
||||||
|
// Datenschutzbeauftragten hinterlegt. Bei Firmenanlage werden
|
||||||
|
// risikogestaffelte Vorschlagswerte vorbelegt (siehe
|
||||||
|
// internal/web/loeschfrist_handlers.go), frei editierbar.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoeschfristEinstellung struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
DatenklasseID string
|
||||||
|
MaxTage int
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertLoeschfristEinstellung legt eine Einstellung an oder
|
||||||
|
// aktualisiert sie — ein Mandant hat höchstens eine Frist je
|
||||||
|
// Datenklasse (UNIQUE-Constraint).
|
||||||
|
func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (LoeschfristEinstellung, error) {
|
||||||
|
var e LoeschfristEinstellung
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO loeschfrist_einstellung (account_id, datenklasse_id, max_tage)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (account_id, datenklasse_id)
|
||||||
|
DO UPDATE SET max_tage = $3, updated_at = now()
|
||||||
|
RETURNING id, account_id, datenklasse_id, max_tage, updated_at
|
||||||
|
`, accountID, datenklasseID, maxTage).Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return LoeschfristEinstellung{}, fmt.Errorf("store: upsert loeschfrist einstellung: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLoeschfristEinstellungenForAccount liefert alle konfigurierten
|
||||||
|
// Fristen eines Mandanten.
|
||||||
|
func (s *Store) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]LoeschfristEinstellung, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT id, account_id, datenklasse_id, max_tage, updated_at
|
||||||
|
FROM loeschfrist_einstellung WHERE account_id = $1 ORDER BY datenklasse_id
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []LoeschfristEinstellung
|
||||||
|
for rows.Next() {
|
||||||
|
var e LoeschfristEinstellung
|
||||||
|
if err := rows.Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan loeschfrist einstellung: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
41
internal/store/loeschfrist_test.go
Normal file
41
internal/store/loeschfrist_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoeschfristEinstellungUpsert(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
e, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 90)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
|
||||||
|
}
|
||||||
|
if e.MaxTage != 90 {
|
||||||
|
t.Fatalf("MaxTage = %d, want 90", e.MaxTage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erneutes Upsert für dieselbe Datenklasse überschreibt statt zu duplizieren.
|
||||||
|
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 30); err != nil {
|
||||||
|
t.Fatalf("UpsertLoeschfristEinstellung (Update): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "besondere_kategorie", 14); err != nil {
|
||||||
|
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
liste, err := s.ListLoeschfristEinstellungenForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListLoeschfristEinstellungenForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(liste) != 2 {
|
||||||
|
t.Fatalf("got %d Einstellungen, want 2 (Upsert darf nicht duplizieren)", len(liste))
|
||||||
|
}
|
||||||
|
for _, e := range liste {
|
||||||
|
if e.DatenklasseID == "personenbezogen" && e.MaxTage != 30 {
|
||||||
|
t.Fatalf("personenbezogen MaxTage = %d, want 30 nach Update", e.MaxTage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE registereintrag;
|
||||||
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- Registereintrag: wird automatisch bei jeder Genehmigung erzeugt
|
||||||
|
-- (genehmigt / genehmigt_mit_auflagen), siehe CLAUDE.md, Entscheidung/
|
||||||
|
-- Register/Wiedervorlage. Bewusst denormalisiert (eigene Textspalten
|
||||||
|
-- statt Joins über bewertung/entscheidung/werkzeug) — ein Registereintrag
|
||||||
|
-- ist ein für sich lesbarer, exportierbarer Nachweis, der nicht von
|
||||||
|
-- einem späteren Katalog- oder Abteilungs-Wandel abhängen darf.
|
||||||
|
-- Append-only wie bewertung/entscheidung.
|
||||||
|
CREATE TABLE registereintrag (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||||
|
entscheidung_id UUID NOT NULL REFERENCES entscheidung (id),
|
||||||
|
zweck TEXT NOT NULL,
|
||||||
|
abteilung TEXT NOT NULL DEFAULT '',
|
||||||
|
werkzeug TEXT NOT NULL,
|
||||||
|
datenklasse TEXT NOT NULL,
|
||||||
|
einstufung TEXT NOT NULL,
|
||||||
|
auflagen TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
verantwortlicher TEXT NOT NULL,
|
||||||
|
entschieden_am TIMESTAMPTZ NOT NULL,
|
||||||
|
gueltig_bis TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER registereintrag_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON registereintrag
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE app_user DROP COLUMN active;
|
||||||
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Nutzer können nicht gelöscht werden (app_user wird von antrag,
|
||||||
|
-- session, bewertung [über antrag], entscheidung, audit_log,
|
||||||
|
-- registereintrag [über antrag] per Foreign Key referenziert — ein
|
||||||
|
-- Hard-Delete würde die Historie zerstören). Stattdessen: deaktivieren.
|
||||||
|
-- Ein deaktivierter Nutzer kann sich nicht mehr anmelden, bleibt aber
|
||||||
|
-- als Akteur in Anträgen/Entscheidungen/Audit-Log nachvollziehbar.
|
||||||
|
ALTER TABLE app_user ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE account DROP COLUMN einladung_token;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, "Einladung
|
||||||
|
-- annehmen" — siehe CLAUDE.md, Onboarding: "Einladungslink [Sammellink,
|
||||||
|
-- Selbstanmeldung, Abteilung beim ersten Antrag]"). Ein Token pro
|
||||||
|
-- Account, kein Ablaufdatum, per Admin erneuerbar (macht den alten
|
||||||
|
-- Link ungültig). DEFAULT gen_random_uuid()::text befüllt bestehende
|
||||||
|
-- Zeilen automatisch und liefert künftigen INSERTs einen Wert, ohne
|
||||||
|
-- dass store.CreateAccount etwas ändern muss.
|
||||||
|
ALTER TABLE account ADD COLUMN einladung_token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE session DROP COLUMN impersonated_by_user_id;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Support-Login (Betreiber meldet sich als Kunden-Nutzer an, siehe
|
||||||
|
-- internal/web/betreiber_handlers.go). impersonated_by_user_id ist
|
||||||
|
-- gesetzt, wenn diese Sitzung durch einen Betreiber-Support-Login
|
||||||
|
-- entstanden ist (nicht durch den regulären Login des Nutzers selbst)
|
||||||
|
-- — die authenticate-Middleware liest das, um in der Nav einen
|
||||||
|
-- deutlichen Hinweis-Banner anzuzeigen, damit niemand vergisst, dass
|
||||||
|
-- er/sie gerade im Kontext eines fremden Kontos handelt.
|
||||||
|
ALTER TABLE session ADD COLUMN impersonated_by_user_id UUID REFERENCES app_user (id);
|
||||||
5
internal/store/migrations/0015_werkzeug_details.down.sql
Normal file
5
internal/store/migrations/0015_werkzeug_details.down.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE werkzeug DROP COLUMN subprozessoren;
|
||||||
|
|
||||||
|
UPDATE werkzeug SET aufbewahrung_tage = 0 WHERE aufbewahrung_tage IS NULL;
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET DEFAULT 0;
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET NOT NULL;
|
||||||
12
internal/store/migrations/0015_werkzeug_details.up.sql
Normal file
12
internal/store/migrations/0015_werkzeug_details.up.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- aufbewahrung_tage war bisher NOT NULL DEFAULT 0 — das machte "0" doppeldeutig:
|
||||||
|
-- mal eine echte, belegte Zusicherung (z. B. DeepL: sofortige Löschung),
|
||||||
|
-- mal ein Platzhalter für "vom Anbieter nicht beziffert". NULL trennt das
|
||||||
|
-- sauber: "unbekannt" statt "angeblich sofort gelöscht".
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP NOT NULL;
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP DEFAULT;
|
||||||
|
|
||||||
|
-- Subprozessoren (z. B. Anthropic als Unterauftragsverarbeiter bei
|
||||||
|
-- Microsoft 365 Copilot, AWS bei DeepL) verändern die EU/USA-Einstufung
|
||||||
|
-- eines Werkzeugs materiell — bisher nur als Freitext in
|
||||||
|
-- "einschraenkungen" erfasst, jetzt als eigenes, durchsuchbares Feld.
|
||||||
|
ALTER TABLE werkzeug ADD COLUMN subprozessoren TEXT[] NOT NULL DEFAULT '{}';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE werkzeug ADD COLUMN verarbeitungsort TEXT;
|
||||||
|
|
||||||
|
-- Ein Rückbau von Freitext-Ländern auf den alten Vier-Werte-Eimer ist
|
||||||
|
-- verlustbehaftet (welches Land genau "gemischt" war, geht verloren) —
|
||||||
|
-- bestmögliche Näherung statt Datenverlust durch NULL.
|
||||||
|
UPDATE werkzeug SET verarbeitungsort =
|
||||||
|
CASE
|
||||||
|
WHEN verarbeitungslaender = ARRAY['On-Premise (selbst gehostet)'] THEN 'on-prem'
|
||||||
|
WHEN verarbeitungslaender = ARRAY['USA'] THEN 'USA'
|
||||||
|
WHEN array_length(verarbeitungslaender, 1) IS NULL OR array_length(verarbeitungslaender, 1) = 0 THEN 'gemischt'
|
||||||
|
WHEN 'USA' = ANY(verarbeitungslaender) THEN 'gemischt'
|
||||||
|
ELSE 'EU'
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN verarbeitungsort SET NOT NULL;
|
||||||
|
ALTER TABLE werkzeug ADD CONSTRAINT werkzeug_verarbeitungsort_check
|
||||||
|
CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem'));
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN verarbeitungslaender;
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN dpf_zertifiziert;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- "EU"/"USA"/"gemischt"/"on-prem" als grobe Eimer verschleierten, dass
|
||||||
|
-- auch die USA bereits ein Drittland sind (DSGVO Art. 44 ff. kennt nur
|
||||||
|
-- EU/EWR vs. Drittland) und dass "gemischt" nichts über die tatsächlich
|
||||||
|
-- beteiligten Länder aussagt (z. B. Irland+USA bei Microsoft 365
|
||||||
|
-- Copilot vs. Frankreich bei Mistral) — beides macht einen Unterschied
|
||||||
|
-- für die Einschätzung, nicht nur für die Anzeige. Ersetzt durch eine
|
||||||
|
-- Liste tatsächlicher Länder (oder einer Region, wenn der Anbieter
|
||||||
|
-- selbst keinen einzelnen Staat benennt, z. B. "EU-Region" bei
|
||||||
|
-- OpenAI/Microsoft).
|
||||||
|
ALTER TABLE werkzeug ADD COLUMN verarbeitungslaender TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP CONSTRAINT werkzeug_verarbeitungsort_check;
|
||||||
|
|
||||||
|
UPDATE werkzeug SET verarbeitungslaender =
|
||||||
|
CASE verarbeitungsort
|
||||||
|
WHEN 'EU' THEN ARRAY['EU-Region (Land nicht migriert, bitte prüfen)']
|
||||||
|
WHEN 'USA' THEN ARRAY['USA']
|
||||||
|
WHEN 'on-prem' THEN ARRAY['On-Premise (selbst gehostet)']
|
||||||
|
ELSE ARRAY['nicht spezifiziert (bitte prüfen)']
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN verarbeitungsort;
|
||||||
|
|
||||||
|
-- Ob ein US-Anbieter unter dem EU-US Data Privacy Framework zertifiziert
|
||||||
|
-- ist (schmalere, für Schrems-Nachfolgeverfahren anfällige Rechtsgrundlage)
|
||||||
|
-- oder sich rein auf Standardvertragsklauseln stützt, stand bisher nur als
|
||||||
|
-- Fließtext in "einschraenkungen" — jetzt strukturiert und damit künftig
|
||||||
|
-- filterbar.
|
||||||
|
ALTER TABLE werkzeug ADD COLUMN dpf_zertifiziert BOOLEAN NOT NULL DEFAULT false;
|
||||||
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||||
|
UPDATE antrag SET status = 'eingereicht' WHERE status = 'wartet_auf_freigabe';
|
||||||
|
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||||
|
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden'));
|
||||||
|
|
||||||
|
DROP TABLE freigabeschritt;
|
||||||
|
DROP TABLE freigabe_regel;
|
||||||
|
DROP TABLE nutzer_genehmiger_rolle;
|
||||||
|
DROP TABLE genehmiger_rolle;
|
||||||
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
-- Konfigurierbarer Mehrfach-Freigabe-Workflow (2026-08-31): manche
|
||||||
|
-- Anträge brauchen zusätzlich zur normalen Fachebene-Entscheidung eine
|
||||||
|
-- Freigabe durch bestimmte Personen (z. B. Datenschutzbeauftragte bei
|
||||||
|
-- dsfa_erforderlich, Geschäftsführung bei hochrisiko). Bewusst additiv
|
||||||
|
-- zur bestehenden app_user.role (Zugriffskontrolle) — eine
|
||||||
|
-- Genehmiger-Rolle ist eine reine Freigabe-Funktion, keine Berechtigung,
|
||||||
|
-- und eine Person kann mehrere davon gleichzeitig innehaben.
|
||||||
|
|
||||||
|
-- Stammdaten wie abteilung, pro Mandant frei benennbar.
|
||||||
|
CREATE TABLE genehmiger_rolle (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (account_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Welche Personen eine Genehmiger-Rolle innehaben (n:m).
|
||||||
|
CREATE TABLE nutzer_genehmiger_rolle (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||||
|
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (app_user_id, genehmiger_rolle_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
|
||||||
|
-- Genehmiger-Rolle Y nötig." Bedingung ist bewusst eine der bereits vom
|
||||||
|
-- Regelwerk abgeleiteten Größen (Anforderungs-ID, Einstufungs-ID,
|
||||||
|
-- Datenklasse-ID) — kein freier Regel-Editor, keine beliebige Logik.
|
||||||
|
CREATE TABLE freigabe_regel (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
bedingung_typ TEXT NOT NULL CHECK (bedingung_typ IN ('anforderung', 'einstufung', 'datenklasse')),
|
||||||
|
bedingung_wert TEXT NOT NULL,
|
||||||
|
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pro Antrag ein Eintrag je durch eine Regel ausgelöster nötiger
|
||||||
|
-- Freigabe. Nicht append-only (wie werkzeug) — ein Freigabeschritt ist
|
||||||
|
-- eine einzelne Aufgabe, die von ausstehend in einen Endzustand
|
||||||
|
-- übergeht, keine Historie mehrerer Entscheidungen zum selben Schritt.
|
||||||
|
CREATE TABLE freigabeschritt (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||||
|
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'ausstehend' CHECK (status IN ('ausstehend', 'genehmigt', 'abgelehnt')),
|
||||||
|
entschieden_von UUID REFERENCES app_user (id),
|
||||||
|
entschieden_am TIMESTAMPTZ,
|
||||||
|
kommentar TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ein Antrag, der auf zusätzliche Freigaben wartet, ist weder "nur
|
||||||
|
-- eingereicht" noch schon "entschieden" — eigener Zwischenzustand.
|
||||||
|
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||||
|
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||||
|
CHECK (status IN ('entwurf', 'eingereicht', 'wartet_auf_freigabe', 'entschieden'));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE genehmiger_rolle DROP COLUMN beschreibung;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE genehmiger_rolle ADD COLUMN beschreibung TEXT NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE password_reset_token;
|
||||||
10
internal/store/migrations/0019_password_reset_token.up.sql
Normal file
10
internal/store/migrations/0019_password_reset_token.up.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE password_reset_token (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES app_user(id),
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX password_reset_token_user_id_idx ON password_reset_token(user_id);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE loeschfrist_einstellung;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE loeschfrist_einstellung (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account(id),
|
||||||
|
datenklasse_id TEXT NOT NULL,
|
||||||
|
max_tage INTEGER NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (account_id, datenklasse_id)
|
||||||
|
);
|
||||||
58
internal/store/migrations/0021_row_level_security.down.sql
Normal file
58
internal/store/migrations/0021_row_level_security.down.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
ALTER TABLE antrag NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE antrag DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON antrag;
|
||||||
|
|
||||||
|
ALTER TABLE registereintrag NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE registereintrag DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON registereintrag;
|
||||||
|
|
||||||
|
ALTER TABLE abteilung NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE abteilung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON abteilung;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug_sperre NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE werkzeug_sperre DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON werkzeug_sperre;
|
||||||
|
|
||||||
|
ALTER TABLE genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE genehmiger_rolle DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON genehmiger_rolle;
|
||||||
|
|
||||||
|
ALTER TABLE freigabe_regel NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE freigabe_regel DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON freigabe_regel;
|
||||||
|
|
||||||
|
ALTER TABLE loeschfrist_einstellung NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE loeschfrist_einstellung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON loeschfrist_einstellung;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE werkzeug DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON werkzeug;
|
||||||
|
|
||||||
|
ALTER TABLE bewertung NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE bewertung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON bewertung;
|
||||||
|
|
||||||
|
ALTER TABLE entscheidung NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE entscheidung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON entscheidung;
|
||||||
|
|
||||||
|
ALTER TABLE freigabeschritt NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE freigabeschritt DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON freigabeschritt;
|
||||||
|
|
||||||
|
ALTER TABLE nutzer_genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE nutzer_genehmiger_rolle DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON nutzer_genehmiger_rolle;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
|
||||||
|
EXECUTE 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM deklarix_app';
|
||||||
|
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM deklarix_app';
|
||||||
|
EXECUTE 'REVOKE USAGE ON SCHEMA public FROM deklarix_app';
|
||||||
|
DROP ROLE deklarix_app;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
187
internal/store/migrations/0021_row_level_security.up.sql
Normal file
187
internal/store/migrations/0021_row_level_security.up.sql
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
-- Mandantenisolation auf Datenbankebene (Postgres Row-Level Security).
|
||||||
|
--
|
||||||
|
-- WICHTIG, per Incident am 2026-09-01 gelernt: RLS-Policies wirken NIE
|
||||||
|
-- bei Postgres-Superusern, und NIE beim Tabellenbesitzer ohne FORCE ROW
|
||||||
|
-- LEVEL SECURITY. Welcher Fall zutrifft, hängt von der Umgebung ab:
|
||||||
|
-- - Produktion verbindet als eigene, nicht-privilegierte Rolle (z. B.
|
||||||
|
-- "deklarix"), die zugleich Eigentümerin der Tabellen ist (sie hat
|
||||||
|
-- sie über die Migrationen selbst angelegt) — für sie reicht FORCE
|
||||||
|
-- ROW LEVEL SECURITY völlig aus, keine weitere Rolle nötig.
|
||||||
|
-- - Manche Entwicklungs-/Testumgebungen verbinden dagegen als
|
||||||
|
-- echter Postgres-Superuser (z. B. lokales Docker-Postgres mit
|
||||||
|
-- "postgres") — für den wirkt FORCE nicht (Superuser sind davon
|
||||||
|
-- laut Postgres-Dokumentation ausdrücklich ausgenommen). Dort kann
|
||||||
|
-- zusätzlich eine eingeschränkte Rolle "deklarix_app" angelegt
|
||||||
|
-- werden, für die die Policies unabhängig von FORCE gelten.
|
||||||
|
--
|
||||||
|
-- Diese Migration deckt BEIDE Fälle ab, ohne bei fehlendem CREATEROLE
|
||||||
|
-- fehlzuschlagen (das brachte den Dienst am 2026-09-01 für ~3 Minuten
|
||||||
|
-- zum Absturz, siehe CLAUDE.md) — das Anlegen von "deklarix_app" ist
|
||||||
|
-- rein optional und wird übersprungen, wenn die aktuelle Rolle dafür
|
||||||
|
-- keine Berechtigung hat.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = current_user AND rolcreaterole) THEN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
|
||||||
|
CREATE ROLE deklarix_app NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOLOGIN;
|
||||||
|
END IF;
|
||||||
|
EXECUTE 'GRANT USAGE ON SCHEMA public TO deklarix_app';
|
||||||
|
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO deklarix_app';
|
||||||
|
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO deklarix_app';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Rolle % hat kein CREATEROLE — deklarix_app wird übersprungen, FORCE ROW LEVEL SECURITY schützt stattdessen direkt die bestehende (Tabellenbesitzer-)Rolle.', current_user;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Login-Fähigkeit + Passwort für deklarix_app (falls angelegt) werden
|
||||||
|
-- einmalig manuell je Umgebung gesetzt, NIE in einer versionierten
|
||||||
|
-- Migration (Klartext-Secret gehört nicht ins Repo):
|
||||||
|
-- ALTER ROLE deklarix_app WITH LOGIN PASSWORD '<generiertes Secret>';
|
||||||
|
-- Danach optional DATABASE_URL_APP in der jeweiligen deklarix.env
|
||||||
|
-- eintragen. Für Umgebungen, in denen die Anwendung bereits als
|
||||||
|
-- Tabellenbesitzer (nicht-Superuser) verbindet, ist das NICHT nötig —
|
||||||
|
-- FORCE ROW LEVEL SECURITY unten reicht dort aus.
|
||||||
|
|
||||||
|
-- ─── Tabellen MIT direkter account_id-Spalte ───────────────────────────
|
||||||
|
-- Eine einzelne Policy (FOR ALL) pro Tabelle deckt SELECT/UPDATE/DELETE
|
||||||
|
-- (USING) und INSERT/UPDATE (WITH CHECK) ab. NULLIF(..., '')::uuid
|
||||||
|
-- verhindert einen harten Cast-Fehler, wenn app.account_id nie gesetzt
|
||||||
|
-- oder auf '' steht (z. B. vor der eigentlichen Anmeldung) — die
|
||||||
|
-- Bedingung wird dann einfach UNKNOWN/false statt eines SQL-Fehlers.
|
||||||
|
CREATE POLICY tenant_isolation ON antrag FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE antrag ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE antrag FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON registereintrag FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE registereintrag ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE registereintrag FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON abteilung FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE abteilung ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE abteilung FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON werkzeug_sperre FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE werkzeug_sperre ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE werkzeug_sperre FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON genehmiger_rolle FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE genehmiger_rolle ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE genehmiger_rolle FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON freigabe_regel FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE freigabe_regel ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE freigabe_regel FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON loeschfrist_einstellung FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE loeschfrist_einstellung ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE loeschfrist_einstellung FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ─── werkzeug: account_id NULLABLE (NULL = zentraler Katalog) ──────────
|
||||||
|
-- Lesen: jeder sieht zentrale (NULL) Einträge plus die eigenen. NUR der
|
||||||
|
-- Betreiber darf einen zentralen (NULL) Eintrag anlegen/ändern, ein
|
||||||
|
-- Mandant nur seine eigenen — sonst könnte ein Mandant über einen
|
||||||
|
-- vergessenen Anwendungscheck einen zentralen Katalogeintrag verändern.
|
||||||
|
CREATE POLICY tenant_isolation ON werkzeug FOR ALL USING (
|
||||||
|
account_id IS NULL
|
||||||
|
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
|
||||||
|
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
);
|
||||||
|
ALTER TABLE werkzeug ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE werkzeug FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ─── Tabellen OHNE eigene account_id, über Fremdschlüssel abgeleitet ───
|
||||||
|
-- antrag/genehmiger_rolle sind selbst schon RLS-geschützt (s. o.) — eine
|
||||||
|
-- Unterabfrage gegen sie erbt in derselben Sitzung automatisch dieselbe
|
||||||
|
-- Mandantengrenze, ohne die Bedingung hier zu duplizieren.
|
||||||
|
CREATE POLICY tenant_isolation ON bewertung FOR ALL USING (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
) WITH CHECK (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
);
|
||||||
|
ALTER TABLE bewertung ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE bewertung FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON entscheidung FOR ALL USING (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
) WITH CHECK (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
);
|
||||||
|
ALTER TABLE entscheidung ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE entscheidung FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON freigabeschritt FOR ALL USING (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
) WITH CHECK (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
);
|
||||||
|
ALTER TABLE freigabeschritt ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE freigabeschritt FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON nutzer_genehmiger_rolle FOR ALL USING (
|
||||||
|
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
|
||||||
|
) WITH CHECK (
|
||||||
|
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
|
||||||
|
);
|
||||||
|
ALTER TABLE nutzer_genehmiger_rolle ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE nutzer_genehmiger_rolle FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ─── Bewusst OHNE RLS ───────────────────────────────────────────────────
|
||||||
|
-- account: hat keine account_id-Spalte (ist selbst der Mandant) und
|
||||||
|
-- muss bei der Registrierung uneingeschränkt INSERT erlauben, bevor
|
||||||
|
-- die neue ID überhaupt bekannt ist.
|
||||||
|
-- app_user: Login/Passwort-Zurücksetzen suchen per E-Mail über ALLE
|
||||||
|
-- Mandanten hinweg (die Ziel-account_id ist zu diesem Zeitpunkt noch
|
||||||
|
-- nicht bekannt) — kein Datenleck, da E-Mail-Adressen exakt und nicht
|
||||||
|
-- in Bulk abgefragt werden, kein sequentiell erratbarer Schlüssel.
|
||||||
|
-- session, password_reset_token: werden ausschließlich über einen
|
||||||
|
-- kryptographisch zufälligen, praktisch unerratbaren Token gesucht,
|
||||||
|
-- nicht über eine sequentielle ID — dieselbe Begründung wie app_user.
|
||||||
|
-- audit_log: plattformweites Protokoll, wird ausschließlich vom
|
||||||
|
-- Betreiber (Ebene 5, sieht ohnehin alle Mandanten) gelesen, hat keine
|
||||||
|
-- eigene account_id-Spalte.
|
||||||
4
internal/store/migrations/0022_email_vorlage.down.sql
Normal file
4
internal/store/migrations/0022_email_vorlage.down.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE email_vorlage NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE email_vorlage DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON email_vorlage;
|
||||||
|
DROP TABLE email_vorlage;
|
||||||
55
internal/store/migrations/0022_email_vorlage.up.sql
Normal file
55
internal/store/migrations/0022_email_vorlage.up.sql
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
-- Editierbare E-Mail-Vorlagen, zweistufig wie der Werkzeugkatalog:
|
||||||
|
-- account_id NULL = plattformweiter Standard (nur vom Betreiber
|
||||||
|
-- editierbar, z. B. künftige E-Mails, die die Plattform selbst an
|
||||||
|
-- Mandanten-Admins schickt), account_id gesetzt = mandantenspezifische
|
||||||
|
-- Übersteuerung (vom Mandanten-Admin editierbar, z. B. eigener Ton/
|
||||||
|
-- Branding für eine E-Mail, die an die eigenen Mitarbeiter geht).
|
||||||
|
-- "typ" identifiziert, welche vom System versendete E-Mail gemeint ist
|
||||||
|
-- (aktuell nur "passwort_zuruecksetzen" — die einzige E-Mail, die das
|
||||||
|
-- System bisher verschickt, siehe internal/mail). Text darf Platzhalter
|
||||||
|
-- wie "{{link}}" enthalten, die beim Versand ersetzt werden (siehe
|
||||||
|
-- internal/web/email_vorlage_handlers.go).
|
||||||
|
CREATE TABLE email_vorlage (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID REFERENCES account(id),
|
||||||
|
typ TEXT NOT NULL,
|
||||||
|
betreff TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ACHTUNG: ein einfaches UNIQUE (account_id, typ) würde NICHT reichen —
|
||||||
|
-- SQL behandelt NULL nie als gleich zu NULL, ein normaler UNIQUE-
|
||||||
|
-- Constraint hätte also beliebig viele Plattform-Standard-Zeilen
|
||||||
|
-- (account_id IS NULL) je typ zugelassen. Zwei partielle Unique-Indizes
|
||||||
|
-- statt eines gemeinsamen Constraints, dafür braucht UpsertEmailVorlage
|
||||||
|
-- zwei unterschiedliche ON-CONFLICT-Ziele (siehe internal/store/email_vorlage.go).
|
||||||
|
CREATE UNIQUE INDEX email_vorlage_plattform_uidx ON email_vorlage (typ) WHERE account_id IS NULL;
|
||||||
|
CREATE UNIQUE INDEX email_vorlage_mandant_uidx ON email_vorlage (account_id, typ) WHERE account_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Plattformweiter Standard für die einzige aktuell existierende
|
||||||
|
-- E-Mail — ohne diese Zeile gäbe es nichts, worauf ResolveEmailVorlage
|
||||||
|
-- zurückfallen könnte, solange ein Mandant keine eigene Vorlage hat.
|
||||||
|
INSERT INTO email_vorlage (account_id, typ, betreff, text) VALUES (
|
||||||
|
NULL,
|
||||||
|
'passwort_zuruecksetzen',
|
||||||
|
'Deklarix — Passwort zurücksetzen',
|
||||||
|
'Hallo,' || E'\n\n' ||
|
||||||
|
'über diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):' || E'\n' ||
|
||||||
|
'{{link}}' || E'\n\n' ||
|
||||||
|
'Falls du das nicht angefordert hast, ignoriere diese E-Mail.'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Wie werkzeug (account_id NULLABLE): NULL-Zeilen sind für alle lesbar,
|
||||||
|
-- aber nur vom Betreiber schreibbar; ein Mandant darf nur seine eigene
|
||||||
|
-- account_id-Zeile anlegen/ändern.
|
||||||
|
CREATE POLICY tenant_isolation ON email_vorlage FOR ALL USING (
|
||||||
|
account_id IS NULL
|
||||||
|
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
|
||||||
|
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
);
|
||||||
|
ALTER TABLE email_vorlage ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE email_vorlage FORCE ROW LEVEL SECURITY;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE account DROP COLUMN strasse;
|
||||||
|
ALTER TABLE account DROP COLUMN plz;
|
||||||
|
ALTER TABLE account DROP COLUMN ort;
|
||||||
|
ALTER TABLE account DROP COLUMN land;
|
||||||
|
ALTER TABLE account DROP COLUMN ust_id;
|
||||||
|
ALTER TABLE account DROP COLUMN rechnungsemail;
|
||||||
20
internal/store/migrations/0023_account_firmendaten.up.sql
Normal file
20
internal/store/migrations/0023_account_firmendaten.up.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Adress- und Abrechnungsdaten der Firma — bisher trug account nur den
|
||||||
|
-- Namen. NOT NULL DEFAULT '' statt einer harten NOT-NULL-Pflicht ohne
|
||||||
|
-- Default: bestehende Accounts (vor dieser Migration angelegt) haben
|
||||||
|
-- diese Daten schlicht noch nicht, das darf die Migration nicht
|
||||||
|
-- blockieren. Die Anwendungsschicht erzwingt Pflichtfelder nur für NEU
|
||||||
|
-- angelegte Firmen (Registrierung, Betreiber-Firmenanlage); bestehende
|
||||||
|
-- Firmen werden nicht rückwirkend gezwungen, sie können es über die
|
||||||
|
-- neue Firmendaten-Seite (Ebene 4) nachtragen.
|
||||||
|
ALTER TABLE account ADD COLUMN strasse TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE account ADD COLUMN plz TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE account ADD COLUMN ort TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE account ADD COLUMN land TEXT NOT NULL DEFAULT '';
|
||||||
|
-- Umsatzsteuer-ID ist bewusst optional (NOT NULL DEFAULT '', keine
|
||||||
|
-- Pflicht auch bei Neuanlage) — Kleinunternehmer nach §19 UStG haben
|
||||||
|
-- keine.
|
||||||
|
ALTER TABLE account ADD COLUMN ust_id TEXT NOT NULL DEFAULT '';
|
||||||
|
-- Rechnungsemail kann von der E-Mail des ersten (admin-)Logins
|
||||||
|
-- abweichen (z. B. eine buchhaltung@-Adresse) — eigenes Feld statt
|
||||||
|
-- Wiederverwendung der Login-E-Mail.
|
||||||
|
ALTER TABLE account ADD COLUMN rechnungsemail TEXT NOT NULL DEFAULT '';
|
||||||
77
internal/store/password_reset.go
Normal file
77
internal/store/password_reset.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Passwort-Zurücksetzen-Tokens (Migration 0019). Ein Token ist einmal
|
||||||
|
// verwendbar (used_at) und läuft ab (expires_at) — GetValidPasswordResetToken
|
||||||
|
// liefert ErrNotFound für "nicht gefunden", "abgelaufen" und "schon
|
||||||
|
// verwendet" gleichermaßen, damit ein Angreifer über die Fehlermeldung
|
||||||
|
// nichts über den Zustand eines geratenen Tokens lernt.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordResetTokenDuration ist die Gültigkeitsdauer eines frisch
|
||||||
|
// erzeugten Zurücksetzen-Links.
|
||||||
|
const PasswordResetTokenDuration = 1 * time.Hour
|
||||||
|
|
||||||
|
type PasswordResetToken struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
Token string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
UsedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePasswordResetToken legt einen neuen Zurücksetzen-Token für
|
||||||
|
// einen Nutzer an. token muss bereits kryptographisch zufällig erzeugt
|
||||||
|
// sein (siehe auth.NewSessionToken) — store erzeugt keine Tokens selbst.
|
||||||
|
func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token string) (PasswordResetToken, error) {
|
||||||
|
var t PasswordResetToken
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO password_reset_token (user_id, token, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, user_id, token, expires_at, used_at, created_at
|
||||||
|
`, userID, token, time.Now().Add(PasswordResetTokenDuration)).Scan(
|
||||||
|
&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordResetToken{}, fmt.Errorf("store: create password reset token: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValidPasswordResetToken liest einen Token nur, wenn er existiert,
|
||||||
|
// noch nicht abgelaufen und noch nicht verwendet ist.
|
||||||
|
func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (PasswordResetToken, error) {
|
||||||
|
var t PasswordResetToken
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, user_id, token, expires_at, used_at, created_at
|
||||||
|
FROM password_reset_token
|
||||||
|
WHERE token = $1 AND used_at IS NULL AND expires_at > now()
|
||||||
|
`, token).Scan(&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return PasswordResetToken{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return PasswordResetToken{}, fmt.Errorf("store: get valid password reset token: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPasswordResetTokenUsed verbraucht einen Token, damit derselbe
|
||||||
|
// Link kein zweites Mal ein Passwort setzen kann.
|
||||||
|
func (s *Store) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: mark password reset token used: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
58
internal/store/password_reset_test.go
Normal file
58
internal/store/password_reset_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPasswordResetTokenLifecycle(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
|
||||||
|
tok, err := s.CreatePasswordResetToken(ctx, userID, "test-token-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePasswordResetToken: %v", err)
|
||||||
|
}
|
||||||
|
if tok.UserID != userID || tok.Token != "test-token-123" {
|
||||||
|
t.Fatalf("PasswordResetToken = %+v, unerwartete Werte", tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetValidPasswordResetToken(ctx, "test-token-123")
|
||||||
|
if err != nil || got.ID != tok.ID {
|
||||||
|
t.Fatalf("GetValidPasswordResetToken = %+v, err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.MarkPasswordResetTokenUsed(ctx, tok.ID); err != nil {
|
||||||
|
t.Fatalf("MarkPasswordResetTokenUsed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein verbrauchter Token gilt nicht mehr als gültig.
|
||||||
|
if _, err := s.GetValidPasswordResetToken(ctx, "test-token-123"); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err nach Verbrauch = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein unbekannter Token ist von Anfang an ErrNotFound.
|
||||||
|
if _, err := s.GetValidPasswordResetToken(ctx, "existiert-nicht"); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err für unbekannten Token = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetUserPassword(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
|
||||||
|
if err := s.SetUserPassword(ctx, userID, "neuer-hash"); err != nil {
|
||||||
|
t.Fatalf("SetUserPassword: %v", err)
|
||||||
|
}
|
||||||
|
got, err := s.GetUser(ctx, userID)
|
||||||
|
if err != nil || got.PasswordHash != "neuer-hash" {
|
||||||
|
t.Fatalf("GetUser nach SetUserPassword = %+v, err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/store/registereintrag.go
Normal file
106
internal/store/registereintrag.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registereintrag wird automatisch bei jeder Genehmigung erzeugt
|
||||||
|
// (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage). Append-only
|
||||||
|
// — siehe Migration. Bewusst denormalisiert: eigene Textspalten statt
|
||||||
|
// Joins über bewertung/entscheidung/werkzeug, damit ein einmal
|
||||||
|
// erzeugter Eintrag nicht von einem späteren Katalog- oder Abteilungs-
|
||||||
|
// Wandel abhängt.
|
||||||
|
type Registereintrag struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
AntragID string
|
||||||
|
EntscheidungID string
|
||||||
|
Zweck string
|
||||||
|
Abteilung string
|
||||||
|
Werkzeug string
|
||||||
|
Datenklasse string
|
||||||
|
Einstufung string
|
||||||
|
Auflagen []string
|
||||||
|
Verantwortlicher string
|
||||||
|
EntschiedenAm time.Time
|
||||||
|
GueltigBis *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const registereintragColumns = `id, account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||||
|
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis, created_at`
|
||||||
|
|
||||||
|
func scanRegistereintrag(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Registereintrag, error) {
|
||||||
|
var e Registereintrag
|
||||||
|
err := row.Scan(
|
||||||
|
&e.ID, &e.AccountID, &e.AntragID, &e.EntscheidungID, &e.Zweck, &e.Abteilung, &e.Werkzeug,
|
||||||
|
&e.Datenklasse, &e.Einstufung, &e.Auflagen, &e.Verantwortlicher, &e.EntschiedenAm, &e.GueltigBis, &e.CreatedAt,
|
||||||
|
)
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistereintragInput bündelt die Felder eines neuen Registereintrags.
|
||||||
|
type RegistereintragInput struct {
|
||||||
|
AccountID string
|
||||||
|
AntragID string
|
||||||
|
EntscheidungID string
|
||||||
|
Zweck string
|
||||||
|
Abteilung string
|
||||||
|
Werkzeug string
|
||||||
|
Datenklasse string
|
||||||
|
Einstufung string
|
||||||
|
Auflagen []string
|
||||||
|
Verantwortlicher string
|
||||||
|
EntschiedenAm time.Time
|
||||||
|
GueltigBis *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRegistereintrag speichert einen Registereintrag.
|
||||||
|
func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInput) (Registereintrag, error) {
|
||||||
|
if in.Auflagen == nil {
|
||||||
|
in.Auflagen = []string{}
|
||||||
|
}
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO registereintrag (
|
||||||
|
account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||||
|
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING `+registereintragColumns,
|
||||||
|
in.AccountID, in.AntragID, in.EntscheidungID, in.Zweck, in.Abteilung, in.Werkzeug,
|
||||||
|
in.Datenklasse, in.Einstufung, in.Auflagen, in.Verantwortlicher, in.EntschiedenAm, in.GueltigBis,
|
||||||
|
)
|
||||||
|
e, err := scanRegistereintrag(row)
|
||||||
|
if err != nil {
|
||||||
|
return Registereintrag{}, fmt.Errorf("store: create registereintrag: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRegistereintraegeForAccount liefert alle Registereinträge eines
|
||||||
|
// Mandanten, neueste zuerst.
|
||||||
|
func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Registereintrag
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanRegistereintrag(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan registereintrag: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
100
internal/store/registereintrag_test.go
Normal file
100
internal/store/registereintrag_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRegistereintragInput(accountID, antragID, entscheidungID string) store.RegistereintragInput {
|
||||||
|
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||||
|
return store.RegistereintragInput{
|
||||||
|
AccountID: accountID, AntragID: antragID, EntscheidungID: entscheidungID,
|
||||||
|
Zweck: "Angebotstexte generieren", Abteilung: "Vertrieb", Werkzeug: "ChatGPT Enterprise",
|
||||||
|
Datenklasse: "personenbezogen", Einstufung: "minimal",
|
||||||
|
Auflagen: []string{"AVV erforderlich", "kein Training auf Eingaben"},
|
||||||
|
Verantwortlicher: "verantwortlicher@example.com", EntschiedenAm: time.Now().Truncate(time.Millisecond),
|
||||||
|
GueltigBis: &gueltigBis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEntscheidungID(t *testing.T, s *store.Store, antragID, bewertungID, userID string) string {
|
||||||
|
t.Helper()
|
||||||
|
e, err := s.CreateEntscheidung(context.Background(), store.EntscheidungInput{
|
||||||
|
AntragID: antragID, BewertungID: bewertungID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung: %v", err)
|
||||||
|
}
|
||||||
|
return e.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistereintragCreateAndList(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||||
|
|
||||||
|
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||||
|
}
|
||||||
|
if e.Werkzeug != "ChatGPT Enterprise" || len(e.Auflagen) != 2 {
|
||||||
|
t.Fatalf("CreateRegistereintrag = %+v, unerwartete Werte", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListRegistereintraegeForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRegistereintraegeForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].ID != e.ID {
|
||||||
|
t.Fatalf("ListRegistereintraegeForAccount = %+v, want exactly one entry", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistereintragIsolatesTenants(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accA := testAccountID(t, s)
|
||||||
|
userA := testUserID(t, s, accA)
|
||||||
|
antragA, bewertungA := testAntragMitBewertung(t, s, accA, userA)
|
||||||
|
entscheidungA := testEntscheidungID(t, s, antragA.ID, bewertungA.ID, userA)
|
||||||
|
if _, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accA, antragA.ID, entscheidungA)); err != nil {
|
||||||
|
t.Fatalf("CreateRegistereintrag (A): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accB := testAccountID(t, s)
|
||||||
|
list, err := s.ListRegistereintraegeForAccount(ctx, accB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRegistereintraegeForAccount (B): %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("expected no registereintraege for a different tenant, got %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistereintragIsAppendOnly(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||||
|
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Pool.Exec(ctx, `UPDATE registereintrag SET werkzeug = 'geaendert' WHERE id = $1`, e.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected UPDATE on registereintrag to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
_, err = s.Pool.Exec(ctx, `DELETE FROM registereintrag WHERE id = $1`, e.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected DELETE on registereintrag to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,38 +12,70 @@ import (
|
|||||||
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
||||||
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
||||||
// eine Session wird immer über ihren Token nachgeschlagen.
|
// eine Session wird immer über ihren Token nachgeschlagen.
|
||||||
|
// ImpersonatedByUserID ist gesetzt, wenn diese Sitzung durch einen
|
||||||
|
// Betreiber-Support-Login entstanden ist (siehe CreateImpersonatedSession)
|
||||||
|
// statt durch den regulären Login des Nutzers selbst.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Token string
|
Token string
|
||||||
UserID string
|
UserID string
|
||||||
ExpiresAt time.Time
|
ImpersonatedByUserID *string
|
||||||
CreatedAt time.Time
|
ExpiresAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionColumns = `token, user_id, impersonated_by_user_id, expires_at, created_at`
|
||||||
|
|
||||||
|
func scanSession(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Session, error) {
|
||||||
|
var sess Session
|
||||||
|
err := row.Scan(&sess.Token, &sess.UserID, &sess.ImpersonatedByUserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||||
|
return sess, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||||
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||||
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||||
var sess Session
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO session (token, user_id, expires_at)
|
INSERT INTO session (token, user_id, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING token, user_id, expires_at, created_at
|
RETURNING `+sessionColumns,
|
||||||
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
token, userID, expiresAt,
|
||||||
|
)
|
||||||
|
sess, err := scanSession(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Session{}, fmt.Errorf("store: create session: %w", err)
|
return Session{}, fmt.Errorf("store: create session: %w", err)
|
||||||
}
|
}
|
||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateImpersonatedSession speichert eine Support-Login-Sitzung: ein
|
||||||
|
// Betreiber meldet sich als ein bestimmter Kunden-Nutzer an, ohne
|
||||||
|
// dessen Passwort zu kennen (siehe handleBetreiberLoginAls). Anders als
|
||||||
|
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
|
||||||
|
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
|
||||||
|
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING `+sessionColumns,
|
||||||
|
token, userID, impersonatedByUserID, expiresAt,
|
||||||
|
)
|
||||||
|
sess, err := scanSession(row)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, fmt.Errorf("store: create impersonated session: %w", err)
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
||||||
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
||||||
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
||||||
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||||
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||||
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||||
var sess Session
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||||
err := s.Pool.QueryRow(ctx, `
|
sess, err := scanSession(row)
|
||||||
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
|
|
||||||
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Session{}, ErrNotFound
|
return Session{}, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -55,7 +87,7 @@ func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
|||||||
|
|
||||||
// DeleteSession beendet eine Sitzung (Logout).
|
// DeleteSession beendet eine Sitzung (Logout).
|
||||||
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||||
if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
|
if _, err := s.db(ctx).Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
|
||||||
return fmt.Errorf("store: delete session: %w", err)
|
return fmt.Errorf("store: delete session: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func openTestStore(t *testing.T) *store.Store {
|
|||||||
// testAccountID legt einen Mandanten an und liefert dessen ID.
|
// testAccountID legt einen Mandanten an und liefert dessen ID.
|
||||||
func testAccountID(t *testing.T, s *store.Store) string {
|
func testAccountID(t *testing.T, s *store.Store) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
|
acc, err := s.CreateAccount(context.Background(), store.AccountInput{Name: "Test-Mandant"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
104
internal/store/tenant_scope.go
Normal file
104
internal/store/tenant_scope.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Row-Level-Security-Unterstützung. Isolation zwischen Mandanten lief
|
||||||
|
// bisher ausschließlich anwendungsseitig (Handler vergleichen AccountID,
|
||||||
|
// siehe CLAUDE.md) — das ist die einzige Stelle, die einen vergessenen
|
||||||
|
// WHERE-account_id-Filter in einer neuen Store-Methode nicht auffängt.
|
||||||
|
// WithTenantScope öffnet für die Dauer eines Requests eine Transaktion
|
||||||
|
// und setzt zwei Postgres-Sitzungsvariablen (SET LOCAL, per set_config
|
||||||
|
// mit Parameterbindung statt String-Interpolation — SQL-Injection-frei
|
||||||
|
// und automatisch auf die Transaktion begrenzt, kein manuelles Zurück-
|
||||||
|
// setzen nötig):
|
||||||
|
//
|
||||||
|
// - app.account_id — der Mandant, für den dieser Request angemeldet ist
|
||||||
|
// - app.is_betreiber — "true" für Ebene-5-Zugriff (sieht alle Mandanten)
|
||||||
|
//
|
||||||
|
// Migration 0021 aktiviert FORCE ROW LEVEL SECURITY auf den Tabellen mit
|
||||||
|
// echten Mandanten-Geschäftsdaten und legt Policies an, die genau diese
|
||||||
|
// beiden Variablen auswerten. Bewusst NICHT auf account/app_user/
|
||||||
|
// session/password_reset_token (siehe Migration 0021 für die Begründung
|
||||||
|
// — diese vier brauchen unmandantierte Lookups, z. B. Login per E-Mail).
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pgxIface wird sowohl von *pgxpool.Pool als auch von pgx.Tx erfüllt —
|
||||||
|
// db(ctx) kann so transparent zwischen "kein Tenant-Kontext" (Pool,
|
||||||
|
// z. B. in Store-Tests ohne WithTenantScope) und "innerhalb eines
|
||||||
|
// Requests" (Tx mit gesetzten Sitzungsvariablen) wählen, ohne dass jede
|
||||||
|
// einzelne Store-Methode das selbst unterscheiden müsste.
|
||||||
|
type pgxIface interface {
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tenantTxKey struct{}
|
||||||
|
|
||||||
|
// db liefert die für ctx passende Ausführungsschnittstelle: die aktive
|
||||||
|
// Transaktion, falls WithTenantScope sie gesetzt hat, sonst den Pool
|
||||||
|
// direkt (z. B. für Store-Tests, die ohne Tenant-Kontext laufen — diese
|
||||||
|
// Tabellen sind dann nicht durch RLS geschützt, was für White-Box-Tests
|
||||||
|
// der Store-Logik selbst unkritisch ist, siehe dedizierte RLS-Tests in
|
||||||
|
// tenant_scope_test.go für den tatsächlichen Isolationsnachweis).
|
||||||
|
func (s *Store) db(ctx context.Context) pgxIface {
|
||||||
|
if tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx); ok {
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
return s.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTenantScope führt fn in einer Transaktion aus, die die Sitzungs-
|
||||||
|
// variablen für RLS setzt. accountID kann leer sein (z. B. während der
|
||||||
|
// Registrierung, bevor der neue Account existiert) — SetTenantScope
|
||||||
|
// erlaubt, die Variable mitten in derselben Transaktion nachträglich zu
|
||||||
|
// setzen, sobald die ID bekannt ist.
|
||||||
|
func (s *Store) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error {
|
||||||
|
tx, err := s.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: begin tenant scope: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck // no-op nach erfolgreichem Commit
|
||||||
|
|
||||||
|
scopedCtx := context.WithValue(ctx, tenantTxKey{}, tx)
|
||||||
|
if err := setTenantSessionVars(scopedCtx, tx, accountID, isBetreiber); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fn(scopedCtx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("store: commit tenant scope: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTenantScope aktualisiert die Sitzungsvariablen innerhalb einer
|
||||||
|
// bereits laufenden WithTenantScope-Transaktion — nötig, wenn eine neue
|
||||||
|
// Firma erst mitten im Request entsteht (die account_id ist vorher
|
||||||
|
// nicht bekannt, siehe handleRegister/handleBetreiberAccountCreate).
|
||||||
|
// Ruft man es außerhalb von WithTenantScope auf, ist es ein No-op ohne
|
||||||
|
// Effekt (kein Tx im Context) — daher immer den Rückgabewert prüfen,
|
||||||
|
// falls das je außerhalb eines Handlers genutzt wird.
|
||||||
|
func (s *Store) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error {
|
||||||
|
tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("store: SetTenantScope außerhalb von WithTenantScope aufgerufen")
|
||||||
|
}
|
||||||
|
return setTenantSessionVars(ctx, tx, accountID, isBetreiber)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTenantSessionVars(ctx context.Context, tx pgx.Tx, accountID string, isBetreiber bool) error {
|
||||||
|
betreiberFlag := "false"
|
||||||
|
if isBetreiber {
|
||||||
|
betreiberFlag = "true"
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `SELECT set_config('app.account_id', $1, true), set_config('app.is_betreiber', $2, true)`, accountID, betreiberFlag); err != nil {
|
||||||
|
return fmt.Errorf("store: set tenant scope: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
152
internal/store/tenant_scope_test.go
Normal file
152
internal/store/tenant_scope_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// Beweis, dass Row-Level-Security tatsächlich greift — nicht nur, dass
|
||||||
|
// die Store-Methoden mit einer Transaktion statt dem Pool funktionieren
|
||||||
|
// (das würde auch mit einer Superuser-Verbindung "bestehen", ohne dass
|
||||||
|
// RLS irgendetwas tut, siehe CLAUDE.md/Migration 0021: Superuser
|
||||||
|
// umgehen RLS-Policies immer). Diese Tests laufen deshalb NICHT gegen
|
||||||
|
// DATABASE_URL (Superuser, für Migrationen), sondern gegen
|
||||||
|
// DATABASE_URL_APP — die eingeschränkte Rolle "deklarix_app", für die
|
||||||
|
// die Policies tatsächlich wirken. Ohne DATABASE_URL_APP werden sie
|
||||||
|
// übersprungen (die Rolle existiert erst nach manuellem Passwort-Setup,
|
||||||
|
// siehe Migration 0021 und CLAUDE.md).
|
||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openRestrictedTestStore öffnet eine zweite Verbindung über
|
||||||
|
// DATABASE_URL_APP (die eingeschränkte Rolle) — Migrationen und
|
||||||
|
// Fixture-Aufbau laufen weiterhin über die normale, privilegierte
|
||||||
|
// openTestStore-Verbindung.
|
||||||
|
func openRestrictedTestStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
url := os.Getenv("DATABASE_URL_APP")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("DATABASE_URL_APP nicht gesetzt, überspringe RLS-Test (siehe Migration 0021)")
|
||||||
|
}
|
||||||
|
s, err := store.Open(context.Background(), url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open (restricted): %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRLSAbteilungIsoliertZwischenMandanten(t *testing.T) {
|
||||||
|
privileged := openTestStore(t)
|
||||||
|
restricted := openRestrictedTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
accA := testAccountID(t, privileged)
|
||||||
|
accB := testAccountID(t, privileged)
|
||||||
|
abtA, err := privileged.CreateAbteilung(ctx, accA, "Abteilung-A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAbteilung A: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := privileged.CreateAbteilung(ctx, accB, "Abteilung-B"); err != nil {
|
||||||
|
t.Fatalf("CreateAbteilung B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit Tenant-Kontext A: nur die eigene Abteilung ist sichtbar, auch
|
||||||
|
// wenn wir explizit nach IDs von B fragen würden — hier geprüft über
|
||||||
|
// die Listen-Methode, die auf accA gefiltert nach RLS zusätzlich
|
||||||
|
// noch mal (redundant) accountID=accA übergibt; der Beweis liegt in
|
||||||
|
// TestRLSVerweigertFremdenAccountAuchBeiFalscherAccountID unten, wo
|
||||||
|
// die Anwendungsschicht bewusst "falsch" fragt.
|
||||||
|
err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
|
||||||
|
liste, err := restricted.ListAbteilungenForAccount(scoped, accA)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(liste) != 1 || liste[0].Name != "Abteilung-A" {
|
||||||
|
t.Fatalf("liste = %+v, want genau [Abteilung-A]", liste)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithTenantScope A: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der eigentliche RLS-Beweis: im Tenant-Kontext A nach ListAbteilungenForAccount(B)
|
||||||
|
// fragen — ein Programmierfehler, der die AccountID nicht prüft, wäre
|
||||||
|
// ohne RLS ein echtes Datenleck. Mit RLS liefert die DB trotzdem 0 Zeilen,
|
||||||
|
// weil die Sitzungsvariable (Kontext A) nicht zu den B-Zeilen passt.
|
||||||
|
err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
|
||||||
|
liste, err := restricted.ListAbteilungenForAccount(scoped, accB)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(liste) != 0 {
|
||||||
|
t.Fatalf("RLS-LECK: Kontext A sieht %d Zeilen von Account B, want 0", len(liste))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithTenantScope A->B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAbteilung(abtA.ID) im Kontext B muss ErrNotFound liefern — RLS
|
||||||
|
// versteckt die fremde Zeile, unabhängig davon, ob die Anwendung die
|
||||||
|
// AccountID selbst vergleicht.
|
||||||
|
err = restricted.WithTenantScope(ctx, accB, false, func(scoped context.Context) error {
|
||||||
|
_, err := restricted.GetAbteilung(scoped, abtA.ID)
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("RLS-LECK: GetAbteilung(A) im Kontext B err=%v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithTenantScope B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Betreiber-Kontext sieht beide.
|
||||||
|
err = restricted.WithTenantScope(ctx, "", true, func(scoped context.Context) error {
|
||||||
|
listeA, err := restricted.ListAbteilungenForAccount(scoped, accA)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
listeB, err := restricted.ListAbteilungenForAccount(scoped, accB)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(listeA) != 1 || len(listeB) != 1 {
|
||||||
|
t.Fatalf("Betreiber-Kontext: listeA=%+v listeB=%+v, want je 1", listeA, listeB)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithTenantScope Betreiber: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne jeden Tenant-Kontext (direkter Pool-Zugriff, keine Transaktion,
|
||||||
|
// keine Sitzungsvariable gesetzt): fail closed, 0 Zeilen — nicht "alle".
|
||||||
|
liste, err := restricted.ListAbteilungenForAccount(ctx, accA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAbteilungenForAccount ohne Kontext: %v", err)
|
||||||
|
}
|
||||||
|
if len(liste) != 0 {
|
||||||
|
t.Fatalf("RLS-LECK: ohne Tenant-Kontext sichtbar: %+v, want 0 Zeilen (fail closed)", liste)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRLSVerhindertInsertFuerFremdenAccount(t *testing.T) {
|
||||||
|
privileged := openTestStore(t)
|
||||||
|
restricted := openRestrictedTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
accA := testAccountID(t, privileged)
|
||||||
|
accB := testAccountID(t, privileged)
|
||||||
|
|
||||||
|
err := restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
|
||||||
|
_, err := restricted.CreateAbteilung(scoped, accB, "Boesartig-Eingeschleust")
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("RLS-LECK: INSERT für fremde account_id im Kontext A wurde nicht abgelehnt")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,28 +15,41 @@ import (
|
|||||||
// Login: falsche E-Mail vs. Datenbankfehler).
|
// Login: falsche E-Mail vs. Datenbankfehler).
|
||||||
var ErrNotFound = errors.New("store: nicht gefunden")
|
var ErrNotFound = errors.New("store: nicht gefunden")
|
||||||
|
|
||||||
// User ist ein Login innerhalb eines Account (Mandanten).
|
// User ist ein Login innerhalb eines Account (Mandanten). Nutzer werden
|
||||||
|
// nicht gelöscht (app_user wird von antrag/session/entscheidung/
|
||||||
|
// audit_log per Foreign Key referenziert — ein Hard-Delete würde die
|
||||||
|
// Historie zerstören), sondern über Active deaktiviert.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string
|
ID string
|
||||||
AccountID string
|
AccountID string
|
||||||
Email string
|
Email string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
Role string
|
Role string
|
||||||
|
Active bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userColumns = `id, account_id, email, password_hash, role, active, created_at`
|
||||||
|
|
||||||
|
func scanUser(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (User, error) {
|
||||||
|
var u User
|
||||||
|
err := row.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.Active, &u.CreatedAt)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
||||||
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
||||||
// speichert nur, es hasht nicht selbst.
|
// speichert nur, es hasht nicht selbst.
|
||||||
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
||||||
var u User
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO app_user (account_id, email, password_hash, role)
|
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, account_id, email, password_hash, role, created_at
|
RETURNING `+userColumns,
|
||||||
`, accountID, email, passwordHash, role).Scan(
|
accountID, email, passwordHash, role,
|
||||||
&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt,
|
|
||||||
)
|
)
|
||||||
|
u, err := scanUser(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return User{}, fmt.Errorf("store: create user: %w", err)
|
return User{}, fmt.Errorf("store: create user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -46,11 +59,8 @@ func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash,
|
|||||||
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
||||||
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||||
var u User
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
||||||
err := s.Pool.QueryRow(ctx, `
|
u, err := scanUser(row)
|
||||||
SELECT id, account_id, email, password_hash, role, created_at
|
|
||||||
FROM app_user WHERE email = $1
|
|
||||||
`, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return User{}, ErrNotFound
|
return User{}, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -62,11 +72,8 @@ func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error)
|
|||||||
|
|
||||||
// GetUser liest einen Nutzer anhand seiner ID.
|
// GetUser liest einen Nutzer anhand seiner ID.
|
||||||
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||||
var u User
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
||||||
err := s.Pool.QueryRow(ctx, `
|
u, err := scanUser(row)
|
||||||
SELECT id, account_id, email, password_hash, role, created_at
|
|
||||||
FROM app_user WHERE id = $1
|
|
||||||
`, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return User{}, ErrNotFound
|
return User{}, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -79,9 +86,8 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
|||||||
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
||||||
// Admin-Bereich (Account-Detailansicht).
|
// Admin-Bereich (Account-Detailansicht).
|
||||||
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT id, account_id, email, password_hash, role, created_at
|
SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
|
||||||
FROM app_user WHERE account_id = $1 ORDER BY created_at
|
|
||||||
`, accountID)
|
`, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: list users for account: %w", err)
|
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||||
@@ -90,8 +96,8 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
|
|||||||
|
|
||||||
var out []User
|
var out []User
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u User
|
u, err := scanUser(rows)
|
||||||
if err := rows.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: scan user: %w", err)
|
return nil, fmt.Errorf("store: scan user: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, u)
|
out = append(out, u)
|
||||||
@@ -101,3 +107,32 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetUserPassword ersetzt den Passwort-Hash eines Nutzers (Passwort-
|
||||||
|
// Zurücksetzen). passwordHash muss bereits gehasht sein, wie bei
|
||||||
|
// CreateUser.
|
||||||
|
func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: set user password: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
|
||||||
|
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
|
||||||
|
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
||||||
|
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
|
||||||
|
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: set user active: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,17 +16,30 @@ import (
|
|||||||
// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog
|
// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog
|
||||||
// muss belegbar sein.
|
// muss belegbar sein.
|
||||||
type Werkzeug struct {
|
type Werkzeug struct {
|
||||||
ID string
|
ID string
|
||||||
AccountID *string
|
AccountID *string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
Verarbeitungsort string
|
// Verarbeitungslaender nennt die tatsächlichen Länder (oder, wenn
|
||||||
AVVVerfuegbar bool
|
// der Anbieter selbst keinen einzelnen Staat zusichert, eine Region
|
||||||
AVVURL string
|
// wie "EU-Region") statt eines groben EU/USA/gemischt-Eimers — die
|
||||||
TrainingOptOut bool
|
// USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere,
|
||||||
TrainingStandard bool
|
// "gemischt" verschleierte, welche Länder konkret beteiligt sind.
|
||||||
AufbewahrungTage int
|
Verarbeitungslaender []string
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
AVVURL string
|
||||||
|
TrainingOptOut bool
|
||||||
|
TrainingStandard bool
|
||||||
|
// DPFZertifiziert: EU-US Data Privacy Framework — schmalere,
|
||||||
|
// gerichtlich schon zweimal gekippte Rechtsgrundlage (Safe Harbor,
|
||||||
|
// Privacy Shield) für Transfers in die USA, gilt nur für zertifizierte
|
||||||
|
// Unternehmen. false bedeutet nicht "nicht zertifiziert" im Sinne von
|
||||||
|
// widerlegt, sondern "nicht als Zusicherung belegt" — wie bei den
|
||||||
|
// übrigen Booleans hier.
|
||||||
|
DPFZertifiziert bool
|
||||||
|
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
|
||||||
Zertifizierungen []string
|
Zertifizierungen []string
|
||||||
|
Subprozessoren []string
|
||||||
GeeigneteZwecke []string
|
GeeigneteZwecke []string
|
||||||
Einschraenkungen []string
|
Einschraenkungen []string
|
||||||
LetztePruefung time.Time
|
LetztePruefung time.Time
|
||||||
@@ -36,37 +49,39 @@ type Werkzeug struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
|
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
|
||||||
// Create/Update — bei 14 Feldern lesbarer als eine positionale
|
// Create/Update — bei 15 Feldern lesbarer als eine positionale
|
||||||
// Parameterliste.
|
// Parameterliste.
|
||||||
type WerkzeugInput struct {
|
type WerkzeugInput struct {
|
||||||
AccountID *string
|
AccountID *string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
Verarbeitungsort string
|
Verarbeitungslaender []string
|
||||||
AVVVerfuegbar bool
|
AVVVerfuegbar bool
|
||||||
AVVURL string
|
AVVURL string
|
||||||
TrainingOptOut bool
|
TrainingOptOut bool
|
||||||
TrainingStandard bool
|
TrainingStandard bool
|
||||||
AufbewahrungTage int
|
DPFZertifiziert bool
|
||||||
Zertifizierungen []string
|
AufbewahrungTage *int
|
||||||
GeeigneteZwecke []string
|
Zertifizierungen []string
|
||||||
Einschraenkungen []string
|
Subprozessoren []string
|
||||||
LetztePruefung time.Time
|
GeeigneteZwecke []string
|
||||||
Quelle string
|
Einschraenkungen []string
|
||||||
|
LetztePruefung time.Time
|
||||||
|
Quelle string
|
||||||
}
|
}
|
||||||
|
|
||||||
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
|
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||||
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
||||||
|
|
||||||
func scanWerkzeug(row interface {
|
func scanWerkzeug(row interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}) (Werkzeug, error) {
|
}) (Werkzeug, error) {
|
||||||
var w Werkzeug
|
var w Werkzeug
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
|
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungslaender, &w.AVVVerfuegbar, &w.AVVURL,
|
||||||
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
|
&w.TrainingOptOut, &w.TrainingStandard, &w.DPFZertifiziert, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
|
||||||
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
&w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
||||||
)
|
)
|
||||||
return w, err
|
return w, err
|
||||||
}
|
}
|
||||||
@@ -77,6 +92,9 @@ func scanWerkzeug(row interface {
|
|||||||
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
|
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
|
||||||
// Fall wie früher bei finding.sources).
|
// Fall wie früher bei finding.sources).
|
||||||
func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||||
|
if in.Verarbeitungslaender == nil {
|
||||||
|
in.Verarbeitungslaender = []string{}
|
||||||
|
}
|
||||||
if in.Zertifizierungen == nil {
|
if in.Zertifizierungen == nil {
|
||||||
in.Zertifizierungen = []string{}
|
in.Zertifizierungen = []string{}
|
||||||
}
|
}
|
||||||
@@ -86,20 +104,23 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
|||||||
if in.Einschraenkungen == nil {
|
if in.Einschraenkungen == nil {
|
||||||
in.Einschraenkungen = []string{}
|
in.Einschraenkungen = []string{}
|
||||||
}
|
}
|
||||||
|
if in.Subprozessoren == nil {
|
||||||
|
in.Subprozessoren = []string{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateWerkzeug legt einen Katalogeintrag an.
|
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||||
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
|
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
|
||||||
normalizeWerkzeugSlices(&in)
|
normalizeWerkzeugSlices(&in)
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO werkzeug (
|
INSERT INTO werkzeug (
|
||||||
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
|
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||||
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
|
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||||
RETURNING `+werkzeugColumns,
|
RETURNING `+werkzeugColumns,
|
||||||
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||||
)
|
)
|
||||||
w, err := scanWerkzeug(row)
|
w, err := scanWerkzeug(row)
|
||||||
@@ -116,16 +137,16 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug,
|
|||||||
// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein.
|
// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein.
|
||||||
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
|
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
|
||||||
normalizeWerkzeugSlices(&in)
|
normalizeWerkzeugSlices(&in)
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
UPDATE werkzeug SET
|
UPDATE werkzeug SET
|
||||||
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
|
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||||
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
|
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
|
||||||
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
|
zertifizierungen = $11, subprozessoren = $12, geeignete_zwecke = $13, einschraenkungen = $14,
|
||||||
letzte_pruefung = $13, quelle = $14, updated_at = now()
|
letzte_pruefung = $15, quelle = $16, updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING `+werkzeugColumns,
|
RETURNING `+werkzeugColumns,
|
||||||
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
id, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||||
)
|
)
|
||||||
w, err := scanWerkzeug(row)
|
w, err := scanWerkzeug(row)
|
||||||
@@ -140,7 +161,7 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
|
|||||||
|
|
||||||
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
|
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
|
||||||
func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
|
||||||
w, err := scanWerkzeug(row)
|
w, err := scanWerkzeug(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Werkzeug{}, ErrNotFound
|
return Werkzeug{}, ErrNotFound
|
||||||
@@ -153,7 +174,7 @@ func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
|||||||
|
|
||||||
// DeleteWerkzeug entfernt einen Katalogeintrag.
|
// DeleteWerkzeug entfernt einen Katalogeintrag.
|
||||||
func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
|
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: delete werkzeug: %w", err)
|
return fmt.Errorf("store: delete werkzeug: %w", err)
|
||||||
}
|
}
|
||||||
@@ -167,7 +188,7 @@ func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
|||||||
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
|
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
|
||||||
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
|
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
|
||||||
func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) {
|
func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT `+werkzeugColumns+` FROM werkzeug w
|
SELECT `+werkzeugColumns+` FROM werkzeug w
|
||||||
WHERE (w.account_id IS NULL AND NOT EXISTS (
|
WHERE (w.account_id IS NULL AND NOT EXISTS (
|
||||||
SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1
|
SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1
|
||||||
@@ -197,7 +218,7 @@ func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) (
|
|||||||
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
|
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
|
||||||
// Mandanten-Sperrungen.
|
// Mandanten-Sperrungen.
|
||||||
func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name
|
SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -227,7 +248,7 @@ func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
|||||||
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||||
var count int
|
var count int
|
||||||
var lastUpdate time.Time
|
var lastUpdate time.Time
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug
|
SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug
|
||||||
`).Scan(&count, &lastUpdate)
|
`).Scan(&count, &lastUpdate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -250,7 +271,7 @@ type WerkzeugSperre struct {
|
|||||||
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
|
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
|
||||||
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
|
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
|
||||||
var sp WerkzeugSperre
|
var sp WerkzeugSperre
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
|
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
|
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
|
||||||
@@ -263,7 +284,7 @@ func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID,
|
|||||||
|
|
||||||
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
|
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
|
||||||
func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `
|
tag, err := s.db(ctx).Exec(ctx, `
|
||||||
DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2
|
DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2
|
||||||
`, accountID, werkzeugID)
|
`, accountID, werkzeugID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -277,7 +298,7 @@ func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID
|
|||||||
|
|
||||||
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
|
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
|
||||||
func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) {
|
func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT id, account_id, werkzeug_id, grund, gesperrt_am
|
SELECT id, account_id, werkzeug_id, grund, gesperrt_am
|
||||||
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
|
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
|
||||||
`, accountID)
|
`, accountID)
|
||||||
|
|||||||
@@ -9,22 +9,25 @@ import (
|
|||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func intPtr(v int) *int { return &v }
|
||||||
|
|
||||||
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
||||||
return store.WerkzeugInput{
|
return store.WerkzeugInput{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Anbieter: "Beispiel-Anbieter GmbH",
|
Anbieter: "Beispiel-Anbieter GmbH",
|
||||||
Verarbeitungsort: "EU",
|
Verarbeitungslaender: []string{"Deutschland"},
|
||||||
AVVVerfuegbar: true,
|
AVVVerfuegbar: true,
|
||||||
AVVURL: "https://beispiel.example/avv",
|
AVVURL: "https://beispiel.example/avv",
|
||||||
TrainingOptOut: true,
|
TrainingOptOut: true,
|
||||||
TrainingStandard: true,
|
TrainingStandard: true,
|
||||||
AufbewahrungTage: 30,
|
AufbewahrungTage: intPtr(30),
|
||||||
Zertifizierungen: []string{"ISO 27001"},
|
Zertifizierungen: []string{"ISO 27001"},
|
||||||
GeeigneteZwecke: []string{"Textgenerierung"},
|
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
|
||||||
Einschraenkungen: nil,
|
GeeigneteZwecke: []string{"Textgenerierung"},
|
||||||
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
|
Einschraenkungen: nil,
|
||||||
Quelle: "https://beispiel.example/beleg",
|
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
|
||||||
|
Quelle: "https://beispiel.example/beleg",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +45,24 @@ func TestWerkzeugCRUD(t *testing.T) {
|
|||||||
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
|
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
|
||||||
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
|
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
|
||||||
}
|
}
|
||||||
|
if len(w.Subprozessoren) != 1 || w.Subprozessoren[0] != "Beispiel-Subprozessor Inc." {
|
||||||
|
t.Fatalf("Subprozessoren = %v, want [Beispiel-Subprozessor Inc.]", w.Subprozessoren)
|
||||||
|
}
|
||||||
|
if w.AufbewahrungTage == nil || *w.AufbewahrungTage != 30 {
|
||||||
|
t.Fatalf("AufbewahrungTage = %v, want 30", w.AufbewahrungTage)
|
||||||
|
}
|
||||||
|
|
||||||
|
unbekannt, err := s.CreateWerkzeug(ctx, func() store.WerkzeugInput {
|
||||||
|
in := testWerkzeugInput(nil, "Werkzeug ohne Aufbewahrungsangabe")
|
||||||
|
in.AufbewahrungTage = nil
|
||||||
|
return in
|
||||||
|
}())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug (ohne Aufbewahrungsangabe): %v", err)
|
||||||
|
}
|
||||||
|
if unbekannt.AufbewahrungTage != nil {
|
||||||
|
t.Fatalf("AufbewahrungTage = %v, want nil (unbekannt)", *unbekannt.AufbewahrungTage)
|
||||||
|
}
|
||||||
|
|
||||||
got, err := s.GetWerkzeug(ctx, w.ID)
|
got, err := s.GetWerkzeug(ctx, w.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,12 +73,12 @@ func TestWerkzeugCRUD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
||||||
updateInput.AufbewahrungTage = 14
|
updateInput.AufbewahrungTage = intPtr(14)
|
||||||
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpdateWerkzeug: %v", err)
|
t.Fatalf("UpdateWerkzeug: %v", err)
|
||||||
}
|
}
|
||||||
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage != 14 {
|
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage == nil || *updated.AufbewahrungTage != 14 {
|
||||||
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
|
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
96
internal/web/abteilung_handlers_test.go
Normal file
96
internal/web/abteilung_handlers_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminCanCreateAndDeleteAbteilung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/abteilungen", url.Values{"name": {"Vertrieb"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getWithCookie(t, s, adminCookie, "/verwaltung/abteilungen")
|
||||||
|
if !strings.Contains(list.Body.String(), "Vertrieb") {
|
||||||
|
t.Errorf("expected Vertrieb in the Abteilungsliste, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var abtID string
|
||||||
|
for accID, abts := range fs.abteilungen {
|
||||||
|
_ = accID
|
||||||
|
for _, a := range abts {
|
||||||
|
if a.Name == "Vertrieb" {
|
||||||
|
abtID = a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if abtID == "" {
|
||||||
|
t.Fatal("expected Vertrieb to exist in fakeStore")
|
||||||
|
}
|
||||||
|
|
||||||
|
delResp := postForm(t, s, adminCookie, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{})
|
||||||
|
if delResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("delete status = %d, body: %s", delResp.Code, delResp.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := fs.GetAbteilung(context.Background(), abtID); err == nil {
|
||||||
|
t.Error("expected the Abteilung to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeueAntragFormZeigtAngelegteAbteilung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/abteilungen", url.Values{"name": {"Marketing"}})
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/antraege/neu")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Marketing") {
|
||||||
|
t.Errorf("expected the newly created Abteilung in the Fragebogen, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessAbteilungsverwaltung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/verwaltung/abteilungen")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotDeleteForeignAbteilung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||||
|
postForm(t, s, adminA, "/verwaltung/abteilungen", url.Values{"name": {"Nur A"}})
|
||||||
|
var abtID string
|
||||||
|
for _, abts := range fs.abteilungen {
|
||||||
|
for _, a := range abts {
|
||||||
|
if a.Name == "Nur A" {
|
||||||
|
abtID = a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminB, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a foreign account's Abteilung", resp.Code)
|
||||||
|
}
|
||||||
|
if _, err := fs.GetAbteilung(context.Background(), abtID); err != nil {
|
||||||
|
t.Error("expected the foreign Abteilung to still exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
72
internal/web/admin_fachebene_test.go
Normal file
72
internal/web/admin_fachebene_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin
|
||||||
|
// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md,
|
||||||
|
// Offene Punkte / requireFachebene): ein admin sieht den Posteingang
|
||||||
|
// und kann entscheiden, genau wie ein verantwortlicher.
|
||||||
|
func TestAdminSiehtPosteingang(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
_, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/faelle")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSiehtEntscheidenFormular(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/faelle/"+antragID)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
|
||||||
|
t.Errorf("expected the Entscheiden form for admin, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminKannEntscheiden(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if antrag.Status != "entschieden" {
|
||||||
|
t.Errorf("Status = %q, want entschieden", antrag.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
for _, path := range []string{"/registereintraege", "/wiedervorlage"} {
|
||||||
|
resp := getWithCookie(t, s, adminCookie, path)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen für GENAU EINEN
|
// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen und Abteilungen für
|
||||||
// Mandanten, siehe CLAUDE.md, Fünf Ebenen. Aktuell nur Nutzerverwaltung
|
// GENAU EINEN Mandanten, siehe CLAUDE.md, Fünf Ebenen. Anmeldeverfahren,
|
||||||
// (Abteilungen, Anmeldeverfahren, Werkzeug-Freigaben, Rechnungsdaten
|
// Werkzeug-Freigaben/-Sperrungen und Rechnungsdaten sind weiterhin
|
||||||
// sind spätere Ausbaustufen). Es gibt noch keinen Einladungslink/CSV-
|
// spätere Ausbaustufen. Es gibt noch keinen Einladungslink/CSV-Import/
|
||||||
// Import/SSO (siehe CLAUDE.md, Onboarding) — ein Admin setzt hier
|
// SSO (siehe CLAUDE.md, Onboarding) — ein Admin setzt hier direkt ein
|
||||||
// direkt ein Initialpasswort, wie schon bei der Firma-Registrierung.
|
// Initialpasswort, wie schon bei der Firma-Registrierung.
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/auth"
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen
|
// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen
|
||||||
@@ -27,32 +29,86 @@ func istMandantenRolle(role string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type nutzerListItem struct {
|
type nutzerListItem struct {
|
||||||
Email string
|
ID string
|
||||||
Role string
|
Email string
|
||||||
|
Role string
|
||||||
|
Active bool
|
||||||
|
IstSelbst bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type nutzerListeData struct {
|
type nutzerListeData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Nutzer []nutzerListItem
|
Nutzer []nutzerListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNutzerListe zeigt alle Logins des eigenen Mandanten.
|
// handleNutzerListe zeigt alle Logins des eigenen Mandanten.
|
||||||
func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) {
|
||||||
users, err := s.store.ListUsersForAccount(r.Context(), currentUser(r).AccountID)
|
self := currentUser(r)
|
||||||
|
users, err := s.store.ListUsersForAccount(r.Context(), self.AccountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)}
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.User
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
data.Nutzer = append(data.Nutzer, nutzerListItem{Email: u.Email, Role: u.Role})
|
if matchesQuery(q, u.Email, u.Role) {
|
||||||
|
gefiltert = append(gefiltert, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||||
|
for _, u := range paginate(gefiltert, pag) {
|
||||||
|
data.Nutzer = append(data.Nutzer, nutzerListItem{
|
||||||
|
ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleNutzerDeaktivieren sperrt einen Login des eigenen Mandanten —
|
||||||
|
// nicht löschen, siehe store.User (Fremdschlüssel auf antrag/entscheidung/
|
||||||
|
// audit_log würden das verhindern und die Historie zerstören). Ein Admin
|
||||||
|
// kann sich nicht selbst deaktivieren, sonst könnte ein Mandant sich
|
||||||
|
// versehentlich komplett aussperren.
|
||||||
|
func (s *Server) handleNutzerDeaktivieren(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
self := currentUser(r)
|
||||||
|
if id == self.ID {
|
||||||
|
http.Error(w, "Der eigene Zugang kann nicht deaktiviert werden", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target, err := s.store.GetUser(r.Context(), id)
|
||||||
|
if err != nil || target.AccountID != self.AccountID {
|
||||||
|
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetUserActive(r.Context(), id, false); err != nil {
|
||||||
|
http.Error(w, "Nutzer konnte nicht deaktiviert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleNutzerAktivieren(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
target, err := s.store.GetUser(r.Context(), id)
|
||||||
|
if err != nil || target.AccountID != currentUser(r).AccountID {
|
||||||
|
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetUserActive(r.Context(), id, true); err != nil {
|
||||||
|
http.Error(w, "Nutzer konnte nicht aktiviert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
type nutzerNeuData struct {
|
type nutzerNeuData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
@@ -106,3 +162,69 @@ func (s *Server) handleNutzerCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type abteilungListItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type abteilungListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Abteilungen []abteilungListItem
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAbteilungListe zeigt alle Abteilungen des eigenen Mandanten
|
||||||
|
// mit einem Formular zum Anlegen weiterer — bewusst eine Seite statt
|
||||||
|
// Liste+Formular getrennt, da Abteilungen reine Stammdaten ohne
|
||||||
|
// weitere Felder sind (nur `name`).
|
||||||
|
func (s *Server) handleAbteilungListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := abteilungListeData{Title: "Abteilungen", Nav: navFor(r)}
|
||||||
|
for _, a := range abteilungen {
|
||||||
|
data.Abteilungen = append(data.Abteilungen, abteilungListItem{ID: a.ID, Name: a.Name})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "abteilung-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAbteilungCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAbteilung(r.Context(), currentUser(r).AccountID, name); err != nil {
|
||||||
|
http.Error(w, "Abteilung konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAbteilungDelete entfernt eine Abteilung des eigenen Mandanten.
|
||||||
|
// Fremde Abteilungen (anderer Mandant) sind über die ID nicht löschbar
|
||||||
|
// — GetAbteilung prüft AccountID gegen den angemeldeten Mandanten,
|
||||||
|
// bevor gelöscht wird.
|
||||||
|
func (s *Server) handleAbteilungDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
abt, err := s.store.GetAbteilung(r.Context(), id)
|
||||||
|
if err != nil || abt.AccountID != currentUser(r).AccountID {
|
||||||
|
http.Error(w, "Abteilung nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteAbteilung(r.Context(), id); err != nil {
|
||||||
|
http.Error(w, "Abteilung konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
@@ -27,6 +28,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
|
||||||
@@ -181,11 +291,24 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
|
|||||||
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||||
for i, w := range werkzeuge {
|
for i, w := range werkzeuge {
|
||||||
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
|
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
|
||||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
|
||||||
|
var loeschfristMaxTage *int
|
||||||
|
loeschfristen, err := s.store.ListLoeschfristEinstellungenForAccount(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, l := range loeschfristen {
|
||||||
|
if l.DatenklasseID == datenklasse.ID {
|
||||||
|
maxTage := l.MaxTage
|
||||||
|
loeschfristMaxTage = &maxTage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs, loeschfristMaxTage)
|
||||||
in.ZulaessigeWerkzeuge = zulaessig
|
in.ZulaessigeWerkzeuge = zulaessig
|
||||||
for _, a := range ausgeschlossen {
|
for _, a := range ausgeschlossen {
|
||||||
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
||||||
@@ -221,9 +344,11 @@ type antragListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type antragListData struct {
|
type antragListData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Antraege []antragListItem
|
Antraege []antragListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
|
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
|
||||||
@@ -238,8 +363,16 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := antragListData{Title: "Meine Anträge", Nav: navFor(r)}
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.Antrag
|
||||||
for _, a := range list {
|
for _, a := range list {
|
||||||
|
if matchesQuery(q, a.Titel, a.Status, a.Haeufigkeit) {
|
||||||
|
gefiltert = append(gefiltert, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := antragListData{Title: "Meine Anträge", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||||
|
for _, a := range paginate(gefiltert, pag) {
|
||||||
data.Antraege = append(data.Antraege, antragListItem{
|
data.Antraege = append(data.Antraege, antragListItem{
|
||||||
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
|
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
|
||||||
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
@@ -251,7 +384,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 +410,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 +437,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 +464,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +248,51 @@ func TestAntragCreateFiltersWerkzeugeByAnforderungen(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAntragCreateFiltertNachLoeschfrist(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
|
||||||
|
sess := fs.sessions[cookie.Value]
|
||||||
|
accountID := fs.users[sess.UserID].AccountID
|
||||||
|
// fullAntragForm loest Datenklasse "personenbezogen" aus (b1=ja).
|
||||||
|
fs.loeschfristen[accountID] = map[string]int{"personenbezogen": 30}
|
||||||
|
|
||||||
|
fs.werkzeuge["kurze-aufbewahrung"] = store.Werkzeug{
|
||||||
|
ID: "kurze-aufbewahrung", Name: "Kurz-Tool",
|
||||||
|
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(14),
|
||||||
|
}
|
||||||
|
fs.werkzeuge["lange-aufbewahrung"] = store.Werkzeug{
|
||||||
|
ID: "lange-aufbewahrung", Name: "Lang-Tool",
|
||||||
|
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(90),
|
||||||
|
}
|
||||||
|
fs.werkzeuge["unbekannte-aufbewahrung"] = store.Werkzeug{
|
||||||
|
ID: "unbekannte-aufbewahrung", Name: "Unbekannt-Tool",
|
||||||
|
AVVVerfuegbar: true, TrainingStandard: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "kurze-aufbewahrung" {
|
||||||
|
t.Fatalf("ZulaessigeWerkzeuge = %v, want [kurze-aufbewahrung]", b.ZulaessigeWerkzeuge)
|
||||||
|
}
|
||||||
|
if len(b.AusgeschlosseneWerkzeuge) != 2 {
|
||||||
|
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want 2 ausgeschlossene", b.AusgeschlosseneWerkzeuge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(i int) *int { return &i }
|
||||||
|
|
||||||
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/auth"
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type authPageData struct {
|
type authPageData struct {
|
||||||
Title string
|
Title string
|
||||||
Error string
|
Error string
|
||||||
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
|
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
|
||||||
@@ -23,7 +26,11 @@ func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
|
data := authPageData{Title: "Anmelden"}
|
||||||
|
if r.URL.Query().Get("reset") == "1" {
|
||||||
|
data.Message = "Passwort erfolgreich geändert. Bitte melde dich mit dem neuen Passwort an."
|
||||||
|
}
|
||||||
|
s.renderAuthPage(w, "login", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
|
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
|
||||||
@@ -31,19 +38,30 @@ 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"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accountName := r.FormValue("account_name")
|
accountInput := store.AccountInput{
|
||||||
|
Name: r.FormValue("account_name"),
|
||||||
|
Strasse: r.FormValue("strasse"),
|
||||||
|
PLZ: r.FormValue("plz"),
|
||||||
|
Ort: r.FormValue("ort"),
|
||||||
|
Land: r.FormValue("land"),
|
||||||
|
UStID: r.FormValue("ust_id"), // optional, siehe Migration 0023 (Kleinunternehmer)
|
||||||
|
Rechnungsemail: r.FormValue("rechnungsemail"),
|
||||||
|
}
|
||||||
email := r.FormValue("email")
|
email := r.FormValue("email")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
if accountName == "" || email == "" || password == "" {
|
if accountInput.Name == "" || email == "" || password == "" ||
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
|
accountInput.Strasse == "" || accountInput.PLZ == "" || accountInput.Ort == "" ||
|
||||||
|
accountInput.Land == "" || accountInput.Rechnungsemail == "" {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder außer USt-IdNr. sind Pflicht"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,20 +71,52 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
// Die account_id ist zu Beginn noch nicht bekannt (die Firma entsteht
|
||||||
acc, err := s.store.CreateAccount(ctx, accountName)
|
// ja gerade erst) — WithTenantScope startet die RLS-Transaktion
|
||||||
|
// zunächst ohne Mandant, SetTenantScope trägt die neue ID nach,
|
||||||
|
// sobald CreateAccount sie liefert. Ohne das würden die anschließenden
|
||||||
|
// Schreibzugriffe auf genehmiger_rolle/loeschfrist_einstellung (beide
|
||||||
|
// RLS-geschützt) von der Policy abgelehnt.
|
||||||
|
var errMsg string
|
||||||
|
err = s.store.WithTenantScope(r.Context(), "", false, func(ctx context.Context) error {
|
||||||
|
acc, err := s.store.CreateAccount(ctx, accountInput)
|
||||||
|
if err != nil {
|
||||||
|
errMsg = "Konto konnte nicht angelegt werden"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.store.SetTenantScope(ctx, acc.ID, false); err != nil {
|
||||||
|
errMsg = "Interner Fehler"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
|
||||||
|
if err != nil {
|
||||||
|
errMsg = "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
|
||||||
|
errMsg = "Genehmiger-Rollen konnten nicht angelegt werden"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||||
|
errMsg = "Löschfristen konnten nicht angelegt werden"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// r.WithContext(ctx): startSession muss in DERSELBEN Transaktion
|
||||||
|
// laufen wie CreateUser darüber — sonst sieht die Session-Zeile
|
||||||
|
// (andere Verbindung über s.Pool, da r.Context() sonst ohne Tx
|
||||||
|
// wäre) den gerade erst angelegten, noch nicht committeten
|
||||||
|
// Nutzer nicht und die Fremdschlüsselprüfung schlägt fehl.
|
||||||
|
if err := s.startSession(w, r.WithContext(ctx), user.ID); err != nil {
|
||||||
|
errMsg = "Sitzung konnte nicht gestartet werden"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
|
if errMsg == "" {
|
||||||
return
|
errMsg = "Registrierung fehlgeschlagen"
|
||||||
}
|
}
|
||||||
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: errMsg})
|
||||||
if err != nil {
|
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.startSession(w, r, user.ID); err != nil {
|
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
@@ -93,6 +143,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Erst NACH erfolgreicher Passwortprüfung unterscheiden — sonst würde
|
||||||
|
// diese Meldung einem Angreifer ohne gültiges Passwort verraten, dass
|
||||||
|
// die E-Mail-Adresse zu einem (deaktivierten) Konto gehört.
|
||||||
|
if !user.Active {
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Dieses Konto ist deaktiviert"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.startSession(w, r, user.ID); err != nil {
|
if err := s.startSession(w, r, user.ID); err != nil {
|
||||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
|
||||||
@@ -132,6 +189,14 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
|
|||||||
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von
|
||||||
|
// startSession (regulärer Login) und handleBetreiberLoginAls
|
||||||
|
// (Support-Login).
|
||||||
|
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: sessionCookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
@@ -141,5 +206,4 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
|
|||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
Expires: expiresAt,
|
Expires: expiresAt,
|
||||||
})
|
})
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
100
internal/web/betreiber_account_crud_test.go
Normal file
100
internal/web/betreiber_account_crud_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", mergeValues(url.Values{
|
||||||
|
"account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||||
|
}, firmenPflichtfelder()))
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := fs.GetUserByEmail(context.Background(), "admin@neue-firma.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if user.Role != "admin" {
|
||||||
|
t.Errorf("Role = %q, want admin", user.Role)
|
||||||
|
}
|
||||||
|
acc, err := fs.GetAccount(context.Background(), user.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if acc.Name != "Neue Firma GmbH" {
|
||||||
|
t.Errorf("Name = %q, want Neue Firma GmbH", acc.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der neue Login funktioniert tatsächlich.
|
||||||
|
loginResp := postForm(t, s, nil, "/login", url.Values{
|
||||||
|
"email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||||
|
})
|
||||||
|
if loginResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberFirmaAnlegenRejectsMissingFields(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{"account_name": {"Nur Name"}})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Pflicht") {
|
||||||
|
t.Errorf("expected a validation error, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberKannFirmaUmbenennen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Alter Name"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID+"/umbenennen", url.Values{"name": {"Neuer Name"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := fs.GetAccount(context.Background(), acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Neuer Name" {
|
||||||
|
t.Errorf("Name = %q, want Neuer Name", got.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID)
|
||||||
|
if !strings.Contains(detail.Body.String(), "Neuer Name") {
|
||||||
|
t.Errorf("expected the new name on the detail page, got: %s", detail.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotCreateFirma(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/betreiber/accounts/neu")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,18 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type betreiberDashboardData struct {
|
type betreiberDashboardData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
AccountCount int
|
AccountCount int
|
||||||
|
WerkzeugCount int
|
||||||
RecentAuditCount int
|
RecentAuditCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +27,11 @@ func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request
|
|||||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
werkzeuge, err := s.store.ListZentraleWerkzeuge(ctx)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
auditLog, err := s.store.ListAuditLog(ctx, 5)
|
auditLog, err := s.store.ListAuditLog(ctx, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
@@ -28,7 +39,8 @@ func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := betreiberDashboardData{
|
data := betreiberDashboardData{
|
||||||
Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts), RecentAuditCount: len(auditLog),
|
Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts),
|
||||||
|
WerkzeugCount: len(werkzeuge), RecentAuditCount: len(auditLog),
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
@@ -42,9 +54,11 @@ type betreiberAccountListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type betreiberAccountListData struct {
|
type betreiberAccountListData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Accounts []betreiberAccountListItem
|
Accounts []betreiberAccountListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleBetreiberAccountList listet alle Mandanten der Plattform.
|
// handleBetreiberAccountList listet alle Mandanten der Plattform.
|
||||||
@@ -54,8 +68,16 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
|
|||||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r)}
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.Account
|
||||||
for _, a := range accounts {
|
for _, a := range accounts {
|
||||||
|
if matchesQuery(q, a.Name) {
|
||||||
|
gefiltert = append(gefiltert, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||||
|
for _, a := range paginate(gefiltert, pag) {
|
||||||
data.Accounts = append(data.Accounts, betreiberAccountListItem{
|
data.Accounts = append(data.Accounts, betreiberAccountListItem{
|
||||||
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
})
|
})
|
||||||
@@ -65,9 +87,127 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type betreiberAccountNeuData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBetreiberAccountNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r)}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountCreate legt eine Firma samt erstem admin-Login
|
||||||
|
// an — dasselbe Ergebnis wie die öffentliche Firma-Registrierung
|
||||||
|
// (handleRegister), nur vom Betreiber ausgelöst statt von der Firma
|
||||||
|
// selbst (z. B. bei vertriebsunterstütztem Onboarding oder zum Anlegen
|
||||||
|
// eines Testkontos). Der Betreiber setzt das Initialpasswort direkt,
|
||||||
|
// wie überall sonst im Produkt ohne E-Mail-Infrastruktur.
|
||||||
|
func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountInput := store.AccountInput{
|
||||||
|
Name: r.FormValue("account_name"),
|
||||||
|
Strasse: r.FormValue("strasse"),
|
||||||
|
PLZ: r.FormValue("plz"),
|
||||||
|
Ort: r.FormValue("ort"),
|
||||||
|
Land: r.FormValue("land"),
|
||||||
|
UStID: r.FormValue("ust_id"), // optional, siehe Migration 0023 (Kleinunternehmer)
|
||||||
|
Rechnungsemail: r.FormValue("rechnungsemail"),
|
||||||
|
}
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if accountInput.Name == "" || email == "" || password == "" ||
|
||||||
|
accountInput.Strasse == "" || accountInput.PLZ == "" || accountInput.Ort == "" ||
|
||||||
|
accountInput.Land == "" || accountInput.Rechnungsemail == "" {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder außer USt-IdNr. sind Pflicht"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
acc, err := s.store.CreateAccount(ctx, accountInput)
|
||||||
|
if err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Konto konnte nicht angelegt werden: " + err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin"); err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Genehmiger-Rollen konnten nicht angelegt werden: " + err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Löschfristen konnten nicht angelegt werden: " + err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountInput.Name+" / "+email); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/betreiber/accounts/"+acc.ID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountRename benennt eine Firma um (z. B. Tippfehler
|
||||||
|
// korrigieren) — kein sonstiges Bearbeiten, Löschen bleibt bewusst
|
||||||
|
// ungebaut (siehe CLAUDE.md, Offene Punkte: hängt an der noch nicht
|
||||||
|
// getroffenen Abrechnungs-/Freischaltungs-Architektur, und ein
|
||||||
|
// Hard-Delete würde gegen die Fremdschlüssel aus antrag/app_user/
|
||||||
|
// audit_log laufen).
|
||||||
|
func (s *Server) handleBetreiberAccountRename(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if _, err := s.store.UpdateAccount(r.Context(), id, name); err != nil {
|
||||||
|
http.Error(w, "Firma konnte nicht umbenannt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/betreiber/accounts/"+id, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
type betreiberUserView struct {
|
type betreiberUserView struct {
|
||||||
Email string
|
ID string
|
||||||
Role string
|
Email string
|
||||||
|
Role string
|
||||||
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type betreiberAccountDetailData struct {
|
type betreiberAccountDetailData struct {
|
||||||
@@ -98,7 +238,7 @@ func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Req
|
|||||||
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 {
|
||||||
data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role})
|
data.Users = append(data.Users, betreiberUserView{ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active})
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
@@ -114,20 +254,37 @@ type betreiberAuditEntryView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type betreiberAuditLogData struct {
|
type betreiberAuditLogData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Entries []betreiberAuditEntryView
|
Entries []betreiberAuditEntryView
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auditLogFetchLimit begrenzt, wie viele der neuesten Einträge aus der
|
||||||
|
// DB geholt werden, bevor im Go-Code gefiltert/paginiert wird — echte
|
||||||
|
// DB-seitige Offset-Paginierung wäre für ein potenziell sehr groß
|
||||||
|
// werdendes Audit-Log der sauberere Ansatz, ist hier aber (noch) nicht
|
||||||
|
// umgesetzt (siehe CLAUDE.md, Offene Punkte).
|
||||||
|
const auditLogFetchLimit = 1000
|
||||||
|
|
||||||
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
|
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
|
||||||
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||||
entries, err := s.store.ListAuditLog(r.Context(), 200)
|
entries, err := s.store.ListAuditLog(r.Context(), auditLogFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.AuditEntry
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
|
if matchesQuery(q, e.Action, e.TargetType, e.Details) {
|
||||||
|
gefiltert = append(gefiltert, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||||
|
for _, e := range paginate(gefiltert, pag) {
|
||||||
data.Entries = append(data.Entries, betreiberAuditEntryView{
|
data.Entries = append(data.Entries, betreiberAuditEntryView{
|
||||||
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
||||||
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
||||||
@@ -137,3 +294,46 @@ func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request)
|
|||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleBetreiberLoginAls meldet den Betreiber als einen bestimmten
|
||||||
|
// Kunden-Nutzer an (Support-Login) — ohne dessen Passwort zu kennen.
|
||||||
|
// Nur für aktive Nutzer möglich (ein deaktivierter Login soll auch per
|
||||||
|
// Support-Zugriff nicht nutzbar sein). Die neue Sitzung ersetzt die
|
||||||
|
// eigene Betreiber-Sitzung (kein Sitzungs-Stack) — der Betreiber meldet
|
||||||
|
// sich danach mit den eigenen Zugangsdaten wieder an, das ist für die
|
||||||
|
// seltene Nutzung dieser Funktion einfacher als ein "Zurück zum
|
||||||
|
// Betreiber"-Mechanismus. Jede Nutzung wird append-only im Audit-Log
|
||||||
|
// festgehalten (Actor = Betreiber, Target = der Kunden-Nutzer) und die
|
||||||
|
// entstehende Sitzung trägt den Betreiber als ImpersonatedByUserID —
|
||||||
|
// die Nav zeigt während der gesamten Sitzung einen auffälligen Hinweis
|
||||||
|
// (siehe currentImpersonator, layout.html), damit nie unklar ist, dass
|
||||||
|
// gerade im Kontext eines fremden Kontos gehandelt wird.
|
||||||
|
func (s *Server) handleBetreiberLoginAls(w http.ResponseWriter, r *http.Request) {
|
||||||
|
betreiber := currentUser(r)
|
||||||
|
ziel, err := s.store.GetUser(r.Context(), r.PathValue("userID"))
|
||||||
|
if err != nil || ziel.AccountID != r.PathValue("accountID") {
|
||||||
|
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ziel.Active {
|
||||||
|
http.Error(w, "Nutzer ist deaktiviert", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||||
|
if _, err := s.store.CreateImpersonatedSession(r.Context(), token, ziel.ID, betreiber.ID, expiresAt); err != nil {
|
||||||
|
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(r.Context(), betreiber.ID, "betreiber_login_als_nutzer", "app_user", ziel.ID, ziel.Email); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|||||||
449
internal/web/betreiber_werkzeug_handlers.go
Normal file
449
internal/web/betreiber_werkzeug_handlers.go
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
// Ebene 5 (Plattform) — Pflege des zentralen Werkzeugkatalogs
|
||||||
|
// (`werkzeug.account_id IS NULL`). Mandantenspezifische Ergänzungen
|
||||||
|
// (`account_id` gesetzt) sind NICHT Teil dieser Seiten — die bleiben
|
||||||
|
// Sache des jeweiligen Mandanten (Ebene 4, "eigene Werkzeug-
|
||||||
|
// Freigaben/-Sperrungen", noch nicht gebaut), siehe CLAUDE.md.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// werkzeugFilter bündelt die Filterkriterien für die Katalog-Listen
|
||||||
|
// (Betreiber wie Mandant) — ein einziges GET-Formular setzt "q"
|
||||||
|
// (Freitext über Name/Anbieter/Länder) und optional "zweck".
|
||||||
|
type werkzeugFilter struct {
|
||||||
|
Suche string
|
||||||
|
Zweck string
|
||||||
|
}
|
||||||
|
|
||||||
|
func werkzeugFilterFromRequest(r *http.Request) werkzeugFilter {
|
||||||
|
return werkzeugFilter{
|
||||||
|
Suche: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||||
|
Zweck: r.URL.Query().Get("zweck"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktiv meldet, ob mindestens ein Filter gesetzt ist — steuert, ob ein
|
||||||
|
// "Zurücksetzen"-Link angezeigt wird. Exportiert, weil html/template
|
||||||
|
// nur exportierte Methoden per Reflection aufrufen kann.
|
||||||
|
func (f werkzeugFilter) Aktiv() bool {
|
||||||
|
return f.Suche != "" || f.Zweck != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f werkzeugFilter) matches(wz store.Werkzeug) bool {
|
||||||
|
if f.Zweck != "" {
|
||||||
|
gefunden := false
|
||||||
|
for _, z := range wz.GeeigneteZwecke {
|
||||||
|
if z == f.Zweck {
|
||||||
|
gefunden = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gefunden {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Suche != "" {
|
||||||
|
q := strings.ToLower(f.Suche)
|
||||||
|
heuhaufen := strings.ToLower(wz.Name + " " + wz.Anbieter + " " + strings.Join(wz.Verarbeitungslaender, " "))
|
||||||
|
if !strings.Contains(heuhaufen, q) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// istGueltigesLand validiert gegen weltLaenderliste (laenderliste.go)
|
||||||
|
// — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck,
|
||||||
|
// nur für Verarbeitungsländer statt Zwecke.
|
||||||
|
func istGueltigesLand(v string) bool {
|
||||||
|
for _, l := range weltLaenderliste {
|
||||||
|
if l == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
|
||||||
|
// (siehe CLAUDE.md, Bewertungslogik Schritt 3). Vorher war das Feld
|
||||||
|
// Freitext; ein Tippfehler ("Text" statt "Textgenerierung") hätte den
|
||||||
|
// künftigen automatischen Zweckabgleich unbemerkt kaputt gemacht. Am
|
||||||
|
// 2026-08-31 von sechs auf zwölf Kategorien erweitert (bewusste
|
||||||
|
// v1-Scope-Entscheidung überarbeitet, nicht stillschweigend geändert)
|
||||||
|
// — die ursprünglichen sechs deckten nur klassische Wissensarbeit ab,
|
||||||
|
// nicht Datenanalyse/Video/Audio/Kundenservice/Design/Automatisierung.
|
||||||
|
var gueltigeZwecke = []string{
|
||||||
|
"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche",
|
||||||
|
"Datenanalyse", "Video", "Audio/Sprache", "Kundenservice/Chatbot", "Präsentation/Design", "Automatisierung/Agenten",
|
||||||
|
}
|
||||||
|
|
||||||
|
func istGueltigerZweck(v string) bool {
|
||||||
|
for _, z := range gueltigeZwecke {
|
||||||
|
if z == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitListe zerlegt ein kommasepariertes Formularfeld (Zertifizierungen,
|
||||||
|
// geeignete Zwecke, Einschränkungen) in eine bereinigte Liste — ohne
|
||||||
|
// führende/folgende Leerzeichen und ohne leere Einträge.
|
||||||
|
func splitListe(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, part := range strings.Split(s, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type werkzeugListItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
Verarbeitungslaender string
|
||||||
|
DPFZertifiziert bool
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
TrainingStandard bool
|
||||||
|
Aufbewahrung string
|
||||||
|
Zertifizierungen string
|
||||||
|
GeeigneteZwecke string
|
||||||
|
LetztePruefung string
|
||||||
|
PruefStatusLabel string
|
||||||
|
PruefStatusCSS string
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat
|
||||||
|
// keine Angabe gemacht) nie mit "0 Tage" (belegte Zusicherung einer
|
||||||
|
// sofortigen Löschung) verwechselt wird.
|
||||||
|
func formatAufbewahrung(tage *int) string {
|
||||||
|
if tage == nil {
|
||||||
|
return "unbekannt"
|
||||||
|
}
|
||||||
|
if *tage == 0 {
|
||||||
|
return "0 Tage (sofort)"
|
||||||
|
}
|
||||||
|
return strconv.Itoa(*tage) + " Tage"
|
||||||
|
}
|
||||||
|
|
||||||
|
type werkzeugListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Werkzeuge []werkzeugListItem
|
||||||
|
Filter werkzeugFilter
|
||||||
|
ZweckeOptionen []string
|
||||||
|
Pagination pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
|
||||||
|
// Katalogeintrag im Ergebnis als "abgelaufen" markiert wird (siehe
|
||||||
|
// CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss
|
||||||
|
// belegbar UND aktuell sein. werkzeugPruefungBaldFaelligNach liegt
|
||||||
|
// 30 Tage davor (dasselbe Vorwarnfenster wie die Wiedervorlage,
|
||||||
|
// wiedervorlageSchwelle) — "bald fällig" soll rechtzeitig auffallen,
|
||||||
|
// nicht erst wenn die Frist schon gerissen ist.
|
||||||
|
const (
|
||||||
|
werkzeugPruefungBaldFaelligNach = 150 * 24 * time.Hour
|
||||||
|
werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// pruefStatus liefert eine Ampel-Kennzeichnung für das Alter einer
|
||||||
|
// letzten Werkzeug-Prüfung: aktuell (grün) / bald fällig (gelb) /
|
||||||
|
// abgelaufen (rot) — die CSS-Klassen sind dieselben wie für die
|
||||||
|
// Risiko-Ampel anderswo im Produkt (status-niedrig/-mittel/-hoch).
|
||||||
|
func pruefStatus(letztePruefung time.Time) (label, css string) {
|
||||||
|
alter := time.Since(letztePruefung)
|
||||||
|
switch {
|
||||||
|
case alter > werkzeugPruefungVeraltetNach:
|
||||||
|
return "abgelaufen", "status-hoch"
|
||||||
|
case alter > werkzeugPruefungBaldFaelligNach:
|
||||||
|
return "bald fällig", "status-mittel"
|
||||||
|
default:
|
||||||
|
return "aktuell", "status-niedrig"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
|
||||||
|
func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter := werkzeugFilterFromRequest(r)
|
||||||
|
var gefiltert []store.Werkzeug
|
||||||
|
for _, wz := range werkzeuge {
|
||||||
|
if filter.matches(wz) {
|
||||||
|
gefiltert = append(gefiltert, wz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
seite := paginate(gefiltert, pag)
|
||||||
|
|
||||||
|
data := werkzeugListeData{
|
||||||
|
Title: "Werkzeugkatalog", Nav: navFor(r),
|
||||||
|
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
|
||||||
|
}
|
||||||
|
for _, wz := range seite {
|
||||||
|
label, css := pruefStatus(wz.LetztePruefung)
|
||||||
|
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
||||||
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
DPFZertifiziert: wz.DPFZertifiziert,
|
||||||
|
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
|
||||||
|
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
|
||||||
|
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||||
|
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||||
|
PruefStatusLabel: label, PruefStatusCSS: css,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// werkzeugFormData bedient sowohl das Betreiber-Formular (zentraler
|
||||||
|
// Katalog, ActionBase "/betreiber/werkzeuge") als auch das Mandanten-
|
||||||
|
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
|
||||||
|
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
|
||||||
|
type werkzeugFormData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
ActionBase string
|
||||||
|
ID string // leer bei "neu"
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
LaenderOptionen []string
|
||||||
|
LaenderAusgewaehlt map[string]bool
|
||||||
|
DPFZertifiziert bool
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
AVVURL string
|
||||||
|
TrainingOptOut bool
|
||||||
|
TrainingStandard bool
|
||||||
|
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
|
||||||
|
Zertifizierungen string
|
||||||
|
Subprozessoren string
|
||||||
|
ZweckeOptionen []string
|
||||||
|
ZweckeAusgewaehlt map[string]bool
|
||||||
|
Einschraenkungen string
|
||||||
|
LetztePruefung string
|
||||||
|
Quelle string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// auswahlSet baut die Checkbox-Vorbelegung aus einer bereits
|
||||||
|
// gespeicherten oder erneut eingereichten Liste von Zwecken.
|
||||||
|
func auswahlSet(ausgewaehlt []string) map[string]bool {
|
||||||
|
m := make(map[string]bool, len(ausgewaehlt))
|
||||||
|
for _, z := range ausgewaehlt {
|
||||||
|
m[z] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// werkzeugFormDataFromWerkzeug baut die Formular-Vorbelegung zum
|
||||||
|
// Bearbeiten eines bestehenden Katalogeintrags — gemeinsam für Betreiber-
|
||||||
|
// und Mandanten-Formular, damit beide bei neuen Feldern nicht auseinanderlaufen.
|
||||||
|
func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, actionBase string) werkzeugFormData {
|
||||||
|
aufbewahrung := ""
|
||||||
|
if wz.AufbewahrungTage != nil {
|
||||||
|
aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage)
|
||||||
|
}
|
||||||
|
return werkzeugFormData{
|
||||||
|
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID,
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(wz.Verarbeitungslaender), DPFZertifiziert: wz.DPFZertifiziert,
|
||||||
|
Name: wz.Name, Anbieter: wz.Anbieter,
|
||||||
|
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
||||||
|
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: aufbewahrung,
|
||||||
|
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "),
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(wz.GeeigneteZwecke),
|
||||||
|
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
|
||||||
|
Quelle: wz.Quelle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := werkzeugFormData{
|
||||||
|
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular.
|
||||||
|
// accountID ist nil für den zentralen Katalog (Betreiber) oder gesetzt
|
||||||
|
// für einen mandantenspezifischen Eintrag (Ebene 4).
|
||||||
|
func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugInput, string) {
|
||||||
|
letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung"))
|
||||||
|
if err != nil {
|
||||||
|
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
|
||||||
|
}
|
||||||
|
var verarbeitungslaender []string
|
||||||
|
for _, l := range r.Form["verarbeitungslaender"] {
|
||||||
|
if istGueltigesLand(l) {
|
||||||
|
verarbeitungslaender = append(verarbeitungslaender, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(verarbeitungslaender) == 0 {
|
||||||
|
return store.WerkzeugInput{}, "mindestens ein Verarbeitungsland ist Pflicht"
|
||||||
|
}
|
||||||
|
name := r.FormValue("name")
|
||||||
|
anbieter := r.FormValue("anbieter")
|
||||||
|
quelle := r.FormValue("quelle")
|
||||||
|
if name == "" || anbieter == "" || quelle == "" {
|
||||||
|
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
|
||||||
|
}
|
||||||
|
var aufbewahrungTage *int
|
||||||
|
if rohwert := strings.TrimSpace(r.FormValue("aufbewahrung_tage")); rohwert != "" {
|
||||||
|
wert, err := strconv.Atoi(rohwert)
|
||||||
|
if err != nil || wert < 0 {
|
||||||
|
return store.WerkzeugInput{}, "Aufbewahrung in Tagen muss eine Zahl ≥ 0 sein (leer lassen, wenn unbekannt)"
|
||||||
|
}
|
||||||
|
aufbewahrungTage = &wert
|
||||||
|
}
|
||||||
|
|
||||||
|
var geeigneteZwecke []string
|
||||||
|
for _, z := range r.Form["geeignete_zwecke"] {
|
||||||
|
if istGueltigerZweck(z) {
|
||||||
|
geeigneteZwecke = append(geeigneteZwecke, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.WerkzeugInput{
|
||||||
|
AccountID: accountID,
|
||||||
|
Name: name,
|
||||||
|
Anbieter: anbieter,
|
||||||
|
Verarbeitungslaender: verarbeitungslaender,
|
||||||
|
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
|
||||||
|
AVVURL: r.FormValue("avv_url"),
|
||||||
|
TrainingOptOut: r.FormValue("training_opt_out") == "on",
|
||||||
|
TrainingStandard: r.FormValue("training_standard") == "on",
|
||||||
|
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
|
||||||
|
AufbewahrungTage: aufbewahrungTage,
|
||||||
|
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
||||||
|
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
|
||||||
|
GeeigneteZwecke: geeigneteZwecke,
|
||||||
|
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
||||||
|
LetztePruefung: letztePruefung,
|
||||||
|
Quelle: quelle,
|
||||||
|
}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei
|
||||||
|
// einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten,
|
||||||
|
// statt das Formular zu leeren.
|
||||||
|
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
|
||||||
|
return werkzeugFormData{
|
||||||
|
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id,
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(r.Form["verarbeitungslaender"]),
|
||||||
|
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
|
||||||
|
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"),
|
||||||
|
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
|
||||||
|
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
|
||||||
|
AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")),
|
||||||
|
Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"),
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(r.Form["geeignete_zwecke"]),
|
||||||
|
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
|
||||||
|
Quelle: r.FormValue("quelle"), Error: errMsg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberWerkzeugCreate legt einen zentralen Katalogeintrag an.
|
||||||
|
func (s *Server) handleBetreiberWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||||
|
if errMsg != "" {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", errMsg)
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberWerkzeugEditForm zeigt einen zentralen Katalogeintrag
|
||||||
|
// zum Bearbeiten. Ein mandantenspezifischer Eintrag (account_id
|
||||||
|
// gesetzt) ist hier bewusst nicht erreichbar — das ist Sache des
|
||||||
|
// jeweiligen Mandanten, nicht der Plattform.
|
||||||
|
func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
|
||||||
|
if err != nil || wz.AccountID != nil {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge")
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberWerkzeugUpdate speichert Änderungen an einem zentralen
|
||||||
|
// Katalogeintrag. Werkzeug ist nicht append-only (siehe store.UpdateWerkzeug)
|
||||||
|
// — eine bereits getroffene Entscheidung hält ihren eigenen, zum
|
||||||
|
// Entscheidungszeitpunkt eingefrorenen Snapshot (entscheidung.werkzeug_snapshot).
|
||||||
|
func (s *Server) handleBetreiberWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||||
|
if err != nil || wz.AccountID != nil {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||||
|
if errMsg != "" {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, errMsg)
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberWerkzeugDelete entfernt einen zentralen Katalogeintrag.
|
||||||
|
func (s *Server) handleBetreiberWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||||
|
if err != nil || wz.AccountID != nil {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
|
||||||
|
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
188
internal/web/betreiber_werkzeug_handlers_test.go
Normal file
188
internal/web/betreiber_werkzeug_handlers_test.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func werkzeugForm() url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
|
||||||
|
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
||||||
|
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
|
||||||
|
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},
|
||||||
|
"einschraenkungen": {""}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberCanCreateZentralesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var w store.Werkzeug
|
||||||
|
for _, wz := range fs.werkzeuge {
|
||||||
|
w = wz
|
||||||
|
}
|
||||||
|
if w.Name != "ChatGPT Enterprise" || w.AccountID != nil {
|
||||||
|
t.Fatalf("Werkzeug = %+v, unerwartete Werte", w)
|
||||||
|
}
|
||||||
|
if len(w.Zertifizierungen) != 2 {
|
||||||
|
t.Errorf("Zertifizierungen = %v, want 2 Einträge", w.Zertifizierungen)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
|
||||||
|
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Errorf("expected the new werkzeug in the Katalogliste, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberWerkzeugCreateRequiresLetztePruefungUndQuelle(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
form := werkzeugForm()
|
||||||
|
form.Set("letzte_pruefung", "")
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", form)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (Formular mit Fehler) ohne letzte_pruefung", resp.Code)
|
||||||
|
}
|
||||||
|
if len(fs.werkzeuge) != 0 {
|
||||||
|
t.Errorf("expected no werkzeug to be created, got %d", len(fs.werkzeuge))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberCanUpdateAndDeleteZentralesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm())
|
||||||
|
var id string
|
||||||
|
for wid := range fs.werkzeuge {
|
||||||
|
id = wid
|
||||||
|
}
|
||||||
|
|
||||||
|
editResp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id)
|
||||||
|
if editResp.Code != http.StatusOK || !strings.Contains(editResp.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Fatalf("edit form status = %d, body: %s", editResp.Code, editResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
updateForm := werkzeugForm()
|
||||||
|
updateForm.Set("name", "ChatGPT Enterprise (aktualisiert)")
|
||||||
|
updateResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id, updateForm)
|
||||||
|
if updateResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||||
|
}
|
||||||
|
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (aktualisiert)" {
|
||||||
|
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||||
|
if deleteResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := fs.werkzeuge[id]; ok {
|
||||||
|
t.Error("expected the werkzeug to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBetreiberCannotEditMandantenEigenesWerkzeug: ein mandantenspezifischer
|
||||||
|
// Katalogeintrag (account_id gesetzt) ist über die Betreiber-Seiten
|
||||||
|
// nicht erreichbar — das bleibt Sache des jeweiligen Mandanten.
|
||||||
|
func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Mandant mit eigenem Werkzeug"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
eigenesID := "eigenes-werkzeug"
|
||||||
|
fs.werkzeuge[eigenesID] = store.Werkzeug{ID: eigenesID, AccountID: &acc.ID, Name: "Mandanten-eigenes Tool"}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+eigenesID)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberWerkzeugListeFiltertNachSucheUndZweck(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
fs.werkzeuge["a"] = store.Werkzeug{
|
||||||
|
ID: "a", Name: "ChatGPT Enterprise", Anbieter: "OpenAI",
|
||||||
|
Verarbeitungslaender: []string{"USA"}, GeeigneteZwecke: []string{"Textgenerierung"},
|
||||||
|
LetztePruefung: time.Now(),
|
||||||
|
}
|
||||||
|
fs.werkzeuge["b"] = store.Werkzeug{
|
||||||
|
ID: "b", Name: "DeepL Pro", Anbieter: "DeepL SE",
|
||||||
|
Verarbeitungslaender: []string{"Deutschland"}, GeeigneteZwecke: []string{"Übersetzung"},
|
||||||
|
LetztePruefung: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=deepl")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", resp.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || !strings.Contains(resp.Body.String(), "DeepL Pro") {
|
||||||
|
t.Fatalf("q=deepl sollte nur DeepL Pro zeigen, body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?zweck=Textgenerierung")
|
||||||
|
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || strings.Contains(resp.Body.String(), "DeepL Pro") {
|
||||||
|
t.Fatalf("zweck=Textgenerierung sollte nur ChatGPT Enterprise zeigen, body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=nichts-passt")
|
||||||
|
if !strings.Contains(resp.Body.String(), "Keine Treffer") {
|
||||||
|
t.Fatalf("erwartet 'Keine Treffer' bei erfolgloser Suche, body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberWerkzeugListePaginiert(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
id := fmt.Sprintf("wz-%02d", i)
|
||||||
|
fs.werkzeuge[id] = store.Werkzeug{
|
||||||
|
ID: id, Name: fmt.Sprintf("Werkzeug %02d", i), Anbieter: "Testanbieter",
|
||||||
|
LetztePruefung: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", resp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Seite 1 von 2") {
|
||||||
|
t.Fatalf("erwartet 'Seite 1 von 2' bei 25 Einträgen (20 pro Seite), body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Weiter") || strings.Contains(resp.Body.String(), "Zurück") {
|
||||||
|
t.Fatalf("Seite 1 sollte einen Weiter-, aber keinen Zurück-Button zeigen")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?page=2")
|
||||||
|
if !strings.Contains(resp.Body.String(), "Seite 2 von 2") {
|
||||||
|
t.Fatalf("erwartet 'Seite 2 von 2', body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "Weiter") || !strings.Contains(resp.Body.String(), "Zurück") {
|
||||||
|
t.Fatalf("Seite 2 sollte einen Zurück-, aber keinen Weiter-Button zeigen")
|
||||||
|
}
|
||||||
|
}
|
||||||
184
internal/web/csv_import_handlers.go
Normal file
184
internal/web/csv_import_handlers.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten
|
||||||
|
// Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine
|
||||||
|
// E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie
|
||||||
|
// beim einzelnen "Nutzer anlegen" generiert der Import ein
|
||||||
|
// Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG
|
||||||
|
// sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern:
|
||||||
|
// gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet
|
||||||
|
// (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer
|
||||||
|
// Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren).
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type csvMitarbeiterZeile struct {
|
||||||
|
Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile.
|
||||||
|
// Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder
|
||||||
|
// "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst
|
||||||
|
// beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine
|
||||||
|
// einzelne falsche Rolle nicht den ganzen Import verhindert).
|
||||||
|
func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) {
|
||||||
|
cr := csv.NewReader(r)
|
||||||
|
cr.TrimLeadingSpace = true
|
||||||
|
cr.FieldsPerRecord = -1
|
||||||
|
|
||||||
|
header, err := cr.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err)
|
||||||
|
}
|
||||||
|
emailCol, roleCol := -1, -1
|
||||||
|
for i, h := range header {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(h)) {
|
||||||
|
case "email", "e-mail":
|
||||||
|
emailCol = i
|
||||||
|
case "role", "rolle":
|
||||||
|
roleCol = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if emailCol == -1 {
|
||||||
|
return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []csvMitarbeiterZeile
|
||||||
|
zeile := 1
|
||||||
|
for {
|
||||||
|
record, err := cr.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err)
|
||||||
|
}
|
||||||
|
zeile++
|
||||||
|
if emailCol >= len(record) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(record[emailCol])
|
||||||
|
if email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role := "mitarbeiter"
|
||||||
|
if roleCol != -1 && roleCol < len(record) {
|
||||||
|
if v := strings.TrimSpace(record[roleCol]); v != "" {
|
||||||
|
role = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für
|
||||||
|
// eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt
|
||||||
|
// einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine
|
||||||
|
// Länge, die sich noch manuell weitergeben lässt.
|
||||||
|
func generateEinmalPasswort() (string, error) {
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token[:16], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvImportFormData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||||
|
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvImportErgebnisZeile struct {
|
||||||
|
Zeile int
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
Passwort string // nur bei Erfolg gesetzt
|
||||||
|
Fehler string // nur bei Fehler gesetzt
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvImportErgebnisData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Ergebnisse []csvImportErgebnisZeile
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt
|
||||||
|
// pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis
|
||||||
|
// (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite
|
||||||
|
// gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden.
|
||||||
|
func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste
|
||||||
|
s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, _, err := r.FormFile("csv")
|
||||||
|
if err != nil {
|
||||||
|
s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
zeilen, err := parseCSVMitarbeiter(file)
|
||||||
|
if err != nil {
|
||||||
|
s.renderCSVImportForm(w, r, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(zeilen) == 0 {
|
||||||
|
s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
var ergebnisse []csvImportErgebnisZeile
|
||||||
|
for _, z := range zeilen {
|
||||||
|
if !istMandantenRolle(z.Role) {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
passwort, err := generateEinmalPasswort()
|
||||||
|
if err != nil {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(passwort)
|
||||||
|
if err != nil {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort})
|
||||||
|
}
|
||||||
|
|
||||||
|
data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
149
internal/web/csv_import_handlers_test.go
Normal file
149
internal/web/csv_import_handlers_test.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// postCSV lädt csvContent als multipart-Formular mit Feldname "csv" hoch
|
||||||
|
// — reale Browser-Uploads laufen genauso über multipart/form-data.
|
||||||
|
func postCSV(t *testing.T, s *web.Server, cookie *http.Cookie, path, csvContent string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
fw, err := mw.CreateFormFile("csv", "mitarbeiter.csv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write([]byte(csvContent)); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatalf("mw.Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCanImportMitarbeiterPerCSV(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, "lisa@example.com") || !strings.Contains(body, "tom@example.com") {
|
||||||
|
t.Fatalf("expected both imported emails in the result, got: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
lisa, err := fs.GetUserByEmail(context.Background(), "lisa@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail (lisa): %v", err)
|
||||||
|
}
|
||||||
|
if lisa.Role != "verantwortlicher" {
|
||||||
|
t.Errorf("lisa.Role = %q, want verantwortlicher", lisa.Role)
|
||||||
|
}
|
||||||
|
tom, err := fs.GetUserByEmail(context.Background(), "tom@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail (tom): %v", err)
|
||||||
|
}
|
||||||
|
if tom.Role != "mitarbeiter" {
|
||||||
|
t.Errorf("tom.Role = %q, want default mitarbeiter", tom.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSVImportZeigtEinmalpasswortDasFunktioniert(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email\nneu@example.com\n")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das angezeigte Einmalpasswort aus der <code>-Zelle extrahieren.
|
||||||
|
body := resp.Body.String()
|
||||||
|
start := strings.Index(body, "<code>")
|
||||||
|
end := strings.Index(body, "</code>")
|
||||||
|
if start == -1 || end == -1 {
|
||||||
|
t.Fatalf("expected a <code>-Zelle mit dem Einmalpasswort, got: %s", body)
|
||||||
|
}
|
||||||
|
passwort := body[start+len("<code>") : end]
|
||||||
|
|
||||||
|
loginResp := postForm(t, s, nil, "/login", map[string][]string{
|
||||||
|
"email": {"neu@example.com"}, "password": {passwort},
|
||||||
|
})
|
||||||
|
if loginResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login with the generated password: status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSVImportMeldetUngueltigeRolleOhneDieAnderenZuBlockieren(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nboese@example.com,betreiber\ngut@example.com,mitarbeiter\n")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := fs.GetUserByEmail(context.Background(), "boese@example.com"); err == nil {
|
||||||
|
t.Error("expected no user to be created for an invalid role (betreiber)")
|
||||||
|
}
|
||||||
|
if _, err := fs.GetUserByEmail(context.Background(), "gut@example.com"); err != nil {
|
||||||
|
t.Error("expected the valid row to still be imported despite the earlier invalid row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSVImportOhneDateiZeigtFehler(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatalf("mw.Close: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/verwaltung/nutzer/import", &body)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.AddCookie(adminCookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "CSV-Datei auswählen") {
|
||||||
|
t.Errorf("expected an error asking for a file, got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessCSVImport(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer/import")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/web/csv_import_parse_test.go
Normal file
64
internal/web/csv_import_parse_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterMitRolle(t *testing.T) {
|
||||||
|
csv := "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 2 {
|
||||||
|
t.Fatalf("zeilen = %+v, want 2", zeilen)
|
||||||
|
}
|
||||||
|
if zeilen[0].Email != "lisa@example.com" || zeilen[0].Role != "verantwortlicher" {
|
||||||
|
t.Errorf("zeilen[0] = %+v, unerwartet", zeilen[0])
|
||||||
|
}
|
||||||
|
if zeilen[1].Email != "tom@example.com" || zeilen[1].Role != "mitarbeiter" {
|
||||||
|
t.Errorf("zeilen[1] = %+v, want default role mitarbeiter", zeilen[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterOhneRollenspalte(t *testing.T) {
|
||||||
|
csv := "email\nlisa@example.com\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 1 || zeilen[0].Role != "mitarbeiter" {
|
||||||
|
t.Fatalf("zeilen = %+v, unerwartet", zeilen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterUeberspringtLeereEmail(t *testing.T) {
|
||||||
|
csv := "email,role\n,mitarbeiter\nlisa@example.com,mitarbeiter\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 1 || zeilen[0].Email != "lisa@example.com" {
|
||||||
|
t.Fatalf("zeilen = %+v, want exactly lisa@example.com", zeilen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterOhneEmailSpalteSchlaegtFehl(t *testing.T) {
|
||||||
|
csv := "name,role\nLisa,mitarbeiter\n"
|
||||||
|
_, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when the header has no email column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterZeilennummernZaehlenKopfzeileAlsEins(t *testing.T) {
|
||||||
|
csv := "email\nlisa@example.com\ntom@example.com\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 2 || zeilen[0].Zeile != 2 || zeilen[1].Zeile != 3 {
|
||||||
|
t.Fatalf("zeilen = %+v, want Zeile 2 and 3", zeilen)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/web/einladung_handlers.go
Normal file
140
internal/web/einladung_handlers.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// Ebene 1 (öffentlich) — Mitarbeiter-Selbstanmeldung über den
|
||||||
|
// Sammellink eines Mandanten ("Einladung annehmen", siehe CLAUDE.md,
|
||||||
|
// Onboarding: "Einladungslink [Sammellink, Selbstanmeldung, Abteilung
|
||||||
|
// beim ersten Antrag]"). Ein Link gilt für beliebig viele Mitarbeiter
|
||||||
|
// desselben Mandanten, kein Einmal-Token pro Person — die Abteilung
|
||||||
|
// wählt der/die Mitarbeiter:in erst beim ersten Antrag (Fragebogen
|
||||||
|
// Abschnitt A), nicht hier.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type einladungFormData struct {
|
||||||
|
Title string
|
||||||
|
Firmenname string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEinladungForm zeigt das Selbstanmelde-Formular für einen
|
||||||
|
// gültigen Einladungslink. Ein unbekanntes oder erneuertes (damit
|
||||||
|
// ungültig gewordenes) Token bekommt dieselbe Fehlermeldung wie ein
|
||||||
|
// abgelaufener Link — es wird nicht unterschieden, ob das Token nie
|
||||||
|
// existiert hat oder nur nicht mehr gültig ist.
|
||||||
|
func (s *Server) handleEinladungForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
|
||||||
|
if err != nil {
|
||||||
|
s.renderEinladungError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderEinladungForm(w, acc.Name, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderEinladungForm(w http.ResponseWriter, firmenname, errMsg string) {
|
||||||
|
data := einladungFormData{Title: "Einladung annehmen", Firmenname: firmenname, Error: errMsg}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "einladung", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderEinladungError(w http.ResponseWriter) {
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "einladung-ungueltig", authPageData{Title: "Einladung"}); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEinladungAnnehmen legt einen neuen Login mit Rolle
|
||||||
|
// "mitarbeiter" im Mandanten des Einladungslinks an und meldet die
|
||||||
|
// Person direkt an — wie bei der Firma-Registrierung gibt es keine
|
||||||
|
// E-Mail-Bestätigung (noch nicht gebaut, siehe CLAUDE.md, Onboarding).
|
||||||
|
func (s *Server) handleEinladungAnnehmen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
|
||||||
|
if err != nil {
|
||||||
|
s.renderEinladungError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.renderEinladungForm(w, acc.Name, "ungültiges Formular")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if email == "" || password == "" {
|
||||||
|
s.renderEinladungForm(w, acc.Name, "E-Mail und Passwort sind Pflicht")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
s.renderEinladungForm(w, acc.Name, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.store.CreateUser(r.Context(), acc.ID, email, passwordHash, "mitarbeiter")
|
||||||
|
if err != nil {
|
||||||
|
s.renderEinladungForm(w, acc.Name, "Konto konnte nicht angelegt werden — E-Mail evtl. schon vergeben")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.startSession(w, r, user.ID); err != nil {
|
||||||
|
s.renderEinladungForm(w, acc.Name, "Sitzung konnte nicht gestartet werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
type einladungVerwaltungData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Link string
|
||||||
|
}
|
||||||
|
|
||||||
|
// einladungBasisURL leitet Schema+Host aus dem Request ab, damit der
|
||||||
|
// vollständige Link (nicht nur das Token) angezeigt werden kann — hinter
|
||||||
|
// einem Reverse-Proxy (siehe CLAUDE.md, Testsystem: nginx+TLS) trägt
|
||||||
|
// r.TLS bei HTTPS-Terminierung am Proxy nichts, deshalb zusätzlich
|
||||||
|
// X-Forwarded-Proto berücksichtigen.
|
||||||
|
func einladungBasisURL(r *http.Request) string {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return scheme + "://" + r.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEinladungVerwaltung zeigt den aktuellen Sammellink des eigenen
|
||||||
|
// Mandanten zum Kopieren.
|
||||||
|
func (s *Server) handleEinladungVerwaltung(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Account konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := einladungVerwaltungData{
|
||||||
|
Title: "Einladungslink", Nav: navFor(r),
|
||||||
|
Link: einladungBasisURL(r) + "/einladung/" + acc.EinladungToken,
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "einladung-verwaltung", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEinladungErneuern ersetzt den Sammellink des eigenen Mandanten
|
||||||
|
// — der alte Link wird sofort ungültig (z. B. wenn er versehentlich
|
||||||
|
// außerhalb des Unternehmens geteilt wurde).
|
||||||
|
func (s *Server) handleEinladungErneuern(w http.ResponseWriter, r *http.Request) {
|
||||||
|
newToken, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Neuer Link konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.RegenerateEinladungToken(r.Context(), currentUser(r).AccountID, newToken); err != nil {
|
||||||
|
http.Error(w, "Link konnte nicht erneuert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/einladung", http.StatusSeeOther)
|
||||||
|
}
|
||||||
146
internal/web/einladung_handlers_test.go
Normal file
146
internal/web/einladung_handlers_test.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEinladungFormZeigtFirmenname(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "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(), store.AccountInput{Name: "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
207
internal/web/email_vorlage_handlers.go
Normal file
207
internal/web/email_vorlage_handlers.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
|
||||||
|
// der Betreiber pflegt den plattformweiten Standard
|
||||||
|
// (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
|
||||||
|
// übersteuern (/verwaltung/email-vorlagen). emailVorlageTypen ist die
|
||||||
|
// feste, bekannte Liste der vom System versendeten E-Mails — aktuell
|
||||||
|
// nur Passwort-Zurücksetzen, die einzige E-Mail, die es bisher gibt,
|
||||||
|
// aber bewusst als Liste-mit-Bearbeiten-Seite gebaut (wie Werkzeugkatalog/
|
||||||
|
// Nutzerverwaltung), nicht als eine Seite mit allen Formularen gestapelt
|
||||||
|
// — mit der Zeit kommen weitere Benachrichtigungstypen dazu.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type emailVorlageTypInfo struct {
|
||||||
|
Typ string
|
||||||
|
Label string
|
||||||
|
Platzhalter string
|
||||||
|
}
|
||||||
|
|
||||||
|
var emailVorlageTypen = []emailVorlageTypInfo{
|
||||||
|
{
|
||||||
|
Typ: "passwort_zuruecksetzen",
|
||||||
|
Label: "Passwort zurücksetzen",
|
||||||
|
Platzhalter: "{{link}} — der Zurücksetzen-Link, wird beim Versand eingesetzt.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func findEmailVorlageTyp(typ string) (emailVorlageTypInfo, bool) {
|
||||||
|
for _, t := range emailVorlageTypen {
|
||||||
|
if t.Typ == typ {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return emailVorlageTypInfo{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderEmailVorlage ersetzt die bekannten Platzhalter im Text einer
|
||||||
|
// Vorlage — aktuell nur {{link}} (Passwort-Zurücksetzen-Link).
|
||||||
|
func renderEmailVorlage(text, link string) string {
|
||||||
|
return strings.ReplaceAll(text, "{{link}}", link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mandant (Ebene 4, admin-only): eigene Übersteuerung ───────────────
|
||||||
|
|
||||||
|
type emailVorlageZeile struct {
|
||||||
|
Typ string
|
||||||
|
Label string
|
||||||
|
IstUebersteuert bool // eigene Vorlage vorhanden statt Plattform-Standard geerbt
|
||||||
|
}
|
||||||
|
|
||||||
|
type emailVorlagenListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Zeilen []emailVorlageZeile
|
||||||
|
ActionBase string // unterscheidet Mandant- von Betreiber-Ziel-URL im gemeinsamen Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
data := emailVorlagenListeData{Title: "E-Mail-Vorlagen", Nav: navFor(r), ActionBase: "/verwaltung/email-vorlagen"}
|
||||||
|
for _, t := range emailVorlageTypen {
|
||||||
|
_, err := s.store.GetEmailVorlage(r.Context(), &accountID, t.Typ)
|
||||||
|
data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label, IstUebersteuert: err == nil})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type emailVorlageBearbeitenData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Typ string
|
||||||
|
Label string
|
||||||
|
Platzhalter string
|
||||||
|
Betreff string
|
||||||
|
Text string
|
||||||
|
IstUebersteuert bool
|
||||||
|
ActionBase string
|
||||||
|
Error string
|
||||||
|
Gespeichert bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
typ := r.PathValue("typ")
|
||||||
|
info, ok := findEmailVorlageTyp(typ)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
resolved, err := s.store.ResolveEmailVorlage(r.Context(), accountID, typ)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, uebersteuert := s.store.GetEmailVorlage(r.Context(), &accountID, typ)
|
||||||
|
data := emailVorlageBearbeitenData{
|
||||||
|
Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter,
|
||||||
|
Betreff: resolved.Betreff, Text: resolved.Text, IstUebersteuert: uebersteuert == nil,
|
||||||
|
ActionBase: "/verwaltung/email-vorlagen", Gespeichert: r.URL.Query().Get("gespeichert") == "1",
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
|
||||||
|
typ := r.PathValue("typ")
|
||||||
|
if _, ok := findEmailVorlageTyp(typ); !ok {
|
||||||
|
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
betreff := strings.TrimSpace(r.FormValue("betreff"))
|
||||||
|
text := strings.TrimSpace(r.FormValue("text"))
|
||||||
|
if betreff == "" || text == "" {
|
||||||
|
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpsertEmailVorlage(r.Context(), &accountID, typ, betreff, text); err != nil {
|
||||||
|
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEmailVorlageZuruecksetzen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
typ := r.PathValue("typ")
|
||||||
|
if _, ok := findEmailVorlageTyp(typ); !ok {
|
||||||
|
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
if err := s.store.DeleteEmailVorlage(r.Context(), &accountID, typ); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.Error(w, "Zurücksetzen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Betreiber (Ebene 5): plattformweiter Standard ─────────────────────
|
||||||
|
|
||||||
|
func (s *Server) handleBetreiberEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := emailVorlagenListeData{Title: "E-Mail-Vorlagen (Plattform-Standard)", Nav: navFor(r), ActionBase: "/betreiber/email-vorlagen"}
|
||||||
|
for _, t := range emailVorlageTypen {
|
||||||
|
data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBetreiberEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
typ := r.PathValue("typ")
|
||||||
|
info, ok := findEmailVorlageTyp(typ)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := s.store.GetEmailVorlage(r.Context(), nil, typ)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := emailVorlageBearbeitenData{
|
||||||
|
Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter,
|
||||||
|
Betreff: v.Betreff, Text: v.Text, ActionBase: "/betreiber/email-vorlagen",
|
||||||
|
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBetreiberEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
|
||||||
|
typ := r.PathValue("typ")
|
||||||
|
if _, ok := findEmailVorlageTyp(typ); !ok {
|
||||||
|
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
betreff := strings.TrimSpace(r.FormValue("betreff"))
|
||||||
|
text := strings.TrimSpace(r.FormValue("text"))
|
||||||
|
if betreff == "" || text == "" {
|
||||||
|
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpsertEmailVorlage(r.Context(), nil, typ, betreff, text); err != nil {
|
||||||
|
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/betreiber/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
113
internal/web/email_vorlage_handlers_test.go
Normal file
113
internal/web/email_vorlage_handlers_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminKannEigeneEmailVorlageSpeichernUndZuruecksetzen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
listResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
|
||||||
|
if listResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Liste-Status = %d, want 200", listResp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(listResp.Body.String(), "/verwaltung/email-vorlagen/passwort_zuruecksetzen") {
|
||||||
|
t.Fatalf("erwartet Link zur Bearbeiten-Seite, body: %s", listResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
getResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
|
||||||
|
if getResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET Bearbeiten-Seite status = %d, want 200", getResp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(getResp.Body.String(), "Plattform-Standard, nicht angepasst") {
|
||||||
|
t.Fatalf("erwartet Hinweis auf geerbten Plattform-Standard, body: %s", getResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
saveResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
|
||||||
|
"betreff": {"Mein eigener Betreff"}, "text": {"Mein eigener Text {{link}}"},
|
||||||
|
})
|
||||||
|
if saveResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
afterResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
|
||||||
|
body := afterResp.Body.String()
|
||||||
|
if !strings.Contains(body, "Mein eigener Betreff") {
|
||||||
|
t.Fatalf("gespeicherter Betreff fehlt, body: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "(eigene Vorlage)") {
|
||||||
|
t.Fatalf("erwartet Hinweis auf eigene Vorlage, body: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen/zuruecksetzen", url.Values{})
|
||||||
|
if resetResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("reset status = %d, want 303", resetResp.Code)
|
||||||
|
}
|
||||||
|
finalResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
|
||||||
|
if strings.Contains(finalResp.Body.String(), "Mein eigener Betreff") {
|
||||||
|
t.Fatal("nach Zurücksetzen ist die eigene Vorlage immer noch aktiv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberKannPlattformStandardBearbeiten(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Betreiber-Firma", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
saveResp := postForm(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen", url.Values{
|
||||||
|
"betreff": {"Neuer Plattform-Betreff"}, "text": {"Neuer Plattform-Text {{link}}"},
|
||||||
|
})
|
||||||
|
if saveResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
getResp := getWithCookie(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen")
|
||||||
|
if !strings.Contains(getResp.Body.String(), "Neuer Plattform-Betreff") {
|
||||||
|
t.Fatalf("neuer Plattform-Standard fehlt, body: %s", getResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNichtAdminKannEmailVorlagenNichtBearbeiten(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/email-vorlagen")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswortVergessenNutztMandantenspezifischeVorlage(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s, fm := newServerWithMailer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Vorlage-Firma", "vorlage-admin@example.com", "admin")
|
||||||
|
|
||||||
|
if resp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
|
||||||
|
"betreff": {"Firmenspezifischer Betreff"}, "text": {"Hallo! Link: {{link}} Ende."},
|
||||||
|
}); resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("save status = %d, want 303", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"vorlage-admin@example.com"}}); resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("passwort-vergessen status = %d, want 200", resp.Code)
|
||||||
|
}
|
||||||
|
if len(fm.Sent) != 1 {
|
||||||
|
t.Fatalf("got %d gesendete Mails, want 1", len(fm.Sent))
|
||||||
|
}
|
||||||
|
if fm.Sent[0].Subject != "Firmenspezifischer Betreff" {
|
||||||
|
t.Fatalf("Subject = %q, want die firmenspezifische Vorlage", fm.Sent[0].Subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fm.Sent[0].Body, "Hallo! Link: ") || strings.Contains(fm.Sent[0].Body, "{{link}}") {
|
||||||
|
t.Fatalf("Body = %q, want Platzhalter ersetzt und Rest der eigenen Vorlage erhalten", fm.Sent[0].Body)
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`/passwort-zuruecksetzen/[a-f0-9]+`).MatchString(fm.Sent[0].Body) {
|
||||||
|
t.Fatalf("kein Reset-Link im Body gefunden: %q", fm.Sent[0].Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
@@ -19,9 +22,11 @@ type fallListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fallListData struct {
|
type fallListData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Faelle []fallListItem
|
Faelle []fallListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status
|
// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status
|
||||||
@@ -33,16 +38,25 @@ func (s *Server) handleFaelleListe(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := fallListData{Title: "Posteingang", Nav: navFor(r)}
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var offene []fallListItem
|
||||||
for _, a := range antraege {
|
for _, a := range antraege {
|
||||||
if a.Status != "eingereicht" {
|
if a.Status != "eingereicht" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
data.Faelle = append(data.Faelle, fallListItem{
|
if !matchesQuery(q, a.Titel, a.Haeufigkeit) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
offene = append(offene, fallListItem{
|
||||||
ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit,
|
ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit,
|
||||||
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
pag := paginationFromRequest(r, len(offene))
|
||||||
|
data := fallListData{
|
||||||
|
Title: "Posteingang", Nav: navFor(r),
|
||||||
|
Faelle: paginate(offene, pag), Suche: q, Pagination: pag,
|
||||||
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -71,13 +85,30 @@ 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
|
||||||
WerkzeugOptionen []werkzeugOption
|
WerkzeugOptionen []werkzeugOption
|
||||||
|
Freigabeschritte []freigabeschrittView
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// freigabeschrittView zeigt den Stand eines einzelnen, zusätzlich zur
|
||||||
|
// Fachebene-Entscheidung nötigen Freigabeschritts an.
|
||||||
|
type freigabeschrittView struct {
|
||||||
|
RolleName string
|
||||||
|
Status string
|
||||||
|
Kommentar 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 +133,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 +151,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 {
|
||||||
@@ -132,6 +168,24 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
data.Entscheidung = v
|
data.Entscheidung = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nicht auf Status "wartet_auf_freigabe" beschränkt: die Freigabehistorie
|
||||||
|
// bleibt auch nach vollständiger Entscheidung sichtbar (Grundregel: eine
|
||||||
|
// Entscheidung wird nie ohne vollständige Herleitung angezeigt). Für
|
||||||
|
// Anträge ohne ausgelöste Freigabe-Regel liefert die Abfrage einfach
|
||||||
|
// eine leere Liste, das Template blendet den Abschnitt dann aus.
|
||||||
|
schritte, err := s.store.ListFreigabeschritteForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err == nil {
|
||||||
|
for _, schritt := range schritte {
|
||||||
|
rolleName := ""
|
||||||
|
if rolle, err := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID); err == nil {
|
||||||
|
rolleName = rolle.Name
|
||||||
|
}
|
||||||
|
data.Freigabeschritte = append(data.Freigabeschritte, freigabeschrittView{
|
||||||
|
RolleName: rolleName, Status: schritt.Status, Kommentar: schritt.Kommentar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -147,13 +201,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
|
||||||
}
|
}
|
||||||
@@ -192,11 +247,12 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen"
|
||||||
in := store.EntscheidungInput{
|
in := store.EntscheidungInput{
|
||||||
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
||||||
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
||||||
}
|
}
|
||||||
if entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen" {
|
if istGenehmigung {
|
||||||
zulaessig := false
|
zulaessig := false
|
||||||
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
||||||
if id == werkzeugID {
|
if id == werkzeugID {
|
||||||
@@ -219,10 +275,44 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
|||||||
in.GueltigBis = &gueltigBis
|
in.GueltigBis = &gueltigBis
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.store.CreateEntscheidung(r.Context(), in); err != nil {
|
entscheidung, err := s.store.CreateEntscheidung(r.Context(), in)
|
||||||
|
if err != nil {
|
||||||
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Konfigurierbarer Mehrfach-Freigabe-Workflow (siehe CLAUDE.md): eine
|
||||||
|
// Genehmigung ist nicht zwangsläufig sofort final — löst die
|
||||||
|
// Bewertung eine Freigabe-Regel des Mandanten aus (z. B.
|
||||||
|
// dsfa_erforderlich → Datenschutzbeauftragter), wartet der Antrag
|
||||||
|
// erst auf diese zusätzlichen Freigaben, bevor SetAntragStatus auf
|
||||||
|
// "entschieden" geht und ein Registereintrag entsteht.
|
||||||
|
if istGenehmigung {
|
||||||
|
regeln, err := s.store.ListFreigabeRegelnForAccount(r.Context(), user.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Freigabe-Regeln konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rollenIDs := triggeredGenehmigerRollen(regeln, bewertung); len(rollenIDs) > 0 {
|
||||||
|
for _, rolleID := range rollenIDs {
|
||||||
|
if _, err := s.store.CreateFreigabeschritt(r.Context(), antrag.ID, rolleID); err != nil {
|
||||||
|
http.Error(w, "Freigabeschritt konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "wartet_auf_freigabe"); err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "antrag_wartet_auf_freigabe", "antrag", antrag.ID, entscheidungTyp); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
||||||
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -231,6 +321,76 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if istGenehmigung {
|
||||||
|
if err := s.registriereGenehmigung(r.Context(), antrag, entscheidung, bewertung); err != nil {
|
||||||
|
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registriereGenehmigung schließt eine (ggf. erst nach zusätzlichen
|
||||||
|
// Freigaben) endgültig genehmigte Entscheidung ab: Registereintrag
|
||||||
|
// anlegen (siehe CLAUDE.md — Antragstatus ist an dieser Stelle bereits
|
||||||
|
// auf "entschieden" gesetzt). Wiederverwendet von handleFallEntscheiden
|
||||||
|
// (keine zusätzliche Freigabe nötig) und handleFreigabeEntscheiden
|
||||||
|
// (letzter ausstehender Freigabeschritt wurde soeben genehmigt).
|
||||||
|
func (s *Server) registriereGenehmigung(ctx context.Context, antrag store.Antrag, entscheidung store.Entscheidung, bewertung store.Bewertung) error {
|
||||||
|
abteilungName := ""
|
||||||
|
if antrag.AbteilungID != nil {
|
||||||
|
if abt, err := s.store.GetAbteilung(ctx, *antrag.AbteilungID); err == nil {
|
||||||
|
abteilungName = abt.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
werkzeugName := ""
|
||||||
|
if entscheidung.WerkzeugSnapshot != nil {
|
||||||
|
werkzeugName = entscheidung.WerkzeugSnapshot.Name
|
||||||
|
}
|
||||||
|
verantwortlicherEmail := ""
|
||||||
|
if u, err := s.store.GetUser(ctx, entscheidung.EntscheiderUserID); err == nil {
|
||||||
|
verantwortlicherEmail = u.Email
|
||||||
|
}
|
||||||
|
var auflagen []string
|
||||||
|
for _, a := range bewertung.Anforderungen {
|
||||||
|
auflagen = append(auflagen, a.Beschreibung)
|
||||||
|
}
|
||||||
|
_, err := s.store.CreateRegistereintrag(ctx, store.RegistereintragInput{
|
||||||
|
AccountID: antrag.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
|
||||||
|
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
|
||||||
|
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
|
||||||
|
Verantwortlicher: verantwortlicherEmail, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggeredGenehmigerRollen liefert die eindeutigen Genehmiger-Rollen,
|
||||||
|
// deren Freigabe-Regel durch diese Bewertung ausgelöst wird — Bedingung
|
||||||
|
// ist eine der vom Regelwerk bereits abgeleiteten Größen (Anforderungs-
|
||||||
|
// ID, Einstufung, Datenklasse), kein freier Regel-Editor.
|
||||||
|
func triggeredGenehmigerRollen(regeln []store.FreigabeRegel, bewertung store.Bewertung) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, regel := range regeln {
|
||||||
|
var treffer bool
|
||||||
|
switch regel.BedingungTyp {
|
||||||
|
case "anforderung":
|
||||||
|
for _, a := range bewertung.Anforderungen {
|
||||||
|
if a.ID == regel.BedingungWert {
|
||||||
|
treffer = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "einstufung":
|
||||||
|
treffer = bewertung.Einstufung == regel.BedingungWert
|
||||||
|
case "datenklasse":
|
||||||
|
treffer = bewertung.Datenklasse == regel.BedingungWert
|
||||||
|
}
|
||||||
|
if treffer && !seen[regel.GenehmigerRolleID] {
|
||||||
|
seen[regel.GenehmigerRolleID] = true
|
||||||
|
out = append(out, regel.GenehmigerRolleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
|
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant")
|
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: "Fachebene-Mandant"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen.
|
// Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen.
|
||||||
otherAcc, err := fs.CreateAccount(context.Background(), "Anderer Mandant")
|
otherAcc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Anderer Mandant"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
@@ -152,7 +174,7 @@ func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(t *testi
|
|||||||
|
|
||||||
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
|
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
acc, err := fs.CreateAccount(context.Background(), "Mit Katalog")
|
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Mit Katalog"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -203,7 +225,7 @@ func TestFallDetailRejectsForeignAccount(t *testing.T) {
|
|||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher")
|
antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
otherAcc, err := fs.CreateAccount(context.Background(), "Fremder Mandant")
|
otherAcc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Fremder Mandant"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
74
internal/web/firmendaten_handlers.go
Normal file
74
internal/web/firmendaten_handlers.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Firmendaten (Migration 0023) — Adress- und Abrechnungsdaten des
|
||||||
|
// eigenen Mandanten einsehen/pflegen. Bei der Neuanlage (Registrierung,
|
||||||
|
// Betreiber-Firmenanlage) sind diese Felder Pflicht; bestehende, davor
|
||||||
|
// angelegte Accounts können sie hier nachtragen.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type firmendatenData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Name string
|
||||||
|
Strasse string
|
||||||
|
PLZ string
|
||||||
|
Ort string
|
||||||
|
Land string
|
||||||
|
UStID string
|
||||||
|
Rechnungsemail string
|
||||||
|
Error string
|
||||||
|
Gespeichert bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFirmendaten(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Firmendaten konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := firmendatenData{
|
||||||
|
Title: "Firmendaten", Nav: navFor(r),
|
||||||
|
Name: acc.Name, Strasse: acc.Strasse, PLZ: acc.PLZ, Ort: acc.Ort,
|
||||||
|
Land: acc.Land, UStID: acc.UStID, Rechnungsemail: acc.Rechnungsemail,
|
||||||
|
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "firmendaten", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFirmendatenSpeichern(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in := store.AccountInput{
|
||||||
|
Name: r.FormValue("account_name"),
|
||||||
|
Strasse: r.FormValue("strasse"),
|
||||||
|
PLZ: r.FormValue("plz"),
|
||||||
|
Ort: r.FormValue("ort"),
|
||||||
|
Land: r.FormValue("land"),
|
||||||
|
UStID: r.FormValue("ust_id"),
|
||||||
|
Rechnungsemail: r.FormValue("rechnungsemail"),
|
||||||
|
}
|
||||||
|
if in.Name == "" || in.Strasse == "" || in.PLZ == "" || in.Ort == "" || in.Land == "" || in.Rechnungsemail == "" {
|
||||||
|
data := firmendatenData{
|
||||||
|
Title: "Firmendaten", Nav: navFor(r), Error: "Alle Felder außer USt-IdNr. sind Pflicht",
|
||||||
|
Name: in.Name, Strasse: in.Strasse, PLZ: in.PLZ, Ort: in.Ort, Land: in.Land,
|
||||||
|
UStID: in.UStID, Rechnungsemail: in.Rechnungsemail,
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "firmendaten", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpdateAccountDetails(r.Context(), currentUser(r).AccountID, in); err != nil {
|
||||||
|
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/firma?gespeichert=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
87
internal/web/firmendaten_handlers_test.go
Normal file
87
internal/web/firmendaten_handlers_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegisterRequiresFirmenadresse(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/register", url.Values{
|
||||||
|
"account_name": {"Unvollständig GmbH"}, "email": {"unvollstaendig@example.com"}, "password": {"ein-sicheres-passwort"},
|
||||||
|
// Adresse/Rechnungsemail fehlen bewusst.
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Pflicht") {
|
||||||
|
t.Fatalf("erwartet Validierungsfehler, body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterSpeichertFirmendaten(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/register", mergeValues(url.Values{
|
||||||
|
"account_name": {"Vollständig GmbH"}, "email": {"admin@vollstaendig.example.com"}, "password": {"ein-sicheres-passwort"},
|
||||||
|
}, firmenPflichtfelder()))
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := fs.GetUserByEmail(context.Background(), "admin@vollstaendig.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
acc, err := fs.GetAccount(context.Background(), user.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if acc.Strasse != "Teststraße 1" || acc.PLZ != "12345" || acc.Rechnungsemail != "rechnung@example.com" {
|
||||||
|
t.Fatalf("Account = %+v, Firmendaten nicht wie erwartet gespeichert", acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminKannFirmendatenBearbeiten(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Alte Firma", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
getResp := getWithCookie(t, s, cookie, "/verwaltung/firma")
|
||||||
|
if getResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET status = %d, want 200", getResp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(getResp.Body.String(), "Alte Firma") {
|
||||||
|
t.Fatalf("erwartet aktuellen Firmennamen im Formular, body: %s", getResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
saveResp := postForm(t, s, cookie, "/verwaltung/firma", url.Values{
|
||||||
|
"account_name": {"Neue Firma"}, "strasse": {"Neue Straße 5"}, "plz": {"99999"}, "ort": {"Neustadt"},
|
||||||
|
"land": {"Deutschland"}, "rechnungsemail": {"buchhaltung@neue-firma.example.com"},
|
||||||
|
})
|
||||||
|
if saveResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
afterResp := getWithCookie(t, s, cookie, "/verwaltung/firma")
|
||||||
|
if !strings.Contains(afterResp.Body.String(), "Neue Firma") || !strings.Contains(afterResp.Body.String(), "Neustadt") {
|
||||||
|
t.Fatalf("gespeicherte Firmendaten fehlen, body: %s", afterResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNichtAdminKannFirmendatenNichtBearbeiten(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/firma")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
493
internal/web/freigabe_handlers.go
Normal file
493
internal/web/freigabe_handlers.go
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
|
||||||
|
// und CLAUDE.md. Drei Seitengruppen:
|
||||||
|
// - Admin (Ebene 4): Genehmiger-Rollen anlegen/Mitglieder zuweisen,
|
||||||
|
// Freigabe-Regeln definieren ("wenn Bedingung X, dann Freigabe
|
||||||
|
// durch Rolle Y nötig").
|
||||||
|
// - Jeder angemeldete Nutzer: "Meine Freigaben" — eigene ausstehende
|
||||||
|
// Freigabeschritte, unabhängig von app_user.role (eine
|
||||||
|
// Genehmiger-Rolle ist orthogonal zur Zugriffsrolle).
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// standardGenehmigerRollen wird bei jeder neuen Firma automatisch angelegt
|
||||||
|
// (ohne Mitglieder/Freigabe-Regeln) — der Admin muss nur noch Personen
|
||||||
|
// zuordnen und bei Bedarf Freigabe-Regeln ergänzen, statt bei null
|
||||||
|
// anzufangen. Die Beschreibung ist reine Orientierungshilfe im UI; welche
|
||||||
|
// Bedingung welche Rolle tatsächlich auslöst, entscheidet weiterhin jede
|
||||||
|
// Firma selbst über /verwaltung/freigabe-regeln — das System schreibt
|
||||||
|
// keine fachliche Bindung vor.
|
||||||
|
var standardGenehmigerRollen = []struct {
|
||||||
|
Name string
|
||||||
|
Beschreibung string
|
||||||
|
}{
|
||||||
|
{"Datenschutzbeauftragter", "Prüft Anträge mit besonderen Datenschutzanforderungen, z. B. wenn eine Datenschutz-Folgenabschätzung erforderlich ist."},
|
||||||
|
{"Geschäftsführer", "Trifft die unternehmerische Entscheidung bei besonders risikobehafteten Vorhaben."},
|
||||||
|
{"KI-Manager", "Bewertet den Antrag fachlich."},
|
||||||
|
{"CISO", "Prüft den Antrag aus Sicherheitssicht (Auditor)."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedStandardGenehmigerRollen wird direkt nach dem Anlegen einer neuen
|
||||||
|
// Firma aufgerufen (öffentliche Registrierung und Betreiber-Firmenanlage).
|
||||||
|
func (s *Server) seedStandardGenehmigerRollen(ctx context.Context, accountID string) error {
|
||||||
|
for _, r := range standardGenehmigerRollen {
|
||||||
|
if _, err := s.store.CreateGenehmigerRolle(ctx, accountID, r.Name, r.Beschreibung); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin: Genehmiger-Rollen ───────────────────────────────────────
|
||||||
|
|
||||||
|
type genehmigerRolleItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Beschreibung string
|
||||||
|
Mitglieder []nutzerOption
|
||||||
|
}
|
||||||
|
|
||||||
|
type nutzerOption struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
type genehmigerRollenListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Rollen []genehmigerRolleItem
|
||||||
|
Nutzer []nutzerOption
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGenehmigerRollenListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
rollen, err := s.store.ListGenehmigerRollenForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := s.store.ListUsersForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := genehmigerRollenListeData{Title: "Genehmiger-Rollen", Nav: navFor(r)}
|
||||||
|
for _, u := range users {
|
||||||
|
data.Nutzer = append(data.Nutzer, nutzerOption{ID: u.ID, Email: u.Email})
|
||||||
|
}
|
||||||
|
for _, g := range rollen {
|
||||||
|
item := genehmigerRolleItem{ID: g.ID, Name: g.Name, Beschreibung: g.Beschreibung}
|
||||||
|
mitglieder, err := s.store.ListNutzerForGenehmigerRolle(r.Context(), g.ID)
|
||||||
|
if err == nil {
|
||||||
|
for _, m := range mitglieder {
|
||||||
|
item.Mitglieder = append(item.Mitglieder, nutzerOption{ID: m.UserID, Email: m.Email})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.Rollen = append(data.Rollen, item)
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "genehmiger-rollen-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGenehmigerRolleCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
beschreibung := strings.TrimSpace(r.FormValue("beschreibung"))
|
||||||
|
if _, err := s.store.CreateGenehmigerRolle(r.Context(), currentUser(r).AccountID, name, beschreibung); err != nil {
|
||||||
|
http.Error(w, "Genehmiger-Rolle konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGenehmigerRolleDelete entfernt eine Genehmiger-Rolle. Solange
|
||||||
|
// noch Mitglieder zugewiesen sind oder eine Freigabe-Regel darauf
|
||||||
|
// verweist, schlägt das per Fremdschlüssel fehl — bewusst kein CASCADE,
|
||||||
|
// ein stillschweigendes Mit-Löschen wirksamer Freigabe-Regeln wäre
|
||||||
|
// überraschend und sicherheitsrelevant.
|
||||||
|
func (s *Server) handleGenehmigerRolleDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
rolle, err := s.store.GetGenehmigerRolle(r.Context(), id)
|
||||||
|
if err != nil || rolle.AccountID != currentUser(r).AccountID {
|
||||||
|
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteGenehmigerRolle(r.Context(), id); err != nil {
|
||||||
|
http.Error(w, "Genehmiger-Rolle wird noch verwendet (Mitglieder oder Freigabe-Regeln) und kann nicht gelöscht werden", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGenehmigerRolleMitgliedHinzufuegen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
rolleID := r.PathValue("id")
|
||||||
|
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
|
||||||
|
if err != nil || rolle.AccountID != accountID {
|
||||||
|
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := r.FormValue("user_id")
|
||||||
|
target, err := s.store.GetUser(r.Context(), userID)
|
||||||
|
if err != nil || target.AccountID != accountID {
|
||||||
|
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.AddNutzerGenehmigerRolle(r.Context(), userID, rolleID); err != nil {
|
||||||
|
http.Error(w, "Zuweisung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGenehmigerRolleMitgliedEntfernen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
rolleID := r.PathValue("id")
|
||||||
|
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
|
||||||
|
if err != nil || rolle.AccountID != accountID {
|
||||||
|
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := r.PathValue("userID")
|
||||||
|
if err := s.store.RemoveNutzerGenehmigerRolle(r.Context(), userID, rolleID); err != nil {
|
||||||
|
http.Error(w, "Entfernen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin: Freigabe-Regeln ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// bedingungOption ist ein Auswahlpunkt im Freigabe-Regel-Formular —
|
||||||
|
// Value kodiert "typ:wert" (z. B. "anforderung:dsfa_erforderlich"),
|
||||||
|
// damit ein einziges <select> ohne JavaScript reicht statt zweier
|
||||||
|
// voneinander abhängiger Dropdowns.
|
||||||
|
type bedingungOption struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
type bedingungGruppe struct {
|
||||||
|
Label string
|
||||||
|
Optionen []bedingungOption
|
||||||
|
}
|
||||||
|
|
||||||
|
type freigabeRegelItem struct {
|
||||||
|
ID string
|
||||||
|
BedingungLabel string
|
||||||
|
GenehmigerRolle string
|
||||||
|
}
|
||||||
|
|
||||||
|
type freigabeRegelnListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Regeln []freigabeRegelItem
|
||||||
|
BedingungGruppen []bedingungGruppe
|
||||||
|
RollenOptionen []store.GenehmigerRolle
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// bedingungGruppenAusRegelwerk baut die Freigabe-Regel-Bedingungsauswahl
|
||||||
|
// aus dem tatsächlich geladenen Regelwerk (nicht frei erfunden) — jede
|
||||||
|
// Anforderungs-/Einstufungs-/Datenklasse-ID, die das System ohnehin
|
||||||
|
// kennt, ist eine mögliche Bedingung.
|
||||||
|
func (s *Server) bedingungGruppenAusRegelwerk() []bedingungGruppe {
|
||||||
|
var anforderungen []bedingungOption
|
||||||
|
for _, a := range s.regelwerk.Anforderungen.Anforderungen {
|
||||||
|
label := a.ID
|
||||||
|
if a.Beschreibung != "" {
|
||||||
|
label = a.Beschreibung
|
||||||
|
}
|
||||||
|
anforderungen = append(anforderungen, bedingungOption{Value: "anforderung:" + a.ID, Label: label})
|
||||||
|
}
|
||||||
|
var einstufungen []bedingungOption
|
||||||
|
for _, e := range s.regelwerk.Einstufung.Stufen {
|
||||||
|
einstufungen = append(einstufungen, bedingungOption{Value: "einstufung:" + e.ID, Label: e.ID})
|
||||||
|
}
|
||||||
|
var datenklassen []bedingungOption
|
||||||
|
for _, d := range s.regelwerk.Datenklasse.Stufen {
|
||||||
|
datenklassen = append(datenklassen, bedingungOption{Value: "datenklasse:" + d.ID, Label: d.ID})
|
||||||
|
}
|
||||||
|
return []bedingungGruppe{
|
||||||
|
{Label: "Anforderung", Optionen: anforderungen},
|
||||||
|
{Label: "Einstufung", Optionen: einstufungen},
|
||||||
|
{Label: "Datenklasse", Optionen: datenklassen},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFreigabeRegelnListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
regeln, err := s.store.ListFreigabeRegelnForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Freigabe-Regeln konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rollen, err := s.store.ListGenehmigerRollenForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rollenName := map[string]string{}
|
||||||
|
for _, g := range rollen {
|
||||||
|
rollenName[g.ID] = g.Name
|
||||||
|
}
|
||||||
|
data := freigabeRegelnListeData{
|
||||||
|
Title: "Freigabe-Regeln", Nav: navFor(r),
|
||||||
|
BedingungGruppen: s.bedingungGruppenAusRegelwerk(), RollenOptionen: rollen,
|
||||||
|
}
|
||||||
|
for _, regel := range regeln {
|
||||||
|
data.Regeln = append(data.Regeln, freigabeRegelItem{
|
||||||
|
ID: regel.ID,
|
||||||
|
BedingungLabel: bedingungTypLabel(regel.BedingungTyp) + ": " + regel.BedingungWert,
|
||||||
|
GenehmigerRolle: rollenName[regel.GenehmigerRolleID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "freigabe-regeln-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bedingungTypLabel(typ string) string {
|
||||||
|
switch typ {
|
||||||
|
case "anforderung":
|
||||||
|
return "Anforderung"
|
||||||
|
case "einstufung":
|
||||||
|
return "Einstufung"
|
||||||
|
case "datenklasse":
|
||||||
|
return "Datenklasse"
|
||||||
|
default:
|
||||||
|
return typ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFreigabeRegelCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
teile := strings.SplitN(r.FormValue("bedingung"), ":", 2)
|
||||||
|
rolleID := r.FormValue("genehmiger_rolle_id")
|
||||||
|
if len(teile) != 2 || teile[0] == "" || teile[1] == "" || rolleID == "" {
|
||||||
|
http.Error(w, "Bedingung und Genehmiger-Rolle sind Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
typ, wert := teile[0], teile[1]
|
||||||
|
if typ != "anforderung" && typ != "einstufung" && typ != "datenklasse" {
|
||||||
|
http.Error(w, "ungültiger Bedingungstyp", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
|
||||||
|
if err != nil || rolle.AccountID != accountID {
|
||||||
|
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateFreigabeRegel(r.Context(), accountID, typ, wert, rolleID); err != nil {
|
||||||
|
http.Error(w, "Freigabe-Regel konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/freigabe-regeln", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFreigabeRegelDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
regel, err := s.store.GetFreigabeRegel(r.Context(), id)
|
||||||
|
if err != nil || regel.AccountID != currentUser(r).AccountID {
|
||||||
|
http.Error(w, "Freigabe-Regel nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteFreigabeRegel(r.Context(), id); err != nil {
|
||||||
|
http.Error(w, "Freigabe-Regel konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/freigabe-regeln", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Meine Freigaben (jeder angemeldete Nutzer) ─────────────────────
|
||||||
|
|
||||||
|
type freigabeItem struct {
|
||||||
|
ID string
|
||||||
|
AntragID string
|
||||||
|
AntragTitel string
|
||||||
|
RolleName string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type freigabenListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Freigaben []freigabeItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFreigabenListe zeigt die eigenen ausstehenden Freigabeschritte
|
||||||
|
// — unabhängig von app_user.role, denn eine Genehmiger-Rolle ist eine
|
||||||
|
// orthogonale Freigabe-Funktion, keine Zugriffsrolle.
|
||||||
|
func (s *Server) handleFreigabenListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUser(r)
|
||||||
|
schritte, err := s.store.ListAusstehendeFreigabeschritteForUser(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Freigaben konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := freigabenListeData{Title: "Meine Freigaben", Nav: navFor(r)}
|
||||||
|
for _, schritt := range schritte {
|
||||||
|
antrag, err := s.store.GetAntrag(r.Context(), schritt.AntragID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rolleName := ""
|
||||||
|
if rolle, err := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID); err == nil {
|
||||||
|
rolleName = rolle.Name
|
||||||
|
}
|
||||||
|
data.Freigaben = append(data.Freigaben, freigabeItem{
|
||||||
|
ID: schritt.ID, AntragID: antrag.ID, AntragTitel: antrag.Titel,
|
||||||
|
RolleName: rolleName, CreatedAt: schritt.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "freigaben-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFreigabeEntscheiden trägt die Entscheidung einer Person zu
|
||||||
|
// einem einzelnen Freigabeschritt ein. Genehmigt sie den letzten noch
|
||||||
|
// ausstehenden Schritt eines Antrags, wird die ursprüngliche Genehmigung
|
||||||
|
// jetzt final abgeschlossen (Registereintrag). Lehnt sie ab, kippt das
|
||||||
|
// den gesamten Antrag — alle anderen offenen Schritte desselben Antrags
|
||||||
|
// werden automatisch mit abgelehnt (KaskadiereAblehnung), und eine neue
|
||||||
|
// Entscheidung "abgelehnt" übersteuert die ursprüngliche Genehmigung
|
||||||
|
// (entscheidung ist append-only, siehe CLAUDE.md).
|
||||||
|
func (s *Server) handleFreigabeEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUser(r)
|
||||||
|
id := r.PathValue("id")
|
||||||
|
|
||||||
|
schritt, err := s.store.GetFreigabeschritt(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Freigabeschritt nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rollen, err := s.store.ListGenehmigerRollenForUser(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hatRolle := false
|
||||||
|
for _, rl := range rollen {
|
||||||
|
if rl.ID == schritt.GenehmigerRolleID {
|
||||||
|
hatRolle = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hatRolle {
|
||||||
|
http.Error(w, "Freigabeschritt nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if schritt.Status != "ausstehend" {
|
||||||
|
http.Error(w, "dieser Freigabeschritt wurde bereits entschieden", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entscheidungTyp := r.FormValue("entscheidung")
|
||||||
|
kommentar := r.FormValue("kommentar")
|
||||||
|
if entscheidungTyp != "genehmigt" && entscheidungTyp != "abgelehnt" {
|
||||||
|
http.Error(w, "ungültige Entscheidung", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.store.EntscheideFreigabeschritt(r.Context(), id, entscheidungTyp, user.ID, kommentar); err != nil {
|
||||||
|
http.Error(w, "Freigabeschritt konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
antrag, err := s.store.GetAntrag(r.Context(), schritt.AntragID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Antrag nicht gefunden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rolle, _ := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID)
|
||||||
|
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "freigabeschritt_entschieden", "antrag", antrag.ID, rolle.Name+": "+entscheidungTyp); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Bewertung nicht gefunden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if entscheidungTyp == "abgelehnt" {
|
||||||
|
if err := s.store.KaskadiereAblehnung(r.Context(), antrag.ID, id); err != nil {
|
||||||
|
http.Error(w, "Kaskadierung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
begruendung := "Freigabe durch " + rolle.Name + " wurde verweigert."
|
||||||
|
if kommentar != "" {
|
||||||
|
begruendung += " Kommentar: " + kommentar
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateEntscheidung(r.Context(), store.EntscheidungInput{
|
||||||
|
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
||||||
|
Entscheidung: "abgelehnt", Begruendung: begruendung,
|
||||||
|
}); err != nil {
|
||||||
|
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alle, err := s.store.ListFreigabeschritteForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Freigabeschritte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vollstaendig := true
|
||||||
|
for _, fs := range alle {
|
||||||
|
if fs.Status != "genehmigt" {
|
||||||
|
vollstaendig = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vollstaendig {
|
||||||
|
letzteEntscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Entscheidung nicht gefunden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.registriereGenehmigung(r.Context(), antrag, letzteEntscheidung, bewertung); err != nil {
|
||||||
|
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/freigaben", http.StatusSeeOther)
|
||||||
|
}
|
||||||
284
internal/web/freigabe_handlers_test.go
Normal file
284
internal/web/freigabe_handlers_test.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// besondereKategorieAntragForm loest ueber b2=ja die Datenklasse
|
||||||
|
// besondere_kategorie aus, die laut rules/anforderungen.yaml die
|
||||||
|
// Anforderung dsfa_erforderlich nach sich zieht — Grundlage fuer die
|
||||||
|
// Freigabe-Workflow-Tests.
|
||||||
|
func besondereKategorieAntragForm() url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"titel": {"Gesundheitsdaten auswerten"}, "beschreibung": {"KI wertet Gesundheitsdaten aus"},
|
||||||
|
"ergebnis": {"Auswertung"}, "haeufigkeit": {"gelegentlich"},
|
||||||
|
"b1": {"nein"}, "b2": {"ja"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"},
|
||||||
|
"c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"},
|
||||||
|
"c4": {"nein"}, "c5": {"ja"},
|
||||||
|
"d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedFreigabeSzenario legt einen Mandanten mit Mitarbeiter (reicht
|
||||||
|
// einen Antrag ein, der dsfa_erforderlich ausloest), Verantwortlichem
|
||||||
|
// und einem zentralen Werkzeug an — die Grundlage, auf der die
|
||||||
|
// einzelnen Freigabe-Workflow-Tests aufbauen.
|
||||||
|
func seedFreigabeSzenario(t *testing.T, fs *fakeStore, s *web.Server) (accountID, antragID string, verantwortlicherCookie *http.Cookie) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: "Freigabe-Mandant"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter-fg@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
werkzeugForm := url.Values{
|
||||||
|
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
|
||||||
|
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
||||||
|
"training_standard": {"on"}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"},
|
||||||
|
}
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber FG", "betreiber-fg@example.com", "betreiber")
|
||||||
|
if resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm); resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Werkzeug anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, mitarbeiterCookie, "/antraege", besondereKategorieAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Antrag anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
for id, a := range fs.antraege {
|
||||||
|
if a.AccountID == acc.ID {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if antragID == "" {
|
||||||
|
t.Fatal("kein Antrag im Mandanten gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
verantwortlicherCookie = seedUserInAccount(t, fs, acc.ID, "verantwortlicher-fg@example.com", "verantwortlicher")
|
||||||
|
return acc.ID, antragID, verantwortlicherCookie
|
||||||
|
}
|
||||||
|
|
||||||
|
// werkzeugIDFor findet die ID des einzigen im fakeStore angelegten
|
||||||
|
// Werkzeugs — fuer die Genehmigungs-Formulare in diesen Tests reicht ein
|
||||||
|
// einziges zentrales Werkzeug.
|
||||||
|
func werkzeugIDFor(fs *fakeStore, name string) string {
|
||||||
|
for id, w := range fs.werkzeuge {
|
||||||
|
if w.Name == name {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOhneFreigabeRegelnVerhaeltSichWieVorher(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
_, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||||
|
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||||
|
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if fs.antraege[antragID].Status != "entschieden" {
|
||||||
|
t.Fatalf("Status = %q, want entschieden (keine Freigabe-Regel konfiguriert)", fs.antraege[antragID].Status)
|
||||||
|
}
|
||||||
|
regEintraege := fs.registereintraege[fs.antraege[antragID].AccountID]
|
||||||
|
if len(regEintraege) != 1 {
|
||||||
|
t.Fatalf("erwartet 1 Registereintrag ohne Freigabe-Regeln, got %d", len(regEintraege))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeWorkflowGatesBisAlleZugestimmtHaben(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||||
|
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||||
|
|
||||||
|
dsb, err := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID); err != nil {
|
||||||
|
t.Fatalf("CreateFreigabeRegel: %v", err)
|
||||||
|
}
|
||||||
|
dsbCookie := seedUserInAccount(t, fs, accountID, "dsb@example.com", "mitarbeiter")
|
||||||
|
dsbUser, err := fs.GetUserByEmail(context.Background(), "dsb@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if err := fs.AddNutzerGenehmigerRolle(context.Background(), dsbUser.ID, dsb.ID); err != nil {
|
||||||
|
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if got := fs.antraege[antragID].Status; got != "wartet_auf_freigabe" {
|
||||||
|
t.Fatalf("Status = %q, want wartet_auf_freigabe", got)
|
||||||
|
}
|
||||||
|
if len(fs.registereintraege[accountID]) != 0 {
|
||||||
|
t.Fatalf("erwartet noch keinen Registereintrag vor vollstaendiger Freigabe, got %d", len(fs.registereintraege[accountID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
listResp := getWithCookie(t, s, dsbCookie, "/freigaben")
|
||||||
|
if listResp.Code != http.StatusOK || !strings.Contains(listResp.Body.String(), "Gesundheitsdaten auswerten") {
|
||||||
|
t.Fatalf("Meine Freigaben zeigt den Antrag nicht, status=%d body=%s", listResp.Code, listResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var schrittID string
|
||||||
|
for id, fsr := range fs.freigabeschritte {
|
||||||
|
if fsr.AntragID == antragID {
|
||||||
|
schrittID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if schrittID == "" {
|
||||||
|
t.Fatal("kein Freigabeschritt fuer den Antrag gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
decideResp := postForm(t, s, dsbCookie, "/freigaben/"+schrittID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
|
||||||
|
if decideResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("freigabe entscheiden: status = %d, body: %s", decideResp.Code, decideResp.Body.String())
|
||||||
|
}
|
||||||
|
if got := fs.antraege[antragID].Status; got != "entschieden" {
|
||||||
|
t.Fatalf("Status nach vollstaendiger Freigabe = %q, want entschieden", got)
|
||||||
|
}
|
||||||
|
if len(fs.registereintraege[accountID]) != 1 {
|
||||||
|
t.Fatalf("erwartet 1 Registereintrag nach vollstaendiger Freigabe, got %d", len(fs.registereintraege[accountID]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeAblehnungKipptGesamtenAntrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||||
|
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||||
|
|
||||||
|
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
|
||||||
|
gf, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Geschaeftsfuehrung", "")
|
||||||
|
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
|
||||||
|
fs.CreateFreigabeRegel(context.Background(), accountID, "datenklasse", "besondere_kategorie", gf.ID)
|
||||||
|
|
||||||
|
dsbCookie := seedUserInAccount(t, fs, accountID, "dsb2@example.com", "mitarbeiter")
|
||||||
|
dsbUser, _ := fs.GetUserByEmail(context.Background(), "dsb2@example.com")
|
||||||
|
fs.AddNutzerGenehmigerRolle(context.Background(), dsbUser.ID, dsb.ID)
|
||||||
|
seedUserInAccount(t, fs, accountID, "gf@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var dsbSchrittID string
|
||||||
|
for id, fsr := range fs.freigabeschritte {
|
||||||
|
if fsr.AntragID == antragID && fsr.GenehmigerRolleID == dsb.ID {
|
||||||
|
dsbSchrittID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dsbSchrittID == "" {
|
||||||
|
t.Fatal("kein DSB-Freigabeschritt gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
decideResp := postForm(t, s, dsbCookie, "/freigaben/"+dsbSchrittID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"abgelehnt"}, "kommentar": {"Nicht ausreichend pseudonymisiert"},
|
||||||
|
})
|
||||||
|
if decideResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("freigabe ablehnen: status = %d, body: %s", decideResp.Code, decideResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := fs.antraege[antragID].Status; got != "entschieden" {
|
||||||
|
t.Fatalf("Status nach Ablehnung = %q, want entschieden", got)
|
||||||
|
}
|
||||||
|
letzte := fs.entscheidungen[antragID]
|
||||||
|
if len(letzte) == 0 || letzte[len(letzte)-1].Entscheidung != "abgelehnt" {
|
||||||
|
t.Fatalf("letzte Entscheidung = %+v, want abgelehnt", letzte)
|
||||||
|
}
|
||||||
|
for _, fsr := range fs.freigabeschritte {
|
||||||
|
if fsr.AntragID == antragID && fsr.Status == "ausstehend" {
|
||||||
|
t.Fatalf("erwartet keine ausstehenden Freigabeschritte mehr nach Ablehnung, gefunden: %+v", fsr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fs.registereintraege[accountID]) != 0 {
|
||||||
|
t.Fatalf("erwartet keinen Registereintrag nach Ablehnung, got %d", len(fs.registereintraege[accountID]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeEntscheidenOhneRolleWirdAbgelehnt(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||||
|
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||||
|
|
||||||
|
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
|
||||||
|
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
|
||||||
|
|
||||||
|
fremdCookie := seedUserInAccount(t, fs, accountID, "fremd@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||||
|
})
|
||||||
|
var schrittID string
|
||||||
|
for id, fsr := range fs.freigabeschritte {
|
||||||
|
if fsr.AntragID == antragID {
|
||||||
|
schrittID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, fremdCookie, "/freigaben/"+schrittID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 fuer eine Person ohne die noetige Genehmiger-Rolle", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenehmigerRolleCRUD(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "GR-Mandant", "admin-gr@example.com", "admin")
|
||||||
|
admin, err := fs.GetUserByEmail(context.Background(), "admin-gr@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen", url.Values{"name": {"Geschaeftsfuehrung"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var rolleID string
|
||||||
|
for id, g := range fs.genehmigerRollen {
|
||||||
|
if g.Name == "Geschaeftsfuehrung" {
|
||||||
|
rolleID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rolleID == "" {
|
||||||
|
t.Fatal("Genehmiger-Rolle wurde nicht angelegt")
|
||||||
|
}
|
||||||
|
|
||||||
|
assignResp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen/"+rolleID+"/mitglieder", url.Values{"user_id": {admin.ID}})
|
||||||
|
if assignResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("zuweisen: status = %d, body: %s", assignResp.Code, assignResp.Body.String())
|
||||||
|
}
|
||||||
|
list := getWithCookie(t, s, adminCookie, "/verwaltung/genehmiger-rollen")
|
||||||
|
if !strings.Contains(list.Body.String(), "admin-gr@example.com") {
|
||||||
|
t.Errorf("erwartet das zugewiesene Mitglied auf der Liste, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
removeResp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen/"+rolleID+"/mitglieder/"+admin.ID+"/entfernen", url.Values{})
|
||||||
|
if removeResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("entfernen: status = %d, body: %s", removeResp.Code, removeResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,15 +11,49 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
65
internal/web/handlers_test.go
Normal file
65
internal/web/handlers_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, `href="/antraege"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Meine offenen Anträge") {
|
||||||
|
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, `href="/faelle"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Posteingang") {
|
||||||
|
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`) || !strings.Contains(resp.Body.String(), "Zur Plattform") {
|
||||||
|
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "Antrag stellen") {
|
||||||
|
t.Errorf("expected no antrag tiles for betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
120
internal/web/impersonation_test.go
Normal file
120
internal/web/impersonation_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBetreiberKannAlsNutzerAnmelden(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
cookies := resp.Result().Cookies()
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
t.Fatal("expected a new session cookie")
|
||||||
|
}
|
||||||
|
neueCookie := cookies[0]
|
||||||
|
|
||||||
|
// Die neue Sitzung ist tatsächlich der Kunden-Login.
|
||||||
|
protected := getWithCookie(t, s, neueCookie, "/antraege")
|
||||||
|
if protected.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected the impersonated session to work, status = %d", protected.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberLoginAlsZeigtBanner(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
neueCookie := loginResp.Result().Cookies()[0]
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, neueCookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Support-Zugriff durch") || !strings.Contains(resp.Body.String(), "betreiber@example.com") {
|
||||||
|
t.Errorf("expected the impersonation banner naming the betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberLoginAlsProtokolliertAuditEintrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
|
||||||
|
var gefunden bool
|
||||||
|
for _, e := range fs.auditLog {
|
||||||
|
if e.Action == "betreiber_login_als_nutzer" && e.ActorUserID == betreiber.ID && e.TargetID == kunde.ID {
|
||||||
|
gefunden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gefunden {
|
||||||
|
t.Errorf("expected an audit log entry for the impersonation, got: %+v", fs.auditLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberCannotLoginAlsDeaktiviertenNutzer(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Kunde GmbH", "kunde-admin@example.com", "admin")
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||||
|
})
|
||||||
|
weg, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+weg.ID+"/deaktivieren", url.Values{})
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+weg.AccountID+"/nutzer/"+weg.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 for a deactivated user", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotLoginAls(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
self, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/betreiber/accounts/"+self.AccountID+"/nutzer/"+self.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
internal/web/laenderliste.go
Normal file
53
internal/web/laenderliste.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
// weltLaenderliste ist die kontrollierte Auswahl für "Verarbeitungsländer"
|
||||||
|
// im Werkzeugkatalog — eine echte Länderliste statt grober Eimer wie
|
||||||
|
// "EU"/"USA"/"gemischt" (die USA sind DSGVO-rechtlich bereits ein
|
||||||
|
// Drittland wie jedes andere, siehe CLAUDE.md). Enthält zusätzlich zwei
|
||||||
|
// Pseudo-Einträge für Fälle, in denen der Anbieter selbst keinen
|
||||||
|
// einzelnen Staat zusichert bzw. keine Cloud-Verarbeitung stattfindet.
|
||||||
|
var weltLaenderliste = []string{
|
||||||
|
"EU-Region (kein einzelnes Land benannt)",
|
||||||
|
"On-Premise (selbst gehostet)",
|
||||||
|
|
||||||
|
"Afghanistan", "Ägypten", "Albanien", "Algerien", "Andorra", "Angola",
|
||||||
|
"Antigua und Barbuda", "Äquatorialguinea", "Argentinien", "Armenien",
|
||||||
|
"Aserbaidschan", "Äthiopien", "Australien", "Bahamas", "Bahrain",
|
||||||
|
"Bangladesch", "Barbados", "Belgien", "Belize", "Benin", "Bhutan",
|
||||||
|
"Bolivien", "Bosnien und Herzegowina", "Botsuana", "Brasilien",
|
||||||
|
"Brunei", "Bulgarien", "Burkina Faso", "Burundi", "Chile", "China",
|
||||||
|
"Costa Rica", "Dänemark", "Deutschland", "Dominica",
|
||||||
|
"Dominikanische Republik", "Dschibuti", "Ecuador", "El Salvador",
|
||||||
|
"Elfenbeinküste", "Eritrea", "Estland", "Eswatini", "Fidschi",
|
||||||
|
"Finnland", "Frankreich", "Gabun", "Gambia", "Georgien", "Ghana",
|
||||||
|
"Grenada", "Griechenland", "Guatemala", "Guinea", "Guinea-Bissau",
|
||||||
|
"Guyana", "Haiti", "Honduras", "Indien", "Indonesien", "Irak",
|
||||||
|
"Iran", "Irland", "Island", "Israel", "Italien", "Jamaika", "Japan",
|
||||||
|
"Jemen", "Jordanien", "Kambodscha", "Kamerun", "Kanada", "Kap Verde",
|
||||||
|
"Kasachstan", "Katar", "Kenia", "Kirgisistan", "Kiribati",
|
||||||
|
"Kolumbien", "Komoren", "Kongo (Republik)",
|
||||||
|
"Kongo (Demokratische Republik)", "Kosovo", "Kroatien", "Kuba",
|
||||||
|
"Kuwait", "Laos", "Lesotho", "Lettland", "Libanon", "Liberia",
|
||||||
|
"Libyen", "Liechtenstein", "Litauen", "Luxemburg", "Madagaskar",
|
||||||
|
"Malawi", "Malaysia", "Malediven", "Mali", "Malta", "Marokko",
|
||||||
|
"Marshallinseln", "Mauretanien", "Mauritius", "Mexiko",
|
||||||
|
"Mikronesien", "Moldau", "Monaco", "Mongolei", "Montenegro",
|
||||||
|
"Mosambik", "Myanmar", "Namibia", "Nauru", "Nepal", "Neuseeland",
|
||||||
|
"Nicaragua", "Niederlande", "Niger", "Nigeria", "Nordkorea",
|
||||||
|
"Nordmazedonien", "Norwegen", "Oman", "Österreich", "Osttimor",
|
||||||
|
"Pakistan", "Palau", "Palästina", "Panama", "Papua-Neuguinea",
|
||||||
|
"Paraguay", "Peru", "Philippinen", "Polen", "Portugal", "Ruanda",
|
||||||
|
"Rumänien", "Russland", "Salomonen", "Sambia", "Samoa",
|
||||||
|
"San Marino", "São Tomé und Príncipe", "Saudi-Arabien", "Schweden",
|
||||||
|
"Schweiz", "Senegal", "Serbien", "Seychellen", "Sierra Leone",
|
||||||
|
"Simbabwe", "Singapur", "Slowakei", "Slowenien", "Somalia",
|
||||||
|
"Spanien", "Sri Lanka", "St. Kitts und Nevis", "St. Lucia",
|
||||||
|
"St. Vincent und die Grenadinen", "Südafrika", "Sudan", "Südkorea",
|
||||||
|
"Südsudan", "Suriname", "Syrien", "Tadschikistan", "Taiwan",
|
||||||
|
"Tansania", "Thailand", "Togo", "Tonga", "Trinidad und Tobago",
|
||||||
|
"Tschad", "Tschechien", "Tunesien", "Türkei", "Turkmenistan",
|
||||||
|
"Tuvalu", "Uganda", "Ukraine", "Ungarn", "Uruguay", "USA",
|
||||||
|
"Usbekistan", "Vanuatu", "Vatikanstadt", "Venezuela",
|
||||||
|
"Vereinigte Arabische Emirate", "Vereinigtes Königreich", "Vietnam",
|
||||||
|
"Zentralafrikanische Republik", "Zypern",
|
||||||
|
}
|
||||||
98
internal/web/loeschfrist_handlers.go
Normal file
98
internal/web/loeschfrist_handlers.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// Löschfristen je Datenklasse (Migration 0020) — schließt rules/OPEN.md,
|
||||||
|
// Punkt 4. Die Tageswerte sind KEINE gesetzliche Vorgabe (die DSGVO
|
||||||
|
// nennt keine festen Fristen, nur den Grundsatz "so lange wie für den
|
||||||
|
// Zweck nötig", Art. 5 Abs. 1 lit. e) — deshalb pro Mandant einstellbar,
|
||||||
|
// vom Datenschutzbeauftragten hinterlegt, mit risikogestaffelten
|
||||||
|
// Vorschlagswerten vorbelegt. Wie alle anderen /verwaltung/*-Seiten
|
||||||
|
// bewusst admin-only (konsistent mit Nutzer-/Abteilungs-/Genehmiger-
|
||||||
|
// Rollen-Verwaltung) statt an den Namen einer Genehmiger-Rolle
|
||||||
|
// gekoppelt — Genehmiger-Rollen sind laut Architektur explizit ohne
|
||||||
|
// eigenes Zugriffsrecht (siehe CLAUDE.md, Freigabe-Workflow).
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// standardLoeschfristen sind risikogestaffelte Vorschlagswerte, mit
|
||||||
|
// denen jede neue Firma vorbelegt wird — je sensibler die Datenklasse,
|
||||||
|
// desto kürzer die vorgeschlagene Frist. Frei editierbar unter
|
||||||
|
// /verwaltung/loeschfristen, keine Rechtsvorgabe.
|
||||||
|
var standardLoeschfristen = []struct {
|
||||||
|
DatenklasseID string
|
||||||
|
MaxTage int
|
||||||
|
}{
|
||||||
|
{"oeffentlich", 365},
|
||||||
|
{"intern", 180},
|
||||||
|
{"auftragsdaten", 90},
|
||||||
|
{"personenbezogen", 90},
|
||||||
|
{"berufsgeheimnis", 30},
|
||||||
|
{"besondere_kategorie", 30},
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedStandardLoeschfristen wird direkt nach dem Anlegen einer neuen
|
||||||
|
// Firma aufgerufen (öffentliche Registrierung und Betreiber-Firmenanlage),
|
||||||
|
// analog zu seedStandardGenehmigerRollen.
|
||||||
|
func (s *Server) seedStandardLoeschfristen(ctx context.Context, accountID string) error {
|
||||||
|
for _, l := range standardLoeschfristen {
|
||||||
|
if _, err := s.store.UpsertLoeschfristEinstellung(ctx, accountID, l.DatenklasseID, l.MaxTage); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type loeschfristZeile struct {
|
||||||
|
DatenklasseID string
|
||||||
|
MaxTage int
|
||||||
|
}
|
||||||
|
|
||||||
|
type loeschfristenListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Zeilen []loeschfristZeile
|
||||||
|
Error string
|
||||||
|
Gespeichert bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLoeschfristenListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
einstellungen, err := s.store.ListLoeschfristEinstellungenForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Löschfristen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := loeschfristenListeData{Title: "Löschfristen", Nav: navFor(r), Gespeichert: r.URL.Query().Get("gespeichert") == "1"}
|
||||||
|
for _, e := range einstellungen {
|
||||||
|
data.Zeilen = append(data.Zeilen, loeschfristZeile{DatenklasseID: e.DatenklasseID, MaxTage: e.MaxTage})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "loeschfristen-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLoeschfristenSpeichern(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
for _, l := range standardLoeschfristen {
|
||||||
|
raw := r.FormValue("max_tage_" + l.DatenklasseID)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
maxTage, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || maxTage < 0 {
|
||||||
|
http.Error(w, "ungültiger Wert für "+l.DatenklasseID, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpsertLoeschfristEinstellung(r.Context(), accountID, l.DatenklasseID, maxTage); err != nil {
|
||||||
|
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/loeschfristen?gespeichert=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
103
internal/web/mandant_eigenes_werkzeug_test.go
Normal file
103
internal/web/mandant_eigenes_werkzeug_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminCanCreateEditAndDeleteEigenesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge", werkzeugForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
for wid, w := range fs.werkzeuge {
|
||||||
|
if w.AccountID != nil && *w.AccountID == admin.AccountID {
|
||||||
|
id = wid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("expected an eigenes werkzeug to exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||||
|
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Errorf("expected the eigenes werkzeug in the list, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
editForm := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/"+id)
|
||||||
|
if editForm.Code != http.StatusOK || !strings.Contains(editForm.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Fatalf("edit form status = %d, body: %s", editForm.Code, editForm.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
updateForm := werkzeugForm()
|
||||||
|
updateForm.Set("name", "ChatGPT Enterprise (eigene Version)")
|
||||||
|
updateResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id, updateForm)
|
||||||
|
if updateResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||||
|
}
|
||||||
|
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (eigene Version)" {
|
||||||
|
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||||
|
if deleteResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := fs.werkzeuge[id]; ok {
|
||||||
|
t.Error("expected the eigenes werkzeug to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotEditZentralesWerkzeugUeberMandantenRoute(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a central werkzeug via the tenant route", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotEditFremdesEigenesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||||
|
postForm(t, s, adminA, "/verwaltung/werkzeuge", werkzeugForm())
|
||||||
|
adminAUser, err := fs.GetUserByEmail(context.Background(), "admin-a@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
for wid, w := range fs.werkzeuge {
|
||||||
|
if w.AccountID != nil && *w.AccountID == adminAUser.AccountID {
|
||||||
|
id = wid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||||
|
resp := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge/"+id)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a foreign account's eigenes werkzeug", resp.Code)
|
||||||
|
}
|
||||||
|
deleteResp := postForm(t, s, adminB, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||||
|
if deleteResp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("delete status = %d, want 404 for a foreign account's eigenes werkzeug", deleteResp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
243
internal/web/mandant_werkzeug_handlers.go
Normal file
243
internal/web/mandant_werkzeug_handlers.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
// Ebene 4 — "eigene Werkzeug-Freigaben/-Sperrungen" (siehe CLAUDE.md,
|
||||||
|
// Fünf Ebenen): ein Mandant kann einen zentralen Katalogeintrag für
|
||||||
|
// sich sperren, ohne den zentralen Katalog selbst zu verändern (das
|
||||||
|
// bleibt Sache der Plattform, siehe betreiber_werkzeug_handlers.go),
|
||||||
|
// UND eigene, mandantenspezifische Katalogeinträge pflegen
|
||||||
|
// (`werkzeug.account_id` = der eigene Account) — dieselbe
|
||||||
|
// Formular-Vorlage wie der Betreiber nutzt (werkzeugFormData,
|
||||||
|
// betreiber-werkzeug-form.html, ActionBase unterscheidet die Ziel-URL).
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mandantWerkzeugItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
Verarbeitungslaender string
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
TrainingStandard bool
|
||||||
|
Aufbewahrung string
|
||||||
|
GeeigneteZwecke string
|
||||||
|
LetztePruefung string
|
||||||
|
PruefStatusLabel string
|
||||||
|
PruefStatusCSS string
|
||||||
|
Gesperrt bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type eigenesWerkzeugItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
Verarbeitungslaender string
|
||||||
|
LetztePruefung string
|
||||||
|
}
|
||||||
|
|
||||||
|
type mandantWerkzeugListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Werkzeuge []mandantWerkzeugItem
|
||||||
|
Filter werkzeugFilter
|
||||||
|
ZweckeOptionen []string
|
||||||
|
Pagination pagination
|
||||||
|
EigeneWerkzeuge []eigenesWerkzeugItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des
|
||||||
|
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag, sowie
|
||||||
|
// die eigenen (mandantenspezifischen) Katalogeinträge mit Bearbeiten/
|
||||||
|
// Löschen.
|
||||||
|
func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
zentral, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sperren, err := s.store.ListWerkzeugSperrenForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Sperrungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eigene, err := s.store.ListWerkzeugeForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Werkzeuge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gesperrt := make(map[string]bool, len(sperren))
|
||||||
|
for _, sp := range sperren {
|
||||||
|
gesperrt[sp.WerkzeugID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := werkzeugFilterFromRequest(r)
|
||||||
|
var gefiltert []store.Werkzeug
|
||||||
|
for _, wz := range zentral {
|
||||||
|
if filter.matches(wz) {
|
||||||
|
gefiltert = append(gefiltert, wz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
seite := paginate(gefiltert, pag)
|
||||||
|
|
||||||
|
data := mandantWerkzeugListeData{
|
||||||
|
Title: "Werkzeugkatalog", Nav: navFor(r),
|
||||||
|
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
|
||||||
|
}
|
||||||
|
for _, wz := range seite {
|
||||||
|
label, css := pruefStatus(wz.LetztePruefung)
|
||||||
|
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
|
||||||
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
|
||||||
|
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
|
||||||
|
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||||
|
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||||
|
PruefStatusLabel: label, PruefStatusCSS: css,
|
||||||
|
Gesperrt: gesperrt[wz.ID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, wz := range eigene {
|
||||||
|
if wz.AccountID == nil {
|
||||||
|
continue // zentral, oben schon gelistet
|
||||||
|
}
|
||||||
|
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{
|
||||||
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := werkzeugFormData{
|
||||||
|
Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge",
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugCreate legt einen mandantenspezifischen
|
||||||
|
// Katalogeintrag an (`werkzeug.account_id` = eigener Account) — sichtbar
|
||||||
|
// nur für diesen Mandanten, verändert den zentralen Katalog nicht.
|
||||||
|
func (s *Server) handleMandantWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
in, errMsg := werkzeugInputFromForm(r, &accountID)
|
||||||
|
if errMsg != "" {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", errMsg)
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugEditForm zeigt einen eigenen Katalogeintrag zum
|
||||||
|
// Bearbeiten. Ein zentraler oder fremder Eintrag ist hier nicht
|
||||||
|
// erreichbar (404) — das bleibt Sache der Plattform bzw. des jeweils
|
||||||
|
// anderen Mandanten.
|
||||||
|
func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := werkzeugFormDataFromWerkzeug(r, wz, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge")
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugUpdate speichert Änderungen an einem eigenen
|
||||||
|
// Katalogeintrag.
|
||||||
|
func (s *Server) handleMandantWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||||
|
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, errMsg := werkzeugInputFromForm(r, &accountID)
|
||||||
|
if errMsg != "" {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, errMsg)
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugDelete entfernt einen eigenen Katalogeintrag.
|
||||||
|
func (s *Server) handleMandantWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||||
|
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
|
||||||
|
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugSperren sperrt einen zentralen Katalogeintrag
|
||||||
|
// für den eigenen Mandanten — der zentrale Eintrag selbst bleibt
|
||||||
|
// unverändert, er verschwindet nur aus ListWerkzeugeForAccount für
|
||||||
|
// diesen einen Mandanten.
|
||||||
|
func (s *Server) handleMandantWerkzeugSperren(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
grund := r.FormValue("grund")
|
||||||
|
if _, err := s.store.CreateWerkzeugSperre(r.Context(), currentUser(r).AccountID, r.PathValue("id"), grund); err != nil {
|
||||||
|
http.Error(w, "Werkzeug konnte nicht gesperrt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMandantWerkzeugEntsperren(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.store.DeleteWerkzeugSperre(r.Context(), currentUser(r).AccountID, r.PathValue("id")); err != nil {
|
||||||
|
http.Error(w, "Sperrung konnte nicht aufgehoben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
66
internal/web/mandant_werkzeug_handlers_test.go
Normal file
66
internal/web/mandant_werkzeug_handlers_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminCanSperrenUndEntsperrenZentralesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||||
|
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Fatalf("expected the zentrale werkzeug in the list, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central/sperren", url.Values{"grund": {"zu teuer"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("sperren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
afterSperren := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||||
|
if !strings.Contains(afterSperren.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||||
|
t.Errorf("expected the werkzeug to show as gesperrt, got: %s", afterSperren.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = postForm(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central/entsperren", url.Values{})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("entsperren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
afterEntsperren := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||||
|
if strings.Contains(afterEntsperren.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||||
|
t.Errorf("expected the werkzeug to no longer show as gesperrt, got: %s", afterEntsperren.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSperreIstProMandantIsoliert(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||||
|
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||||
|
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||||
|
|
||||||
|
postForm(t, s, adminA, "/verwaltung/werkzeuge/werkzeug-central/sperren", url.Values{})
|
||||||
|
|
||||||
|
respB := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge")
|
||||||
|
if strings.Contains(respB.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||||
|
t.Errorf("expected Mandant B to see the werkzeug as unsperrt, got: %s", respB.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessWerkzeugverwaltung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/verwaltung/werkzeuge")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,44 +12,85 @@ const sessionCookieName = "deklarix_session"
|
|||||||
|
|
||||||
type contextKey int
|
type contextKey int
|
||||||
|
|
||||||
const userContextKey contextKey = iota
|
const (
|
||||||
|
userContextKey contextKey = iota
|
||||||
|
impersonatorContextKey
|
||||||
|
)
|
||||||
|
|
||||||
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
||||||
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
|
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, "", false),
|
||||||
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
||||||
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
||||||
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
|
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der
|
||||||
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
|
// zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung
|
||||||
|
// durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession),
|
||||||
|
// sonst leer.
|
||||||
|
func (s *Server) authenticate(r *http.Request) (store.User, string, bool) {
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
if err != nil || cookie.Value == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
if time.Now().After(sess.ExpiresAt) {
|
if time.Now().After(sess.ExpiresAt) {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
||||||
if err != nil {
|
if err != nil || !user.Active {
|
||||||
return store.User{}, false
|
return store.User{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
impersonator := ""
|
||||||
|
if sess.ImpersonatedByUserID != nil {
|
||||||
|
if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil {
|
||||||
|
impersonator = betreiber.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user, impersonator, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den
|
||||||
|
// Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares.
|
||||||
|
func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request {
|
||||||
|
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||||
|
if impersonator != "" {
|
||||||
|
ctx = context.WithValue(ctx, impersonatorContextKey, impersonator)
|
||||||
|
}
|
||||||
|
return r.WithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTenantScope führt next innerhalb einer Postgres-Transaktion aus,
|
||||||
|
// die die Sitzungsvariablen für Row-Level-Security setzt (siehe
|
||||||
|
// store.WithTenantScope) — jede require*-Middleware ruft das anstelle
|
||||||
|
// eines direkten next(w, r) auf, damit JEDE Store-Anfrage innerhalb
|
||||||
|
// dieses Requests automatisch auf den angemeldeten Mandanten begrenzt
|
||||||
|
// ist, nicht nur die, die ein Handler zufällig selbst noch mal prüft.
|
||||||
|
// Ein Fehler hier ist ein Infrastrukturproblem (z. B. DB down), kein
|
||||||
|
// normaler Handler-Fehlerpfad — 500 statt eines spezifischeren Codes.
|
||||||
|
func (s *Server) withTenantScope(w http.ResponseWriter, r *http.Request, user store.User, next http.HandlerFunc) {
|
||||||
|
err := s.store.WithTenantScope(r.Context(), user.AccountID, user.Role == "betreiber", func(ctx context.Context) error {
|
||||||
|
next(w, r.WithContext(ctx))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Datenbankfehler: "+err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return user, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
||||||
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
||||||
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +100,12 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
||||||
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +117,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
||||||
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -85,30 +126,37 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
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
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +165,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
||||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, impersonator, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -126,7 +174,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +183,10 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// currentUser zu geben — die Navigation braucht nur, ob ein
|
// currentUser zu geben — die Navigation braucht nur, ob ein
|
||||||
// Plattform-Link gezeigt werden soll.
|
// Plattform-Link gezeigt werden soll.
|
||||||
type navData struct {
|
type navData struct {
|
||||||
IsBetreiber bool
|
IsBetreiber bool
|
||||||
IsFachebene bool
|
IsFachebene bool
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer
|
||||||
}
|
}
|
||||||
|
|
||||||
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
||||||
@@ -147,8 +196,10 @@ 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.
|
||||||
IsAdmin: role == "admin",
|
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
|
||||||
|
IsAdmin: role == "admin",
|
||||||
|
Impersonator: currentImpersonator(r),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,3 +214,10 @@ func currentUser(r *http.Request) store.User {
|
|||||||
}
|
}
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentImpersonator liefert die E-Mail des Betreibers, falls die
|
||||||
|
// aktuelle Sitzung ein Support-Login ist, sonst einen leeren String.
|
||||||
|
func currentImpersonator(r *http.Request) string {
|
||||||
|
v, _ := r.Context().Value(impersonatorContextKey).(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|||||||
102
internal/web/nutzer_aktivierung_test.go
Normal file
102
internal/web/nutzer_aktivierung_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminKannNutzerDeaktivierenUndReaktivieren(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||||
|
})
|
||||||
|
target, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/deaktivieren", url.Values{})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("deaktivieren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
deaktiviert, err := fs.GetUser(context.Background(), target.ID)
|
||||||
|
if err != nil || deaktiviert.Active {
|
||||||
|
t.Fatalf("expected user to be inactive, got %+v (err %v)", deaktiviert, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResp := postForm(t, s, nil, "/login", url.Values{"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}})
|
||||||
|
if !strings.Contains(loginResp.Body.String(), "deaktiviert") {
|
||||||
|
t.Errorf("expected a deactivation message on login, got: %s", loginResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
reaktivierenResp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/aktivieren", url.Values{})
|
||||||
|
if reaktivierenResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("aktivieren status = %d, body: %s", reaktivierenResp.Code, reaktivierenResp.Body.String())
|
||||||
|
}
|
||||||
|
reaktiviert, err := fs.GetUser(context.Background(), target.ID)
|
||||||
|
if err != nil || !reaktiviert.Active {
|
||||||
|
t.Fatalf("expected user to be active again, got %+v (err %v)", reaktiviert, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminKannSichNichtSelbstDeaktivieren(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
self, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+self.ID+"/deaktivieren", url.Values{})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 for self-deactivation", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeaktivierterNutzerVerliertLaufendeSitzungSofort(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
mitarbeiterCookie := seedUserInAccount(t, fs, admin.AccountID, "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
if resp := getWithCookie(t, s, mitarbeiterCookie, "/antraege"); resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected active session to work, status = %d", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/deaktivieren", url.Values{})
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, mitarbeiterCookie, "/antraege")
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected the deactivated user's session to be rejected, status = %d", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotDeactivateForeignAccountUser(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||||
|
seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||||
|
targetB, err := fs.GetUserByEmail(context.Background(), "admin-b@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminA, "/verwaltung/nutzer/"+targetB.ID+"/deaktivieren", url.Values{})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a foreign account's user", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
internal/web/pagination.go
Normal file
95
internal/web/pagination.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// pagination.go — wiederverwendbare serverseitige Paginierung für
|
||||||
|
// Listen-Seiten (Werkzeugkatalog, Register, ...). Bewusst ohne
|
||||||
|
// JavaScript: Seiten-/Filterwechsel läuft über ein einziges GET-Formular,
|
||||||
|
// das Query-Parameter (q, zweck, page, ...) setzt — siehe CLAUDE.md,
|
||||||
|
// Frontend.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultPerPage = 20
|
||||||
|
|
||||||
|
// pagination beschreibt die aktuelle Seite einer Liste. Templates lesen
|
||||||
|
// daraus Seitenzahl, Gesamtzahl und ob Vor/Zurück-Buttons sichtbar sein
|
||||||
|
// sollen.
|
||||||
|
type pagination struct {
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
Total int
|
||||||
|
TotalPages int
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginationFromRequest liest "page" aus der Query (Standard: 1, nie
|
||||||
|
// kleiner 1, nie größer als die letzte tatsächlich vorhandene Seite).
|
||||||
|
// per_page ist bewusst nicht vom Nutzer einstellbar (kein Query-Param
|
||||||
|
// dafür) — eine feste Seitengröße hält die Filter-URLs einfach und
|
||||||
|
// verhindert versehentlich riesige Anfragen.
|
||||||
|
func paginationFromRequest(r *http.Request, total int) pagination {
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
perPage := defaultPerPage
|
||||||
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
return pagination{
|
||||||
|
Page: page, PerPage: perPage, Total: total, TotalPages: totalPages,
|
||||||
|
HasPrev: page > 1, HasNext: page < totalPages,
|
||||||
|
PrevPage: page - 1, NextPage: page + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounds liefert den Start-/End-Index für die aktuelle Seite innerhalb
|
||||||
|
// der (bereits gefilterten) Gesamtliste.
|
||||||
|
func (p pagination) bounds() (start, end int) {
|
||||||
|
start = (p.Page - 1) * p.PerPage
|
||||||
|
end = start + p.PerPage
|
||||||
|
if end > p.Total {
|
||||||
|
end = p.Total
|
||||||
|
}
|
||||||
|
if start > end {
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesQuery prüft, ob q (Freitext-Suche, ohne Beachtung von
|
||||||
|
// Groß-/Kleinschreibung) in mindestens einem der übergebenen Felder
|
||||||
|
// vorkommt — leeres q trifft immer zu. Gemeinsame Basis für die
|
||||||
|
// Freitext-Filter mehrerer Listen-Seiten (Register, Nutzerverwaltung,
|
||||||
|
// Posteingang, ...), damit nicht jede Liste ihre eigene Such-Logik
|
||||||
|
// erfindet.
|
||||||
|
func matchesQuery(q string, felder ...string) bool {
|
||||||
|
if q == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
q = strings.ToLower(q)
|
||||||
|
for _, f := range felder {
|
||||||
|
if strings.Contains(strings.ToLower(f), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginate schneidet den Ausschnitt für die aktuelle Seite aus einer
|
||||||
|
// bereits gefilterten/sortierten Liste heraus — generisch, damit jede
|
||||||
|
// Listen-Seite (Werkzeuge, Register, ...) dieselbe Funktion nutzt statt
|
||||||
|
// eigene Slice-Arithmetik zu wiederholen.
|
||||||
|
func paginate[T any](items []T, p pagination) []T {
|
||||||
|
start, end := p.bounds()
|
||||||
|
return items[start:end]
|
||||||
|
}
|
||||||
106
internal/web/passwort_reset_handlers.go
Normal file
106
internal/web/passwort_reset_handlers.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Passwort-Zurücksetzen (Ebene 1, öffentlich) — nutzt internal/mail für
|
||||||
|
// den Versand. Wie beim Login verrät keine Fehlermeldung, ob eine
|
||||||
|
// E-Mail-Adresse überhaupt existiert (User-Enumeration-Schutz): die
|
||||||
|
// Anfrage-Seite zeigt immer dieselbe Erfolgsmeldung, unabhängig davon,
|
||||||
|
// ob tatsächlich eine Mail verschickt wurde.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
const passwortVergessenHinweis = "Falls diese E-Mail-Adresse bei uns registriert ist, wurde ein Link zum Zurücksetzen verschickt."
|
||||||
|
|
||||||
|
func (s *Server) handlePasswortVergessenForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Error: "ungültiges Formular"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := r.FormValue("email")
|
||||||
|
|
||||||
|
user, err := s.store.GetUserByEmail(r.Context(), email)
|
||||||
|
if err == nil && user.Active {
|
||||||
|
token, tokenErr := auth.NewSessionToken()
|
||||||
|
if tokenErr == nil {
|
||||||
|
if _, createErr := s.store.CreatePasswordResetToken(r.Context(), user.ID, token); createErr == nil {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token)
|
||||||
|
betreff, text := "Deklarix — Passwort zurücksetzen", "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
|
||||||
|
if vorlage, err := s.store.ResolveEmailVorlage(r.Context(), user.AccountID, "passwort_zuruecksetzen"); err == nil {
|
||||||
|
betreff, text = vorlage.Betreff, vorlage.Text
|
||||||
|
}
|
||||||
|
// Versandfehler wird bewusst nicht an den Nutzer durchgereicht
|
||||||
|
// (keine Enumeration über unterschiedliches Verhalten bei
|
||||||
|
// Versandfehlern), aber serverseitig geloggt statt
|
||||||
|
// verschluckt — sonst bleibt ein dauerhaft falsch
|
||||||
|
// konfigurierter SMTP-Server unbemerkt.
|
||||||
|
if err := s.mailer.Send(user.Email, betreff, renderEmailVorlage(text, link)); err != nil {
|
||||||
|
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Message: passwortVergessenHinweis})
|
||||||
|
}
|
||||||
|
|
||||||
|
type passwortZuruecksetzenData struct {
|
||||||
|
Title string
|
||||||
|
Token string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasswortZuruecksetzenForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.PathValue("token")
|
||||||
|
if _, err := s.store.GetValidPasswordResetToken(r.Context(), token); err != nil {
|
||||||
|
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token}); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasswortZuruecksetzen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.PathValue("token")
|
||||||
|
resetToken, err := s.store.GetValidPasswordResetToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.renderPasswortZuruecksetzenError(w, token, "ungültiges Formular")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passwordHash, err := auth.HashPassword(r.FormValue("password"))
|
||||||
|
if err != nil {
|
||||||
|
s.renderPasswortZuruecksetzenError(w, token, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetUserPassword(r.Context(), resetToken.UserID, passwordHash); err != nil {
|
||||||
|
s.renderPasswortZuruecksetzenError(w, token, "Passwort konnte nicht gesetzt werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.MarkPasswordResetTokenUsed(r.Context(), resetToken.ID); err != nil {
|
||||||
|
s.renderPasswortZuruecksetzenError(w, token, "Passwort wurde gesetzt, Token konnte aber nicht als verwendet markiert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login?reset=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderPasswortZuruecksetzenError(w http.ResponseWriter, token, msg string) {
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token, Error: msg}); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
internal/web/passwort_reset_handlers_test.go
Normal file
94
internal/web/passwort_reset_handlers_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var resetLinkPattern = regexp.MustCompile(`/passwort-zuruecksetzen/([a-f0-9]+)`)
|
||||||
|
|
||||||
|
func TestPasswortVergessenSendetLinkUndSetztNeuesPasswort(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s, fm := newServerWithMailer(t, fs)
|
||||||
|
|
||||||
|
regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{
|
||||||
|
"account_name": {"Reset-Firma"}, "email": {"reset@example.com"},
|
||||||
|
"password": {"altes-passwort"},
|
||||||
|
}, firmenPflichtfelder()))
|
||||||
|
if regResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("register status = %d, want 303", regResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
vergessenResp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"reset@example.com"}})
|
||||||
|
if vergessenResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("passwort-vergessen status = %d, want 200", vergessenResp.Code)
|
||||||
|
}
|
||||||
|
if len(fm.Sent) != 1 {
|
||||||
|
t.Fatalf("got %d gesendete Mails, want 1", len(fm.Sent))
|
||||||
|
}
|
||||||
|
match := resetLinkPattern.FindStringSubmatch(fm.Sent[0].Body)
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("kein Reset-Link in Mail-Body gefunden: %q", fm.Sent[0].Body)
|
||||||
|
}
|
||||||
|
token := match[1]
|
||||||
|
|
||||||
|
getResp := getWithCookie(t, s, nil, "/passwort-zuruecksetzen/"+token)
|
||||||
|
if getResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get reset form status = %d, want 200", getResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetResp := postForm(t, s, nil, "/passwort-zuruecksetzen/"+token, url.Values{"password": {"neues-passwort-123"}})
|
||||||
|
if resetResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("reset status = %d, want 303, body: %s", resetResp.Code, resetResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derselbe Link darf kein zweites Mal funktionieren.
|
||||||
|
reuseResp := postForm(t, s, nil, "/passwort-zuruecksetzen/"+token, url.Values{"password": {"noch-ein-passwort"}})
|
||||||
|
if reuseResp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("reuse status = %d, want 404 (Token bereits verwendet)", reuseResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Altes Passwort funktioniert nicht mehr.
|
||||||
|
oldLoginResp := postForm(t, s, nil, "/login", url.Values{"email": {"reset@example.com"}, "password": {"altes-passwort"}})
|
||||||
|
if oldLoginResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login mit altem Passwort status = %d, want 200 (abgelehnt)", oldLoginResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neues Passwort funktioniert.
|
||||||
|
newLoginResp := postForm(t, s, nil, "/login", url.Values{"email": {"reset@example.com"}, "password": {"neues-passwort-123"}})
|
||||||
|
if newLoginResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login mit neuem Passwort status = %d, want 303", newLoginResp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswortVergessenFuerUnbekannteEmailVerraetNichts(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s, fm := newServerWithMailer(t, fs)
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"gibt-es-nicht@example.com"}})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.Code)
|
||||||
|
}
|
||||||
|
if len(fm.Sent) != 0 {
|
||||||
|
t.Fatalf("got %d gesendete Mails für unbekannte E-Mail, want 0", len(fm.Sent))
|
||||||
|
}
|
||||||
|
if !containsHinweis(resp.Body.String()) {
|
||||||
|
t.Fatalf("erwartete generische Erfolgsmeldung, body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsHinweis(body string) bool {
|
||||||
|
return regexp.MustCompile(`Falls diese E-Mail-Adresse`).MatchString(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswortZuruecksetzenMitUngueltigemTokenGibt404(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, nil, "/passwort-zuruecksetzen/nicht-existierendes-token")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
internal/web/register_handlers.go
Normal file
146
internal/web/register_handlers.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// Ebene 3 — Register und Export (Schritt 6 der Baureihenfolge, siehe
|
||||||
|
// CLAUDE.md). Jede Genehmigung erzeugt automatisch einen Registereintrag
|
||||||
|
// (siehe handleFallEntscheiden); diese Seiten zeigen und exportieren
|
||||||
|
// sie. Reuse des bereits vorhandenen (bisher ungenutzten) go-pdf/fpdf-
|
||||||
|
// Dependency statt einer neuen Bibliothek für den PDF-Export.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-pdf/fpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type registerEintragView struct {
|
||||||
|
Zweck string
|
||||||
|
Abteilung string
|
||||||
|
Werkzeug string
|
||||||
|
Datenklasse string
|
||||||
|
Einstufung string
|
||||||
|
Auflagen string
|
||||||
|
Verantwortlicher string
|
||||||
|
EntschiedenAm string
|
||||||
|
GueltigBis string
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Eintraege []registerEintragView
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) registerEintraegeFuerAccount(r *http.Request) ([]registerEintragView, error) {
|
||||||
|
eintraege, err := s.store.ListRegistereintraegeForAccount(r.Context(), currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views := make([]registerEintragView, len(eintraege))
|
||||||
|
for i, e := range eintraege {
|
||||||
|
gueltigBis := ""
|
||||||
|
if e.GueltigBis != nil {
|
||||||
|
gueltigBis = e.GueltigBis.Format("02.01.2006")
|
||||||
|
}
|
||||||
|
views[i] = registerEintragView{
|
||||||
|
Zweck: e.Zweck, Abteilung: e.Abteilung, Werkzeug: e.Werkzeug,
|
||||||
|
Datenklasse: e.Datenklasse, Einstufung: e.Einstufung, Auflagen: strings.Join(e.Auflagen, "; "),
|
||||||
|
Verantwortlicher: e.Verantwortlicher, EntschiedenAm: e.EntschiedenAm.Format("02.01.2006 15:04"), GueltigBis: gueltigBis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegisterListe zeigt das Register (alle Genehmigungen des
|
||||||
|
// Mandanten) — Ebene 3, für verantwortlicher und pruefer gleichermaßen
|
||||||
|
// lesbar.
|
||||||
|
func (s *Server) handleRegisterListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
views, err := s.registerEintraegeFuerAccount(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []registerEintragView
|
||||||
|
for _, e := range views {
|
||||||
|
if matchesQuery(q, e.Zweck, e.Abteilung, e.Werkzeug, e.Verantwortlicher) {
|
||||||
|
gefiltert = append(gefiltert, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := registerListeData{
|
||||||
|
Title: "Register", Nav: navFor(r), Eintraege: paginate(gefiltert, pag),
|
||||||
|
Suche: q, Pagination: pag,
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "register-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var registerCSVHeader = []string{"Zweck", "Abteilung", "Werkzeug", "Datenklasse", "Einstufung", "Auflagen", "Verantwortliche/r", "Entschieden am", "Gültig bis"}
|
||||||
|
|
||||||
|
func registerCSVRow(e registerEintragView) []string {
|
||||||
|
return []string{e.Zweck, e.Abteilung, e.Werkzeug, e.Datenklasse, e.Einstufung, e.Auflagen, e.Verantwortlicher, e.EntschiedenAm, e.GueltigBis}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegisterExportCSV exportiert das Register als CSV.
|
||||||
|
func (s *Server) handleRegisterExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||||
|
views, err := s.registerEintraegeFuerAccount(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="register.csv"`)
|
||||||
|
cw := csv.NewWriter(w)
|
||||||
|
_ = cw.Write(registerCSVHeader)
|
||||||
|
for _, e := range views {
|
||||||
|
_ = cw.Write(registerCSVRow(e))
|
||||||
|
}
|
||||||
|
cw.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegisterExportPDF exportiert das Register als PDF — eine
|
||||||
|
// einfache Tabelle, keine Gestaltung über den Design-System-Anspruch
|
||||||
|
// des restlichen Produkts hinaus.
|
||||||
|
func (s *Server) handleRegisterExportPDF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
views, err := s.registerEintraegeFuerAccount(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf := fpdf.New("L", "mm", "A4", "")
|
||||||
|
// cp1252 deckt deutsche Umlaute/ß ab — die eingebauten Helvetica-
|
||||||
|
// Fonts von fpdf können sonst kein UTF-8 darstellen.
|
||||||
|
tr := pdf.UnicodeTranslatorFromDescriptor("")
|
||||||
|
pdf.AddPage()
|
||||||
|
pdf.SetFont("Helvetica", "B", 14)
|
||||||
|
pdf.CellFormat(0, 10, tr("Register"), "", 1, "L", false, 0, "")
|
||||||
|
pdf.Ln(2)
|
||||||
|
|
||||||
|
colWidths := []float64{45, 25, 35, 30, 30, 55, 40, 30, 25}
|
||||||
|
pdf.SetFont("Helvetica", "B", 8)
|
||||||
|
for i, h := range registerCSVHeader {
|
||||||
|
pdf.CellFormat(colWidths[i], 8, tr(h), "1", 0, "L", false, 0, "")
|
||||||
|
}
|
||||||
|
pdf.Ln(-1)
|
||||||
|
|
||||||
|
pdf.SetFont("Helvetica", "", 8)
|
||||||
|
for _, e := range views {
|
||||||
|
row := registerCSVRow(e)
|
||||||
|
for i, cell := range row {
|
||||||
|
pdf.CellFormat(colWidths[i], 8, tr(cell), "1", 0, "L", false, 0, "")
|
||||||
|
}
|
||||||
|
pdf.Ln(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="register.pdf"`)
|
||||||
|
if err := pdf.Output(w); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("PDF konnte nicht erzeugt werden: %v", err), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
142
internal/web/register_handlers_test.go
Normal file
142
internal/web/register_handlers_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// genehmigeFall reicht einen Antrag ein und genehmigt ihn mit einem
|
||||||
|
// echten, zulässigen Werkzeug — Vorbereitung für Register-Tests, die
|
||||||
|
// einen tatsächlichen Registereintrag brauchen.
|
||||||
|
func genehmigeFall(t *testing.T, fs *fakeStore, s *web.Server) (accountID string) {
|
||||||
|
t.Helper()
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Register-Mandant"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true}
|
||||||
|
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
createResp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
|
||||||
|
if createResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Antrag anlegen: status = %d, body: %s", createResp.Code, createResp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
if fs.antraege[id].AccountID == acc.ID {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verantwortlicherCookie := seedUserInAccount(t, fs, acc.ID, "verantwortlicher@example.com", "verantwortlicher")
|
||||||
|
entscheidenResp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"},
|
||||||
|
})
|
||||||
|
if entscheidenResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Entscheiden: status = %d, body: %s", entscheidenResp.Code, entscheidenResp.Body.String())
|
||||||
|
}
|
||||||
|
return acc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenehmigungErzeugtRegistereintrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID := genehmigeFall(t, fs, s)
|
||||||
|
|
||||||
|
list := fs.registereintraege[accountID]
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("registereintraege = %+v, want exactly one entry", list)
|
||||||
|
}
|
||||||
|
e := list[0]
|
||||||
|
if e.Werkzeug != "ChatGPT Enterprise" || e.Zweck != "Angebotstexte generieren" || e.Datenklasse != "personenbezogen" {
|
||||||
|
t.Errorf("Registereintrag = %+v, unerwartete Werte", e)
|
||||||
|
}
|
||||||
|
if e.Verantwortlicher != "verantwortlicher@example.com" {
|
||||||
|
t.Errorf("Verantwortlicher = %q, want verantwortlicher@example.com", e.Verantwortlicher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbgelehnteEntscheidungErzeugtKeinenRegistereintrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"abgelehnt"}, "begruendung": {"Kein Werkzeug verfügbar"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, list := range fs.registereintraege {
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("expected no registereintrag for an abgelehnt Entscheidung, got %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterListeZeigtGenehmigung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID := genehmigeFall(t, fs, s)
|
||||||
|
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Errorf("expected the genehmigtes werkzeug in the register, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterExportCSVEnthaeltEintrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID := genehmigeFall(t, fs, s)
|
||||||
|
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.csv")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if ct := resp.Header().Get("Content-Type"); !strings.Contains(ct, "text/csv") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/csv", ct)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Errorf("expected the werkzeug in the CSV export, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterExportPDFReturnsPDF(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID := genehmigeFall(t, fs, s)
|
||||||
|
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.pdf")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if ct := resp.Header().Get("Content-Type"); ct != "application/pdf" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/pdf", ct)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(resp.Body.String(), "%PDF") {
|
||||||
|
t.Error("expected the response body to start with the PDF magic bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessRegister(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/registereintraege")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter on /registereintraege", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/mail"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
@@ -28,20 +29,47 @@ var staticFS embed.FS
|
|||||||
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
||||||
// einen Fake statt eine echte Datenbank zu brauchen.
|
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
// WithTenantScope/SetTenantScope: siehe internal/store/tenant_scope.go
|
||||||
|
// (Row-Level-Security-Sitzungskontext). fakeStore implementiert beide
|
||||||
|
// als reinen Passthrough — Tests laufen ohne echtes Postgres/RLS,
|
||||||
|
// die Isolation wird dort weiterhin (wie bisher) auf Anwendungsebene
|
||||||
|
// geprüft; die tatsächliche RLS-Durchsetzung hat eigene Tests gegen
|
||||||
|
// echtes Postgres, siehe internal/store/tenant_scope_test.go.
|
||||||
|
WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error
|
||||||
|
SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error
|
||||||
|
|
||||||
|
CreateAccount(ctx context.Context, in store.AccountInput) (store.Account, error)
|
||||||
|
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
|
||||||
|
UpdateAccountDetails(ctx context.Context, id string, in store.AccountInput) (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)
|
||||||
GetUser(ctx context.Context, id string) (store.User, error)
|
GetUser(ctx context.Context, id string) (store.User, error)
|
||||||
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||||
|
SetUserActive(ctx context.Context, id string, active bool) error
|
||||||
|
SetUserPassword(ctx context.Context, id, passwordHash string) error
|
||||||
|
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
|
||||||
|
ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error)
|
||||||
|
UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error)
|
||||||
|
GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error)
|
||||||
|
ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error)
|
||||||
|
DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error
|
||||||
|
CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error)
|
||||||
|
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
|
||||||
|
MarkPasswordResetTokenUsed(ctx context.Context, id string) error
|
||||||
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||||
|
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
|
||||||
GetSession(ctx context.Context, token string) (store.Session, error)
|
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||||
DeleteSession(ctx context.Context, token string) error
|
DeleteSession(ctx context.Context, token string) error
|
||||||
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
||||||
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
||||||
|
|
||||||
ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error)
|
ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error)
|
||||||
|
CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error)
|
||||||
|
DeleteAbteilung(ctx context.Context, id string) error
|
||||||
CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error)
|
CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error)
|
||||||
GetAntrag(ctx context.Context, id string) (store.Antrag, error)
|
GetAntrag(ctx context.Context, id string) (store.Antrag, error)
|
||||||
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
|
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
|
||||||
@@ -50,13 +78,46 @@ type Store interface {
|
|||||||
ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error)
|
ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error)
|
||||||
|
|
||||||
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
|
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
|
||||||
|
ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error)
|
||||||
|
CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error)
|
||||||
|
DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error
|
||||||
|
ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error)
|
||||||
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
|
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
|
||||||
|
CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error)
|
||||||
|
UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error)
|
||||||
|
DeleteWerkzeug(ctx context.Context, id string) error
|
||||||
CurrentKatalogVersion(ctx context.Context) (string, error)
|
CurrentKatalogVersion(ctx context.Context) (string, error)
|
||||||
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
|
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
|
||||||
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
|
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
|
||||||
|
|
||||||
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
|
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
|
||||||
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error)
|
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error)
|
||||||
|
ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error)
|
||||||
|
|
||||||
|
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
|
||||||
|
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
|
||||||
|
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
|
||||||
|
|
||||||
|
CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error)
|
||||||
|
GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error)
|
||||||
|
ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error)
|
||||||
|
DeleteGenehmigerRolle(ctx context.Context, id string) error
|
||||||
|
AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error
|
||||||
|
RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error
|
||||||
|
ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error)
|
||||||
|
ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error)
|
||||||
|
|
||||||
|
CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error)
|
||||||
|
ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error)
|
||||||
|
GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error)
|
||||||
|
DeleteFreigabeRegel(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error)
|
||||||
|
GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error)
|
||||||
|
ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error)
|
||||||
|
ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error)
|
||||||
|
EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error)
|
||||||
|
KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
||||||
@@ -75,11 +136,15 @@ type Server struct {
|
|||||||
store Store
|
store Store
|
||||||
regelwerk Regelwerk
|
regelwerk Regelwerk
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
|
mailer mail.Mailer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
||||||
// wird einmal beim Start geladen, nicht pro Request.
|
// wird einmal beim Start geladen, nicht pro Request. mailer ist
|
||||||
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
// austauschbar (siehe mail.FakeMailer für Tests) — ohne konfigurierten
|
||||||
|
// SMTP-Server schlägt nur der tatsächliche Versand fehl (z. B.
|
||||||
|
// Passwort-Zurücksetzen), der restliche Server bleibt funktionsfähig.
|
||||||
|
func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, error) {
|
||||||
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||||
@@ -89,14 +154,32 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
|||||||
store: st,
|
store: st,
|
||||||
regelwerk: regelwerk,
|
regelwerk: regelwerk,
|
||||||
templates: tmpl,
|
templates: tmpl,
|
||||||
|
mailer: mailer,
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
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("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
|
||||||
|
mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern))
|
||||||
|
mux.HandleFunc("GET /verwaltung/firma", s.requireAdmin(s.handleFirmendaten))
|
||||||
|
mux.HandleFunc("POST /verwaltung/firma", s.requireAdmin(s.handleFirmendatenSpeichern))
|
||||||
|
mux.HandleFunc("GET /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlagenListe))
|
||||||
|
mux.HandleFunc("GET /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageBearbeitenForm))
|
||||||
|
mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageSpeichern))
|
||||||
|
mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}/zuruecksetzen", s.requireAdmin(s.handleEmailVorlageZuruecksetzen))
|
||||||
|
mux.HandleFunc("GET /betreiber/email-vorlagen", s.requireBetreiber(s.handleBetreiberEmailVorlagenListe))
|
||||||
|
mux.HandleFunc("GET /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageBearbeitenForm))
|
||||||
|
mux.HandleFunc("POST /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageSpeichern))
|
||||||
|
mux.HandleFunc("GET /passwort-vergessen", s.handlePasswortVergessenForm)
|
||||||
|
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
|
||||||
|
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)
|
||||||
|
mux.HandleFunc("POST /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzen)
|
||||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||||
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
||||||
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
|
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
|
||||||
@@ -106,13 +189,54 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
|||||||
mux.HandleFunc("GET /faelle", s.requireFachebene(s.handleFaelleListe))
|
mux.HandleFunc("GET /faelle", s.requireFachebene(s.handleFaelleListe))
|
||||||
mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail))
|
mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail))
|
||||||
mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden))
|
mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden))
|
||||||
|
mux.HandleFunc("GET /registereintraege", s.requireFachebene(s.handleRegisterListe))
|
||||||
|
mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV))
|
||||||
|
mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF))
|
||||||
|
mux.HandleFunc("GET /wiedervorlage", s.requireFachebene(s.handleWiedervorlageListe))
|
||||||
mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe))
|
mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe))
|
||||||
mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm))
|
mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm))
|
||||||
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}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
|
||||||
|
mux.HandleFunc("GET /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportForm))
|
||||||
|
mux.HandleFunc("POST /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportUpload))
|
||||||
|
mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
|
||||||
|
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
|
||||||
|
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
|
||||||
|
mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate))
|
||||||
|
mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete))
|
||||||
|
mux.HandleFunc("GET /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugListe))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/sperren", s.requireAdmin(s.handleMandantWerkzeugSperren))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren))
|
||||||
|
mux.HandleFunc("GET /verwaltung/werkzeuge/neu", s.requireAdmin(s.handleMandantWerkzeugNeuForm))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugCreate))
|
||||||
|
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
||||||
|
mux.HandleFunc("GET /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRollenListe))
|
||||||
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRolleCreate))
|
||||||
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/loeschen", s.requireAdmin(s.handleGenehmigerRolleDelete))
|
||||||
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder", s.requireAdmin(s.handleGenehmigerRolleMitgliedHinzufuegen))
|
||||||
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder/{userID}/entfernen", s.requireAdmin(s.handleGenehmigerRolleMitgliedEntfernen))
|
||||||
|
mux.HandleFunc("GET /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelnListe))
|
||||||
|
mux.HandleFunc("POST /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelCreate))
|
||||||
|
mux.HandleFunc("POST /verwaltung/freigabe-regeln/{id}/loeschen", s.requireAdmin(s.handleFreigabeRegelDelete))
|
||||||
|
mux.HandleFunc("GET /freigaben", s.requirePage(s.handleFreigabenListe))
|
||||||
|
mux.HandleFunc("POST /freigaben/{id}/entscheiden", s.requireAPI(s.handleFreigabeEntscheiden))
|
||||||
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||||
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||||
|
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountCreate))
|
||||||
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts/{id}/umbenennen", s.requireBetreiber(s.handleBetreiberAccountRename))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls))
|
||||||
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
||||||
|
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
||||||
|
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
||||||
|
mux.HandleFunc("POST /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugCreate))
|
||||||
|
mux.HandleFunc("GET /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugEditForm))
|
||||||
|
mux.HandleFunc("POST /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugUpdate))
|
||||||
|
mux.HandleFunc("POST /betreiber/werkzeuge/{id}/loeschen", s.requireBetreiber(s.handleBetreiberWerkzeugDelete))
|
||||||
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||||
s.mux = mux
|
s.mux = mux
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/auth"
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/mail"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
@@ -24,47 +25,191 @@ const testSessionCookie = "deklarix_session"
|
|||||||
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
|
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
|
||||||
// Handler-Tests keine echte Postgres-Instanz brauchen.
|
// Handler-Tests keine echte Postgres-Instanz brauchen.
|
||||||
type fakeStore struct {
|
type fakeStore struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
nextID int
|
nextID int
|
||||||
accounts map[string]store.Account
|
accounts map[string]store.Account
|
||||||
users map[string]store.User
|
users map[string]store.User
|
||||||
usersByEmail map[string]string // email -> user id
|
usersByEmail map[string]string // email -> user id
|
||||||
sessions map[string]store.Session
|
sessions map[string]store.Session
|
||||||
auditLog []store.AuditEntry
|
auditLog []store.AuditEntry
|
||||||
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
|
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
|
||||||
antraege map[string]store.Antrag
|
antraege map[string]store.Antrag
|
||||||
werkzeuge map[string]store.Werkzeug
|
werkzeuge map[string]store.Werkzeug
|
||||||
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
|
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
|
||||||
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
|
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
|
||||||
|
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
|
||||||
|
werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen
|
||||||
|
|
||||||
|
genehmigerRollen map[string]store.GenehmigerRolle
|
||||||
|
nutzerGenehmigerRollen map[string]map[string]bool // userID -> Set von genehmigerRolleID
|
||||||
|
freigabeRegeln map[string]store.FreigabeRegel
|
||||||
|
freigabeschritte map[string]store.Freigabeschritt
|
||||||
|
passwordResetTokens map[string]store.PasswordResetToken
|
||||||
|
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
|
||||||
|
emailVorlagen map[string]store.EmailVorlage // key: accountKeyFor(accountID)+"|"+typ
|
||||||
|
}
|
||||||
|
|
||||||
|
// accountKeyFor macht nil und "" für die Plattform-Vorlage im
|
||||||
|
// fakeStore-Key ununterscheidbar von einer echten, aber leeren
|
||||||
|
// account_id — genügt für Tests, da echte account_ids nie leer sind.
|
||||||
|
func accountKeyFor(accountID *string) string {
|
||||||
|
if accountID == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *accountID
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeStore() *fakeStore {
|
func newFakeStore() *fakeStore {
|
||||||
return &fakeStore{
|
return &fakeStore{
|
||||||
accounts: map[string]store.Account{},
|
accounts: map[string]store.Account{},
|
||||||
users: map[string]store.User{},
|
users: map[string]store.User{},
|
||||||
usersByEmail: map[string]string{},
|
usersByEmail: map[string]string{},
|
||||||
sessions: map[string]store.Session{},
|
sessions: map[string]store.Session{},
|
||||||
abteilungen: map[string][]store.Abteilung{},
|
abteilungen: map[string][]store.Abteilung{},
|
||||||
antraege: map[string]store.Antrag{},
|
antraege: map[string]store.Antrag{},
|
||||||
werkzeuge: map[string]store.Werkzeug{},
|
werkzeuge: map[string]store.Werkzeug{},
|
||||||
bewertungen: map[string][]store.Bewertung{},
|
bewertungen: map[string][]store.Bewertung{},
|
||||||
entscheidungen: map[string][]store.Entscheidung{},
|
entscheidungen: map[string][]store.Entscheidung{},
|
||||||
|
registereintraege: map[string][]store.Registereintrag{},
|
||||||
|
werkzeugSperren: map[string][]store.WerkzeugSperre{},
|
||||||
|
genehmigerRollen: map[string]store.GenehmigerRolle{},
|
||||||
|
nutzerGenehmigerRollen: map[string]map[string]bool{},
|
||||||
|
freigabeRegeln: map[string]store.FreigabeRegel{},
|
||||||
|
freigabeschritte: map[string]store.Freigabeschritt{},
|
||||||
|
passwordResetTokens: map[string]store.PasswordResetToken{},
|
||||||
|
loeschfristen: map[string]map[string]int{},
|
||||||
|
emailVorlagen: map[string]store.EmailVorlage{
|
||||||
|
// Platzhalter für den plattformweiten Standard, wie ihn
|
||||||
|
// Migration 0022 für echtes Postgres seedet — sonst würde
|
||||||
|
// ResolveEmailVorlage im fakeStore ErrNotFound liefern,
|
||||||
|
// wo die echte DB immer einen Treffer hat.
|
||||||
|
"|passwort_zuruecksetzen": {
|
||||||
|
Typ: "passwort_zuruecksetzen", Betreff: "Deklarix — Passwort zurücksetzen",
|
||||||
|
Text: "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail.",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithTenantScope/SetTenantScope sind im fakeStore reine Passthroughs —
|
||||||
|
// fakeStore hat kein RLS-Äquivalent, Tests prüfen Isolation weiterhin
|
||||||
|
// wie bisher auf Anwendungsebene (AccountID-Vergleich in den Handlern).
|
||||||
|
func (f *fakeStore) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error {
|
||||||
|
return fn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
key := accountKeyFor(accountID) + "|" + typ
|
||||||
|
v := store.EmailVorlage{ID: f.newID(), AccountID: accountID, Typ: typ, Betreff: betreff, Text: text, UpdatedAt: time.Now()}
|
||||||
|
f.emailVorlagen[key] = v
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
v, ok := f.emailVorlagen[accountKeyFor(accountID)+"|"+typ]
|
||||||
|
if !ok {
|
||||||
|
return store.EmailVorlage{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if v, ok := f.emailVorlagen[accountID+"|"+typ]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if v, ok := f.emailVorlagen["|"+typ]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return store.EmailVorlage{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
key := accountKeyFor(accountID) + "|" + typ
|
||||||
|
if _, ok := f.emailVorlagen[key]; !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
delete(f.emailVorlagen, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) newID() string {
|
func (f *fakeStore) newID() string {
|
||||||
f.nextID++
|
f.nextID++
|
||||||
return fmt.Sprintf("id-%d", f.nextID)
|
return fmt.Sprintf("id-%d", f.nextID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
|
func (f *fakeStore) CreateAccount(ctx context.Context, in store.AccountInput) (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: in.Name, EinladungToken: "einladung-token-" + id,
|
||||||
|
Strasse: in.Strasse, PLZ: in.PLZ, Ort: in.Ort, Land: in.Land,
|
||||||
|
UStID: in.UStID, Rechnungsemail: in.Rechnungsemail, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
f.accounts[acc.ID] = acc
|
f.accounts[acc.ID] = acc
|
||||||
return acc, nil
|
return acc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
acc, ok := f.accounts[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Account{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
acc.Name = name
|
||||||
|
f.accounts[id] = acc
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpdateAccountDetails(ctx context.Context, id string, in store.AccountInput) (store.Account, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
acc, ok := f.accounts[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Account{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
acc.Name, acc.Strasse, acc.PLZ, acc.Ort, acc.Land, acc.UStID, acc.Rechnungsemail =
|
||||||
|
in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail
|
||||||
|
f.accounts[id] = acc
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, acc := range f.accounts {
|
||||||
|
if acc.EinladungToken == token {
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.Account{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
acc, ok := f.accounts[accountID]
|
||||||
|
if !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
acc.EinladungToken = newToken
|
||||||
|
f.accounts[accountID] = acc
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
|
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()
|
||||||
@@ -93,7 +238,7 @@ func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHa
|
|||||||
}
|
}
|
||||||
u := store.User{
|
u := store.User{
|
||||||
ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash,
|
ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash,
|
||||||
Role: role, CreatedAt: time.Now(),
|
Role: role, Active: true, CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
f.users[u.ID] = u
|
f.users[u.ID] = u
|
||||||
f.usersByEmail[email] = u.ID
|
f.usersByEmail[email] = u.ID
|
||||||
@@ -120,6 +265,85 @@ func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error)
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetUserActive(ctx context.Context, id string, active bool) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
u, ok := f.users[id]
|
||||||
|
if !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
u.Active = active
|
||||||
|
f.users[id] = u
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
u, ok := f.users[id]
|
||||||
|
if !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
u.PasswordHash = passwordHash
|
||||||
|
f.users[id] = u
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
t := store.PasswordResetToken{
|
||||||
|
ID: f.newID(), UserID: userID, Token: token,
|
||||||
|
ExpiresAt: time.Now().Add(store.PasswordResetTokenDuration), CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.passwordResetTokens[t.ID] = t
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, t := range f.passwordResetTokens {
|
||||||
|
if t.Token == token && t.UsedAt == nil && t.ExpiresAt.After(time.Now()) {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.PasswordResetToken{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
t, ok := f.passwordResetTokens[id]
|
||||||
|
if !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
t.UsedAt = &now
|
||||||
|
f.passwordResetTokens[id] = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.loeschfristen[accountID] == nil {
|
||||||
|
f.loeschfristen[accountID] = map[string]int{}
|
||||||
|
}
|
||||||
|
f.loeschfristen[accountID][datenklasseID] = maxTage
|
||||||
|
return store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage, UpdatedAt: time.Now()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.LoeschfristEinstellung
|
||||||
|
for datenklasseID, maxTage := range f.loeschfristen[accountID] {
|
||||||
|
out = append(out, store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -140,6 +364,14 @@ func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, exp
|
|||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sess := store.Session{Token: token, UserID: userID, ImpersonatedByUserID: &impersonatedByUserID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
|
||||||
|
f.sessions[token] = sess
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -187,6 +419,41 @@ func (f *fakeStore) ListAbteilungenForAccount(ctx context.Context, accountID str
|
|||||||
return f.abteilungen[accountID], nil
|
return f.abteilungen[accountID], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetAbteilung(ctx context.Context, id string) (store.Abteilung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, list := range f.abteilungen {
|
||||||
|
for _, a := range list {
|
||||||
|
if a.ID == id {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.Abteilung{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
a := store.Abteilung{ID: f.newID(), AccountID: accountID, Name: name, CreatedAt: time.Now()}
|
||||||
|
f.abteilungen[accountID] = append(f.abteilungen[accountID], a)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteAbteilung(ctx context.Context, id string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for accID, list := range f.abteilungen {
|
||||||
|
for i, a := range list {
|
||||||
|
if a.ID == id {
|
||||||
|
f.abteilungen[accID] = append(list[:i], list[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) {
|
func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -269,6 +536,85 @@ func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID strin
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Werkzeug
|
||||||
|
for _, w := range f.werkzeuge {
|
||||||
|
if w.AccountID == nil {
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sp := store.WerkzeugSperre{ID: f.newID(), AccountID: accountID, WerkzeugID: werkzeugID, Grund: grund, GesperrtAm: time.Now()}
|
||||||
|
f.werkzeugSperren[accountID] = append(f.werkzeugSperren[accountID], sp)
|
||||||
|
return sp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
list := f.werkzeugSperren[accountID]
|
||||||
|
for i, sp := range list {
|
||||||
|
if sp.WerkzeugID == werkzeugID {
|
||||||
|
f.werkzeugSperren[accountID] = append(list[:i], list[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.werkzeugSperren[accountID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
w := store.Werkzeug{
|
||||||
|
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
|
||||||
|
Verarbeitungslaender: in.Verarbeitungslaender, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
|
||||||
|
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, DPFZertifiziert: in.DPFZertifiziert, AufbewahrungTage: in.AufbewahrungTage,
|
||||||
|
Zertifizierungen: in.Zertifizierungen, Subprozessoren: in.Subprozessoren, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
|
||||||
|
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.werkzeuge[w.ID] = w
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
w, ok := f.werkzeuge[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Werkzeug{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
w.Name, w.Anbieter, w.Verarbeitungslaender = in.Name, in.Anbieter, in.Verarbeitungslaender
|
||||||
|
w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL
|
||||||
|
w.TrainingOptOut, w.TrainingStandard, w.DPFZertifiziert, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage
|
||||||
|
w.Zertifizierungen, w.Subprozessoren, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.Subprozessoren, in.GeeigneteZwecke, in.Einschraenkungen
|
||||||
|
w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now()
|
||||||
|
f.werkzeuge[id] = w
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteWerkzeug(ctx context.Context, id string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, ok := f.werkzeuge[id]; !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
delete(f.werkzeuge, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) {
|
func (f *fakeStore) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -333,6 +679,248 @@ func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID
|
|||||||
return es[len(es)-1], nil
|
return es[len(es)-1], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Entscheidung
|
||||||
|
for antragID, a := range f.antraege {
|
||||||
|
if a.AccountID != accountID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
es := f.entscheidungen[antragID]
|
||||||
|
if len(es) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
latest := es[len(es)-1]
|
||||||
|
if latest.Entscheidung == "genehmigt" || latest.Entscheidung == "genehmigt_mit_auflagen" {
|
||||||
|
out = append(out, latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
e := store.Registereintrag{
|
||||||
|
ID: f.newID(), AccountID: in.AccountID, AntragID: in.AntragID, EntscheidungID: in.EntscheidungID,
|
||||||
|
Zweck: in.Zweck, Abteilung: in.Abteilung, Werkzeug: in.Werkzeug,
|
||||||
|
Datenklasse: in.Datenklasse, Einstufung: in.Einstufung, Auflagen: in.Auflagen,
|
||||||
|
Verantwortlicher: in.Verantwortlicher, EntschiedenAm: in.EntschiedenAm, GueltigBis: in.GueltigBis,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.registereintraege[in.AccountID] = append(f.registereintraege[in.AccountID], e)
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.registereintraege[accountID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
g := store.GenehmigerRolle{ID: f.newID(), AccountID: accountID, Name: name, Beschreibung: beschreibung, CreatedAt: time.Now()}
|
||||||
|
f.genehmigerRollen[g.ID] = g
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
g, ok := f.genehmigerRollen[id]
|
||||||
|
if !ok {
|
||||||
|
return store.GenehmigerRolle{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.GenehmigerRolle
|
||||||
|
for _, g := range f.genehmigerRollen {
|
||||||
|
if g.AccountID == accountID {
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, ok := f.genehmigerRollen[id]; !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
delete(f.genehmigerRollen, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.nutzerGenehmigerRollen[userID] == nil {
|
||||||
|
f.nutzerGenehmigerRollen[userID] = map[string]bool{}
|
||||||
|
}
|
||||||
|
f.nutzerGenehmigerRollen[userID][genehmigerRolleID] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
delete(f.nutzerGenehmigerRollen[userID], genehmigerRolleID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.GenehmigerRolle
|
||||||
|
for grID := range f.nutzerGenehmigerRollen[userID] {
|
||||||
|
if g, ok := f.genehmigerRollen[grID]; ok {
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.GenehmigerMitglied
|
||||||
|
for userID, set := range f.nutzerGenehmigerRollen {
|
||||||
|
if !set[genehmigerRolleID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u, ok := f.users[userID]; ok {
|
||||||
|
out = append(out, store.GenehmigerMitglied{UserID: u.ID, Email: u.Email})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
fr := store.FreigabeRegel{
|
||||||
|
ID: f.newID(), AccountID: accountID, BedingungTyp: bedingungTyp,
|
||||||
|
BedingungWert: bedingungWert, GenehmigerRolleID: genehmigerRolleID, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.freigabeRegeln[fr.ID] = fr
|
||||||
|
return fr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.FreigabeRegel
|
||||||
|
for _, fr := range f.freigabeRegeln {
|
||||||
|
if fr.AccountID == accountID {
|
||||||
|
out = append(out, fr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
fr, ok := f.freigabeRegeln[id]
|
||||||
|
if !ok {
|
||||||
|
return store.FreigabeRegel{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return fr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, ok := f.freigabeRegeln[id]; !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
delete(f.freigabeRegeln, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
fs := store.Freigabeschritt{
|
||||||
|
ID: f.newID(), AntragID: antragID, GenehmigerRolleID: genehmigerRolleID,
|
||||||
|
Status: "ausstehend", CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.freigabeschritte[fs.ID] = fs
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
fs, ok := f.freigabeschritte[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Freigabeschritt{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Freigabeschritt
|
||||||
|
for _, fs := range f.freigabeschritte {
|
||||||
|
if fs.AntragID == antragID {
|
||||||
|
out = append(out, fs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Freigabeschritt
|
||||||
|
for grID := range f.nutzerGenehmigerRollen[userID] {
|
||||||
|
for _, fs := range f.freigabeschritte {
|
||||||
|
if fs.GenehmigerRolleID == grID && fs.Status == "ausstehend" {
|
||||||
|
out = append(out, fs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
fs, ok := f.freigabeschritte[id]
|
||||||
|
if !ok || fs.Status != "ausstehend" {
|
||||||
|
return store.Freigabeschritt{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
fs.Status, fs.EntschiedenVon, fs.EntschiedenAm, fs.Kommentar = status, &entschiedenVon, &now, kommentar
|
||||||
|
f.freigabeschritte[id] = fs
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for id, fs := range f.freigabeschritte {
|
||||||
|
if fs.AntragID == antragID && fs.Status == "ausstehend" && id != ausloesenderSchrittID {
|
||||||
|
fs.Status = "abgelehnt"
|
||||||
|
now := time.Now()
|
||||||
|
fs.EntschiedenAm = &now
|
||||||
|
fs.Kommentar = "Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde."
|
||||||
|
f.freigabeschritte[id] = fs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Test-Setup ───────────────────────────────────────────────────────
|
// ─── Test-Setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
|
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
|
||||||
@@ -359,11 +947,18 @@ func loadTestRegelwerk(t *testing.T) web.Regelwerk {
|
|||||||
|
|
||||||
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, err := web.NewServer(fs, loadTestRegelwerk(t))
|
s, _ := newServerWithMailer(t, fs)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServerWithMailer(t *testing.T, fs *fakeStore) (*web.Server, *mail.FakeMailer) {
|
||||||
|
t.Helper()
|
||||||
|
fm := &mail.FakeMailer{}
|
||||||
|
s, err := web.NewServer(fs, loadTestRegelwerk(t), fm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewServer: %v", err)
|
t.Fatalf("NewServer: %v", err)
|
||||||
}
|
}
|
||||||
return s
|
return s, fm
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
|
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
|
||||||
@@ -372,7 +967,7 @@ func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
|||||||
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
|
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
acc, err := fs.CreateAccount(ctx, accountName)
|
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: accountName})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -432,6 +1027,28 @@ func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) {
|
|||||||
return s, fs, cookie
|
return s, fs, cookie
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// firmenPflichtfelder liefert die seit Migration 0023 bei Firmenanlage
|
||||||
|
// (Registrierung und Betreiber-Firmenanlage) verlangten Adress-/
|
||||||
|
// Abrechnungsfelder — gemeinsame Basis für Tests, die nicht speziell
|
||||||
|
// deren Validierung prüfen, sonst müsste jeder Testfall sie einzeln
|
||||||
|
// nachpflegen.
|
||||||
|
func firmenPflichtfelder() url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"strasse": {"Teststraße 1"}, "plz": {"12345"}, "ort": {"Teststadt"},
|
||||||
|
"land": {"Deutschland"}, "rechnungsemail": {"rechnung@example.com"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeValues kopiert alle Werte aus extra in base (base wird mutiert
|
||||||
|
// und zurückgegeben) — für Tests, die ein Basisformular um wenige
|
||||||
|
// eigene Felder ergänzen wollen.
|
||||||
|
func mergeValues(base url.Values, extra url.Values) url.Values {
|
||||||
|
for k, v := range extra {
|
||||||
|
base[k] = v
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder {
|
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||||
@@ -493,10 +1110,10 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
|||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
|
|
||||||
regResp := postForm(t, s, nil, "/register", url.Values{
|
regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{
|
||||||
"account_name": {"Meine Firma"}, "email": {"neu@example.com"},
|
"account_name": {"Meine Firma"}, "email": {"neu@example.com"},
|
||||||
"password": {"ein-sicheres-passwort"},
|
"password": {"ein-sicheres-passwort"},
|
||||||
})
|
}, firmenPflichtfelder()))
|
||||||
if regResp.Code != http.StatusSeeOther {
|
if regResp.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
||||||
}
|
}
|
||||||
@@ -519,13 +1136,60 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
|
||||||
|
regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{
|
||||||
|
"account_name": {"Meine Firma"}, "email": {"neu2@example.com"},
|
||||||
|
"password": {"ein-sicheres-passwort"},
|
||||||
|
}, firmenPflichtfelder()))
|
||||||
|
if regResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountID string
|
||||||
|
for _, u := range fs.users {
|
||||||
|
if u.Email == "neu2@example.com" {
|
||||||
|
accountID = u.AccountID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accountID == "" {
|
||||||
|
t.Fatal("neuer Nutzer nicht gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
rollen, err := fs.ListGenehmigerRollenForAccount(context.Background(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListGenehmigerRollenForAccount: %v", err)
|
||||||
|
}
|
||||||
|
wantNamen := []string{"Datenschutzbeauftragter", "Geschäftsführer", "KI-Manager", "CISO"}
|
||||||
|
if len(rollen) != len(wantNamen) {
|
||||||
|
t.Fatalf("got %d Genehmiger-Rollen, want %d", len(rollen), len(wantNamen))
|
||||||
|
}
|
||||||
|
var namen []string
|
||||||
|
for _, r := range rollen {
|
||||||
|
namen = append(namen, r.Name)
|
||||||
|
}
|
||||||
|
for _, want := range wantNamen {
|
||||||
|
found := false
|
||||||
|
for _, n := range namen {
|
||||||
|
if n == want {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("Standard-Rolle %q wurde nicht angelegt, vorhanden: %v", want, namen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
form := url.Values{
|
form := mergeValues(url.Values{
|
||||||
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
||||||
"password": {"ein-sicheres-passwort"},
|
"password": {"ein-sicheres-passwort"},
|
||||||
}
|
}, firmenPflichtfelder())
|
||||||
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
|
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("first register status = %d, want 303", resp.Code)
|
t.Fatalf("first register status = %d, want 303", resp.Code)
|
||||||
}
|
}
|
||||||
@@ -545,7 +1209,7 @@ func TestLoginWithCorrectPassword(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("HashPassword: %v", err)
|
t.Fatalf("HashPassword: %v", err)
|
||||||
}
|
}
|
||||||
acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant")
|
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Bestehender Mandant"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -569,7 +1233,7 @@ func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("HashPassword: %v", err)
|
t.Fatalf("HashPassword: %v", err)
|
||||||
}
|
}
|
||||||
acc, err := fs.CreateAccount(context.Background(), "Deklarix Betreiber")
|
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Deklarix Betreiber"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -593,7 +1257,7 @@ func TestLoginRejectsWrongPassword(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("HashPassword: %v", err)
|
t.Fatalf("HashPassword: %v", err)
|
||||||
}
|
}
|
||||||
acc, _ := fs.CreateAccount(context.Background(), "X")
|
acc, _ := fs.CreateAccount(context.Background(), store.AccountInput{Name: "X"})
|
||||||
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil {
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
@@ -666,7 +1330,7 @@ func TestHandleIndexRendersForMitarbeiter(t *testing.T) {
|
|||||||
func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) {
|
func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) {
|
||||||
s, _, cookie := newAuthedTestServer(t)
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log"} {
|
for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log", "/betreiber/werkzeuge"} {
|
||||||
resp := getWithCookie(t, s, cookie, path)
|
resp := getWithCookie(t, s, cookie, path)
|
||||||
if resp.Code != http.StatusNotFound {
|
if resp.Code != http.StatusNotFound {
|
||||||
t.Errorf("GET %s status = %d, want 404 for a non-betreiber user", path, resp.Code)
|
t.Errorf("GET %s status = %d, want 404 for a non-betreiber user", path, resp.Code)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
|
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
|
||||||
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
|
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
|
||||||
Marke (Primärblau #1677ff, Inter, Radius-Skala), aber ohne dessen
|
Marke (Primärblau #1677ff, Inter, Radius-Skala). Mobile-first:
|
||||||
Ant-Design-/Desktop-Sidebar-Layout, das für Deklarix nicht passt.
|
Basis-Stile gelten fürs Telefon (Nav als aufklappbares <details>-Menü
|
||||||
Mobile-first: Basis-Stile gelten fürs Telefon, @media (min-width)
|
ohne JavaScript), @media (min-width: 960px) verwandelt dieselbe
|
||||||
erweitert für größere Bildschirme. */
|
Nav-Markup-Struktur per CSS in eine feste Seitenleiste (siehe
|
||||||
|
.topbar/.app-nav) — kein separates Desktop-Template nötig. */
|
||||||
|
|
||||||
@import url('/static/inter.css');
|
@import url('/static/inter.css');
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
--radius-lg: 10px;
|
--radius-lg: 10px;
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
--color-bg: #f8fafc;
|
--color-bg: #f8fafc;
|
||||||
--color-text: #334155;
|
--color-text: #334155;
|
||||||
@@ -27,6 +29,12 @@
|
|||||||
--color-mittel-bg: #fffbeb;
|
--color-mittel-bg: #fffbeb;
|
||||||
--color-niedrig: #166534;
|
--color-niedrig: #166534;
|
||||||
--color-niedrig-bg: #f0fdf4;
|
--color-niedrig-bg: #f0fdf4;
|
||||||
|
|
||||||
|
--sidebar-width: 220px;
|
||||||
|
--sidebar-bg: linear-gradient(180deg, #0b1426 0%, #101d33 50%, #0d1829 100%);
|
||||||
|
--sidebar-text: #b6c2d9;
|
||||||
|
--sidebar-text-hover: #ffffff;
|
||||||
|
--sidebar-hover-bg: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -51,7 +59,15 @@ h1, h2, h3 {
|
|||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
margin: 0 0 0.5em;
|
margin: 0 0 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.0625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -74,23 +90,109 @@ a {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
/* ── Navigation ──────────────────────────────────────────────────────
|
||||||
|
Mobil: .topbar ist eine schmale, fixierte Kopfleiste mit einem
|
||||||
|
aufklappbaren <details>-Menü (siehe layout.html) — kein JavaScript,
|
||||||
|
das <details>-Element liefert Auf-/Zuklappen nativ. Ab Desktop-Breite
|
||||||
|
(siehe Media Query unten) wird dieselbe Markup-Struktur per CSS in
|
||||||
|
eine permanent sichtbare, dunkle Seitenleiste verwandelt. */
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a {
|
.brand {
|
||||||
color: var(--color-muted);
|
font-weight: 600;
|
||||||
|
color: var(--color-heading);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox-Hack: eine unsichtbare Checkbox plus ein <label for=...>
|
||||||
|
als Klick-/Tap-Ziel steuern per CSS-Geschwister-Selektor, ob .app-nav
|
||||||
|
sichtbar ist — funktioniert ohne JavaScript und ohne die Falle von
|
||||||
|
<details> (siehe Kommentar in layout.html). */
|
||||||
|
.nav-toggle {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle-label {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle:focus-visible ~ .nav-toggle-label {
|
||||||
|
outline: 2px solid var(--branding-primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle:checked ~ .nav-toggle-label {
|
||||||
|
border-color: var(--branding-primary);
|
||||||
|
color: var(--branding-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 16px;
|
||||||
|
z-index: 20;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle:checked ~ .app-nav {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a:hover {
|
.app-nav a:hover {
|
||||||
color: var(--color-text);
|
background: var(--color-bg);
|
||||||
|
color: var(--branding-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav form {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-hoch);
|
||||||
|
border: 1px solid var(--color-hoch-bg);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
|
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
|
||||||
@@ -181,6 +283,38 @@ nav button {
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Support-Login-Hinweis (siehe currentImpersonator in middleware.go) —
|
||||||
|
bewusst auffällig (nicht im normalen .hinweis-Grauton), damit niemand
|
||||||
|
vergisst, gerade im Kontext eines fremden Kontos zu handeln. */
|
||||||
|
.impersonation-banner {
|
||||||
|
background: var(--color-mittel-bg);
|
||||||
|
color: var(--color-mittel);
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-mittel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.impersonation-banner form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.impersonation-banner button {
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.impersonation-banner {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fehler {
|
.fehler {
|
||||||
background: var(--color-hoch-bg);
|
background: var(--color-hoch-bg);
|
||||||
color: var(--color-hoch);
|
color: var(--color-hoch);
|
||||||
@@ -266,6 +400,16 @@ nav button {
|
|||||||
color: var(--color-mittel);
|
color: var(--color-mittel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-niedrig {
|
||||||
|
background: var(--color-niedrig-bg);
|
||||||
|
color: var(--color-niedrig);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-hoch {
|
||||||
|
background: var(--color-hoch-bg);
|
||||||
|
color: var(--color-hoch);
|
||||||
|
}
|
||||||
|
|
||||||
.beitraege-liste {
|
.beitraege-liste {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
@@ -275,7 +419,12 @@ nav button {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.beitraege-liste li a {
|
.beitraege-liste li a,
|
||||||
|
/* Einträge ohne umschließenden Link (Zeilen mit Badge/Aktions-Buttons
|
||||||
|
statt Navigation, z. B. Nutzer-/Abteilungsliste) bekommen dieselbe
|
||||||
|
Karte direkt auf dem <li> — :has() unterscheidet beide Fälle, damit
|
||||||
|
ein verlinkter Eintrag nicht doppelt (li UND a) gerahmt wird. */
|
||||||
|
.beitraege-liste > li:not(:has(> a)) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -288,6 +437,16 @@ nav button {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.beitraege-liste > li:not(:has(> a)) form {
|
||||||
|
margin: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beitraege-liste > li:not(:has(> a)) button {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.beteiligte {
|
.beteiligte {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
@@ -392,8 +551,8 @@ button.entfernen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fragebogen legend {
|
.fragebogen legend {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
font-size: 0.9375rem;
|
font-size: 1rem;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +564,135 @@ button.entfernen {
|
|||||||
margin: 4px 12px 4px 0;
|
margin: 4px 12px 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Formular-Karten (Enterprise-Look, siehe design/enterprise.css
|
||||||
|
.ant-card/.ant-form-item) ─────────────────────────────────────────
|
||||||
|
Ersetzt "ein Formular = eine flache, ununterbrochene Spalte" durch
|
||||||
|
klar abgegrenzte Abschnitte mit Titel und ein Grid für kurze Felder
|
||||||
|
nebeneinander statt jedes Feld einzeln untereinander. */
|
||||||
|
.form-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card form {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* form-section ist bewusst ein <div>, kein <fieldset> — <legend>
|
||||||
|
positioniert sich per Spezifikation IMMER auf der oberen Rahmenlinie
|
||||||
|
des <fieldset> (das ist seine ganze Funktion: die Box zu betiteln),
|
||||||
|
das lässt sich nicht per CSS aufheben, nur mit fragilen Hacks
|
||||||
|
kaschieren (padding/Breite anpassen ändert nichts an der
|
||||||
|
Border-Überlappung selbst — genau das führte zu einer bereits einmal
|
||||||
|
"reparierten" Überschrift, die nach der nächsten neuen Seite wieder
|
||||||
|
zu klein/zu randnah aussah). Eine echte Übersicht (.form-section-title)
|
||||||
|
verhält sich wie jede normale Überschrift: volle CSS-Kontrolle, keine
|
||||||
|
browserseitigen Sonderregeln. */
|
||||||
|
.form-section {
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section + .form-section {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-title {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid .form-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox-/Radio-Gruppen als Chips statt einer langen
|
||||||
|
Untereinander-Liste — v. a. bei den zwölf Zweck-Kategorien war das
|
||||||
|
vorher eine kaum überblickbare Spalte. */
|
||||||
|
.chip-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-group label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-group input[type="checkbox"],
|
||||||
|
.chip-group input[type="radio"] {
|
||||||
|
width: auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-group label:has(input:checked) {
|
||||||
|
border-color: var(--branding-primary);
|
||||||
|
background: rgba(22, 119, 255, 0.08);
|
||||||
|
color: var(--branding-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions form,
|
||||||
|
.form-actions button {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-gefahrenzone {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-gefahrenzone form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.fragebogen fieldset input[type="radio"] {
|
.fragebogen fieldset input[type="radio"] {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -424,7 +712,276 @@ button.entfernen {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
|
/* Tabellen (z. B. Register-Export-Ansicht) — bewusst horizontal
|
||||||
|
scrollbar in einem eigenen Wrapper statt schrumpfender Spalten: bei
|
||||||
|
neun Spalten wie im Register würde Schrumpfen nur unlesbaren Text
|
||||||
|
erzeugen, siehe .table-scroll in register_liste.html. */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ohne dieses Signal sieht eine breite, am Fensterrand abgeschnittene
|
||||||
|
Tabelle wie ein Rendering-Fehler aus statt wie "hier geht's mit
|
||||||
|
Scrollen weiter" — der Fade ist bewusst immer sichtbar (kein JS, das
|
||||||
|
den Scroll-Zustand prüfen könnte), auch am Ende der Tabelle. */
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||||
|
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter-/Paginierungsleiste für Listen-Seiten (Werkzeugkatalog,
|
||||||
|
Register, ...) — ein einziges GET-Formular ohne JavaScript, siehe
|
||||||
|
pagination.go. */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
/* explizit setzen, nicht dem initial-value überlassen: .filter-bar
|
||||||
|
landet manchmal direkt auf einem <form>, und "form { flex-direction:
|
||||||
|
column }" (oben) hat dieselbe Spezifität für DIESE Property, wenn
|
||||||
|
.filter-bar sie nicht selbst deklariert — das Formular würde dann
|
||||||
|
senkrecht stapeln statt in einer Zeile. */
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar input[type="search"] {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar select {
|
||||||
|
flex: 0 1 200px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar button {
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive Tabellen — Card-Ansicht auf dem Handy ────────────────
|
||||||
|
enconf-Pattern (ProTable.tsx): auf schmalen Screens wird nicht
|
||||||
|
horizontal gescrollt, sondern jede Zeile zu einer eigenen Karte mit
|
||||||
|
Label/Wert-Paaren. Braucht data-label="Spaltenname" auf jedem <td>.
|
||||||
|
Ab 640px bleibt es eine normale Tabelle (mit Scroll-Fade als
|
||||||
|
Sicherheitsnetz bei vielen Spalten). */
|
||||||
|
.table-responsive {
|
||||||
|
overflow-x: auto;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||||
|
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.table-responsive {
|
||||||
|
overflow-x: visible;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
-webkit-mask-image: none;
|
||||||
|
mask-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive table,
|
||||||
|
.table-responsive tbody,
|
||||||
|
.table-responsive tr,
|
||||||
|
.table-responsive td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive thead {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive tbody tr {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive td {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: right;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive td:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heading);
|
||||||
|
text-align: left;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Seitenkopf (PageHeader-Pattern, enconf) ─────────────────────────
|
||||||
|
Titel + Unterzeile links, primäre Aktion rechts — statt eines
|
||||||
|
nackten <h1> mit einem Link irgendwo unten auf der Seite. */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .hinweis {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .page-header-action {
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--branding-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .page-header-action:hover {
|
||||||
|
filter: brightness(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Kennzahl-Kacheln (StatCard-Pattern, enconf Dashboard) ───────────
|
||||||
|
Vorher: einfache Text-Links in einer Liste ohne visuelle Priorität.
|
||||||
|
Jetzt: farbiger oberer Akzentrand + große Zahl + Klick navigiert zur
|
||||||
|
Liste, wie enconfs StatCard-Komponente — nur ohne Icon-Bibliothek. */
|
||||||
|
.stat-cards {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: block;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-top: 3px solid var(--branding-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 16px 18px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-heading);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-mittel {
|
||||||
|
border-top-color: var(--color-mittel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-hoch {
|
||||||
|
border-top-color: var(--color-hoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-action {
|
||||||
|
background: var(--branding-primary);
|
||||||
|
border-color: var(--branding-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-action .stat-card-label {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
|
||||||
Rand statt volle Breite, sonst bleibt alles identisch. */
|
Rand statt volle Breite, sonst bleibt alles identisch. */
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.page {
|
.page {
|
||||||
@@ -435,3 +992,87 @@ button.entfernen {
|
|||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ab Desktop-Breite: dieselbe .topbar/.app-nav-Markup wird zu einer
|
||||||
|
permanent sichtbaren, dunklen Seitenleiste — kein separates
|
||||||
|
Desktop-Template, nur CSS. Der `details`/`summary`-Aufklapp-
|
||||||
|
Mechanismus wird hier überschrieben (Inhalt immer sichtbar,
|
||||||
|
summary versteckt), das Handy-Verhalten bleibt darunter unverändert. */
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.topbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px 16px;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
border-bottom: none;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
position: static;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a {
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a:hover {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
|
color: var(--sidebar-text-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button {
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button:hover {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
|
color: var(--sidebar-text-hover);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
max-width: 1100px;
|
||||||
|
padding: 32px 40px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabellen-/Listen-/Dashboard-Seiten verschwenden bei 1100px sichtbar
|
||||||
|
Platz auf breiten Bildschirmen, während Tabellen selbst schon
|
||||||
|
horizontal gescrollt/gequetscht werden müssen — .page-wide hebt die
|
||||||
|
Deckelung für genau diese Seiten an. Bewusst 1600px statt "kein
|
||||||
|
Limit": manche dieser Seiten (Fall-/Antrag-Detail) enthalten auch
|
||||||
|
Fließtext-Absätze, die auf einem Ultrawide-Monitor sonst unlesbar
|
||||||
|
lange Zeilen bekämen. Reine Formular-/Auth-Seiten (Login,
|
||||||
|
Registrieren, Einladung, ...) bleiben bei 1100px, dort ist die
|
||||||
|
schmalere Spalte weiterhin die bessere Lesbarkeit. */
|
||||||
|
.page-wide {
|
||||||
|
max-width: 1600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
46
internal/web/templates/abteilung_liste.html
Normal file
46
internal/web/templates/abteilung_liste.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{{define "abteilung-liste"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page page-wide">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Abteilungen</h1>
|
||||||
|
<p class="hinweis">Stammdaten für den Fragebogen (Feld "Abteilung").</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
{{if not .Abteilungen}}
|
||||||
|
<p class="hinweis">Noch keine Abteilungen.</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="beitraege-liste">
|
||||||
|
{{range .Abteilungen}}
|
||||||
|
<li>
|
||||||
|
{{.Name}}
|
||||||
|
<form method="post" action="/verwaltung/abteilungen/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?')">
|
||||||
|
<button type="submit" class="entfernen">Löschen</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
<div class="form-card">
|
||||||
|
<form method="post" action="/verwaltung/abteilungen">
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="name">Neue Abteilung</label>
|
||||||
|
<input type="text" id="name" name="name" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -3,23 +3,43 @@
|
|||||||
<head>{{template "head" .}}</head>
|
<head>{{template "head" .}}</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page page-wide">
|
||||||
<h1>Meine Anträge</h1>
|
<div class="page-header">
|
||||||
{{if not .Antraege}}
|
<div>
|
||||||
<p class="hinweis">Noch keine Anträge gestellt.</p>
|
<h1>Meine Anträge</h1>
|
||||||
{{else}}
|
<p class="hinweis">Eigene Anträge und ihr Bearbeitungsstand.</p>
|
||||||
<ul class="beitraege-liste">
|
</div>
|
||||||
{{range .Antraege}}
|
<a class="page-header-action" href="/antraege/neu">Neuen Antrag stellen</a>
|
||||||
<li>
|
</div>
|
||||||
<a href="/antraege/{{.ID}}">
|
<form method="get">
|
||||||
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · {{.CreatedAt}}
|
<div class="filter-bar">
|
||||||
<span class="status status-{{.Status}}">{{.Status}}</span>
|
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Titel, Status, Häufigkeit)">
|
||||||
</a>
|
<button type="submit">Filtern</button>
|
||||||
</li>
|
{{if .Suche}}<a href="/antraege">Zurücksetzen</a>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if not .Antraege}}
|
||||||
|
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Anträge gestellt.{{end}}</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="beitraege-liste">
|
||||||
|
{{range .Antraege}}
|
||||||
|
<li>
|
||||||
|
<a href="/antraege/{{.ID}}">
|
||||||
|
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · {{.CreatedAt}}
|
||||||
|
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<div class="pagination">
|
||||||
|
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||||
|
<span>
|
||||||
|
{{if .Pagination.HasPrev}}<button type="submit" name="page" value="{{.Pagination.PrevPage}}">← Zurück</button>{{end}}
|
||||||
|
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</form>
|
||||||
{{end}}
|
|
||||||
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user