package store import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // Werkzeug ist ein Eintrag im KI-Werkzeugkatalog — der eigentliche Wert // des Produkts (siehe CLAUDE.md). AccountID nil markiert einen zentral // gepflegten, für alle Mandanten identischen Katalogeintrag; ein // gesetzter AccountID ist eine mandantenspezifische Ergänzung. // LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog // muss belegbar sein. type Werkzeug struct { ID string AccountID *string Name string Anbieter string // Verarbeitungslaender nennt die tatsächlichen Länder (oder, wenn // der Anbieter selbst keinen einzelnen Staat zusichert, eine Region // wie "EU-Region") statt eines groben EU/USA/gemischt-Eimers — die // USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere, // "gemischt" verschleierte, welche Länder konkret beteiligt sind. Verarbeitungslaender []string AVVVerfuegbar bool AVVURL string TrainingOptOut bool TrainingStandard bool // DPFZertifiziert: EU-US Data Privacy Framework — schmalere, // gerichtlich schon zweimal gekippte Rechtsgrundlage (Safe Harbor, // Privacy Shield) für Transfers in die USA, gilt nur für zertifizierte // Unternehmen. false bedeutet nicht "nicht zertifiziert" im Sinne von // widerlegt, sondern "nicht als Zusicherung belegt" — wie bei den // übrigen Booleans hier. DPFZertifiziert bool AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen Zertifizierungen []string Subprozessoren []string GeeigneteZwecke []string Einschraenkungen []string LetztePruefung time.Time Quelle string CreatedAt time.Time UpdatedAt time.Time } // WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für // Create/Update — bei 15 Feldern lesbarer als eine positionale // Parameterliste. type WerkzeugInput struct { AccountID *string Name string Anbieter string Verarbeitungslaender []string AVVVerfuegbar bool AVVURL string TrainingOptOut bool TrainingStandard bool DPFZertifiziert bool AufbewahrungTage *int Zertifizierungen []string Subprozessoren []string GeeigneteZwecke []string Einschraenkungen []string LetztePruefung time.Time Quelle string } const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url, training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren, geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at` func scanWerkzeug(row interface { Scan(dest ...any) error }) (Werkzeug, error) { var w Werkzeug err := row.Scan( &w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungslaender, &w.AVVVerfuegbar, &w.AVVURL, &w.TrainingOptOut, &w.TrainingStandard, &w.DPFZertifiziert, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren, &w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt, ) return w, err } // normalizeWerkzeugSlices ersetzt nil-Slices durch leere Slices — die // Spalten sind TEXT[] NOT NULL, ein nil-Slice (z. B. wenn eine // Einschränkung optional ist) käme sonst als SQL-NULL an und würde mit // einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe // Fall wie früher bei finding.sources). func normalizeWerkzeugSlices(in *WerkzeugInput) { if in.Verarbeitungslaender == nil { in.Verarbeitungslaender = []string{} } if in.Zertifizierungen == nil { in.Zertifizierungen = []string{} } if in.GeeigneteZwecke == nil { in.GeeigneteZwecke = []string{} } if in.Einschraenkungen == nil { in.Einschraenkungen = []string{} } if in.Subprozessoren == nil { in.Subprozessoren = []string{} } } // CreateWerkzeug legt einen Katalogeintrag an. func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) { normalizeWerkzeugSlices(&in) row := s.Pool.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, geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING `+werkzeugColumns, in.AccountID, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL, in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren, in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle, ) w, err := scanWerkzeug(row) if err != nil { return Werkzeug{}, fmt.Errorf("store: create werkzeug: %w", err) } return w, nil } // UpdateWerkzeug ersetzt die Felder eines bestehenden Katalogeintrags // (z. B. bei einer erneuten Prüfung der Zusicherungen). Werkzeug ist // bewusst nicht append-only — anders als ein Beweisstück ist der // Katalog ein gepflegter, sich änderender Datenbestand; eine Entscheidung // 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, ` 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, zertifizierungen = $11, subprozessoren = $12, geeignete_zwecke = $13, einschraenkungen = $14, letzte_pruefung = $15, quelle = $16, updated_at = now() WHERE id = $1 RETURNING `+werkzeugColumns, id, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL, in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren, in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle, ) w, err := scanWerkzeug(row) if errors.Is(err, pgx.ErrNoRows) { return Werkzeug{}, ErrNotFound } if err != nil { return Werkzeug{}, fmt.Errorf("store: update werkzeug: %w", err) } return w, nil } // 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) w, err := scanWerkzeug(row) if errors.Is(err, pgx.ErrNoRows) { return Werkzeug{}, ErrNotFound } if err != nil { return Werkzeug{}, fmt.Errorf("store: get werkzeug: %w", err) } return w, nil } // 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) if err != nil { return fmt.Errorf("store: delete werkzeug: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // ListWerkzeugeForAccount liefert den für einen Mandanten sichtbaren // 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, ` 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 )) OR w.account_id = $1 ORDER BY w.name `, accountID) if err != nil { return nil, fmt.Errorf("store: list werkzeuge for account: %w", err) } defer rows.Close() var out []Werkzeug for rows.Next() { w, err := scanWerkzeug(rows) if err != nil { return nil, fmt.Errorf("store: scan werkzeug: %w", err) } out = append(out, w) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("store: list werkzeuge for account: %w", err) } return out, nil } // ListZentraleWerkzeuge liefert den vollständigen zentralen Katalog // (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, ` SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name `) if err != nil { return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err) } defer rows.Close() var out []Werkzeug for rows.Next() { w, err := scanWerkzeug(rows) if err != nil { return nil, fmt.Errorf("store: scan werkzeug: %w", err) } out = append(out, w) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err) } return out, nil } // CurrentKatalogVersion liefert eine reproduzierbare Kennung des // aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird // in jeder Bewertung/Entscheidung eingefroren, damit im Audit // nachvollziehbar bleibt, mit welchem Katalogstand ein Vorschlag // erzeugt wurde. func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) { var count int var lastUpdate time.Time err := s.Pool.QueryRow(ctx, ` SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug `).Scan(&count, &lastUpdate) if err != nil { return "", fmt.Errorf("store: current katalog version: %w", err) } return fmt.Sprintf("%d-%d", count, lastUpdate.Unix()), nil } // WerkzeugSperre ist die Sperrung eines (auch zentralen) Katalogeintrags // durch einen einzelnen Mandanten — der zentrale Katalog selbst bleibt // dabei unverändert. type WerkzeugSperre struct { ID string AccountID string WerkzeugID string Grund string GesperrtAm time.Time } // 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, ` INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund) VALUES ($1, $2, $3) RETURNING id, account_id, werkzeug_id, grund, gesperrt_am `, accountID, werkzeugID, grund).Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm) if err != nil { return WerkzeugSperre{}, fmt.Errorf("store: create werkzeug sperre: %w", err) } return sp, nil } // DeleteWerkzeugSperre hebt eine Sperrung wieder auf. func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error { tag, err := s.Pool.Exec(ctx, ` DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2 `, accountID, werkzeugID) if err != nil { return fmt.Errorf("store: delete werkzeug sperre: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten. func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) { rows, err := s.Pool.Query(ctx, ` SELECT id, account_id, werkzeug_id, grund, gesperrt_am FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC `, accountID) if err != nil { return nil, fmt.Errorf("store: list werkzeug sperren: %w", err) } defer rows.Close() var out []WerkzeugSperre for rows.Next() { var sp WerkzeugSperre if err := rows.Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm); err != nil { return nil, fmt.Errorf("store: scan werkzeug sperre: %w", err) } out = append(out, sp) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("store: list werkzeug sperren: %w", err) } return out, nil }