// Package tenantsettings implementiert Core TEN-03: pro-Tenant-Einstellungen // (Anzeigename, Logo, Farbschema, Zeitzone, Sprache) mit versionierter // Aenderungshistorie und dokumentierten Systemvoreinstellungen. package tenantsettings import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Settings ist der vollstaendig AUFGELOESTE Stand (jede leere Einstellung ist // bereits durch ihren Default ersetzt) — Aufrufer muessen sich nie selbst um // Systemvoreinstellungen kuemmern (Akzeptanzkriterium 2). type Settings struct { DisplayName string LogoURL string ColorScheme string Timezone string Language string Version int } // Defaults liefert die dokumentierten Systemvoreinstellungen. func Defaults() Settings { return Settings{ DisplayName: "", LogoURL: "", ColorScheme: "system", Timezone: "UTC", Language: "de", Version: 0, } } // Patch enthaelt nur die Felder, die tatsaechlich geaendert werden sollen — // nil bedeutet "unveraendert lassen", nicht "auf Default zuruecksetzen". type Patch struct { DisplayName *string LogoURL *string ColorScheme *string Timezone *string Language *string } type Store struct { pool *pgxpool.Pool } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // Get liefert die aufgeloesten Einstellungen eines Tenants. Existiert noch // kein Datensatz oder sind einzelne Spalten NULL, werden die jeweiligen // Systemvoreinstellungen eingesetzt (Akzeptanzkriterium 2 / Pruefung 2) — // niemals ein Fehler wegen fehlender Werte. func (s *Store) Get(ctx context.Context, tenantID string) (Settings, error) { var displayName, logoURL, colorScheme, timezone, language *string var version int row := s.pool.QueryRow(ctx, ` SELECT display_name, logo_url, color_scheme, timezone, language, version FROM tenant_settings WHERE tenant_id = $1 `, tenantID) err := row.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version) if errors.Is(err, pgx.ErrNoRows) { return Defaults(), nil } if err != nil { return Settings{}, fmt.Errorf("einstellungen lesen: %w", err) } d := Defaults() return Settings{ DisplayName: orDefault(displayName, d.DisplayName), LogoURL: orDefault(logoURL, d.LogoURL), ColorScheme: orDefault(colorScheme, d.ColorScheme), Timezone: orDefault(timezone, d.Timezone), Language: orDefault(language, d.Language), Version: version, }, nil } func orDefault(v *string, def string) string { if v == nil { return def } return *v } // Update wendet patch auf den aktuellen Stand an, erhoeht die Version und // schreibt Stand + Historieneintrag atomar (Akzeptanzkriterium 3). Nicht in // patch gesetzte Felder bleiben unveraendert (bzw. weiterhin auf Default, // wenn sie es vorher schon waren). func (s *Store) Update(ctx context.Context, tenantID string, patch Patch) (Settings, error) { tx, err := s.pool.Begin(ctx) if err != nil { return Settings{}, fmt.Errorf("transaktion starten: %w", err) } defer func() { _ = tx.Rollback(ctx) }() var rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage *string var version int row := tx.QueryRow(ctx, ` SELECT display_name, logo_url, color_scheme, timezone, language, version FROM tenant_settings WHERE tenant_id = $1 FOR UPDATE `, tenantID) err = row.Scan(&rawDisplayName, &rawLogoURL, &rawColorScheme, &rawTimezone, &rawLanguage, &version) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return Settings{}, fmt.Errorf("aktuellen stand lesen: %w", err) } if patch.DisplayName != nil { rawDisplayName = patch.DisplayName } if patch.LogoURL != nil { rawLogoURL = patch.LogoURL } if patch.ColorScheme != nil { rawColorScheme = patch.ColorScheme } if patch.Timezone != nil { rawTimezone = patch.Timezone } if patch.Language != nil { rawLanguage = patch.Language } newVersion := version + 1 if _, err := tx.Exec(ctx, ` INSERT INTO tenant_settings (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now()) ON CONFLICT (tenant_id) DO UPDATE SET display_name = $2, logo_url = $3, color_scheme = $4, timezone = $5, language = $6, version = $7, updated_at = now() `, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil { return Settings{}, fmt.Errorf("einstellungen speichern: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO tenant_settings_history (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, changed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now()) `, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil { return Settings{}, fmt.Errorf("historie schreiben: %w", err) } if err := tx.Commit(ctx); err != nil { return Settings{}, fmt.Errorf("transaktion committen: %w", err) } return s.Get(ctx, tenantID) } // History liefert die vollstaendige Versionshistorie eines Tenants // (Akzeptanzkriterium 3 / Pruefung 3-Vorbereitung). func (s *Store) History(ctx context.Context, tenantID string) ([]Settings, error) { rows, err := s.pool.Query(ctx, ` SELECT display_name, logo_url, color_scheme, timezone, language, version FROM tenant_settings_history WHERE tenant_id = $1 ORDER BY version `, tenantID) if err != nil { return nil, fmt.Errorf("historie abfragen: %w", err) } defer rows.Close() d := Defaults() var out []Settings for rows.Next() { var displayName, logoURL, colorScheme, timezone, language *string var version int if err := rows.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version); err != nil { return nil, fmt.Errorf("historieneintrag lesen: %w", err) } out = append(out, Settings{ DisplayName: orDefault(displayName, d.DisplayName), LogoURL: orDefault(logoURL, d.LogoURL), ColorScheme: orDefault(colorScheme, d.ColorScheme), Timezone: orDefault(timezone, d.Timezone), Language: orDefault(language, d.Language), Version: version, }) } return out, rows.Err() }