feat: konfigurierbaren Freigabe-Workflow mit Genehmiger-Rollen einführen
Mandanten können jetzt selbst festlegen, wer bei welcher abgeleiteten Bedingung (Anforderung/Einstufung/Datenklasse) zusätzlich zur Fachebene-Entscheidung zustimmen muss (z. B. Datenschutzbeauftragter, Geschäftsführung) - Genehmiger-Rollen sind orthogonal zu den fünf bestehenden Zugriffsrollen. Alle ausgelösten Rollen müssen zustimmen, eine Ablehnung kippt kaskadierend den gesamten Antrag. Mandanten ohne Freigabe-Regeln (aktuell alle) sind unverändert vom alten Direktpfad betroffen, siehe TestOhneFreigabeRegelnVerhaeltSichWieVorher. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
385
internal/store/freigabe.go
Normal file
385
internal/store/freigabe.go
Normal file
@@ -0,0 +1,385 @@
|
||||
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
|
||||
// und CLAUDE.md. GenehmigerRolle ist eine reine Freigabe-Funktion
|
||||
// (z. B. "Datenschutzbeauftragter", "Geschäftsführung"), unabhängig von
|
||||
// app_user.role (Zugriffskontrolle) — eine Person kann beides zugleich
|
||||
// sein oder keines von beidem.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type GenehmigerRolle struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen
|
||||
// Mandanten an.
|
||||
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name string) (GenehmigerRolle, error) {
|
||||
var g GenehmigerRolle
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO genehmiger_rolle (account_id, name) VALUES ($1, $2)
|
||||
RETURNING id, account_id, name, created_at
|
||||
`, accountID, name).Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt)
|
||||
if err != nil {
|
||||
return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
|
||||
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
|
||||
var g GenehmigerRolle
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, name, created_at FROM genehmiger_rolle WHERE id = $1
|
||||
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GenehmigerRolle{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return GenehmigerRolle{}, fmt.Errorf("store: get genehmiger rolle: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
|
||||
// Mandanten.
|
||||
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, name, created_at FROM genehmiger_rolle
|
||||
WHERE account_id = $1 ORDER BY name
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list genehmiger rollen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []GenehmigerRolle
|
||||
for rows.Next() {
|
||||
var g GenehmigerRolle
|
||||
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteGenehmigerRolle entfernt eine Genehmiger-Rolle. Zuordnungen
|
||||
// (nutzer_genehmiger_rolle) und darauf verweisende Freigabe-Regeln
|
||||
// hängen per Fremdschlüssel daran — der Aufrufer muss sie vorher
|
||||
// entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein
|
||||
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
|
||||
func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete genehmiger rolle: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
|
||||
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
|
||||
`, userID, genehmigerRolleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: add nutzer genehmiger rolle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
|
||||
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2
|
||||
`, userID, genehmigerRolleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: remove nutzer genehmiger rolle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
|
||||
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
|
||||
func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT gr.id, gr.account_id, gr.name, gr.created_at
|
||||
FROM genehmiger_rolle gr
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id
|
||||
WHERE ngr.app_user_id = $1
|
||||
ORDER BY gr.name
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list genehmiger rollen for user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []GenehmigerRolle
|
||||
for rows.Next() {
|
||||
var g GenehmigerRolle
|
||||
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GenehmigerMitglied ist ein Nutzer, der eine bestimmte Genehmiger-Rolle
|
||||
// innehat — für die Anzeige "wer hat diese Rolle" auf der Verwaltungsseite.
|
||||
type GenehmigerMitglied struct {
|
||||
UserID string
|
||||
Email string
|
||||
}
|
||||
|
||||
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
|
||||
// bestimmten Genehmiger-Rolle.
|
||||
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT u.id, u.email
|
||||
FROM app_user u
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id
|
||||
WHERE ngr.genehmiger_rolle_id = $1
|
||||
ORDER BY u.email
|
||||
`, genehmigerRolleID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list nutzer for genehmiger rolle: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []GenehmigerMitglied
|
||||
for rows.Next() {
|
||||
var m GenehmigerMitglied
|
||||
if err := rows.Scan(&m.UserID, &m.Email); err != nil {
|
||||
return nil, fmt.Errorf("store: scan genehmiger mitglied: %w", err)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// FreigabeRegel: "wenn Bedingung X zutrifft, ist zusätzlich eine
|
||||
// Freigabe durch GenehmigerRolleID nötig." BedingungTyp ist eine von
|
||||
// "anforderung"/"einstufung"/"datenklasse", BedingungWert die jeweilige
|
||||
// ID aus dem Regelwerk (z. B. "dsfa_erforderlich", "hochrisiko").
|
||||
type FreigabeRegel struct {
|
||||
ID string
|
||||
AccountID string
|
||||
BedingungTyp string
|
||||
BedingungWert string
|
||||
GenehmigerRolleID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
|
||||
var f FreigabeRegel
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
`, accountID, bedingungTyp, bedingungWert, genehmigerRolleID).Scan(
|
||||
&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return FreigabeRegel{}, fmt.Errorf("store: create freigabe regel: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list freigabe regeln: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []FreigabeRegel
|
||||
for rows.Next() {
|
||||
var f FreigabeRegel
|
||||
if err := rows.Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan freigabe regel: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete freigabe regel: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFreigabeRegel liest eine einzelne Freigabe-Regel — genutzt, um
|
||||
// beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen.
|
||||
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
|
||||
var f FreigabeRegel
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
FROM freigabe_regel WHERE id = $1
|
||||
`, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FreigabeRegel{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return FreigabeRegel{}, fmt.Errorf("store: get freigabe regel: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Freigabeschritt ist eine einzelne, durch eine FreigabeRegel
|
||||
// ausgelöste Freigabe-Aufgabe für einen konkreten Antrag. Nicht
|
||||
// append-only — ein Schritt geht von "ausstehend" in genau einen
|
||||
// Endzustand über, keine Historie mehrerer Entscheidungen zum selben
|
||||
// Schritt.
|
||||
type Freigabeschritt struct {
|
||||
ID string
|
||||
AntragID string
|
||||
GenehmigerRolleID string
|
||||
Status string // ausstehend/genehmigt/abgelehnt
|
||||
EntschiedenVon *string
|
||||
EntschiedenAm *time.Time
|
||||
Kommentar string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
`, antragID, genehmigerRolleID).Scan(
|
||||
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Freigabeschritt{}, fmt.Errorf("store: create freigabeschritt: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
FROM freigabeschritt WHERE id = $1
|
||||
`, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Freigabeschritt{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Freigabeschritt{}, fmt.Errorf("store: get freigabeschritt: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at
|
||||
`, antragID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list freigabeschritte for antrag: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Freigabeschritt
|
||||
for rows.Next() {
|
||||
var f Freigabeschritt
|
||||
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListAusstehendeFreigabeschritteForUser liefert alle offenen
|
||||
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
|
||||
// innehat — Grundlage für "Meine Freigaben".
|
||||
func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at
|
||||
FROM freigabeschritt f
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id
|
||||
WHERE ngr.app_user_id = $1 AND f.status = 'ausstehend'
|
||||
ORDER BY f.created_at
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list ausstehende freigabeschritte: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Freigabeschritt
|
||||
for rows.Next() {
|
||||
var f Freigabeschritt
|
||||
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EntscheideFreigabeschritt trägt die Entscheidung einer Person zu
|
||||
// einem einzelnen Freigabeschritt ein. Nur von "ausstehend" aus
|
||||
// erlaubt — der Aufrufer prüft das vorher (ErrNotFound bei bereits
|
||||
// entschiedenen Schritten wäre irreführend, deshalb hier ein
|
||||
// generischer Fehler statt eines Sentinels).
|
||||
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
UPDATE freigabeschritt
|
||||
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
|
||||
WHERE id = $1 AND status = 'ausstehend'
|
||||
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
`, id, status, entschiedenVon, kommentar).Scan(
|
||||
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Freigabeschritt{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Freigabeschritt{}, fmt.Errorf("store: entscheide freigabeschritt: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// KaskadiereAblehnung lehnt alle noch ausstehenden Freigabeschritte
|
||||
// eines Antrags automatisch ab, nachdem ein anderer Schritt desselben
|
||||
// Antrags bereits abgelehnt wurde — eine einzelne Ablehnung kippt den
|
||||
// gesamten Antrag, die übrigen offenen Freigaben werden dadurch
|
||||
// gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen
|
||||
// hängen zu bleiben.
|
||||
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE freigabeschritt
|
||||
SET status = 'abgelehnt', entschieden_am = now(),
|
||||
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
|
||||
WHERE antrag_id = $1 AND status = 'ausstehend' AND id != $2
|
||||
`, antragID, ausloesenderSchrittID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: kaskadiere ablehnung: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
174
internal/store/freigabe_test.go
Normal file
174
internal/store/freigabe_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestGenehmigerRolleCRUDAndMitgliedschaft(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
if g.AccountID != accID || g.Name != "Datenschutzbeauftragter" {
|
||||
t.Fatalf("GenehmigerRolle = %+v, unerwartete Werte", g)
|
||||
}
|
||||
|
||||
got, err := s.GetGenehmigerRolle(ctx, g.ID)
|
||||
if err != nil || got.Name != "Datenschutzbeauftragter" {
|
||||
t.Fatalf("GetGenehmigerRolle = %+v, err=%v", got, err)
|
||||
}
|
||||
|
||||
liste, err := s.ListGenehmigerRollenForAccount(ctx, accID)
|
||||
if err != nil || len(liste) != 1 {
|
||||
t.Fatalf("ListGenehmigerRollenForAccount = %+v, err=%v", liste, err)
|
||||
}
|
||||
|
||||
user := testUserID(t, s, accID)
|
||||
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||
}
|
||||
// Erneutes Hinzufügen derselben Zuordnung ist ein No-op (ON CONFLICT),
|
||||
// kein Fehler.
|
||||
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||
t.Fatalf("AddNutzerGenehmigerRolle (erneut): %v", err)
|
||||
}
|
||||
|
||||
rollenDesNutzers, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||
if err != nil || len(rollenDesNutzers) != 1 || rollenDesNutzers[0].ID != g.ID {
|
||||
t.Fatalf("ListGenehmigerRollenForUser = %+v, err=%v", rollenDesNutzers, err)
|
||||
}
|
||||
|
||||
mitglieder, err := s.ListNutzerForGenehmigerRolle(ctx, g.ID)
|
||||
if err != nil || len(mitglieder) != 1 || mitglieder[0].UserID != user {
|
||||
t.Fatalf("ListNutzerForGenehmigerRolle = %+v, err=%v", mitglieder, err)
|
||||
}
|
||||
|
||||
if err := s.RemoveNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||
t.Fatalf("RemoveNutzerGenehmigerRolle: %v", err)
|
||||
}
|
||||
rollenNachEntfernen, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||
if err != nil || len(rollenNachEntfernen) != 0 {
|
||||
t.Fatalf("ListGenehmigerRollenForUser nach Entfernen = %+v, err=%v", rollenNachEntfernen, err)
|
||||
}
|
||||
|
||||
if err := s.DeleteGenehmigerRolle(ctx, g.ID); err != nil {
|
||||
t.Fatalf("DeleteGenehmigerRolle: %v", err)
|
||||
}
|
||||
if _, err := s.GetGenehmigerRolle(ctx, g.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreigabeRegelCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
|
||||
regel, err := s.CreateFreigabeRegel(ctx, accID, "einstufung", "hochrisiko", g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFreigabeRegel: %v", err)
|
||||
}
|
||||
if regel.BedingungTyp != "einstufung" || regel.BedingungWert != "hochrisiko" {
|
||||
t.Fatalf("FreigabeRegel = %+v, unerwartete Werte", regel)
|
||||
}
|
||||
|
||||
liste, err := s.ListFreigabeRegelnForAccount(ctx, accID)
|
||||
if err != nil || len(liste) != 1 {
|
||||
t.Fatalf("ListFreigabeRegelnForAccount = %+v, err=%v", liste, err)
|
||||
}
|
||||
|
||||
got, err := s.GetFreigabeRegel(ctx, regel.ID)
|
||||
if err != nil || got.ID != regel.ID {
|
||||
t.Fatalf("GetFreigabeRegel = %+v, err=%v", got, err)
|
||||
}
|
||||
|
||||
if err := s.DeleteFreigabeRegel(ctx, regel.ID); err != nil {
|
||||
t.Fatalf("DeleteFreigabeRegel: %v", err)
|
||||
}
|
||||
if _, err := s.GetFreigabeRegel(ctx, regel.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreigabeschrittLifecycleUndKaskade(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
user := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, user, nil, "Test-Antrag für Freigabeschritte")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
antragID := antrag.ID
|
||||
|
||||
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter")
|
||||
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung")
|
||||
|
||||
schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||
}
|
||||
if schrittDSB.Status != "ausstehend" {
|
||||
t.Fatalf("Status = %q, want ausstehend", schrittDSB.Status)
|
||||
}
|
||||
schrittGF, err := s.CreateFreigabeschritt(ctx, antragID, gf.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||
}
|
||||
|
||||
alle, err := s.ListFreigabeschritteForAntrag(ctx, antragID)
|
||||
if err != nil || len(alle) != 2 {
|
||||
t.Fatalf("ListFreigabeschritteForAntrag = %+v, err=%v", alle, err)
|
||||
}
|
||||
|
||||
ausstehend, err := s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAusstehendeFreigabeschritteForUser: %v", err)
|
||||
}
|
||||
if len(ausstehend) != 0 {
|
||||
t.Fatalf("erwartet 0 ausstehende Freigaben ohne Genehmiger-Rollen-Zuordnung, got %d", len(ausstehend))
|
||||
}
|
||||
if err := s.AddNutzerGenehmigerRolle(ctx, user, dsb.ID); err != nil {
|
||||
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||
}
|
||||
ausstehend, err = s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||
if err != nil || len(ausstehend) != 1 || ausstehend[0].ID != schrittDSB.ID {
|
||||
t.Fatalf("ListAusstehendeFreigabeschritteForUser = %+v, err=%v", ausstehend, err)
|
||||
}
|
||||
|
||||
entschieden, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "abgelehnt", user, "nicht ausreichend")
|
||||
if err != nil {
|
||||
t.Fatalf("EntscheideFreigabeschritt: %v", err)
|
||||
}
|
||||
if entschieden.Status != "abgelehnt" || entschieden.EntschiedenVon == nil || *entschieden.EntschiedenVon != user {
|
||||
t.Fatalf("Freigabeschritt nach Entscheidung = %+v, unerwartete Werte", entschieden)
|
||||
}
|
||||
|
||||
// Ein bereits entschiedener Schritt kann nicht erneut entschieden werden.
|
||||
if _, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "genehmigt", user, ""); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("erneute Entscheidung err = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
if err := s.KaskadiereAblehnung(ctx, antragID, schrittDSB.ID); err != nil {
|
||||
t.Fatalf("KaskadiereAblehnung: %v", err)
|
||||
}
|
||||
nachKaskade, err := s.GetFreigabeschritt(ctx, schrittGF.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFreigabeschritt: %v", err)
|
||||
}
|
||||
if nachKaskade.Status != "abgelehnt" {
|
||||
t.Fatalf("Status nach Kaskade = %q, want abgelehnt", nachKaskade.Status)
|
||||
}
|
||||
}
|
||||
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||
UPDATE antrag SET status = 'eingereicht' WHERE status = 'wartet_auf_freigabe';
|
||||
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden'));
|
||||
|
||||
DROP TABLE freigabeschritt;
|
||||
DROP TABLE freigabe_regel;
|
||||
DROP TABLE nutzer_genehmiger_rolle;
|
||||
DROP TABLE genehmiger_rolle;
|
||||
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- Konfigurierbarer Mehrfach-Freigabe-Workflow (2026-08-31): manche
|
||||
-- Anträge brauchen zusätzlich zur normalen Fachebene-Entscheidung eine
|
||||
-- Freigabe durch bestimmte Personen (z. B. Datenschutzbeauftragte bei
|
||||
-- dsfa_erforderlich, Geschäftsführung bei hochrisiko). Bewusst additiv
|
||||
-- zur bestehenden app_user.role (Zugriffskontrolle) — eine
|
||||
-- Genehmiger-Rolle ist eine reine Freigabe-Funktion, keine Berechtigung,
|
||||
-- und eine Person kann mehrere davon gleichzeitig innehaben.
|
||||
|
||||
-- Stammdaten wie abteilung, pro Mandant frei benennbar.
|
||||
CREATE TABLE genehmiger_rolle (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, name)
|
||||
);
|
||||
|
||||
-- Welche Personen eine Genehmiger-Rolle innehaben (n:m).
|
||||
CREATE TABLE nutzer_genehmiger_rolle (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (app_user_id, genehmiger_rolle_id)
|
||||
);
|
||||
|
||||
-- "Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
|
||||
-- Genehmiger-Rolle Y nötig." Bedingung ist bewusst eine der bereits vom
|
||||
-- Regelwerk abgeleiteten Größen (Anforderungs-ID, Einstufungs-ID,
|
||||
-- Datenklasse-ID) — kein freier Regel-Editor, keine beliebige Logik.
|
||||
CREATE TABLE freigabe_regel (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
bedingung_typ TEXT NOT NULL CHECK (bedingung_typ IN ('anforderung', 'einstufung', 'datenklasse')),
|
||||
bedingung_wert TEXT NOT NULL,
|
||||
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Pro Antrag ein Eintrag je durch eine Regel ausgelöster nötiger
|
||||
-- Freigabe. Nicht append-only (wie werkzeug) — ein Freigabeschritt ist
|
||||
-- eine einzelne Aufgabe, die von ausstehend in einen Endzustand
|
||||
-- übergeht, keine Historie mehrerer Entscheidungen zum selben Schritt.
|
||||
CREATE TABLE freigabeschritt (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||
status TEXT NOT NULL DEFAULT 'ausstehend' CHECK (status IN ('ausstehend', 'genehmigt', 'abgelehnt')),
|
||||
entschieden_von UUID REFERENCES app_user (id),
|
||||
entschieden_am TIMESTAMPTZ,
|
||||
kommentar TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Ein Antrag, der auf zusätzliche Freigaben wartet, ist weder "nur
|
||||
-- eingereicht" noch schon "entschieden" — eigener Zwischenzustand.
|
||||
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||
CHECK (status IN ('entwurf', 'eingereicht', 'wartet_auf_freigabe', 'entschieden'));
|
||||
Reference in New Issue
Block a user