Files
deklarix/internal/web/server.go
noroot d3b171f721 feat: pro Mandant editierbare E-Mail-Vorlagen
Zweistufig wie der Werkzeugkatalog: Betreiber pflegt den plattformweiten
Standard (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
übersteuern (/verwaltung/email-vorlagen) - ResolveEmailVorlage nutzt die
eigene Vorlage falls vorhanden, sonst fällt sie auf den Plattform-
Standard zurück. Passwort-Zurücksetzen ist die einzige aktuell
existierende E-Mail und nutzt jetzt diese Vorlage statt Hardcoding.

Bug beim Live-Verifizieren gefunden: UNIQUE(account_id, typ) verhindert
bei NULLABLE account_id keine Duplikate (NULL != NULL in SQL) - jedes
Speichern des Plattform-Standards erzeugte eine neue Zeile statt sie zu
aktualisieren. Fix: zwei partielle Unique-Indizes statt eines
gemeinsamen Constraints, mit Regressionstest abgesichert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 10:06:09 +02:00

245 lines
16 KiB
Go

// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
// Stack). Jeder Antrag gehört einem Account (Mandant); Auth-Cookies
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
// Account — siehe middleware.go.
package web
import (
"context"
"embed"
"fmt"
"html/template"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/mail"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
// einen Fake statt eine echte Datenbank zu brauchen.
type Store interface {
// 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, name string) (store.Account, error)
UpdateAccount(ctx context.Context, id, name 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)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUser(ctx context.Context, id string) (store.User, error)
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
SetUserActive(ctx context.Context, id string, active bool) error
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)
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
GetSession(ctx context.Context, token string) (store.Session, error)
DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error)
CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error)
DeleteAbteilung(ctx context.Context, id string) error
CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error)
GetAntrag(ctx context.Context, id string) (store.Antrag, error)
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
SetAntragStatus(ctx context.Context, id, status string) error
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error)
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error)
CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error)
DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error
ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error)
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error)
UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error)
DeleteWerkzeug(ctx context.Context, id string) error
CurrentKatalogVersion(ctx context.Context) (string, error)
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error)
ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error)
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
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
// Bewertung eines Antrags braucht (siehe internal/rules). Ein eigener
// Typ statt drei einzelne Server-Felder, damit main.go sie an einer
// Stelle zusammenstellt.
type Regelwerk struct {
Datenklasse rules.DatenklasseRegelwerk
Einstufung rules.EinstufungRegelwerk
Anforderungen rules.AnforderungsRegelwerk
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
type Server struct {
mux *http.ServeMux
store Store
regelwerk Regelwerk
templates *template.Template
mailer mail.Mailer
}
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
// wird einmal beim Start geladen, nicht pro Request. mailer ist
// 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")
if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err)
}
s := &Server{
store: st,
regelwerk: regelwerk,
templates: tmpl,
mailer: mailer,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /register", s.handleRegisterForm)
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("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/email-vorlagen", s.requireAdmin(s.handleEmailVorlagenListe))
mux.HandleFunc("POST /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlageSpeichern))
mux.HandleFunc("POST /verwaltung/email-vorlagen/zuruecksetzen", s.requireAdmin(s.handleEmailVorlageZuruecksetzen))
mux.HandleFunc("GET /betreiber/email-vorlagen", s.requireBetreiber(s.handleBetreiberEmailVorlagenListe))
mux.HandleFunc("POST /betreiber/email-vorlagen", 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("GET /{$}", s.requirePage(s.handleIndex))
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm))
mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate))
mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail))
mux.HandleFunc("GET /faelle", s.requireFachebene(s.handleFaelleListe))
mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail))
mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden))
mux.HandleFunc("GET /registereintraege", s.requireFachebene(s.handleRegisterListe))
mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV))
mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF))
mux.HandleFunc("GET /wiedervorlage", s.requireFachebene(s.handleWiedervorlageListe))
mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe))
mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm))
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))
mux.HandleFunc("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren))
mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
mux.HandleFunc("GET /verwaltung/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/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("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/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
mux.HandleFunc("POST /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugCreate))
mux.HandleFunc("GET /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugEditForm))
mux.HandleFunc("POST /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugUpdate))
mux.HandleFunc("POST /betreiber/werkzeuge/{id}/loeschen", s.requireBetreiber(s.handleBetreiberWerkzeugDelete))
mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux = mux
return s, nil
}
// ServeHTTP macht Server zu einem http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}