Katalog (zentral + mandantenseitig) zeigt jetzt eine mehrspaltige Tabelle statt einer Liste. aufbewahrung_tage ist nullable (NULL = vom Anbieter nicht beziffert, unterscheidbar von echter 0-Tage- Zusicherung). geeignete_zwecke ist Checkbox-Auswahl aus den sechs Fragebogen-Zweck-Kategorien statt Freitext. Neues Feld subprozessoren macht Unterauftragsverarbeiter durchsuchbar statt nur Freitext in Einschränkungen. 180-Tage-Frische-Markierung in beiden Katalogansichten ergänzt.
307 lines
10 KiB
Go
307 lines
10 KiB
Go
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
|
|
Verarbeitungsort string
|
|
AVVVerfuegbar bool
|
|
AVVURL string
|
|
TrainingOptOut bool
|
|
TrainingStandard 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
|
|
Verarbeitungsort string
|
|
AVVVerfuegbar bool
|
|
AVVURL string
|
|
TrainingOptOut bool
|
|
TrainingStandard bool
|
|
AufbewahrungTage *int
|
|
Zertifizierungen []string
|
|
Subprozessoren []string
|
|
GeeigneteZwecke []string
|
|
Einschraenkungen []string
|
|
LetztePruefung time.Time
|
|
Quelle string
|
|
}
|
|
|
|
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
|
training_opt_out, training_standard, 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.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
|
|
&w.TrainingOptOut, &w.TrainingStandard, &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.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, verarbeitungsort, avv_verfuegbar, avv_url,
|
|
training_opt_out, training_standard, 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)
|
|
RETURNING `+werkzeugColumns,
|
|
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
|
in.TrainingOptOut, in.TrainingStandard, 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, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
|
|
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
|
|
zertifizierungen = $10, subprozessoren = $11, geeignete_zwecke = $12, einschraenkungen = $13,
|
|
letzte_pruefung = $14, quelle = $15, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING `+werkzeugColumns,
|
|
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
|
in.TrainingOptOut, in.TrainingStandard, 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
|
|
}
|