Jede neue Firma (Registrierung + Betreiber-Firmenanlage) bekommt jetzt automatisch vier leere Genehmiger-Rollen mit erklärender Beschreibung (Datenschutzbeauftragter, Geschäftsführer, KI-Manager, CISO) - Admin muss nur noch Personen zuordnen statt bei null anzufangen. Welche Bedingung welche Rolle tatsächlich auslöst, bleibt weiterhin komplett konfigurierbar pro Firma (Migration 0018 fügt genehmiger_rolle.beschreibung als reines Freitext-Orientierungsfeld hinzu, keine feste fachliche Bindung). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
389 lines
15 KiB
Go
389 lines
15 KiB
Go
// 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.Pool.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.Pool.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.Pool.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.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
|
|
}
|