Compare commits
7 Commits
8a8295dacd
...
v0.42.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db4707bc5 | ||
|
|
fa5e68c892 | ||
|
|
d3b171f721 | ||
|
|
b0d6b00045 | ||
|
|
fe28278615 | ||
|
|
0fe29f8c80 | ||
|
|
eccf02050b |
232
CLAUDE.md
232
CLAUDE.md
@@ -633,6 +633,227 @@ und live per curl gegen echten Server + Postgres verifiziert.
|
||||
|
||||
---
|
||||
|
||||
## E-Mail-Vorlagen (2026-09-01, Migration 0022)
|
||||
|
||||
Auf Nutzerwunsch: alle vom System versendeten E-Mails sollen editierbar
|
||||
sein — für E-Mails, die an die eigenen Mitarbeiter einer Firma gehen,
|
||||
durch den Mandanten selbst; für E-Mails, die die Plattform an
|
||||
Mandanten-Admins schickt, durch den Betreiber. Zweistufig wie der
|
||||
Werkzeugkatalog: `email_vorlage.account_id` NULL = plattformweiter
|
||||
Standard (`/betreiber/email-vorlagen`, nur Betreiber), gesetzt =
|
||||
mandantenspezifische Übersteuerung (`/verwaltung/email-vorlagen`, nur
|
||||
Admin) — jeweils ein eigener Nav-Punkt (nicht als weiterer Button auf
|
||||
einer bestehenden Seite), mit Liste + eigener Bearbeiten-Seite je Typ
|
||||
(`GET .../email-vorlagen` Liste, `GET/POST .../email-vorlagen/{typ}`
|
||||
Bearbeiten) statt eines einzigen, mit allen Formularen gestapelten
|
||||
Screens — auf ausdrücklichen Nutzerwunsch, da mit der Zeit weitere
|
||||
Benachrichtigungstypen dazukommen sollen und eine gestapelte Liste dann
|
||||
unübersichtlich würde. `ResolveEmailVorlage` liefert die eigene
|
||||
Übersteuerung, falls vorhanden, sonst den Plattform-Standard — ein
|
||||
Mandant, der nie etwas anpasst, bekommt automatisch jede künftige
|
||||
Änderung des Plattform-Standards. `emailVorlageTypen` in
|
||||
`internal/web/email_vorlage_handlers.go` ist die feste, bekannte Liste
|
||||
der vom System versendeten E-Mails — aktuell nur
|
||||
`passwort_zuruecksetzen` (die einzige E-Mail, die es bisher gibt),
|
||||
bewusst keine generische "beliebige E-Mail anlegen"-UI, da jeder Typ an
|
||||
eine echte Code-Stelle gebunden ist, die ihn tatsächlich versendet
|
||||
(Platzhalter wie `{{link}}` sind pro Typ verschieden und müssten sonst
|
||||
geraten werden).
|
||||
|
||||
**Bug beim Live-Verifizieren gefunden und behoben:** `UNIQUE
|
||||
(account_id, typ)` als einzelner Tabellen-Constraint reicht bei
|
||||
NULLABLE `account_id` NICHT — SQL behandelt NULL nie als gleich zu
|
||||
NULL, ein Mandant/Betreiber hätte also bei jedem Speichern eine neue
|
||||
Plattform-Standard-Zeile statt eines Updates bekommen (genau das ist
|
||||
beim ersten Testlauf passiert: zwei "Version 1"/"Version 2"-Zeilen
|
||||
gleichzeitig, `ResolveEmailVorlage` griff die falsche). Fix: zwei
|
||||
partielle Unique-Indizes (`... WHERE account_id IS NULL` /
|
||||
`... WHERE account_id IS NOT NULL`) statt eines gemeinsamen Constraints
|
||||
— `UpsertEmailVorlage` braucht dafür zwei unterschiedliche
|
||||
`ON CONFLICT`-Ziele (SQL erlaubt nur ein Ziel je INSERT-Anweisung), mit
|
||||
dediziertem Regressionstest (`TestUpsertPlattformStandardAktualisiertStattZuDuplizieren`)
|
||||
abgesichert. Live per curl mit echtem SMTP-Test-Server erneut
|
||||
verifiziert: zweimaliges Speichern des Plattform-Standards ergibt eine
|
||||
Zeile mit dem aktuellen Stand, eine neu registrierte Firma ohne eigene
|
||||
Übersteuerung bekommt automatisch den zuletzt gesetzten Plattform-Text.
|
||||
|
||||
---
|
||||
|
||||
## Firmendaten: Adresse und Abrechnung (2026-09-01, Migration 0023)
|
||||
|
||||
Auf Nutzerwunsch: bei jeder Firmenanlage (öffentliche Registrierung
|
||||
`POST /register` UND Betreiber-Firmenanlage `POST /betreiber/accounts`)
|
||||
müssen jetzt vollständige Firmendaten erfasst werden, nicht nur der
|
||||
Name. `account` bekommt sechs neue Spalten: `strasse`, `plz`, `ort`,
|
||||
`land` (Adresse) sowie `ust_id`, `rechnungsemail` (Abrechnung).
|
||||
Pflichtfelder bei Neuanlage: Name, Straße, PLZ, Ort, Land,
|
||||
Rechnungsemail. **Bewusst optional:** USt-IdNr. — Kleinunternehmer nach
|
||||
§19 UStG haben keine, eine Pflichtangabe wäre hier fachlich falsch.
|
||||
|
||||
`store.AccountInput` bündelt diese Felder für `CreateAccount` (Signatur
|
||||
geändert von `(ctx, name string)` auf `(ctx, AccountInput)` — betrifft
|
||||
~20 Testaufrufe, mechanisch auf `AccountInput{Name: "..."}` umgestellt)
|
||||
und die neue Methode `UpdateAccountDetails` (Name + alle Firmendaten in
|
||||
einem Aufruf, getrennt von der bestehenden `UpdateAccount`, die nur den
|
||||
Namen ändert — z. B. für die schnelle Tippfehlerkorrektur durch den
|
||||
Betreiber). Migrationsspalten sind `NOT NULL DEFAULT ''` statt einer
|
||||
harten Pflicht ohne Default: bestehende, vor dieser Migration angelegte
|
||||
Accounts haben diese Daten schlicht noch nicht, die Migration darf sie
|
||||
nicht blockieren — die Pflicht gilt nur auf Anwendungsebene für NEUE
|
||||
Firmen.
|
||||
|
||||
**Neue Seite für bestehende Firmen:** `GET/POST /verwaltung/firma`
|
||||
(Ebene 4, admin-only, eigener Nav-Punkt "Firmendaten") — zum Einsehen
|
||||
und Nachtragen/Korrigieren der eigenen Adress-/Abrechnungsdaten, auch
|
||||
für Accounts, die vor dieser Migration entstanden sind und die Felder
|
||||
sonst dauerhaft leer hätten. `register.html`/`betreiber_account_neu.html`
|
||||
wurden dabei auf das `.form-card`/`.form-section`/`.form-grid`-Muster
|
||||
umgestellt (vorher unstylte Rohformulare) — bei sieben-plus Feldern
|
||||
sonst unübersichtlich.
|
||||
|
||||
Live end-to-end verifiziert: beide Anlage-Wege (Registrierung UND
|
||||
Betreiber-Firmenanlage) speichern alle Felder korrekt, die neue
|
||||
Firmendaten-Seite zeigt den aktuellen Stand vorausgefüllt und
|
||||
Änderungen werden korrekt persistiert.
|
||||
|
||||
---
|
||||
|
||||
## Row-Level-Security (2026-09-01, Migration 0021)
|
||||
|
||||
**Ausgangslage:** RLS-Policies wirken nie bei Postgres-Superusern, und
|
||||
nie beim Tabellenbesitzer ohne `FORCE ROW LEVEL SECURITY` (beides harte,
|
||||
nicht überschreibbare Postgres-Regeln). Lokal verbindet die Anwendung
|
||||
als echter Superuser `postgres` (Docker-Testumgebung) — dort hätte
|
||||
`FORCE` allein nichts bewirkt. Auf dem Produktivserver verbindet sie
|
||||
dagegen über eine eigene, **nicht-privilegierte** Rolle `deklarix`
|
||||
(kein Superuser), die zugleich Eigentümerin der Tabellen ist — dort
|
||||
reicht `FORCE` aus. **Diese Erkenntnis kam erst nach einem
|
||||
Fehlversuch** (siehe "Incident" weiter unten) — die ursprüngliche
|
||||
Annahme, auch Produktion verbinde als Superuser, war falsch und beruhte
|
||||
auf einer Prüfung der falschen Rolle. Migration 0021 deckt seit der
|
||||
Korrektur beide Fälle ab: `FORCE ROW LEVEL SECURITY` auf jeder Tabelle
|
||||
(reicht für Produktion), plus optional eine neue, eingeschränkte Rolle
|
||||
**`deklarix_app`** (kein Superuser, kein Tabellenbesitzer,
|
||||
`NOBYPASSRLS`, zunächst `NOLOGIN`) für Umgebungen mit einer
|
||||
Superuser-Verbindung wie lokal.
|
||||
|
||||
**Architektur:** `internal/store/tenant_scope.go` — `Store.db(ctx)`
|
||||
liefert entweder die aktive Transaktion (falls `WithTenantScope` sie
|
||||
gesetzt hat) oder den Pool direkt; alle ~69 vorherigen `s.Pool.Query/
|
||||
QueryRow/Exec`-Aufrufe wurden mechanisch auf `s.db(ctx).…` umgestellt.
|
||||
`WithTenantScope(ctx, accountID, isBetreiber, fn)` öffnet eine
|
||||
Transaktion, setzt zwei Sitzungsvariablen per `set_config(..., true)`
|
||||
(Parameterbindung, kein String-Interpolation — SQL-Injection-frei,
|
||||
automatisch auf die Transaktion begrenzt) — `app.account_id` und
|
||||
`app.is_betreiber` — und committet am Ende. `SetTenantScope` erlaubt,
|
||||
die Variable **mitten in einer laufenden** Transaktion nachzutragen
|
||||
(nötig bei `handleRegister`: die `account_id` ist erst nach
|
||||
`CreateAccount` bekannt). Jede der fünf `require*`-Middlewares
|
||||
(`requirePage/-API/-Admin/-Fachebene/-Betreiber`) umschließt die
|
||||
komplette Handler-Ausführung jetzt mit `withTenantScope` — jeder
|
||||
Request läuft dadurch automatisch in genau einer Transaktion, nicht nur
|
||||
die Store-Aufrufe, die ein Handler zufällig selbst noch mal prüft.
|
||||
**Nebeneffekt (bewusst, positiv):** ein Handler mit mehreren
|
||||
Schreibzugriffen ist jetzt atomar — schlägt ein späterer Schritt fehl,
|
||||
werden frühere Schritte desselben Requests zurückgerollt (vorher: keine
|
||||
Transaktion, ein halb fehlgeschlagener Handler konnte einen verwaisten
|
||||
Account ohne Nutzer hinterlassen).
|
||||
|
||||
**Geschützte Tabellen** (`ENABLE` + `FORCE ROW LEVEL SECURITY` + Policy
|
||||
`tenant_isolation`): `antrag`, `registereintrag`, `abteilung`,
|
||||
`werkzeug_sperre`, `genehmiger_rolle`, `freigabe_regel`,
|
||||
`loeschfrist_einstellung` (direkte `account_id`-Prüfung), `werkzeug`
|
||||
(nullable — `NULL` = zentraler Katalog, für alle lesbar, nur Betreiber
|
||||
darf `NULL`-Zeilen schreiben), `bewertung`/`entscheidung`/
|
||||
`freigabeschritt` (kein eigenes `account_id`, Policy prüft
|
||||
`antrag_id IN (SELECT id FROM antrag)` — erbt automatisch dieselbe
|
||||
Grenze wie `antrag`, da diese Unterabfrage in derselben Sitzung
|
||||
läuft), `nutzer_genehmiger_rolle` (analog über `genehmiger_rolle_id`).
|
||||
|
||||
**Bewusst OHNE RLS** (dokumentiert in der Migration, kein Versehen):
|
||||
`account` (keine `account_id`-Spalte, muss bei Registrierung
|
||||
uneingeschränkt INSERT erlauben, bevor die ID bekannt ist), `app_user`
|
||||
(Login/Passwort-Reset suchen per E-Mail über alle Mandanten hinweg —
|
||||
die Ziel-`account_id` ist zu dem Zeitpunkt noch nicht bekannt),
|
||||
`session`/`password_reset_token` (werden nur über einen
|
||||
kryptographisch zufälligen, praktisch unerratbaren Token gesucht, kein
|
||||
sequentiell erratbarer Schlüssel), `audit_log` (plattformweites
|
||||
Protokoll ohne eigene `account_id`, wird ausschließlich vom Betreiber
|
||||
gelesen, der ohnehin alle Mandanten sieht).
|
||||
|
||||
**Live end-to-end verifiziert** (nicht nur Unit-Tests): Rohes SQL
|
||||
gegen `deklarix_app` direkt per `psql` (Isolation, Betreiber-Bypass,
|
||||
Fail-Closed ohne Kontext, INSERT-Schutz gegen fremde `account_id` —
|
||||
alle vier Fälle bestätigt), danach derselbe Beweis noch mal über den
|
||||
echten laufenden HTTP-Server mit `DATABASE_URL_APP` auf `deklarix_app`
|
||||
gesetzt: zwei Firmen registriert, Abteilung/Antrag/Bewertung einer
|
||||
Firma über die andere nicht erreichbar, zentraler Werkzeugkatalog
|
||||
(NULL-`account_id`) für beide sichtbar. Dabei einen echten Bug gefunden
|
||||
und behoben: `handleRegister`s `startSession`-Aufruf nutzte noch
|
||||
`r.Context()` statt der lokalen, transaktionsbehafteten `ctx`-Variable
|
||||
— die Session-INSERT lief dadurch auf einer anderen (Pool-)Verbindung
|
||||
als der gerade erst angelegte, noch nicht committete Nutzer, was einen
|
||||
Fremdschlüssel-Konflikt auslöste und die gesamte Registrierung
|
||||
zurückrollte. Zwei dedizierte Tests
|
||||
(`internal/store/tenant_scope_test.go`, `TestRLSAbteilungIsoliertZwischenMandanten`,
|
||||
`TestRLSVerhindertInsertFuerFremdenAccount`) laufen gegen die echte
|
||||
eingeschränkte Rolle und überspringen sich selbst sauber, wenn
|
||||
`DATABASE_URL_APP` nicht gesetzt ist (analog zum bestehenden
|
||||
`DATABASE_URL`-Skip-Muster).
|
||||
|
||||
**Incident 2026-09-01 (~3 Minuten Downtime) und Korrektur.** Die erste
|
||||
Fassung der Migration ging fälschlich davon aus, dass die Anwendung
|
||||
überall als Postgres-**Superuser** `postgres` verbindet (das hatte ich
|
||||
nur lokal und via `sudo -u postgres psql` auf dem Server geprüft — das
|
||||
ist aber ein SSH/OS-Login-Check, nicht die tatsächliche
|
||||
`DATABASE_URL`-Rolle der Anwendung). Tatsächlich verbindet Produktion
|
||||
über eine eigene, **nicht-privilegierte** Rolle `deklarix` (kein
|
||||
Superuser, kein `CREATEROLE`), die zugleich Eigentümerin aller Tabellen
|
||||
ist. Die Migration versuchte dort `CREATE ROLE deklarix_app`
|
||||
auszuführen, scheiterte mit "permission denied to create role", blieb
|
||||
im `dirty`-Zustand hängen und der Dienst crash-loopte beim Start
|
||||
(09:27–09:30 Uhr). Behoben durch: `schema_migrations` manuell auf
|
||||
Version 20 zurückgesetzt, Paket auf v0.37.0 zurückgestuft, Dienst
|
||||
stabilisiert — kein Datenverlust, Postgres hatte die fehlgeschlagene
|
||||
Migration als DDL-Transaktion bereits sauber selbst zurückgerollt, nur
|
||||
golang-migrates eigene Versions-Buchführung musste von Hand korrigiert
|
||||
werden.
|
||||
|
||||
**Korrigierte, robustere Migration:** das Anlegen von `deklarix_app` ist
|
||||
jetzt an eine Prüfung gekoppelt (`SELECT ... WHERE rolname = current_user
|
||||
AND rolcreaterole`) und wird bei fehlendem `CREATEROLE` übersprungen
|
||||
(`RAISE NOTICE`, kein Fehler) statt die ganze Migration scheitern zu
|
||||
lassen. Zusätzlich bekommt jede Tabelle jetzt **`FORCE ROW LEVEL
|
||||
SECURITY`** (vorher nur `ENABLE`) — das bindet auch den **Tabellen-
|
||||
besitzer** an die Policies, sofern er kein Superuser ist. Damit deckt
|
||||
eine einzige Migration beide Fälle ab:
|
||||
- **Produktion** (`deklarix`, Tabellenbesitzer, kein Superuser): `FORCE`
|
||||
allein reicht bereits aus. **Kein manueller Schritt nötig** — nach dem
|
||||
Deploy dieser Migration ist RLS dort sofort aktiv.
|
||||
- **Lokales Docker-Postgres** (`postgres`-Superuser, für den `FORCE`
|
||||
wirkungslos bleibt): `deklarix_app` wird zusätzlich angelegt, für
|
||||
lokale Tests weiterhin per `DATABASE_URL_APP` nutzbar.
|
||||
|
||||
**Erneut end-to-end verifiziert nach der Korrektur**, diesmal
|
||||
zusätzlich produktionsgetreu: eine zweite, temporäre lokale Rolle
|
||||
(`deklarix_sim`, kein Superuser, kein `CREATEROLE`, Eigentümerin einer
|
||||
frischen Test-Datenbank — exakt Produktions-Rechtemodell) durchlief die
|
||||
komplette Migrationskette 0001–0021 fehlerfrei, **und** die Isolation
|
||||
griff nachweislich auch für sie als Tabellenbesitzerin (Kontext A sah
|
||||
ausschließlich Zeile A, trotz voller Tabellen-Eigentümerschaft). Vorher
|
||||
war nur der `deklarix_app`-Pfad (Nicht-Eigentümer-Rolle) bewiesen, nicht
|
||||
der tatsächliche Produktions-Pfad (Eigentümer-Rolle + FORCE) — genau die
|
||||
Lücke, die den Incident verursachte.
|
||||
|
||||
**Lehre für künftige Prüfungen dieser Art:** "welche DB-Rolle verwendet
|
||||
die Anwendung" per `sudo -u postgres psql` zu beantworten prüft die
|
||||
falsche Sache — maßgeblich ist ausschließlich die Rolle **in der
|
||||
tatsächlichen `DATABASE_URL`** (hier: `cat /etc/deklarix/deklarix.env`
|
||||
bzw. `SELECT rolname, rolsuper, rolcreaterole FROM pg_roles WHERE
|
||||
rolname = 'deklarix'`, nicht `current_user` über einen andere Anmeldung).
|
||||
|
||||
---
|
||||
|
||||
## Löschfristen je Datenklasse (2026-09-01, Migration 0020)
|
||||
|
||||
Schließt `rules/OPEN.md`, Punkt 4. Recherche ergab: die DSGVO nennt
|
||||
@@ -1289,13 +1510,10 @@ journalctl -u deklarix -f
|
||||
- ~~Löschfristen je Datenklasse~~ — **erledigt 2026-09-01**, siehe
|
||||
„Löschfristen je Datenklasse" weiter oben: pro Mandant einstellbar
|
||||
statt hartkodiert, da die DSGVO selbst keine festen Fristen nennt.
|
||||
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||
— die Frontend-Spezifikation fordert das explizit ("nicht in der
|
||||
Anwendungslogik"), aktuell ist Isolation ausschließlich
|
||||
anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS
|
||||
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
|
||||
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
|
||||
entstehen, die sonst nachträglich migriert werden müssten.
|
||||
- ~~Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)~~
|
||||
— **Code/Migration/Tests erledigt 2026-09-01, Produktivbetrieb noch
|
||||
ausstehend** (siehe „Row-Level-Security" weiter oben für den vollen
|
||||
Stand und was auf dem Produktivserver noch manuell nachzuziehen ist).
|
||||
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch
|
||||
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
|
||||
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
|
||||
|
||||
@@ -22,7 +22,19 @@ func main() {
|
||||
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 {
|
||||
log.Fatalf("open store: %v", err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type Abteilung struct {
|
||||
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
|
||||
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
|
||||
var a Abteilung
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
|
||||
RETURNING id, account_id, name, created_at
|
||||
`, 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,
|
||||
// alphabetisch — als Auswahlliste für den Fragebogen.
|
||||
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
|
||||
WHERE account_id = $1 ORDER BY name
|
||||
`, accountID)
|
||||
@@ -59,7 +59,7 @@ func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string)
|
||||
// GetAbteilung liest eine Abteilung anhand ihrer ID.
|
||||
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
|
||||
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
|
||||
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||
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
|
||||
// angelegt).
|
||||
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 {
|
||||
return fmt.Errorf("store: delete abteilung: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,30 +13,56 @@ import (
|
||||
// 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 {
|
||||
ID string
|
||||
Name string
|
||||
EinladungToken string
|
||||
Strasse string
|
||||
PLZ string
|
||||
Ort string
|
||||
Land string
|
||||
UStID string
|
||||
Rechnungsemail string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const accountColumns = `id, name, einladung_token, created_at`
|
||||
// AccountInput bündelt die Firmendaten für CreateAccount/
|
||||
// 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
|
||||
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
|
||||
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.Strasse, &a.PLZ, &a.Ort, &a.Land, &a.UStID, &a.Rechnungsemail, &a.CreatedAt)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
|
||||
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
|
||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO account (name) VALUES ($1)
|
||||
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,
|
||||
name,
|
||||
in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
|
||||
)
|
||||
a, err := scanAccount(row)
|
||||
if err != nil {
|
||||
@@ -46,9 +72,10 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
|
||||
}
|
||||
|
||||
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
|
||||
// Betreiber-gestützten Anlage korrigieren).
|
||||
// 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.Pool.QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
|
||||
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
|
||||
@@ -59,9 +86,29 @@ func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, er
|
||||
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.
|
||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
@@ -77,7 +124,7 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
|
||||
// oder bereits erneuertem (damit ungültig gewordenem) Token.
|
||||
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
|
||||
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
|
||||
@@ -92,7 +139,7 @@ func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (A
|
||||
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
|
||||
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
|
||||
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
|
||||
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)
|
||||
}
|
||||
@@ -105,7 +152,7 @@ func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToke
|
||||
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
|
||||
// Admin-Bereich (Accounts-Verwaltung).
|
||||
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||
rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
|
||||
rows, err := s.db(ctx).Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package store_test
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
|
||||
@@ -37,7 +39,7 @@ func TestListAccounts(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func scanAntrag(row interface {
|
||||
|
||||
// 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) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
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,
|
||||
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
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
|
||||
// Status hier nicht).
|
||||
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
|
||||
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
|
||||
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
|
||||
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
|
||||
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 {
|
||||
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
|
||||
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
|
||||
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
|
||||
`, erstellerUserID)
|
||||
if err != nil {
|
||||
@@ -139,7 +139,7 @@ func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string)
|
||||
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
||||
// zuerst.
|
||||
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
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,7 +22,7 @@ type AuditEntry struct {
|
||||
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
||||
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
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.
|
||||
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
|
||||
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
||||
`, limit)
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestAccountCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
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 {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
@@ -30,10 +30,63 @@ func TestAccountCRUD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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, "Alter Name GmbH")
|
||||
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alter Name GmbH"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
@@ -66,7 +119,7 @@ func TestUpdateAccountNotFound(t *testing.T) {
|
||||
func TestGetAccountByEinladungToken(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
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 {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
@@ -88,7 +141,7 @@ func TestGetAccountByEinladungToken(t *testing.T) {
|
||||
func TestRegenerateEinladungToken(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
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 {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertu
|
||||
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err)
|
||||
}
|
||||
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO bewertung (
|
||||
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||
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
|
||||
// existiert.
|
||||
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT `+bewertungColumns+`
|
||||
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||
`, 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 (
|
||||
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
|
||||
) 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 existiert.
|
||||
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT `+entscheidungColumns+`
|
||||
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||
`, antragID)
|
||||
@@ -118,7 +118,7 @@ func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID str
|
||||
// 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.Pool.Query(ctx, `
|
||||
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
|
||||
|
||||
@@ -28,7 +28,7 @@ type GenehmigerRolle struct {
|
||||
// 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.Pool.QueryRow(ctx, `
|
||||
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)
|
||||
@@ -41,7 +41,7 @@ func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, besc
|
||||
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
|
||||
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
|
||||
var g GenehmigerRolle
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
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) {
|
||||
@@ -56,7 +56,7 @@ func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRo
|
||||
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
|
||||
// Mandanten.
|
||||
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
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)
|
||||
@@ -82,7 +82,7 @@ func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID st
|
||||
// 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.Pool.Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
|
||||
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)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||
|
||||
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
|
||||
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
_, 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)
|
||||
@@ -106,7 +106,7 @@ func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmiger
|
||||
|
||||
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
|
||||
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
_, 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 {
|
||||
@@ -118,7 +118,7 @@ func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmi
|
||||
// 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.Pool.Query(ctx, `
|
||||
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
|
||||
@@ -151,7 +151,7 @@ type GenehmigerMitglied struct {
|
||||
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
|
||||
// bestimmten Genehmiger-Rolle.
|
||||
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
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
|
||||
@@ -189,7 +189,7 @@ type FreigabeRegel struct {
|
||||
|
||||
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
|
||||
var f FreigabeRegel
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
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
|
||||
@@ -203,7 +203,7 @@ func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
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)
|
||||
@@ -224,7 +224,7 @@ func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID stri
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
|
||||
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)
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||
// 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.Pool.QueryRow(ctx, `
|
||||
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)
|
||||
@@ -269,7 +269,7 @@ type Freigabeschritt struct {
|
||||
|
||||
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
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(
|
||||
@@ -283,7 +283,7 @@ func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerR
|
||||
|
||||
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
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)
|
||||
@@ -297,7 +297,7 @@ func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschr
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
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)
|
||||
@@ -321,7 +321,7 @@ func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID stri
|
||||
// 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.Pool.Query(ctx, `
|
||||
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
|
||||
@@ -351,7 +351,7 @@ func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, user
|
||||
// generischer Fehler statt eines Sentinels).
|
||||
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
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'
|
||||
@@ -375,7 +375,7 @@ func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entsc
|
||||
// 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.Pool.Exec(ctx, `
|
||||
_, 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.'
|
||||
|
||||
@@ -29,7 +29,7 @@ type LoeschfristEinstellung struct {
|
||||
// Datenklasse (UNIQUE-Constraint).
|
||||
func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (LoeschfristEinstellung, error) {
|
||||
var e LoeschfristEinstellung
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
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)
|
||||
@@ -45,7 +45,7 @@ func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, dat
|
||||
// ListLoeschfristEinstellungenForAccount liefert alle konfigurierten
|
||||
// Fristen eines Mandanten.
|
||||
func (s *Store) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]LoeschfristEinstellung, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
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)
|
||||
|
||||
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 '';
|
||||
@@ -32,7 +32,7 @@ type PasswordResetToken struct {
|
||||
// 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.Pool.QueryRow(ctx, `
|
||||
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
|
||||
@@ -49,7 +49,7 @@ func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token stri
|
||||
// noch nicht abgelaufen und noch nicht verwendet ist.
|
||||
func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (PasswordResetToken, error) {
|
||||
var t PasswordResetToken
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
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()
|
||||
@@ -66,7 +66,7 @@ func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (P
|
||||
// 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.Pool.Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInp
|
||||
if in.Auflagen == nil {
|
||||
in.Auflagen = []string{}
|
||||
}
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
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
|
||||
@@ -83,7 +83,7 @@ func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInp
|
||||
// ListRegistereintraegeForAccount liefert alle Registereinträge eines
|
||||
// Mandanten, neueste zuerst.
|
||||
func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
|
||||
@@ -36,7 +36,7 @@ func scanSession(row interface {
|
||||
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO session (token, user_id, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING `+sessionColumns,
|
||||
@@ -55,7 +55,7 @@ func (s *Store) CreateSession(ctx context.Context, token, userID string, expires
|
||||
// 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.Pool.QueryRow(ctx, `
|
||||
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,
|
||||
@@ -74,7 +74,7 @@ func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, im
|
||||
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||
row := s.db(ctx).QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||
sess, err := scanSession(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
@@ -87,7 +87,7 @@ func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||
|
||||
// DeleteSession beendet eine Sitzung (Logout).
|
||||
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 nil
|
||||
|
||||
@@ -36,7 +36,7 @@ func openTestStore(t *testing.T) *store.Store {
|
||||
// testAccountID legt einen Mandanten an und liefert dessen ID.
|
||||
func testAccountID(t *testing.T, s *store.Store) string {
|
||||
t.Helper()
|
||||
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
|
||||
acc, err := s.CreateAccount(context.Background(), store.AccountInput{Name: "Test-Mandant"})
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func scanUser(row interface {
|
||||
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
||||
// speichert nur, es hasht nicht selbst.
|
||||
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+userColumns,
|
||||
@@ -59,7 +59,7 @@ func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash,
|
||||
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
||||
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
||||
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
||||
u, err := scanUser(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
@@ -72,7 +72,7 @@ func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error)
|
||||
|
||||
// GetUser liest einen Nutzer anhand seiner ID.
|
||||
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
||||
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
||||
u, err := scanUser(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
@@ -86,7 +86,7 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
||||
// Admin-Bereich (Account-Detailansicht).
|
||||
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 `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
@@ -112,7 +112,7 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
|
||||
// Zurücksetzen). passwordHash muss bereits gehasht sein, wie bei
|
||||
// CreateUser.
|
||||
func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash)
|
||||
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)
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) er
|
||||
// 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.Pool.Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO werkzeug (
|
||||
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||
@@ -137,7 +137,7 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug,
|
||||
// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein.
|
||||
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
UPDATE werkzeug SET
|
||||
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
|
||||
@@ -161,7 +161,7 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
|
||||
|
||||
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Werkzeug{}, ErrNotFound
|
||||
@@ -174,7 +174,7 @@ func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
||||
|
||||
// DeleteWerkzeug entfernt einen Katalogeintrag.
|
||||
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 {
|
||||
return fmt.Errorf("store: delete werkzeug: %w", err)
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
||||
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
|
||||
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
|
||||
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
|
||||
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
|
||||
@@ -218,7 +218,7 @@ func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) (
|
||||
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
|
||||
// Mandanten-Sperrungen.
|
||||
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
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -248,7 +248,7 @@ func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
||||
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||
var count int
|
||||
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
|
||||
`).Scan(&count, &lastUpdate)
|
||||
if err != nil {
|
||||
@@ -271,7 +271,7 @@ type WerkzeugSperre struct {
|
||||
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
|
||||
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
|
||||
var sp WerkzeugSperre
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
|
||||
@@ -284,7 +284,7 @@ func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID,
|
||||
|
||||
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
|
||||
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
|
||||
`, accountID, werkzeugID)
|
||||
if err != nil {
|
||||
@@ -298,7 +298,7 @@ func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID
|
||||
|
||||
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
|
||||
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
|
||||
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
|
||||
`, accountID)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
type authPageData struct {
|
||||
@@ -45,11 +47,21 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
password := r.FormValue("password")
|
||||
if accountName == "" || email == "" || password == "" {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
|
||||
if accountInput.Name == "" || email == "" || password == "" ||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -59,29 +71,52 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||
// Die account_id ist zu Beginn noch nicht bekannt (die Firma entsteht
|
||||
// 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 {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
|
||||
return
|
||||
}
|
||||
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
|
||||
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.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Genehmiger-Rollen konnten nicht angelegt werden"})
|
||||
return
|
||||
}
|
||||
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Löschfristen konnten nicht angelegt werden"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
||||
if errMsg == "" {
|
||||
errMsg = "Registrierung fehlgeschlagen"
|
||||
}
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: errMsg})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
|
||||
@@ -13,9 +15,9 @@ func TestBetreiberKannFirmaAnlegen(t *testing.T) {
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{
|
||||
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())
|
||||
}
|
||||
@@ -62,7 +64,7 @@ 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(), "Alter Name")
|
||||
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Alter Name"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
@@ -111,11 +111,21 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
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")
|
||||
password := r.FormValue("password")
|
||||
if accountName == "" || email == "" || password == "" {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder sind Pflicht"}
|
||||
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)
|
||||
}
|
||||
@@ -132,7 +142,7 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||
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 {
|
||||
@@ -161,7 +171,7 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ 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(), "Mandant mit eigenem Werkzeug")
|
||||
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Mandant mit eigenem Werkzeug"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import (
|
||||
"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(), "Beispiel GmbH")
|
||||
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Beispiel GmbH"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
@@ -41,7 +43,7 @@ func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
|
||||
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
|
||||
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Beispiel GmbH"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant")
|
||||
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: "Fachebene-Mandant"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(t *testi
|
||||
|
||||
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
acc, err := fs.CreateAccount(context.Background(), "Mit Katalog")
|
||||
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Mit Katalog"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func TestFallDetailRejectsForeignAccount(t *testing.T) {
|
||||
s := newServer(t, fs)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
@@ -32,7 +33,7 @@ func besondereKategorieAntragForm() url.Values {
|
||||
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, "Freigabe-Mandant")
|
||||
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: "Freigabe-Mandant"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,24 @@ func withAuthContext(r *http.Request, user store.User, impersonator string) *htt
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
||||
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
||||
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -72,7 +90,7 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +105,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +126,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +156,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +174,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,13 +36,16 @@ func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request)
|
||||
scheme = "https"
|
||||
}
|
||||
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token)
|
||||
body := "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."
|
||||
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, "Deklarix — Passwort zurücksetzen", body); err != nil {
|
||||
if err := s.mailer.Send(user.Email, betreff, renderEmailVorlage(text, link)); err != nil {
|
||||
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ func TestPasswortVergessenSendetLinkUndSetztNeuesPasswort(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s, fm := newServerWithMailer(t, fs)
|
||||
|
||||
regResp := postForm(t, s, nil, "/register", url.Values{
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// 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(), "Register-Mandant")
|
||||
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Register-Mandant"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
@@ -29,8 +29,18 @@ var staticFS embed.FS
|
||||
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
||||
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||
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)
|
||||
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
||||
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
||||
@@ -43,6 +53,10 @@ type Store interface {
|
||||
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
|
||||
@@ -153,6 +167,15 @@ func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, erro
|
||||
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)
|
||||
|
||||
@@ -45,7 +45,18 @@ type fakeStore struct {
|
||||
freigabeRegeln map[string]store.FreigabeRegel
|
||||
freigabeschritte map[string]store.Freigabeschritt
|
||||
passwordResetTokens map[string]store.PasswordResetToken
|
||||
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
|
||||
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 {
|
||||
@@ -67,19 +78,86 @@ func newFakeStore() *fakeStore {
|
||||
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 {
|
||||
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()
|
||||
defer f.mu.Unlock()
|
||||
id := f.newID()
|
||||
acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()}
|
||||
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
|
||||
return acc, nil
|
||||
}
|
||||
@@ -96,6 +174,19 @@ func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.A
|
||||
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()
|
||||
@@ -876,7 +967,7 @@ func newServerWithMailer(t *testing.T, fs *fakeStore) (*web.Server, *mail.FakeMa
|
||||
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
acc, err := fs.CreateAccount(ctx, accountName)
|
||||
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: accountName})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
@@ -936,6 +1027,28 @@ func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.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 {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
@@ -997,10 +1110,10 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
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"},
|
||||
"password": {"ein-sicheres-passwort"},
|
||||
})
|
||||
}, firmenPflichtfelder()))
|
||||
if regResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
||||
}
|
||||
@@ -1027,10 +1140,10 @@ func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
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": {"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())
|
||||
}
|
||||
@@ -1073,10 +1186,10 @@ func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) {
|
||||
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
form := url.Values{
|
||||
form := mergeValues(url.Values{
|
||||
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
||||
"password": {"ein-sicheres-passwort"},
|
||||
}
|
||||
}, firmenPflichtfelder())
|
||||
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("first register status = %d, want 303", resp.Code)
|
||||
}
|
||||
@@ -1096,7 +1209,7 @@ func TestLoginWithCorrectPassword(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
@@ -1120,7 +1233,7 @@ func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
@@ -1144,7 +1257,7 @@ func TestLoginRejectsWrongPassword(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
@@ -1057,4 +1057,17 @@ tbody tr:hover {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Abteilungen</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Meine Anträge</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/antraege">← Meine Anträge</a></p>
|
||||
<h1>{{.Titel}}</h1>
|
||||
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/betreiber/accounts">← Alle Accounts</a></p>
|
||||
<h1>{{.Name}}</h1>
|
||||
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
||||
|
||||
@@ -11,19 +11,65 @@
|
||||
Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
|
||||
</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<div class="form-card">
|
||||
<form method="post" action="/betreiber/accounts">
|
||||
<label for="account_name">Firmenname</label>
|
||||
<input type="text" id="account_name" name="account_name" required>
|
||||
|
||||
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
|
||||
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" minlength="8" required>
|
||||
|
||||
<button type="submit">Firma anlegen</button>
|
||||
<fieldset class="form-section">
|
||||
<legend>Firma</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-full">
|
||||
<label for="account_name">Firmenname</label>
|
||||
<input type="text" id="account_name" name="account_name" required>
|
||||
</div>
|
||||
<div class="form-full">
|
||||
<label for="strasse">Straße und Hausnummer</label>
|
||||
<input type="text" id="strasse" name="strasse" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="plz">PLZ</label>
|
||||
<input type="text" id="plz" name="plz" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ort">Ort</label>
|
||||
<input type="text" id="ort" name="ort" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="land">Land</label>
|
||||
<input type="text" id="land" name="land" value="Deutschland" required>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="form-section">
|
||||
<legend>Abrechnung</legend>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="rechnungsemail">Rechnungsemail</label>
|
||||
<input type="email" id="rechnungsemail" name="rechnungsemail" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ust_id">USt-IdNr. (optional)</label>
|
||||
<input type="text" id="ust_id" name="ust_id" placeholder="z. B. DE123456789">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="form-section">
|
||||
<legend>Erster Nutzer (wird Admin)</legend>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" minlength="8" required>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Firma anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<h1>Plattform</h1>
|
||||
<ul class="stat-cards">
|
||||
<li>
|
||||
@@ -24,6 +24,7 @@
|
||||
<span class="stat-card-label">Audit-Log (neueste)</span>
|
||||
</a>
|
||||
</li>
|
||||
<li><a class="stat-card stat-card-action" href="/betreiber/email-vorlagen"><span class="stat-card-label">E-Mail-Vorlagen →</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
|
||||
49
internal/web/templates/email_vorlage_bearbeiten.html
Normal file
49
internal/web/templates/email_vorlage_bearbeiten.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{{define "email-vorlage-bearbeiten"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page page-wide">
|
||||
<p><a href="{{.ActionBase}}">← E-Mail-Vorlagen</a></p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{.Label}}</h1>
|
||||
<p class="hinweis">
|
||||
Platzhalter in geschweiften Klammern werden beim Versand ersetzt.
|
||||
Verfügbar: {{.Platzhalter}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Gespeichert}}<p class="hinweis">Gespeichert.</p>{{end}}
|
||||
<div class="form-card">
|
||||
<form method="post" action="{{.ActionBase}}/{{.Typ}}">
|
||||
<fieldset class="form-section">
|
||||
<legend>{{.Label}}{{if .IstUebersteuert}} <span class="hinweis">(eigene Vorlage)</span>{{else if eq .ActionBase "/verwaltung/email-vorlagen"}} <span class="hinweis">(Plattform-Standard, nicht angepasst)</span>{{end}}</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-full">
|
||||
<label for="betreff">Betreff</label>
|
||||
<input type="text" id="betreff" name="betreff" value="{{.Betreff}}" required>
|
||||
</div>
|
||||
<div class="form-full">
|
||||
<label for="text">Text</label>
|
||||
<textarea id="text" name="text" rows="10" required>{{.Text}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{{if and .IstUebersteuert (eq .ActionBase "/verwaltung/email-vorlagen")}}
|
||||
<div class="form-gefahrenzone">
|
||||
<form method="post" action="/verwaltung/email-vorlagen/{{.Typ}}/zuruecksetzen" onsubmit="return confirm('Eigene Vorlage löschen und zum Plattform-Standard zurückkehren?')">
|
||||
<button type="submit" class="entfernen">Auf Plattform-Standard zurücksetzen</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
30
internal/web/templates/email_vorlagen_liste.html
Normal file
30
internal/web/templates/email_vorlagen_liste.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{{define "email-vorlagen-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>{{.Title}}</h1>
|
||||
<p class="hinweis">Betreff und Text der vom System versendeten E-Mails.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if not .Zeilen}}
|
||||
<p class="hinweis">Es gibt noch keine vom System versendeten E-Mails.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Zeilen}}
|
||||
<li>
|
||||
<a href="{{$.ActionBase}}/{{.Typ}}">
|
||||
<strong>{{.Label}}</strong>
|
||||
{{if .IstUebersteuert}}<span class="status status-published">eigene Vorlage</span>{{else}}<span class="status status-mittel">Plattform-Standard</span>{{end}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Posteingang</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/faelle">← Posteingang</a></p>
|
||||
<h1>{{.Titel}}</h1>
|
||||
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
|
||||
|
||||
63
internal/web/templates/firmendaten.html
Normal file
63
internal/web/templates/firmendaten.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{{define "firmendaten"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Firmendaten</h1>
|
||||
<p class="hinweis">Adress- und Abrechnungsdaten deiner Firma.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Gespeichert}}<p class="hinweis">Gespeichert.</p>{{end}}
|
||||
<div class="form-card">
|
||||
<form method="post" action="/verwaltung/firma">
|
||||
<fieldset class="form-section">
|
||||
<legend>Firma</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-full">
|
||||
<label for="account_name">Firmenname</label>
|
||||
<input type="text" id="account_name" name="account_name" value="{{.Name}}" required>
|
||||
</div>
|
||||
<div class="form-full">
|
||||
<label for="strasse">Straße und Hausnummer</label>
|
||||
<input type="text" id="strasse" name="strasse" value="{{.Strasse}}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="plz">PLZ</label>
|
||||
<input type="text" id="plz" name="plz" value="{{.PLZ}}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ort">Ort</label>
|
||||
<input type="text" id="ort" name="ort" value="{{.Ort}}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="land">Land</label>
|
||||
<input type="text" id="land" name="land" value="{{.Land}}" required>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="form-section">
|
||||
<legend>Abrechnung</legend>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="rechnungsemail">Rechnungsemail</label>
|
||||
<input type="email" id="rechnungsemail" name="rechnungsemail" value="{{.Rechnungsemail}}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ust_id">USt-IdNr. (optional)</label>
|
||||
<input type="text" id="ust_id" name="ust_id" value="{{.UStID}}" placeholder="z. B. DE123456789">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Freigabe-Regeln</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Meine Freigaben</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Genehmiger-Rollen</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<h1>Willkommen bei Deklarix</h1>
|
||||
{{if .Nav.IsBetreiber}}
|
||||
<ul class="stat-cards">
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/firma">Firmendaten</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
||||
@@ -35,6 +36,7 @@
|
||||
{{if .IsAdmin}}<a href="/verwaltung/genehmiger-rollen">Genehmiger-Rollen</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/loeschfristen">Löschfristen</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/email-vorlagen">E-Mail-Vorlagen</a>{{end}}
|
||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Abmelden</button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<h1>Werkzeugkatalog</h1>
|
||||
|
||||
<div class="page-header">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<h1>Import-Ergebnis</h1>
|
||||
<p class="fehler">
|
||||
Die Einmalpasswörter unten werden nach dieser Seite nirgendwo mehr
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Nutzerverwaltung</h1>
|
||||
|
||||
@@ -9,18 +9,64 @@
|
||||
den Betreiber freigeschaltet.
|
||||
</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<div class="form-card">
|
||||
<form method="post" action="/register">
|
||||
<label for="account_name">Firmenname</label>
|
||||
<input type="text" id="account_name" name="account_name" required>
|
||||
|
||||
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
|
||||
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" minlength="8" required>
|
||||
|
||||
<button type="submit">Konto anlegen</button>
|
||||
<fieldset class="form-section">
|
||||
<legend>Firma</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-full">
|
||||
<label for="account_name">Firmenname</label>
|
||||
<input type="text" id="account_name" name="account_name" required>
|
||||
</div>
|
||||
<div class="form-full">
|
||||
<label for="strasse">Straße und Hausnummer</label>
|
||||
<input type="text" id="strasse" name="strasse" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="plz">PLZ</label>
|
||||
<input type="text" id="plz" name="plz" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ort">Ort</label>
|
||||
<input type="text" id="ort" name="ort" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="land">Land</label>
|
||||
<input type="text" id="land" name="land" value="Deutschland" required>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="form-section">
|
||||
<legend>Abrechnung</legend>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="rechnungsemail">Rechnungsemail</label>
|
||||
<input type="email" id="rechnungsemail" name="rechnungsemail" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ust_id">USt-IdNr. (optional)</label>
|
||||
<input type="text" id="ust_id" name="ust_id" placeholder="z. B. DE123456789">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="form-section">
|
||||
<legend>Erster Nutzer (wird Admin)</legend>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" minlength="8" required>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Konto anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Register</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Wiedervorlage</h1>
|
||||
|
||||
Reference in New Issue
Block a user