diff --git a/internal/tenantsettings/handler.go b/internal/tenantsettings/handler.go new file mode 100644 index 0000000..377c6dd --- /dev/null +++ b/internal/tenantsettings/handler.go @@ -0,0 +1,74 @@ +package tenantsettings + +import ( + "encoding/json" + "net/http" +) + +// Handler stellt Lesen/Schreiben der Tenant-Einstellungen als schlanke +// Vorbereitung der Schnittstelle bereit (API-01/TEN-05 sind nicht Teil +// dieser Kachel). tenantID wird hier als Pfad-/Query-Parameter erwartet, +// eine echte Auth-/Berechtigungspruefung kommt erst mit IAM-02/RBAC-01. +type Handler struct { + store *Store +} + +func NewHandler(store *Store) *Handler { + return &Handler{store: store} +} + +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + tenantID := r.URL.Query().Get("tenant_id") + if tenantID == "" { + http.Error(w, "tenant_id fehlt", http.StatusBadRequest) + return + } + + settings, err := h.store.Get(r.Context(), tenantID) + if err != nil { + http.Error(w, "einstellungen konnten nicht geladen werden", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, settings) +} + +type updateRequest struct { + DisplayName *string `json:"display_name"` + LogoURL *string `json:"logo_url"` + ColorScheme *string `json:"color_scheme"` + Timezone *string `json:"timezone"` + Language *string `json:"language"` +} + +func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + tenantID := r.URL.Query().Get("tenant_id") + if tenantID == "" { + http.Error(w, "tenant_id fehlt", http.StatusBadRequest) + return + } + + var req updateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + settings, err := h.store.Update(r.Context(), tenantID, Patch{ + DisplayName: req.DisplayName, + LogoURL: req.LogoURL, + ColorScheme: req.ColorScheme, + Timezone: req.Timezone, + Language: req.Language, + }) + if err != nil { + http.Error(w, "einstellungen konnten nicht gespeichert werden", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, settings) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/tenantsettings/store_test.go b/internal/tenantsettings/store_test.go new file mode 100644 index 0000000..32e06e7 --- /dev/null +++ b/internal/tenantsettings/store_test.go @@ -0,0 +1,156 @@ +package tenantsettings + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupTest(t *testing.T) (*Store, string, string, func()) { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + pool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("pool: %v", err) + } + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + db_name TEXT NOT NULL UNIQUE, + db_dsn TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS tenant_settings ( + tenant_id UUID PRIMARY KEY REFERENCES tenants(id), + display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT, + version INT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS tenant_settings_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, + display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT, + version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + var tenantA, tenantB string + if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_a', 'A', 'tenant_ts_a', 'unused') RETURNING id`).Scan(&tenantA); err != nil { + t.Fatalf("tenant a anlegen: %v", err) + } + if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_b', 'B', 'tenant_ts_b', 'unused') RETURNING id`).Scan(&tenantB); err != nil { + t.Fatalf("tenant b anlegen: %v", err) + } + + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN ($1, $2)`, tenantA, tenantB) + _, _ = pool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN ($1, $2)`, tenantA, tenantB) + _, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id IN ($1, $2)`, tenantA, tenantB) + pool.Close() + } + return NewStore(pool), tenantA, tenantB, cleanup +} + +func strp(s string) *string { return &s } + +// Akzeptanzkriterium 2 + Pruefung 2: fehlende Werte liefern Defaults, kein Fehler. +func TestGet_UnsetTenantReturnsDefaults(t *testing.T) { + store, tenantA, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + got, err := store.Get(ctx, tenantA) + if err != nil { + t.Fatalf("get: %v", err) + } + want := Defaults() + if got != want { + t.Fatalf("erwartet defaults %+v, habe %+v", want, got) + } +} + +// Akzeptanzkriterium 1 + 2: Teil-Update laesst nicht gesetzte Felder auf Default. +func TestUpdate_PartialPatchKeepsOtherFieldsAtDefault(t *testing.T) { + store, tenantA, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + got, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Acme GmbH")}) + if err != nil { + t.Fatalf("update: %v", err) + } + if got.DisplayName != "Acme GmbH" { + t.Fatalf("erwartet 'Acme GmbH', habe %q", got.DisplayName) + } + d := Defaults() + if got.ColorScheme != d.ColorScheme || got.Timezone != d.Timezone || got.Language != d.Language { + t.Fatalf("nicht gesetzte felder haetten auf default bleiben sollen, habe %+v", got) + } + if got.Version != 1 { + t.Fatalf("erwartet version 1, habe %d", got.Version) + } +} + +// Akzeptanzkriterium 1 + Pruefung 1: Aenderung eines Tenants wirkt sich +// nicht auf einen anderen aus. +func TestUpdate_IsolatedBetweenTenants(t *testing.T) { + store, tenantA, tenantB, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Tenant A")}); err != nil { + t.Fatalf("update a: %v", err) + } + + gotB, err := store.Get(ctx, tenantB) + if err != nil { + t.Fatalf("get b: %v", err) + } + if gotB != Defaults() { + t.Fatalf("tenant b sollte unberuehrt bei defaults bleiben, habe %+v", gotB) + } +} + +// Akzeptanzkriterium 3: Aenderungen versioniert nachvollziehbar. +func TestUpdate_HistoryTracksVersions(t *testing.T) { + store, tenantA, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v1")}); err != nil { + t.Fatalf("update 1: %v", err) + } + if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v2")}); err != nil { + t.Fatalf("update 2: %v", err) + } + if _, err := store.Update(ctx, tenantA, Patch{ColorScheme: strp("dark")}); err != nil { + t.Fatalf("update 3: %v", err) + } + + history, err := store.History(ctx, tenantA) + if err != nil { + t.Fatalf("history: %v", err) + } + if len(history) != 3 { + t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history)) + } + if history[0].DisplayName != "v1" || history[0].Version != 1 { + t.Fatalf("history[0] unerwartet: %+v", history[0]) + } + if history[1].DisplayName != "v2" || history[1].Version != 2 { + t.Fatalf("history[1] unerwartet: %+v", history[1]) + } + if history[2].ColorScheme != "dark" || history[2].DisplayName != "v2" || history[2].Version != 3 { + t.Fatalf("history[2] unerwartet (display_name haette aus vorherigem update erhalten bleiben sollen): %+v", history[2]) + } +} diff --git a/internal/tenantsettings/tenantsettings.go b/internal/tenantsettings/tenantsettings.go new file mode 100644 index 0000000..720bb9e --- /dev/null +++ b/internal/tenantsettings/tenantsettings.go @@ -0,0 +1,187 @@ +// 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() +} diff --git a/migrations/0003_tenant_settings.down.sql b/migrations/0003_tenant_settings.down.sql new file mode 100644 index 0000000..788bbaa --- /dev/null +++ b/migrations/0003_tenant_settings.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS tenant_settings_history; +DROP TABLE IF EXISTS tenant_settings; diff --git a/migrations/0003_tenant_settings.up.sql b/migrations/0003_tenant_settings.up.sql new file mode 100644 index 0000000..f24e106 --- /dev/null +++ b/migrations/0003_tenant_settings.up.sql @@ -0,0 +1,27 @@ +-- Pro-Tenant-Einstellungen & Branding (TEN-03, siehe core-kanban/tickets/TEN-03.md). +-- Alle Spalten NULLABLE: ein fehlender Wert bedeutet "System-Default +-- verwenden" (Akzeptanzkriterium 2), niemals ein Fehler. +CREATE TABLE tenant_settings ( + tenant_id UUID PRIMARY KEY REFERENCES tenants(id), + display_name TEXT, + logo_url TEXT, + color_scheme TEXT, + timezone TEXT, + language TEXT, + version INT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE tenant_settings_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + display_name TEXT, + logo_url TEXT, + color_scheme TEXT, + timezone TEXT, + language TEXT, + version INT NOT NULL, + changed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX tenant_settings_history_tenant_idx ON tenant_settings_history (tenant_id, version); diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh new file mode 100755 index 0000000..717018c --- /dev/null +++ b/scripts/reset-test-env.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte +# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle +# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches +# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische +# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]). +# +# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh +set -euo pipefail + +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +ROLE="nexarch_test" + +export PGPASSWORD="$PASS" + +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings_history CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;" + +dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'") +for db in $dbs; do + psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";" +done + +echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt." diff --git a/scripts/run-checks.sh b/scripts/run-checks.sh new file mode 100755 index 0000000..83f26b6 --- /dev/null +++ b/scripts/run-checks.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost: +# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch. +# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf +# dem Testhost teilen (siehe [[project-nexarch-test-infra]]). +# +# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh +set -euo pipefail + +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +cd "$(dirname "$0")/.." + +NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh + +export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable" + +echo "== go build ==" +go build ./... + +echo "== go vet ==" +go vet ./... + +echo "== go test (-p 1) ==" +go test ./... -p 1 -count=1