internal/tenantsettings: pro-Tenant-Einstellungen (Anzeigename, Logo, Farbschema, Zeitzone, Sprache) in der Registry-DB, alle Spalten nullable — fehlender Wert bedeutet immer "Systemvoreinstellung verwenden" (Defaults(): color_scheme=system, timezone=UTC, language=de), niemals ein Fehler (Akzeptanzkriterium 2). Store.Update schreibt aktuellen Stand + Historieneintrag atomar in einer Transaktion mit FOR-UPDATE-Lock auf der aktuellen Zeile (Akzeptanzkriterium 3, Race-sicher bei nebenlaeufigen Updates desselben Tenants). Patch-Typ mit *string-Feldern erlaubt Teil-Updates ohne unbeteiligte Felder zu beruehren. Schlanker Handler (Get/Update ueber tenant_id-Query-Parameter) als Vorbereitung der REST-Schnittstelle — echte Auth/Versionierung kommt erst mit API-01/IAM-02/RBAC-01. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Aenderung eines Tenants wirkt sich nicht auf einen anderen aus — TestUpdate_IsolatedBetweenTenants: Tenant A geaendert, Tenant B bleibt nachweislich bei Defaults(). PASS. 2. Fehlende Werte liefern Defaults statt Fehler — TestGet_UnsetTenantReturnsDefaults (Tenant ganz ohne Datensatz) und TestUpdate_PartialPatchKeepsOtherFieldsAtDefault (nur ein Feld gesetzt, Rest bleibt Default). PASS. 3. API-Schema von zweiter Person gegen Dokumentation geprueft — NICHT durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen. Zusaetzlich automatisiert getestet (Akzeptanzkriterium 3): TestUpdate_HistoryTracksVersions — 3 aufeinanderfolgende Aenderungen, Historie liefert alle 3 in korrekter Reihenfolge, nicht angefasste Felder bleiben aus dem vorherigen Update erhalten. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
188 lines
6.0 KiB
Go
188 lines
6.0 KiB
Go
// 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()
|
|
}
|