// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017 // und CLAUDE.md. GenehmigerRolle ist eine reine Freigabe-Funktion // (z. B. "Datenschutzbeauftragter", "Geschäftsführung"), unabhängig von // app_user.role (Zugriffskontrolle) — eine Person kann beides zugleich // sein oder keines von beidem. package store import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) type GenehmigerRolle struct { ID string AccountID string Name string Beschreibung string CreatedAt time.Time } // CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen // Mandanten an. Beschreibung ist reiner Freitext zur Orientierung des // Admins (z. B. "Prüft den Antrag aus Sicherheitssicht") — bindet keine // Bedingung, das bleibt Sache von freigabe_regel. func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) { var g GenehmigerRolle err := s.db(ctx).QueryRow(ctx, ` INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3) RETURNING id, account_id, name, beschreibung, created_at `, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt) if err != nil { return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err) } return g, nil } // GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID. func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) { var g GenehmigerRolle err := s.db(ctx).QueryRow(ctx, ` SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1 `, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return GenehmigerRolle{}, ErrNotFound } if err != nil { return GenehmigerRolle{}, fmt.Errorf("store: get genehmiger rolle: %w", err) } return g, nil } // ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines // Mandanten. func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) { rows, err := s.db(ctx).Query(ctx, ` SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE account_id = $1 ORDER BY name `, accountID) if err != nil { return nil, fmt.Errorf("store: list genehmiger rollen: %w", err) } defer rows.Close() var out []GenehmigerRolle for rows.Next() { var g GenehmigerRolle if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt); err != nil { return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err) } out = append(out, g) } return out, rows.Err() } // DeleteGenehmigerRolle entfernt eine Genehmiger-Rolle. Zuordnungen // (nutzer_genehmiger_rolle) und darauf verweisende Freigabe-Regeln // hängen per Fremdschlüssel daran — der Aufrufer muss sie vorher // entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein // stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend). func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error { tag, err := s.db(ctx).Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id) if err != nil { return fmt.Errorf("store: delete genehmiger rolle: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu. func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error { _, err := s.db(ctx).Exec(ctx, ` INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2) ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING `, userID, genehmigerRolleID) if err != nil { return fmt.Errorf("store: add nutzer genehmiger rolle: %w", err) } return nil } // RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle. func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error { _, err := s.db(ctx).Exec(ctx, ` DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2 `, userID, genehmigerRolleID) if err != nil { return fmt.Errorf("store: remove nutzer genehmiger rolle: %w", err) } return nil } // ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer // Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht. func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) { rows, err := s.db(ctx).Query(ctx, ` SELECT gr.id, gr.account_id, gr.name, gr.created_at FROM genehmiger_rolle gr JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id WHERE ngr.app_user_id = $1 ORDER BY gr.name `, userID) if err != nil { return nil, fmt.Errorf("store: list genehmiger rollen for user: %w", err) } defer rows.Close() var out []GenehmigerRolle for rows.Next() { var g GenehmigerRolle if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil { return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err) } out = append(out, g) } return out, rows.Err() } // GenehmigerMitglied ist ein Nutzer, der eine bestimmte Genehmiger-Rolle // innehat — für die Anzeige "wer hat diese Rolle" auf der Verwaltungsseite. type GenehmigerMitglied struct { UserID string Email string } // ListNutzerForGenehmigerRolle liefert alle Personen mit einer // bestimmten Genehmiger-Rolle. func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) { rows, err := s.db(ctx).Query(ctx, ` SELECT u.id, u.email FROM app_user u JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id WHERE ngr.genehmiger_rolle_id = $1 ORDER BY u.email `, genehmigerRolleID) if err != nil { return nil, fmt.Errorf("store: list nutzer for genehmiger rolle: %w", err) } defer rows.Close() var out []GenehmigerMitglied for rows.Next() { var m GenehmigerMitglied if err := rows.Scan(&m.UserID, &m.Email); err != nil { return nil, fmt.Errorf("store: scan genehmiger mitglied: %w", err) } out = append(out, m) } return out, rows.Err() } // FreigabeRegel: "wenn Bedingung X zutrifft, ist zusätzlich eine // Freigabe durch GenehmigerRolleID nötig." BedingungTyp ist eine von // "anforderung"/"einstufung"/"datenklasse", BedingungWert die jeweilige // ID aus dem Regelwerk (z. B. "dsfa_erforderlich", "hochrisiko"). type FreigabeRegel struct { ID string AccountID string BedingungTyp string BedingungWert string GenehmigerRolleID string CreatedAt time.Time } func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) { var f FreigabeRegel err := s.db(ctx).QueryRow(ctx, ` INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id) VALUES ($1, $2, $3, $4) RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at `, accountID, bedingungTyp, bedingungWert, genehmigerRolleID).Scan( &f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt, ) if err != nil { return FreigabeRegel{}, fmt.Errorf("store: create freigabe regel: %w", err) } return f, nil } func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) { rows, err := s.db(ctx).Query(ctx, ` SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at `, accountID) if err != nil { return nil, fmt.Errorf("store: list freigabe regeln: %w", err) } defer rows.Close() var out []FreigabeRegel for rows.Next() { var f FreigabeRegel if err := rows.Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt); err != nil { return nil, fmt.Errorf("store: scan freigabe regel: %w", err) } out = append(out, f) } return out, rows.Err() } func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error { tag, err := s.db(ctx).Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id) if err != nil { return fmt.Errorf("store: delete freigabe regel: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // GetFreigabeRegel liest eine einzelne Freigabe-Regel — genutzt, um // beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen. func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) { var f FreigabeRegel err := s.db(ctx).QueryRow(ctx, ` SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at FROM freigabe_regel WHERE id = $1 `, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return FreigabeRegel{}, ErrNotFound } if err != nil { return FreigabeRegel{}, fmt.Errorf("store: get freigabe regel: %w", err) } return f, nil } // Freigabeschritt ist eine einzelne, durch eine FreigabeRegel // ausgelöste Freigabe-Aufgabe für einen konkreten Antrag. Nicht // append-only — ein Schritt geht von "ausstehend" in genau einen // Endzustand über, keine Historie mehrerer Entscheidungen zum selben // Schritt. type Freigabeschritt struct { ID string AntragID string GenehmigerRolleID string Status string // ausstehend/genehmigt/abgelehnt EntschiedenVon *string EntschiedenAm *time.Time Kommentar string CreatedAt time.Time } func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) { var f Freigabeschritt err := s.db(ctx).QueryRow(ctx, ` INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2) RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at `, antragID, genehmigerRolleID).Scan( &f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt, ) if err != nil { return Freigabeschritt{}, fmt.Errorf("store: create freigabeschritt: %w", err) } return f, nil } func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) { var f Freigabeschritt err := s.db(ctx).QueryRow(ctx, ` SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at FROM freigabeschritt WHERE id = $1 `, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return Freigabeschritt{}, ErrNotFound } if err != nil { return Freigabeschritt{}, fmt.Errorf("store: get freigabeschritt: %w", err) } return f, nil } func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) { rows, err := s.db(ctx).Query(ctx, ` SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at `, antragID) if err != nil { return nil, fmt.Errorf("store: list freigabeschritte for antrag: %w", err) } defer rows.Close() var out []Freigabeschritt for rows.Next() { var f Freigabeschritt if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil { return nil, fmt.Errorf("store: scan freigabeschritt: %w", err) } out = append(out, f) } return out, rows.Err() } // ListAusstehendeFreigabeschritteForUser liefert alle offenen // Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person // innehat — Grundlage für "Meine Freigaben". func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) { rows, err := s.db(ctx).Query(ctx, ` SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at FROM freigabeschritt f JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id WHERE ngr.app_user_id = $1 AND f.status = 'ausstehend' ORDER BY f.created_at `, userID) if err != nil { return nil, fmt.Errorf("store: list ausstehende freigabeschritte: %w", err) } defer rows.Close() var out []Freigabeschritt for rows.Next() { var f Freigabeschritt if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil { return nil, fmt.Errorf("store: scan freigabeschritt: %w", err) } out = append(out, f) } return out, rows.Err() } // EntscheideFreigabeschritt trägt die Entscheidung einer Person zu // einem einzelnen Freigabeschritt ein. Nur von "ausstehend" aus // erlaubt — der Aufrufer prüft das vorher (ErrNotFound bei bereits // entschiedenen Schritten wäre irreführend, deshalb hier ein // generischer Fehler statt eines Sentinels). func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) { var f Freigabeschritt err := s.db(ctx).QueryRow(ctx, ` UPDATE freigabeschritt SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4 WHERE id = $1 AND status = 'ausstehend' RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at `, id, status, entschiedenVon, kommentar).Scan( &f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt, ) if errors.Is(err, pgx.ErrNoRows) { return Freigabeschritt{}, ErrNotFound } if err != nil { return Freigabeschritt{}, fmt.Errorf("store: entscheide freigabeschritt: %w", err) } return f, nil } // KaskadiereAblehnung lehnt alle noch ausstehenden Freigabeschritte // eines Antrags automatisch ab, nachdem ein anderer Schritt desselben // Antrags bereits abgelehnt wurde — eine einzelne Ablehnung kippt den // gesamten Antrag, die übrigen offenen Freigaben werden dadurch // gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen // hängen zu bleiben. func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error { _, err := s.db(ctx).Exec(ctx, ` UPDATE freigabeschritt SET status = 'abgelehnt', entschieden_am = now(), kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.' WHERE antrag_id = $1 AND status = 'ausstehend' AND id != $2 `, antragID, ausloesenderSchrittID) if err != nil { return fmt.Errorf("store: kaskadiere ablehnung: %w", err) } return nil }