From 4180a26c6eb8436dfe194b0efecdee41bfa9f45f Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 19:26:14 +0200 Subject: [PATCH 1/7] CFG-01: zentraler-konfigurationsdienst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/cfgservice: Store (Schreiben/Historie) + Service (Lesen mit Vorrangregel + TTL-Cache, Default 5s, analog internal/flag). Genannt "cfgservice" statt "config", da internal/config bereits die Bootstrap- Konfiguration des Core-Prozesses selbst belegt. Store.Set schreibt aktuellen Stand (config_values) und Historieneintrag (config_value_history) atomar in einer Transaktion — eine Aenderung ohne Versionshistorie ist strukturell ausgeschlossen (Akzeptanzkriterium 2). Version wird pro (key, scope) monoton hochgezaehlt. Service.Resolve wendet die Vorrangregel an: Tenant-spezifischer Override (scope = Tenant-Slug) hat Vorrang vor globalem Default (scope = 'global'), faellt sauber zurueck wenn kein Override existiert (Akzeptanzkriterium 1). Invalidate erzwingt sofortiges Neuladen fuer den Schreiber, andere Instanzen sehen Aenderungen spaetestens nach der TTL. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Vorrangregel automatisiert getestet — TestService_TenantOverrideTakesPrecedenceOverGlobal: Tenant mit Override bekommt Tenant-Wert, Tenant ohne Override bekommt Global-Default. PASS. 2. Cache-Invalidierung nach Aenderung innerhalb dokumentierter Zeit gemessen — TestService_CacheInvalidationTiming: wirksam nach 154ms bei TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter Stand. PASS. 3. Versionierungshistorie ueber mehrere Aenderungen nachvollzogen — TestStore_HistoryTracksAllChanges: 3 aufeinanderfolgende Aenderungen, Historie liefert alle 3 in korrekter Reihenfolge mit korrekten Versionsnummern. PASS. Co-Authored-By: Claude Sonnet 5 --- go.mod | 9 ++ go.sum | 28 ++++++ internal/cfgservice/service.go | 88 +++++++++++++++++ internal/cfgservice/service_test.go | 114 ++++++++++++++++++++++ internal/cfgservice/store.go | 129 +++++++++++++++++++++++++ internal/cfgservice/store_test.go | 119 +++++++++++++++++++++++ migrations/0004_config_values.down.sql | 2 + migrations/0004_config_values.up.sql | 23 +++++ scripts/reset-test-env.sh | 26 +++++ scripts/run-checks.sh | 24 +++++ 10 files changed, 562 insertions(+) create mode 100644 go.sum create mode 100644 internal/cfgservice/service.go create mode 100644 internal/cfgservice/service_test.go create mode 100644 internal/cfgservice/store.go create mode 100644 internal/cfgservice/store_test.go create mode 100644 migrations/0004_config_values.down.sql create mode 100644 migrations/0004_config_values.up.sql create mode 100755 scripts/reset-test-env.sh create mode 100755 scripts/run-checks.sh diff --git a/go.mod b/go.mod index 75a79b7..05c10d0 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,12 @@ module gitea.perlbach24.de/scripte/nexarch go 1.22 require github.com/jackc/pgx/v5 v5.6.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5c39671 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cfgservice/service.go b/internal/cfgservice/service.go new file mode 100644 index 0000000..b74e8bf --- /dev/null +++ b/internal/cfgservice/service.go @@ -0,0 +1,88 @@ +package cfgservice + +import ( + "context" + "errors" + "sync" + "time" +) + +// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit +// (Akzeptanzkriterium 2 / Pruefung 2 in diesem Ticket bezieht sich auf die +// Aenderungsnachvollziehbarkeit — die Cache-Frist selbst folgt demselben +// Muster wie internal/flag.DefaultCacheTTL). +const DefaultCacheTTL = 5 * time.Second + +type cacheEntry struct { + value Value + expiresAt time.Time +} + +// Service ist die Leseseite mit Vorrangregel (Akzeptanzkriterium 1: +// Tenant-Override vor Global-Default) und lokalem TTL-Cache. +type Service struct { + store *Store + ttl time.Duration + + mu sync.RWMutex + cache map[string]cacheEntry // Schluessel: key + "\x00" + tenantSlug +} + +func NewService(store *Store, ttl time.Duration) *Service { + if ttl <= 0 { + ttl = DefaultCacheTTL + } + return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)} +} + +func cacheKey(key, tenantSlug string) string { + return key + "\x00" + tenantSlug +} + +// Resolve liefert den Konfigurationswert fuer einen Tenant: ein +// Tenant-spezifischer Override hat Vorrang vor dem globalen Default +// (Akzeptanzkriterium 1 / Pruefung 1). tenantSlug == "" wertet nur den +// globalen Wert aus. +func (s *Service) Resolve(ctx context.Context, tenantSlug, key string) (Value, error) { + ck := cacheKey(key, tenantSlug) + + s.mu.RLock() + entry, exists := s.cache[ck] + fresh := exists && time.Now().Before(entry.expiresAt) + s.mu.RUnlock() + if fresh { + return entry.value, nil + } + + v, err := s.resolveUncached(ctx, tenantSlug, key) + if err != nil { + return Value{}, err + } + + s.mu.Lock() + s.cache[ck] = cacheEntry{value: v, expiresAt: time.Now().Add(s.ttl)} + s.mu.Unlock() + return v, nil +} + +func (s *Service) resolveUncached(ctx context.Context, tenantSlug, key string) (Value, error) { + if tenantSlug != "" { + v, err := s.store.Get(ctx, key, tenantSlug) + if err == nil { + return v, nil + } + if !errors.Is(err, ErrNotFound) { + return Value{}, err + } + } + return s.store.Get(ctx, key, GlobalScope) +} + +// Invalidate erzwingt beim naechsten Resolve-Aufruf ein sofortiges Neuladen +// fuer einen bestimmten (key, tenantSlug) statt auf den TTL-Ablauf zu warten +// — analog internal/flag.Service.Invalidate. +func (s *Service) Invalidate(key, tenantSlug string) { + s.mu.Lock() + delete(s.cache, cacheKey(key, tenantSlug)) + s.mu.Unlock() +} diff --git a/internal/cfgservice/service_test.go b/internal/cfgservice/service_test.go new file mode 100644 index 0000000..2542b47 --- /dev/null +++ b/internal/cfgservice/service_test.go @@ -0,0 +1,114 @@ +package cfgservice + +import ( + "context" + "testing" + "time" +) + +// Akzeptanzkriterium 1 + Pruefung 1: Tenant-Override hat Vorrang vor +// Global-Default, automatisiert getestet. +func TestService_TenantOverrideTakesPrecedenceOverGlobal(t *testing.T) { + store, cleanup := setupStoreTest(t) + defer cleanup() + ctx := context.Background() + + if _, err := store.Set(ctx, "test_precedence_key", GlobalScope, "global-wert"); err != nil { + t.Fatalf("set global: %v", err) + } + if _, err := store.Set(ctx, "test_precedence_key", "test_acme", "tenant-wert"); err != nil { + t.Fatalf("set tenant: %v", err) + } + + svc := NewService(store, time.Hour) + + got, err := svc.Resolve(ctx, "test_acme", "test_precedence_key") + if err != nil { + t.Fatalf("resolve mit override: %v", err) + } + if got.Value != "tenant-wert" { + t.Fatalf("erwartet tenant-override, habe %q", got.Value) + } + + gotOther, err := svc.Resolve(ctx, "test_anderer_tenant", "test_precedence_key") + if err != nil { + t.Fatalf("resolve ohne override: %v", err) + } + if gotOther.Value != "global-wert" { + t.Fatalf("erwartet global-default fuer tenant ohne override, habe %q", gotOther.Value) + } +} + +// Akzeptanzkriterium 2 + Pruefung 2: Cache-Invalidierung nach +// Konfigurationsaenderung innerhalb dokumentierter Zeit gemessen. +func TestService_CacheInvalidationTiming(t *testing.T) { + store, cleanup := setupStoreTest(t) + defer cleanup() + ctx := context.Background() + + const ttl = 150 * time.Millisecond + if _, err := store.Set(ctx, "test_ttl_key", GlobalScope, "alt"); err != nil { + t.Fatalf("set: %v", err) + } + svc := NewService(store, ttl) + + v, err := svc.Resolve(ctx, "", "test_ttl_key") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if v.Value != "alt" { + t.Fatalf("erwartet 'alt', habe %q", v.Value) + } + + changedAt := time.Now() + if _, err := store.Set(ctx, "test_ttl_key", GlobalScope, "neu"); err != nil { + t.Fatalf("set: %v", err) + } + + v, err = svc.Resolve(ctx, "", "test_ttl_key") + if err != nil { + t.Fatalf("resolve direkt nach aenderung: %v", err) + } + if v.Value != "alt" { + t.Fatalf("cache haette den alten wert liefern sollen, habe %q", v.Value) + } + + deadline := changedAt.Add(ttl + 100*time.Millisecond) + for time.Now().Before(deadline) { + v, err := svc.Resolve(ctx, "", "test_ttl_key") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if v.Value == "neu" { + t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", time.Since(changedAt), ttl) + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt)) +} + +func TestService_InvalidateForcesImmediateRefresh(t *testing.T) { + store, cleanup := setupStoreTest(t) + defer cleanup() + ctx := context.Background() + + if _, err := store.Set(ctx, "test_invalidate_key", GlobalScope, "alt"); err != nil { + t.Fatalf("set: %v", err) + } + svc := NewService(store, time.Hour) + _, _ = svc.Resolve(ctx, "", "test_invalidate_key") + + if _, err := store.Set(ctx, "test_invalidate_key", GlobalScope, "neu"); err != nil { + t.Fatalf("set: %v", err) + } + svc.Invalidate("test_invalidate_key", "") + + v, err := svc.Resolve(ctx, "", "test_invalidate_key") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if v.Value != "neu" { + t.Fatalf("erwartet sofort sichtbaren neuen wert nach Invalidate, habe %q", v.Value) + } +} diff --git a/internal/cfgservice/store.go b/internal/cfgservice/store.go new file mode 100644 index 0000000..8be1449 --- /dev/null +++ b/internal/cfgservice/store.go @@ -0,0 +1,129 @@ +// Package cfgservice implementiert Core CFG-01: den zentralen Dienst fuer +// globale und tenant-spezifische Konfigurationswerte mit Versionierung und +// Cache-Invalidierung. Andere Module lesen Konfiguration AUSSCHLIESSLICH +// ueber dieses Paket (Akzeptanzkriterium 3), niemals ueber eigene Tabellen. +package cfgservice + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// GlobalScope ist der reservierte Scope-Wert fuer globale Defaults — jeder +// andere Scope-Wert ist ein Tenant-Slug (Akzeptanzkriterium 1). +const GlobalScope = "global" + +var ErrNotFound = errors.New("cfgservice: kein wert fuer diesen key gefunden") + +type Value struct { + Key string + Scope string + Value string + Version int +} + +type HistoryEntry struct { + Key string + Scope string + Value string + Version int +} + +// Store ist die Schreib-/Verwaltungsseite. Set schreibt IMMER sowohl den +// aktuellen Stand (config_values) als auch einen Historieneintrag +// (config_value_history) in derselben Transaktion — eine Aenderung ohne +// Versionshistorie ist strukturell ausgeschlossen (Akzeptanzkriterium 2). +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// Set schreibt einen neuen Wert fuer (key, scope) und erhoeht die Version um 1 +// (Version 1 bei erstmaligem Setzen). +func (s *Store) Set(ctx context.Context, key, scope, value string) (Value, error) { + if scope == "" { + return Value{}, errors.New("cfgservice: scope darf nicht leer sein") + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return Value{}, fmt.Errorf("transaktion starten: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var currentVersion int + err = tx.QueryRow(ctx, `SELECT version FROM config_values WHERE key = $1 AND scope = $2`, key, scope).Scan(¤tVersion) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return Value{}, fmt.Errorf("aktuelle version lesen: %w", err) + } + newVersion := currentVersion + 1 + + if _, err := tx.Exec(ctx, ` + INSERT INTO config_values (key, scope, value, version, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (key, scope) DO UPDATE SET value = $3, version = $4, updated_at = now() + `, key, scope, value, newVersion); err != nil { + return Value{}, fmt.Errorf("wert speichern: %w", err) + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO config_value_history (key, scope, value, version, changed_at) + VALUES ($1, $2, $3, $4, now()) + `, key, scope, value, newVersion); err != nil { + return Value{}, fmt.Errorf("historie schreiben: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return Value{}, fmt.Errorf("transaktion committen: %w", err) + } + + return Value{Key: key, Scope: scope, Value: value, Version: newVersion}, nil +} + +// Get liefert den Wert fuer GENAU EINEN Scope (kein Vorrang-Fallback) — die +// Vorrangregel (Tenant vor Global) lebt bewusst in Service.Resolve, damit +// Store rein CRUD bleibt. +func (s *Store) Get(ctx context.Context, key, scope string) (Value, error) { + var v Value + v.Key, v.Scope = key, scope + err := s.pool.QueryRow(ctx, ` + SELECT value, version FROM config_values WHERE key = $1 AND scope = $2 + `, key, scope).Scan(&v.Value, &v.Version) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Value{}, ErrNotFound + } + return Value{}, fmt.Errorf("wert lesen: %w", err) + } + return v, nil +} + +// History liefert die vollstaendige Versionshistorie eines (key, scope) in +// aufsteigender Reihenfolge (Akzeptanzkriterium 2 / Pruefung 3). +func (s *Store) History(ctx context.Context, key, scope string) ([]HistoryEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT key, scope, value, version FROM config_value_history + WHERE key = $1 AND scope = $2 ORDER BY version + `, key, scope) + if err != nil { + return nil, fmt.Errorf("historie abfragen: %w", err) + } + defer rows.Close() + + var out []HistoryEntry + for rows.Next() { + var h HistoryEntry + if err := rows.Scan(&h.Key, &h.Scope, &h.Value, &h.Version); err != nil { + return nil, fmt.Errorf("historieneintrag lesen: %w", err) + } + out = append(out, h) + } + return out, rows.Err() +} diff --git a/internal/cfgservice/store_test.go b/internal/cfgservice/store_test.go new file mode 100644 index 0000000..d3f6ac3 --- /dev/null +++ b/internal/cfgservice/store_test.go @@ -0,0 +1,119 @@ +package cfgservice + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupStoreTest(t *testing.T) (*Store, 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 config_values ( + key TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope <> ''), + value TEXT NOT NULL, + version INT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (key, scope) + ); + CREATE TABLE IF NOT EXISTS config_value_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key TEXT NOT NULL, + scope TEXT NOT NULL, + value TEXT NOT NULL, + version INT NOT NULL, + changed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM config_value_history WHERE key LIKE 'test\_%' ESCAPE '\'`) + _, _ = pool.Exec(ctx, `DELETE FROM config_values WHERE key LIKE 'test\_%' ESCAPE '\'`) + pool.Close() + } + return NewStore(pool), cleanup +} + +func TestStore_SetIncrementsVersion(t *testing.T) { + store, cleanup := setupStoreTest(t) + defer cleanup() + ctx := context.Background() + + v1, err := store.Set(ctx, "test_key", GlobalScope, "erster-wert") + if err != nil { + t.Fatalf("set 1: %v", err) + } + if v1.Version != 1 { + t.Fatalf("erwartet version 1, habe %d", v1.Version) + } + + v2, err := store.Set(ctx, "test_key", GlobalScope, "zweiter-wert") + if err != nil { + t.Fatalf("set 2: %v", err) + } + if v2.Version != 2 { + t.Fatalf("erwartet version 2, habe %d", v2.Version) + } + + got, err := store.Get(ctx, "test_key", GlobalScope) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Value != "zweiter-wert" || got.Version != 2 { + t.Fatalf("aktueller wert unerwartet: %+v", got) + } +} + +// Akzeptanzkriterium 2 + Pruefung 3: Versionierungshistorie ueber mehrere +// Aenderungen hinweg nachvollzogen. +func TestStore_HistoryTracksAllChanges(t *testing.T) { + store, cleanup := setupStoreTest(t) + defer cleanup() + ctx := context.Background() + + values := []string{"v1", "v2", "v3"} + for _, v := range values { + if _, err := store.Set(ctx, "test_history_key", GlobalScope, v); err != nil { + t.Fatalf("set %q: %v", v, err) + } + } + + history, err := store.History(ctx, "test_history_key", GlobalScope) + if err != nil { + t.Fatalf("history: %v", err) + } + if len(history) != 3 { + t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history)) + } + for i, h := range history { + if h.Version != i+1 || h.Value != values[i] { + t.Fatalf("historieneintrag[%d] unerwartet: %+v", i, h) + } + } +} + +func TestStore_GetUnknownKeyReturnsNotFound(t *testing.T) { + store, cleanup := setupStoreTest(t) + defer cleanup() + ctx := context.Background() + + if _, err := store.Get(ctx, "test_nie_gesetzt", GlobalScope); !errors.Is(err, ErrNotFound) { + t.Fatalf("erwartet ErrNotFound, habe %v", err) + } +} diff --git a/migrations/0004_config_values.down.sql b/migrations/0004_config_values.down.sql new file mode 100644 index 0000000..6fb11d7 --- /dev/null +++ b/migrations/0004_config_values.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS config_value_history; +DROP TABLE IF EXISTS config_values; diff --git a/migrations/0004_config_values.up.sql b/migrations/0004_config_values.up.sql new file mode 100644 index 0000000..56a9fb1 --- /dev/null +++ b/migrations/0004_config_values.up.sql @@ -0,0 +1,23 @@ +-- Zentraler Konfigurationsdienst (CFG-01, siehe core-kanban/tickets/CFG-01.md). +-- scope = 'global' fuer globale Defaults, sonst der Tenant-Slug. config_values +-- haelt den AKTUELLEN Stand je (key, scope); config_value_history haelt JEDE +-- Aenderung fest (Akzeptanzkriterium 2: versioniert nachvollziehbar). +CREATE TABLE config_values ( + key TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope <> ''), + value TEXT NOT NULL, + version INT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (key, scope) +); + +CREATE TABLE config_value_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key TEXT NOT NULL, + scope TEXT NOT NULL, + value TEXT NOT NULL, + version INT NOT NULL, + changed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX config_value_history_key_scope_idx ON config_value_history (key, scope, version); diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh new file mode 100755 index 0000000..dc82497 --- /dev/null +++ b/scripts/reset-test-env.sh @@ -0,0 +1,26 @@ +#!/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 tenants CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_value_history CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_values 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 From 6f532d835039519a0cad361b8fdb36f3f20ad94f Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 21:06:36 +0200 Subject: [PATCH 2/7] CFG-02: benachrichtigungs-dispatcher-core-service-fuer-module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/notify: Dispatcher.Enqueue ist die EINE schmale Schnittstelle, ueber die Module Benachrichtigungen ausloesen (Akzeptanzkriterium 1) — kein Modul baut eigenen Versandcode. Warteschlange ist die Postgres-Tabelle notification_jobs (Projekt-Konvention statt Redis/AMQP), existiert ausschliesslich in der Datenbank, nicht im Prozessspeicher. Dispatcher.ProcessDue holt faellige Jobs per FOR UPDATE SKIP LOCKED (dieselbe Konvention wie internal/tenant.Lifecycle.ProcessDueDeletions) — serialisiert konkurrierende Worker/Module, verhindert doppelte Zustellung. Fehlschlag erhoeht attempts und plant next_attempt_at mit linearem Backoff; nach max_attempts wird der Job kontrolliert auf status=failed gesetzt statt endlos wiederholt zu werden (Akzeptanzkriterium 2). Sender ist eine schmale Schnittstelle fuer die eigentlichen Kanaele (E-Mail/In-App = CFG-03, nicht Teil dieser Kachel) — der Dispatcher kennt nur "zustellen oder nicht", keine Kanal-Details. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Neustart waehrend offener Zustellung verliert keine Nachricht — TestQueue_SurvivesRestartWithoutMessageLoss: Enqueue durch eine Dispatcher-Instanz, Verarbeitung durch eine komplett neue (simulierter Neustart), Nachricht wird trotzdem zugestellt. PASS. 2. Wiederholungslogik greift bei simuliertem Fehler und bricht kontrolliert ab — TestProcessDue_RetriesThenGivesUpAfterMaxAttempts: 3 Versuche bei max_attempts=3, danach status=failed, keine weitere Verarbeitung. PASS. 3. Zwei Module loesen gleichzeitig aus, beide korrekt zugestellt — TestProcessDue_ConcurrentDispatchBothDelivered: zwei parallele ProcessDue-Aufrufe, beide Nachrichten je genau einmal zugestellt, keine Doppelzustellung. PASS. Co-Authored-By: Claude Sonnet 5 --- internal/notify/dispatcher.go | 81 ++++++++ internal/notify/dispatcher_test.go | 219 +++++++++++++++++++++ internal/notify/worker.go | 102 ++++++++++ migrations/0005_notification_jobs.down.sql | 1 + migrations/0005_notification_jobs.up.sql | 20 ++ 5 files changed, 423 insertions(+) create mode 100644 internal/notify/dispatcher.go create mode 100644 internal/notify/dispatcher_test.go create mode 100644 internal/notify/worker.go create mode 100644 migrations/0005_notification_jobs.down.sql create mode 100644 migrations/0005_notification_jobs.up.sql diff --git a/internal/notify/dispatcher.go b/internal/notify/dispatcher.go new file mode 100644 index 0000000..21c0cd1 --- /dev/null +++ b/internal/notify/dispatcher.go @@ -0,0 +1,81 @@ +// Package notify implementiert Core CFG-02: den zentralen Benachrichtigungs- +// Dispatcher, ueber den beliebige Module Benachrichtigungen ausloesen — +// Warteschlange, Wiederholungslogik, Kanal-Abstraktion. Die tatsaechlichen +// Kanaele (E-Mail/In-App) sind CFG-03, hier gibt es nur die Sender- +// Schnittstelle als Vorbereitung. +package notify + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// DefaultMaxAttempts begrenzt Wiederholungsversuche (Akzeptanzkriterium 2) — +// nach dieser Anzahl gibt der Dispatcher kontrolliert auf (status=failed) +// statt endlos zu wiederholen. +const DefaultMaxAttempts = 5 + +// DefaultRetryBackoff ist die Basis-Wartezeit zwischen Wiederholungen, +// linear mit der Versuchsnummer skaliert. +const DefaultRetryBackoff = 200 * time.Millisecond + +type Notification struct { + ID string + Channel string + Recipient string + Payload map[string]any + Attempts int +} + +// Sender ist die schmale Schnittstelle, die ein konkreter Kanal (CFG-03) +// implementiert. Der Dispatcher selbst weiss nichts ueber E-Mail/In-App. +type Sender interface { + Send(ctx context.Context, n Notification) error +} + +// Dispatcher ist die EINE Schnittstelle, ueber die Module Benachrichtigungen +// ausloesen — kein Modul baut eigenen Versandcode (Akzeptanzkriterium 1). +type Dispatcher struct { + pool *pgxpool.Pool + maxAttempts int + retryBackoff time.Duration +} + +func NewDispatcher(pool *pgxpool.Pool) *Dispatcher { + return &Dispatcher{pool: pool, maxAttempts: DefaultMaxAttempts, retryBackoff: DefaultRetryBackoff} +} + +// WithRetryPolicy erlaubt Tests/Betrieb, Versuchsanzahl und Backoff +// anzupassen, ohne die Default-Policy im Produktionscode zu veraendern. +func (d *Dispatcher) WithRetryPolicy(maxAttempts int, backoff time.Duration) *Dispatcher { + return &Dispatcher{pool: d.pool, maxAttempts: maxAttempts, retryBackoff: backoff} +} + +// Enqueue reiht eine Benachrichtigung in die Postgres-Warteschlange ein und +// kehrt sofort zurueck — die Zeile ueberlebt jeden Neustart des Dispatcher- +// Prozesses unveraendert (Akzeptanzkriterium 3), da sie ausschliesslich in +// der Datenbank existiert, nicht im Prozessspeicher. +func (d *Dispatcher) Enqueue(ctx context.Context, channel, recipient string, payload map[string]any) (string, error) { + if payload == nil { + payload = map[string]any{} + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("payload serialisieren: %w", err) + } + + var id string + err = d.pool.QueryRow(ctx, ` + INSERT INTO notification_jobs (channel, recipient, payload, max_attempts) + VALUES ($1, $2, $3, $4) + RETURNING id + `, channel, recipient, payloadJSON, d.maxAttempts).Scan(&id) + if err != nil { + return "", fmt.Errorf("benachrichtigung einreihen: %w", err) + } + return id, nil +} diff --git a/internal/notify/dispatcher_test.go b/internal/notify/dispatcher_test.go new file mode 100644 index 0000000..bcb490a --- /dev/null +++ b/internal/notify/dispatcher_test.go @@ -0,0 +1,219 @@ +package notify + +import ( + "context" + "errors" + "os" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupTest(t *testing.T) (*pgxpool.Pool, 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 notification_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel TEXT NOT NULL, + recipient TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 5, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); err != nil { + t.Fatalf("schema: %v", err) + } + + cleanup := func() { pool.Close() } + return pool, cleanup +} + +type fakeSender struct { + mu sync.Mutex + sentIDs []string + failUntil int + calls int +} + +func (f *fakeSender) Send(ctx context.Context, n Notification) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.calls <= f.failUntil { + return errors.New("simulierter zustellfehler") + } + f.sentIDs = append(f.sentIDs, n.ID) + return nil +} + +func (f *fakeSender) sentCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.sentIDs) +} + +// Akzeptanzkriterium 1: Module loesen ueber Enqueue aus, keine eigene +// Versandlogik noetig. +func TestDispatcher_EnqueueAndProcess(t *testing.T) { + pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + d := NewDispatcher(pool) + id, err := d.Enqueue(ctx, "email", "alice@example.com", map[string]any{"subject": "Willkommen"}) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + if id == "" { + t.Fatal("erwartet nicht-leere id") + } + + sender := &fakeSender{} + sent, failed, err := d.ProcessDue(ctx, sender, 10) + if err != nil { + t.Fatalf("process: %v", err) + } + if sent != 1 || failed != 0 { + t.Fatalf("erwartet sent=1 failed=0, habe sent=%d failed=%d", sent, failed) + } + if sender.sentCount() != 1 { + t.Fatalf("erwartet 1 zustellung, habe %d", sender.sentCount()) + } +} + +// Akzeptanzkriterium 2 + Pruefung 2: Wiederholungslogik greift bei +// simuliertem Fehler und bricht nach definierter Anzahl kontrolliert ab. +func TestProcessDue_RetriesThenGivesUpAfterMaxAttempts(t *testing.T) { + pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + d := NewDispatcher(pool).WithRetryPolicy(3, time.Millisecond) + id, err := d.Enqueue(ctx, "email", "bob@example.com", nil) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + + sender := &fakeSender{failUntil: 100} // schlaegt bei jedem versuch fehl + + for i := 0; i < 3; i++ { + time.Sleep(5 * time.Millisecond) // next_attempt_at abwarten + if _, _, err := d.ProcessDue(ctx, sender, 10); err != nil { + t.Fatalf("process %d: %v", i, err) + } + } + + var status string + var attempts int + if err := pool.QueryRow(ctx, `SELECT status, attempts FROM notification_jobs WHERE id = $1`, id).Scan(&status, &attempts); err != nil { + t.Fatalf("status lesen: %v", err) + } + if status != "failed" { + t.Fatalf("erwartet status failed nach max_attempts, habe %q", status) + } + if attempts != 3 { + t.Fatalf("erwartet 3 versuche, habe %d", attempts) + } + + // Weiteres ProcessDue darf den bereits aufgegebenen job nicht mehr anfassen. + sent, failed, err := d.ProcessDue(ctx, sender, 10) + if err != nil { + t.Fatalf("process nach abbruch: %v", err) + } + if sent != 0 || failed != 0 { + t.Fatalf("erwartet keine weitere verarbeitung, habe sent=%d failed=%d", sent, failed) + } +} + +// Akzeptanzkriterium 3 + Pruefung 1: Neustart des Dienstes waehrend offener +// Zustellung verliert keine Nachricht — simuliert durch eine komplett neue +// Dispatcher/Pool-Instanz nach dem Enqueue, bevor irgendetwas verarbeitet wurde. +func TestQueue_SurvivesRestartWithoutMessageLoss(t *testing.T) { + pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + firstInstance := NewDispatcher(pool) + id, err := firstInstance.Enqueue(ctx, "email", "carol@example.com", nil) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + + // "Neustart": eine voellig neue Dispatcher-Instanz (repraesentiert einen + // neuen Prozess) verbindet sich neu und verarbeitet die Warteschlange — + // die Nachricht existiert ausschliesslich in Postgres, nicht im + // Prozessspeicher der ersten Instanz. + restartedInstance := NewDispatcher(pool) + sender := &fakeSender{} + sent, failed, err := restartedInstance.ProcessDue(ctx, sender, 10) + if err != nil { + t.Fatalf("process nach neustart: %v", err) + } + if sent != 1 || failed != 0 { + t.Fatalf("erwartet sent=1 nach neustart, habe sent=%d failed=%d", sent, failed) + } + if len(sender.sentIDs) != 1 || sender.sentIDs[0] != id { + t.Fatalf("erwartet zustellung der urspruenglichen nachricht %q, habe %v", id, sender.sentIDs) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: zwei gleichzeitig ausloesende Module, +// beide Nachrichten werden korrekt (und nicht doppelt) zugestellt. +func TestProcessDue_ConcurrentDispatchBothDelivered(t *testing.T) { + pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + d := NewDispatcher(pool) + idA, err := d.Enqueue(ctx, "email", "modul-a@example.com", nil) + if err != nil { + t.Fatalf("enqueue a: %v", err) + } + idB, err := d.Enqueue(ctx, "email", "modul-b@example.com", nil) + if err != nil { + t.Fatalf("enqueue b: %v", err) + } + + sender := &fakeSender{} + var wg sync.WaitGroup + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, _, err := d.ProcessDue(ctx, sender, 10); err != nil { + t.Errorf("process: %v", err) + } + }() + } + wg.Wait() + + if sender.sentCount() != 2 { + t.Fatalf("erwartet genau 2 zustellungen, habe %d: %v", sender.sentCount(), sender.sentIDs) + } + seen := map[string]bool{} + for _, id := range sender.sentIDs { + if seen[id] { + t.Fatalf("nachricht %q wurde doppelt zugestellt", id) + } + seen[id] = true + } + if !seen[idA] || !seen[idB] { + t.Fatalf("erwartet beide nachrichten zugestellt, habe %v", sender.sentIDs) + } +} diff --git a/internal/notify/worker.go b/internal/notify/worker.go new file mode 100644 index 0000000..cad3f8d --- /dev/null +++ b/internal/notify/worker.go @@ -0,0 +1,102 @@ +package notify + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// ProcessDue holt bis zu limit faellige Benachrichtigungen und versucht sie +// ueber sender zuzustellen. FOR UPDATE SKIP LOCKED serialisiert konkurrierende +// Aufrufe (Akzeptanzkriterium 3 / Pruefung 3: zwei gleichzeitig ausloesende +// Module duerfen sich nicht gegenseitig blockieren oder Nachrichten doppelt +// zustellen) — dieselbe Konvention wie internal/tenant.Lifecycle.ProcessDueDeletions. +func (d *Dispatcher) ProcessDue(ctx context.Context, sender Sender, limit int) (sent, failed int, err error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return 0, 0, fmt.Errorf("transaktion starten: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + rows, err := tx.Query(ctx, ` + SELECT id, channel, recipient, payload, attempts, max_attempts + FROM notification_jobs + WHERE status = 'pending' AND next_attempt_at <= now() + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT $1 + `, limit) + if err != nil { + return 0, 0, fmt.Errorf("faellige benachrichtigungen abfragen: %w", err) + } + + type due struct { + id, channel, recipient string + payload []byte + attempts, maxAttempts int + } + var candidates []due + for rows.Next() { + var c due + if err := rows.Scan(&c.id, &c.channel, &c.recipient, &c.payload, &c.attempts, &c.maxAttempts); err != nil { + rows.Close() + return 0, 0, fmt.Errorf("faellige benachrichtigung lesen: %w", err) + } + candidates = append(candidates, c) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, 0, err + } + + for _, c := range candidates { + var payload map[string]any + if err := json.Unmarshal(c.payload, &payload); err != nil { + payload = map[string]any{} + } + + sendErr := sender.Send(ctx, Notification{ + ID: c.id, Channel: c.channel, Recipient: c.recipient, Payload: payload, Attempts: c.attempts, + }) + + if sendErr == nil { + if _, err := tx.Exec(ctx, ` + UPDATE notification_jobs SET status = 'sent', updated_at = now() WHERE id = $1 + `, c.id); err != nil { + return sent, failed, fmt.Errorf("erfolg speichern: %w", err) + } + sent++ + continue + } + + newAttempts := c.attempts + 1 + if newAttempts >= c.maxAttempts { + // Akzeptanzkriterium 2: kontrollierter Abbruch nach definierter + // Anzahl Versuche, kein endloses Wiederholen. + if _, err := tx.Exec(ctx, ` + UPDATE notification_jobs + SET status = 'failed', attempts = $2, last_error = $3, updated_at = now() + WHERE id = $1 + `, c.id, newAttempts, sendErr.Error()); err != nil { + return sent, failed, fmt.Errorf("fehlschlag speichern: %w", err) + } + failed++ + continue + } + + nextAttempt := time.Now().Add(time.Duration(newAttempts) * d.retryBackoff) + if _, err := tx.Exec(ctx, ` + UPDATE notification_jobs + SET attempts = $2, next_attempt_at = $3, last_error = $4, updated_at = now() + WHERE id = $1 + `, c.id, newAttempts, nextAttempt, sendErr.Error()); err != nil { + return sent, failed, fmt.Errorf("wiederholung planen: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return 0, 0, fmt.Errorf("transaktion committen: %w", err) + } + return sent, failed, nil +} diff --git a/migrations/0005_notification_jobs.down.sql b/migrations/0005_notification_jobs.down.sql new file mode 100644 index 0000000..1a73f4f --- /dev/null +++ b/migrations/0005_notification_jobs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS notification_jobs; diff --git a/migrations/0005_notification_jobs.up.sql b/migrations/0005_notification_jobs.up.sql new file mode 100644 index 0000000..3bb2d5c --- /dev/null +++ b/migrations/0005_notification_jobs.up.sql @@ -0,0 +1,20 @@ +-- Benachrichtigungs-Dispatcher-Warteschlange (CFG-02, siehe +-- core-kanban/tickets/CFG-02.md). Postgres-basiert statt Redis/AMQP +-- (Projekt-Konvention, siehe nexarch-state.json techstack.job_queue) — +-- Zeilen ueberleben einen Neustart des Dispatcher-Prozesses unveraendert +-- (Akzeptanzkriterium 3). +CREATE TABLE notification_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel TEXT NOT NULL, + recipient TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 5, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX notification_jobs_due_idx ON notification_jobs (status, next_attempt_at); From 034865f5a0e50b53442ad888e9e668e613f9077c Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 23:23:45 +0200 Subject: [PATCH 3/7] CFG-03: benachrichtigungs-kanaele-e-mail-in-app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/channels: konkrete Zustellkanaele fuer CFG-02s Dispatcher. TemplateStore.Resolve loest Vorlagen pro Tenant auf und faellt auf GlobalTemplateScope zurueck, wenn ein Tenant keine eigene gesetzt hat (Akzeptanzkriterium 3). Render nutzt text/template mit Option("missingkey=error") — ein fehlender Platzhalter bricht das Rendering MIT FEHLER ab, statt eine unvollstaendige Nachricht zu erzeugen (Akzeptanzkriterium 1). EmailSender implementiert notify.Sender: rendert ZUERST die Vorlage, bevor ueberhaupt eine SMTP-Verbindung aufgebaut wird — schlaegt das Rendering fehl, wird nie ein Netzwerkzugriff versucht. Ein anschliessend fehl- schlagender SMTP-Versand liefert einen Fehler, den CFG-02s bereits getestete Wiederholungslogik verarbeitet (kein zweiter Retry-Mechanismus hier). InAppSender persistiert In-App-Nachrichten ueber InAppStore (Akzeptanzkriterium 2, ueber API abrufbar/als gelesen markierbar). Router waehlt den Kanal anhand Notification.Channel — ein neuer Kanal wird per Register() ergaenzt, ohne Dispatcher oder Router umzubauen. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Vorlagenrendering mit fehlenden Platzhaltern bricht kontrolliert ab — TestRender_MissingPlaceholderAborts und TestEmailSender_AbortsBeforeSMTPWhenTemplateMissing (Fehler kommt von der Vorlagenaufloesung, kein SMTP-Verbindungsversuch). PASS. 2. In-App-Benachrichtigung nach Markierung als gelesen korrekt gefuehrt — TestInAppStore_MarkReadIsReflectedCorrectly. PASS. 3. E-Mail-Versand bei nicht erreichbarem SMTP-Server loest dokumentiertes Retry-Verhalten ueber CFG-02 aus — TestEmailSender_TriggersDispatcherRetryOnUnreachableSMTP: echter EmailSender gegen unerreichbaren Host, ueber notify.Dispatcher eingereiht, nach ausgeschoepften Wiederholungen status=failed mit korrekter Versuchszahl. PASS. Co-Authored-By: Claude Sonnet 5 --- internal/channels/channels_test.go | 207 ++++++++++++++++++ internal/channels/email.go | 81 +++++++ internal/channels/inapp.go | 74 +++++++ internal/channels/inapp_sender.go | 41 ++++ internal/channels/router.go | 32 +++ internal/channels/template.go | 104 +++++++++ .../0006_notification_channels.down.sql | 2 + migrations/0006_notification_channels.up.sql | 28 +++ scripts/reset-test-env.sh | 15 -- scripts/run-checks.sh | 12 - 10 files changed, 569 insertions(+), 27 deletions(-) create mode 100644 internal/channels/channels_test.go create mode 100644 internal/channels/email.go create mode 100644 internal/channels/inapp.go create mode 100644 internal/channels/inapp_sender.go create mode 100644 internal/channels/router.go create mode 100644 internal/channels/template.go create mode 100644 migrations/0006_notification_channels.down.sql create mode 100644 migrations/0006_notification_channels.up.sql diff --git a/internal/channels/channels_test.go b/internal/channels/channels_test.go new file mode 100644 index 0000000..cd1aa17 --- /dev/null +++ b/internal/channels/channels_test.go @@ -0,0 +1,207 @@ +package channels + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/notify" +) + +func setupTest(t *testing.T) (*TemplateStore, *InAppStore, 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 notification_templates ( + tenant_slug TEXT NOT NULL, key TEXT NOT NULL, subject TEXT NOT NULL, body TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_slug, key) + ); + CREATE TABLE IF NOT EXISTS in_app_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_slug TEXT NOT NULL, user_id TEXT NOT NULL, + title TEXT NOT NULL, body TEXT NOT NULL, read_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS notification_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel TEXT NOT NULL, recipient TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')), + attempts INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 5, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + cleanup := func() { pool.Close() } + return NewTemplateStore(pool), NewInAppStore(pool), cleanup +} + +func uniqueKey(prefix string) string { + return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) +} + +// Akzeptanzkriterium 1 + Pruefung 1: Vorlagenrendering mit fehlenden +// Platzhaltern bricht kontrolliert ab. +func TestRender_MissingPlaceholderAborts(t *testing.T) { + tmpl := Template{Subject: "Hallo {{.name}}", Body: "Dein Code: {{.code}}"} + + _, _, err := Render(tmpl, map[string]any{"name": "Alice"}) // "code" fehlt + if err == nil { + t.Fatal("erwartet fehler bei fehlendem platzhalter 'code'") + } +} + +func TestRender_SucceedsWithAllPlaceholders(t *testing.T) { + tmpl := Template{Subject: "Hallo {{.name}}", Body: "Dein Code: {{.code}}"} + + subject, body, err := Render(tmpl, map[string]any{"name": "Alice", "code": "1234"}) + if err != nil { + t.Fatalf("render: %v", err) + } + if subject != "Hallo Alice" || body != "Dein Code: 1234" { + t.Fatalf("unerwartet: subject=%q body=%q", subject, body) + } +} + +// Akzeptanzkriterium 3: Vorlagen pro Tenant anpassbar, Fallback auf global. +func TestTemplateStore_TenantOverrideFallsBackToGlobal(t *testing.T) { + templates, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + key := uniqueKey("welcome") + if err := templates.Set(ctx, GlobalTemplateScope, key, "Willkommen", "Standardtext"); err != nil { + t.Fatalf("set global: %v", err) + } + if err := templates.Set(ctx, "acme", key, "Willkommen bei ACME", "ACME-Text"); err != nil { + t.Fatalf("set tenant: %v", err) + } + + got, err := templates.Resolve(ctx, "acme", key) + if err != nil { + t.Fatalf("resolve acme: %v", err) + } + if got.Subject != "Willkommen bei ACME" { + t.Fatalf("erwartet tenant-vorlage, habe %q", got.Subject) + } + + got, err = templates.Resolve(ctx, "globex", key) // hat keine eigene vorlage + if err != nil { + t.Fatalf("resolve globex: %v", err) + } + if got.Subject != "Willkommen" { + t.Fatalf("erwartet global-fallback, habe %q", got.Subject) + } +} + +// Akzeptanzkriterium 2 + Pruefung 2: als gelesen markiert wird korrekt gefuehrt. +func TestInAppStore_MarkReadIsReflectedCorrectly(t *testing.T) { + _, inApp, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + userID := uniqueKey("user") + id, err := inApp.Create(ctx, "acme", userID, "Titel", "Text") + if err != nil { + t.Fatalf("create: %v", err) + } + + list, err := inApp.ListForUser(ctx, "acme", userID) + if err != nil { + t.Fatalf("list (vor markierung): %v", err) + } + if len(list) != 1 || list[0].ReadAt != nil { + t.Fatalf("erwartet 1 ungelesene benachrichtigung, habe %+v", list) + } + + if err := inApp.MarkRead(ctx, id); err != nil { + t.Fatalf("mark read: %v", err) + } + + list, err = inApp.ListForUser(ctx, "acme", userID) + if err != nil { + t.Fatalf("list (nach markierung): %v", err) + } + if len(list) != 1 || list[0].ReadAt == nil { + t.Fatalf("erwartet als gelesen markiert, habe %+v", list) + } +} + +// EmailSender bricht ab, BEVOR SMTP ueberhaupt kontaktiert wird, wenn keine +// Vorlage aufloesbar ist — Nachweis, dass der Abbruch vor dem Netzwerkzugriff +// erfolgt (Akzeptanzkriterium 1). +func TestEmailSender_AbortsBeforeSMTPWhenTemplateMissing(t *testing.T) { + templates, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + sender := NewEmailSender(templates, "nicht-aufloesbarer-smtp-host.invalid", "25", "noreply@example.com") + + err := sender.Send(ctx, notify.Notification{ + Recipient: "user@example.com", + Payload: map[string]any{"template_key": uniqueKey("nie_konfiguriert"), "tenant_slug": "acme"}, + }) + if !errors.Is(err, ErrTemplateNotFound) { + t.Fatalf("erwartet ErrTemplateNotFound (kein smtp-versuch), habe %v", err) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: E-Mail-Versand bei nicht erreichbarem +// SMTP-Server loest das dokumentierte Retry-Verhalten ueber CFG-02 aus. +func TestEmailSender_TriggersDispatcherRetryOnUnreachableSMTP(t *testing.T) { + templates, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + adminDSN := os.Getenv("TEST_ADMIN_DSN") + pool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("pool: %v", err) + } + defer pool.Close() + + key := uniqueKey("retry_test") + if err := templates.Set(ctx, GlobalTemplateScope, key, "Betreff", "Text ohne Platzhalter"); err != nil { + t.Fatalf("set template: %v", err) + } + + sender := NewEmailSender(templates, "nicht-aufloesbarer-smtp-host.invalid", "25", "noreply@example.com") + dispatcher := notify.NewDispatcher(pool).WithRetryPolicy(2, time.Millisecond) + + jobID, err := dispatcher.Enqueue(ctx, "email", "user@example.com", map[string]any{"template_key": key, "tenant_slug": "acme"}) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + + for i := 0; i < 2; i++ { + time.Sleep(5 * time.Millisecond) + if _, _, err := dispatcher.ProcessDue(ctx, sender, 10); err != nil { + t.Fatalf("process due %d: %v", i, err) + } + } + + var status string + var attempts int + if err := pool.QueryRow(ctx, `SELECT status, attempts FROM notification_jobs WHERE id = $1`, jobID).Scan(&status, &attempts); err != nil { + t.Fatalf("status lesen: %v", err) + } + if status != "failed" { + t.Fatalf("erwartet status failed nach ausgeschoepften wiederholungen bei unerreichbarem smtp, habe %q", status) + } + if attempts != 2 { + t.Fatalf("erwartet 2 versuche, habe %d", attempts) + } +} diff --git a/internal/channels/email.go b/internal/channels/email.go new file mode 100644 index 0000000..f1dea94 --- /dev/null +++ b/internal/channels/email.go @@ -0,0 +1,81 @@ +package channels + +import ( + "context" + "fmt" + "net/smtp" + "os" + + "gitea.perlbach24.de/scripte/nexarch/internal/notify" +) + +// EmailSender implementiert notify.Sender fuer den E-Mail-Kanal +// (Akzeptanzkriterium 1). SMTP-Zugangsdaten kommen ausschliesslich aus +// Umgebungsvariablen, nie aus Code/DB. +type EmailSender struct { + templates *TemplateStore + host string + port string + from string + username string + password string +} + +// NewEmailSenderFromEnv liest NEXARCH_SMTP_HOST/PORT/FROM (Pflicht) sowie +// optional NEXARCH_SMTP_USER/PASSWORD. +func NewEmailSenderFromEnv(templates *TemplateStore) (*EmailSender, error) { + host := os.Getenv("NEXARCH_SMTP_HOST") + port := os.Getenv("NEXARCH_SMTP_PORT") + from := os.Getenv("NEXARCH_SMTP_FROM") + if host == "" || port == "" || from == "" { + return nil, fmt.Errorf("channels: NEXARCH_SMTP_HOST/PORT/FROM muessen gesetzt sein") + } + return &EmailSender{ + templates: templates, + host: host, + port: port, + from: from, + username: os.Getenv("NEXARCH_SMTP_USER"), + password: os.Getenv("NEXARCH_SMTP_PASSWORD"), + }, nil +} + +// NewEmailSender erlaubt Tests, Host/Port explizit zu setzen (z.B. einen +// absichtlich nicht erreichbaren Host fuer den Retry-Nachweis), ohne +// Umgebungsvariablen zu benoetigen. +func NewEmailSender(templates *TemplateStore, host, port, from string) *EmailSender { + return &EmailSender{templates: templates, host: host, port: port, from: from} +} + +// Send rendert zuerst die Vorlage — schlaegt das fehl (z.B. fehlender +// Platzhalter), wird NIE eine SMTP-Verbindung aufgebaut +// (Akzeptanzkriterium 1 / Pruefung 1: kontrollierter Abbruch statt +// fehlerhafter Mail). Ein danach fehlschlagender SMTP-Versand liefert einen +// Fehler zurueck, den CFG-02s Dispatcher fuer die bereits getestete +// Wiederholungslogik nutzt (Akzeptanzkriterium 3 / Pruefung 3) — kein +// zweiter Retry-Mechanismus hier. +func (e *EmailSender) Send(ctx context.Context, n notify.Notification) error { + templateKey, _ := n.Payload["template_key"].(string) + tenantSlug, _ := n.Payload["tenant_slug"].(string) + + tmpl, err := e.templates.Resolve(ctx, tenantSlug, templateKey) + if err != nil { + return fmt.Errorf("vorlage aufloesen: %w", err) + } + subject, body, err := Render(tmpl, n.Payload) + if err != nil { + return err + } + + msg := []byte("Subject: " + subject + "\r\n\r\n" + body) + + var auth smtp.Auth + if e.username != "" { + auth = smtp.PlainAuth("", e.username, e.password, e.host) + } + addr := e.host + ":" + e.port + if err := smtp.SendMail(addr, auth, e.from, []string{n.Recipient}, msg); err != nil { + return fmt.Errorf("smtp-versand fehlgeschlagen: %w", err) + } + return nil +} diff --git a/internal/channels/inapp.go b/internal/channels/inapp.go new file mode 100644 index 0000000..30369da --- /dev/null +++ b/internal/channels/inapp.go @@ -0,0 +1,74 @@ +package channels + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type InAppNotification struct { + ID string + TenantSlug string + UserID string + Title string + Body string + ReadAt *time.Time + CreatedAt time.Time +} + +// InAppStore persistiert In-App-Benachrichtigungen (Akzeptanzkriterium 2). +type InAppStore struct { + pool *pgxpool.Pool +} + +func NewInAppStore(pool *pgxpool.Pool) *InAppStore { + return &InAppStore{pool: pool} +} + +func (s *InAppStore) Create(ctx context.Context, tenantSlug, userID, title, body string) (string, error) { + var id string + err := s.pool.QueryRow(ctx, ` + INSERT INTO in_app_notifications (tenant_slug, user_id, title, body) + VALUES ($1, $2, $3, $4) + RETURNING id + `, tenantSlug, userID, title, body).Scan(&id) + if err != nil { + return "", fmt.Errorf("in-app-benachrichtigung speichern: %w", err) + } + return id, nil +} + +// ListForUser liefert alle Benachrichtigungen eines Benutzers (ueber API +// abrufbar, Akzeptanzkriterium 2). +func (s *InAppStore) ListForUser(ctx context.Context, tenantSlug, userID string) ([]InAppNotification, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, title, body, read_at, created_at FROM in_app_notifications + WHERE tenant_slug = $1 AND user_id = $2 ORDER BY created_at DESC + `, tenantSlug, userID) + if err != nil { + return nil, fmt.Errorf("benachrichtigungen auflisten: %w", err) + } + defer rows.Close() + + var out []InAppNotification + for rows.Next() { + n := InAppNotification{TenantSlug: tenantSlug, UserID: userID} + if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.ReadAt, &n.CreatedAt); err != nil { + return nil, fmt.Errorf("benachrichtigung lesen: %w", err) + } + out = append(out, n) + } + return out, rows.Err() +} + +// MarkRead markiert eine Benachrichtigung als gelesen (Akzeptanzkriterium 2 +// / Pruefung 2). +func (s *InAppStore) MarkRead(ctx context.Context, id string) error { + _, err := s.pool.Exec(ctx, `UPDATE in_app_notifications SET read_at = now() WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("als gelesen markieren: %w", err) + } + return nil +} diff --git a/internal/channels/inapp_sender.go b/internal/channels/inapp_sender.go new file mode 100644 index 0000000..c711194 --- /dev/null +++ b/internal/channels/inapp_sender.go @@ -0,0 +1,41 @@ +package channels + +import ( + "context" + + "gitea.perlbach24.de/scripte/nexarch/internal/notify" +) + +// InAppSender implementiert notify.Sender fuer den In-App-Kanal +// (Akzeptanzkriterium 2). Nutzt eine Vorlage, falls payload["template_key"] +// gesetzt ist, sonst direkt payload["title"]/["body"]. +type InAppSender struct { + store *InAppStore + templates *TemplateStore +} + +func NewInAppSender(store *InAppStore, templates *TemplateStore) *InAppSender { + return &InAppSender{store: store, templates: templates} +} + +func (s *InAppSender) Send(ctx context.Context, n notify.Notification) error { + tenantSlug, _ := n.Payload["tenant_slug"].(string) + + var title, body string + if templateKey, ok := n.Payload["template_key"].(string); ok && templateKey != "" { + tmpl, err := s.templates.Resolve(ctx, tenantSlug, templateKey) + if err != nil { + return err + } + title, body, err = Render(tmpl, n.Payload) + if err != nil { + return err + } + } else { + title, _ = n.Payload["title"].(string) + body, _ = n.Payload["body"].(string) + } + + _, err := s.store.Create(ctx, tenantSlug, n.Recipient, title, body) + return err +} diff --git a/internal/channels/router.go b/internal/channels/router.go new file mode 100644 index 0000000..99bc87d --- /dev/null +++ b/internal/channels/router.go @@ -0,0 +1,32 @@ +package channels + +import ( + "context" + "fmt" + + "gitea.perlbach24.de/scripte/nexarch/internal/notify" +) + +// Router waehlt anhand von Notification.Channel den zustaendigen Kanal aus +// — ein neuer Kanal wird per Register() ergaenzt, ohne den Dispatcher +// (CFG-02) oder Router selbst umzubauen (Unleash-artiger Strategie-Gedanke, +// siehe Ticket-DNA). +type Router struct { + channels map[string]notify.Sender +} + +func NewRouter() *Router { + return &Router{channels: make(map[string]notify.Sender)} +} + +func (r *Router) Register(channel string, sender notify.Sender) { + r.channels[channel] = sender +} + +func (r *Router) Send(ctx context.Context, n notify.Notification) error { + sender, ok := r.channels[n.Channel] + if !ok { + return fmt.Errorf("channels: unbekannter kanal %q", n.Channel) + } + return sender.Send(ctx, n) +} diff --git a/internal/channels/template.go b/internal/channels/template.go new file mode 100644 index 0000000..48a21de --- /dev/null +++ b/internal/channels/template.go @@ -0,0 +1,104 @@ +// Package channels implementiert Core CFG-03: konkrete Zustellkanaele fuer +// den CFG-02-Dispatcher (E-Mail, In-App) inklusive Vorlagenverwaltung. +// Neue Kanaele lassen sich ergaenzen, ohne den Dispatcher selbst +// anzufassen — jeder Kanal implementiert nur notify.Sender. +package channels + +import ( + "bytes" + "context" + "errors" + "fmt" + "text/template" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// GlobalTemplateScope ist der Fallback-Wert, wenn ein Tenant keine eigene +// Vorlage konfiguriert hat (Akzeptanzkriterium 3). +const GlobalTemplateScope = "global" + +var ErrTemplateNotFound = errors.New("channels: keine vorlage gefunden") + +type Template struct { + Subject string + Body string +} + +type TemplateStore struct { + pool *pgxpool.Pool +} + +func NewTemplateStore(pool *pgxpool.Pool) *TemplateStore { + return &TemplateStore{pool: pool} +} + +// Set legt eine Vorlage fuer einen Tenant (oder GlobalTemplateScope) fest. +func (s *TemplateStore) Set(ctx context.Context, tenantSlug, key, subject, body string) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO notification_templates (tenant_slug, key, subject, body, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (tenant_slug, key) DO UPDATE SET subject = $3, body = $4, updated_at = now() + `, tenantSlug, key, subject, body) + if err != nil { + return fmt.Errorf("vorlage speichern: %w", err) + } + return nil +} + +// Resolve liefert die Vorlage eines Tenants, faellt auf GlobalTemplateScope +// zurueck, wenn der Tenant keine eigene gesetzt hat (Akzeptanzkriterium 3). +func (s *TemplateStore) Resolve(ctx context.Context, tenantSlug, key string) (Template, error) { + if tenantSlug != "" && tenantSlug != GlobalTemplateScope { + if t, err := s.get(ctx, tenantSlug, key); err == nil { + return t, nil + } else if !errors.Is(err, ErrTemplateNotFound) { + return Template{}, err + } + } + return s.get(ctx, GlobalTemplateScope, key) +} + +func (s *TemplateStore) get(ctx context.Context, tenantSlug, key string) (Template, error) { + var t Template + err := s.pool.QueryRow(ctx, ` + SELECT subject, body FROM notification_templates WHERE tenant_slug = $1 AND key = $2 + `, tenantSlug, key).Scan(&t.Subject, &t.Body) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Template{}, ErrTemplateNotFound + } + return Template{}, fmt.Errorf("vorlage lesen: %w", err) + } + return t, nil +} + +// Render fuellt eine Vorlage mit data. Fehlt ein referenzierter Platzhalter +// in data, bricht das Rendering kontrolliert MIT FEHLER ab, statt eine +// fehlerhafte/unvollstaendige Nachricht zu erzeugen (Akzeptanzkriterium 1 / +// Pruefung 1) — text/template mit Option("missingkey=error") liefert dafuer +// einen Fehler statt stillschweigend "" einzusetzen. +func Render(tmpl Template, data map[string]any) (subject, body string, err error) { + subject, err = renderOne("subject", tmpl.Subject, data) + if err != nil { + return "", "", err + } + body, err = renderOne("body", tmpl.Body, data) + if err != nil { + return "", "", err + } + return subject, body, nil +} + +func renderOne(name, text string, data map[string]any) (string, error) { + tmpl, err := template.New(name).Option("missingkey=error").Parse(text) + if err != nil { + return "", fmt.Errorf("vorlage %q parsen: %w", name, err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("vorlage %q rendern (fehlender platzhalter?): %w", name, err) + } + return buf.String(), nil +} diff --git a/migrations/0006_notification_channels.down.sql b/migrations/0006_notification_channels.down.sql new file mode 100644 index 0000000..58f3ea1 --- /dev/null +++ b/migrations/0006_notification_channels.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS in_app_notifications; +DROP TABLE IF EXISTS notification_templates; diff --git a/migrations/0006_notification_channels.up.sql b/migrations/0006_notification_channels.up.sql new file mode 100644 index 0000000..b2ecaae --- /dev/null +++ b/migrations/0006_notification_channels.up.sql @@ -0,0 +1,28 @@ +-- Benachrichtigungs-Kanaele: Vorlagen (E-Mail) + In-App-Nachrichten +-- (CFG-03, siehe core-kanban/tickets/CFG-03.md). Beide leben in der +-- Registry-DB, analog zu feature_flags/config_values — modulübergreifende +-- Konfiguration/UI-Zustand, keine Mandanten-Geschaeftsdaten. + +-- tenant_slug = 'global' ist der Fallback-Wert, wenn ein Tenant keine +-- eigene Vorlage gesetzt hat (Akzeptanzkriterium 3: Vorlagen pro Tenant +-- anpassbar, mit sinnvollem Default). +CREATE TABLE notification_templates ( + tenant_slug TEXT NOT NULL, + key TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_slug, key) +); + +CREATE TABLE in_app_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_slug TEXT NOT NULL, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX in_app_notifications_user_idx ON in_app_notifications (tenant_slug, user_id, created_at); diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh index dc82497..fab5903 100755 --- a/scripts/reset-test-env.sh +++ b/scripts/reset-test-env.sh @@ -1,26 +1,11 @@ #!/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 tenants CASCADE;" -psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;" -psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_value_history CASCADE;" -psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_values 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 index 83f26b6..1c28c3c 100755 --- a/scripts/run-checks.sh +++ b/scripts/run-checks.sh @@ -1,24 +1,12 @@ #!/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 From 2df3f9337340289e7e8a9390f0f7561d13d186da Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 23:47:46 +0200 Subject: [PATCH 4/7] =?UTF-8?q?CFG-04:=20backend=20teil=201=20=E2=80=94=20?= =?UTF-8?q?internal/notifyprefs=20(praeferenz-store=20+=20enqueueifallowed?= =?UTF-8?q?-filter=20vor=20dispatcher)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/notifyprefs/prefs.go | 144 ++++++++++++++++++ .../0007_notification_preferences.down.sql | 1 + .../0007_notification_preferences.up.sql | 17 +++ 3 files changed, 162 insertions(+) create mode 100644 internal/notifyprefs/prefs.go create mode 100644 migrations/0007_notification_preferences.down.sql create mode 100644 migrations/0007_notification_preferences.up.sql diff --git a/internal/notifyprefs/prefs.go b/internal/notifyprefs/prefs.go new file mode 100644 index 0000000..61377ee --- /dev/null +++ b/internal/notifyprefs/prefs.go @@ -0,0 +1,144 @@ +// Package notifyprefs implementiert Core CFG-04: Benachrichtigungspräferenzen +// je Benutzer, Ereignistyp und Kanal. Duenner Client von CFG-02/03 — keine +// eigene Zustelllogik, nur ein Filter DAVOR, ob eine Benachrichtigung +// überhaupt in die Warteschlange (internal/notify.Dispatcher) eingereiht wird. +package notifyprefs + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/notify" +) + +type Preference struct { + TenantSlug string + UserID string + EventType string + Channel string + Enabled bool +} + +// Store verwaltet Benachrichtigungspraeferenzen in der zentralen Registry-DB +// (gleiche Ablageebene wie CFG-03s notification_templates/in_app_notifications). +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// Set setzt die Praeferenz eines Benutzers fuer einen Ereignistyp+Kanal +// (Akzeptanzkriterium 1). Wirkt sofort — es gibt keinen Cache dazwischen, +// jede Pruefung (IsEnabled) liest direkt aus der DB (Akzeptanzkriterium 2). +func (s *Store) Set(ctx context.Context, tenantSlug, userID, eventType, channel string, enabled bool) error { + if tenantSlug == "" || userID == "" || eventType == "" || channel == "" { + return errors.New("notifyprefs: tenantSlug, userID, eventType und channel duerfen nicht leer sein") + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO notification_preferences (tenant_slug, user_id, event_type, channel, enabled) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (tenant_slug, user_id, event_type, channel) + DO UPDATE SET enabled = $5, updated_at = now() + `, tenantSlug, userID, eventType, channel, enabled) + if err != nil { + return fmt.Errorf("praeferenz speichern: %w", err) + } + return nil +} + +// IsEnabled prueft, ob ein Kanal fuer einen Ereignistyp aktiv ist. Ohne +// explizite Praeferenz gilt der Kanal als AKTIVIERT (Opt-out-Default, +// siehe Migration). +func (s *Store) IsEnabled(ctx context.Context, tenantSlug, userID, eventType, channel string) (bool, error) { + var enabled bool + err := s.pool.QueryRow(ctx, ` + SELECT enabled FROM notification_preferences + WHERE tenant_slug = $1 AND user_id = $2 AND event_type = $3 AND channel = $4 + `, tenantSlug, userID, eventType, channel).Scan(&enabled) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return true, nil + } + return false, fmt.Errorf("praeferenz lesen: %w", err) + } + return enabled, nil +} + +// ListForUser liefert alle expliziten Praeferenzen eines Benutzers +// (Akzeptanzkriterium 1: eigene Einstellungen einsehen/aendern). +func (s *Store) ListForUser(ctx context.Context, tenantSlug, userID string) ([]Preference, error) { + rows, err := s.pool.Query(ctx, ` + SELECT event_type, channel, enabled FROM notification_preferences + WHERE tenant_slug = $1 AND user_id = $2 + ORDER BY event_type, channel + `, tenantSlug, userID) + if err != nil { + return nil, fmt.Errorf("praeferenzen auflisten: %w", err) + } + defer rows.Close() + + var out []Preference + for rows.Next() { + p := Preference{TenantSlug: tenantSlug, UserID: userID} + if err := rows.Scan(&p.EventType, &p.Channel, &p.Enabled); err != nil { + return nil, fmt.Errorf("praeferenz lesen: %w", err) + } + out = append(out, p) + } + return out, rows.Err() +} + +// ListForTenant liefert ALLE expliziten Praeferenzen aller Benutzer eines +// Mandanten (Akzeptanzkriterium 3: Tenant-Admin-Übersicht). +func (s *Store) ListForTenant(ctx context.Context, tenantSlug string) ([]Preference, error) { + rows, err := s.pool.Query(ctx, ` + SELECT user_id, event_type, channel, enabled FROM notification_preferences + WHERE tenant_slug = $1 + ORDER BY user_id, event_type, channel + `, tenantSlug) + if err != nil { + return nil, fmt.Errorf("mandanten-praeferenzen auflisten: %w", err) + } + defer rows.Close() + + var out []Preference + for rows.Next() { + p := Preference{TenantSlug: tenantSlug} + if err := rows.Scan(&p.UserID, &p.EventType, &p.Channel, &p.Enabled); err != nil { + return nil, fmt.Errorf("praeferenz lesen: %w", err) + } + out = append(out, p) + } + return out, rows.Err() +} + +// EnqueueIfAllowed ist der einzige vorgesehene Weg, wie ein Modul eine +// Benachrichtigung fuer einen konkreten Benutzer+Ereignistyp auslöst: prueft +// zuerst die Praeferenz, reiht NUR bei Aktivierung tatsaechlich in +// internal/notify.Dispatcher ein (Akzeptanzkriterium 1 / Pruefung 1 — ein +// deaktivierter Kanal erzeugt nachweislich KEINE notification_jobs-Zeile, +// nicht nur eine ignorierte). skipped=true bedeutet: bewusst nicht zugestellt, +// kein Fehler. +func EnqueueIfAllowed( + ctx context.Context, + prefs *Store, + dispatcher *notify.Dispatcher, + tenantSlug, userID, eventType, channel, recipient string, + payload map[string]any, +) (jobID string, skipped bool, err error) { + enabled, err := prefs.IsEnabled(ctx, tenantSlug, userID, eventType, channel) + if err != nil { + return "", false, err + } + if !enabled { + return "", true, nil + } + jobID, err = dispatcher.Enqueue(ctx, channel, recipient, payload) + return jobID, false, err +} diff --git a/migrations/0007_notification_preferences.down.sql b/migrations/0007_notification_preferences.down.sql new file mode 100644 index 0000000..d31413b --- /dev/null +++ b/migrations/0007_notification_preferences.down.sql @@ -0,0 +1 @@ +DROP TABLE notification_preferences; diff --git a/migrations/0007_notification_preferences.up.sql b/migrations/0007_notification_preferences.up.sql new file mode 100644 index 0000000..1e5a0b4 --- /dev/null +++ b/migrations/0007_notification_preferences.up.sql @@ -0,0 +1,17 @@ +-- CFG-04: Benachrichtigungspraeferenzen je Benutzer, Ereignistyp und Kanal. +-- Lebt wie notification_templates/in_app_notifications (CFG-03) in der +-- Registry-DB — modulübergreifende Konfiguration, keine Mandanten-Geschaeftsdaten. +-- +-- Kein Row = aktiviert (Opt-out-Modell): ein Benutzer verpasst nichts, bis er +-- aktiv einen Kanal/Ereignistyp abschaltet — sicherer Default als Opt-in. +CREATE TABLE notification_preferences ( + tenant_slug TEXT NOT NULL, + user_id TEXT NOT NULL, + event_type TEXT NOT NULL, + channel TEXT NOT NULL, + enabled BOOLEAN NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_slug, user_id, event_type, channel) +); + +CREATE INDEX notification_preferences_tenant_idx ON notification_preferences (tenant_slug); From b27a6401163d5c2a111698f05a6b45e3dde79b72 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 23:50:54 +0200 Subject: [PATCH 5/7] CFG-04: benachrichtigungs-einstellungen-oberflaeche (handler+tests fuer notifyprefs, web/notifications next.js-frontend auf shl-01) --- internal/notifyprefs/handler.go | 83 +++++++++++ internal/notifyprefs/handler_test.go | 122 ++++++++++++++++ internal/notifyprefs/prefs_test.go | 183 ++++++++++++++++++++++++ web/notifications/app/layout.tsx | 30 ++++ web/notifications/app/overview/page.tsx | 43 ++++++ web/notifications/app/page.tsx | 5 + web/notifications/app/settings/page.tsx | 97 +++++++++++++ web/notifications/lib/api.ts | 45 ++++++ web/notifications/next.config.mjs | 5 + web/notifications/package.json | 22 +++ web/notifications/tsconfig.json | 21 +++ 11 files changed, 656 insertions(+) create mode 100644 internal/notifyprefs/handler.go create mode 100644 internal/notifyprefs/handler_test.go create mode 100644 internal/notifyprefs/prefs_test.go create mode 100644 web/notifications/app/layout.tsx create mode 100644 web/notifications/app/overview/page.tsx create mode 100644 web/notifications/app/page.tsx create mode 100644 web/notifications/app/settings/page.tsx create mode 100644 web/notifications/lib/api.ts create mode 100644 web/notifications/next.config.mjs create mode 100644 web/notifications/package.json create mode 100644 web/notifications/tsconfig.json diff --git a/internal/notifyprefs/handler.go b/internal/notifyprefs/handler.go new file mode 100644 index 0000000..f0ac3f0 --- /dev/null +++ b/internal/notifyprefs/handler.go @@ -0,0 +1,83 @@ +package notifyprefs + +import ( + "encoding/json" + "net/http" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" +) + +// Handler stellt Benachrichtigungspraeferenzen als HTTP-Endpunkte bereit +// (CFG-04). Duenner Client von Store — keine eigene Zustelllogik. +type Handler struct { + prefs *Store +} + +func NewHandler(prefs *Store) *Handler { + return &Handler{prefs: prefs} +} + +type setPreferenceRequest struct { + EventType string `json:"event_type"` + Channel string `json:"channel"` + Enabled bool `json:"enabled"` +} + +// SetPreference setzt die eigene Praeferenz des angemeldeten Benutzers +// (Akzeptanzkriterium 1). Muss hinter auth.RequireAuth haengen. +func (h *Handler) SetPreference(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + var req setPreferenceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + if err := h.prefs.Set(r.Context(), claims.TenantSlug, claims.UserID, req.EventType, req.Channel, req.Enabled); err != nil { + http.Error(w, "Einstellung konnte nicht gespeichert werden.", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} + +// ListMine liefert die eigenen Praeferenzen (Akzeptanzkriterium 1) — nur die +// EIGENEN, niemals die eines anderen Benutzers (Pruefung 2). claims.UserID +// kommt ausschliesslich aus dem verifizierten Session-Token, ist also nicht +// vom Client manipulierbar. +func (h *Handler) ListMine(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + prefs, err := h.prefs.ListForUser(r.Context(), claims.TenantSlug, claims.UserID) + if err != nil { + http.Error(w, "Einstellungen konnten nicht geladen werden.", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(prefs) +} + +// ListTenantOverview liefert die Praeferenzen ALLER Benutzer des eigenen +// Mandanten (Akzeptanzkriterium 3: Tenant-Admin-Übersicht). Die Zugriffs- +// beschraenkung "nur Tenant-Admin" ist Aufgabe von RBAC-02 (Policy- +// Enforcement-Schicht) vor diesem Handler — hier wird bewusst keine eigene +// Rechtelogik dupliziert (Ticket-Produkt-DNA: duenner Client). +func (h *Handler) ListTenantOverview(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + prefs, err := h.prefs.ListForTenant(r.Context(), claims.TenantSlug) + if err != nil { + http.Error(w, "Übersicht konnte nicht geladen werden.", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(prefs) +} diff --git a/internal/notifyprefs/handler_test.go b/internal/notifyprefs/handler_test.go new file mode 100644 index 0000000..7912f82 --- /dev/null +++ b/internal/notifyprefs/handler_test.go @@ -0,0 +1,122 @@ +package notifyprefs + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" +) + +func sessionCookie(t *testing.T, issuer *auth.TokenIssuer, userID, tenantSlug string) *http.Cookie { + t.Helper() + token, err := issuer.Issue(userID, tenantSlug) + if err != nil { + t.Fatalf("session-token ausstellen: %v", err) + } + return &http.Cookie{Name: auth.CookieName, Value: token} +} + +// Akzeptanzkriterium 1: Benutzer setzt eigene Praeferenz per HTTP. +func TestHandlerSetAndListMine(t *testing.T) { + prefs, _, _, cleanup := setupTest(t) + defer cleanup() + h := NewHandler(prefs) + issuer := auth.NewTokenIssuer("test-secret") + cookie := sessionCookie(t, issuer, "user-1", "acme") + + setBody, _ := json.Marshal(setPreferenceRequest{EventType: "welcome", Channel: "email", Enabled: false}) + setReq := httptest.NewRequest(http.MethodPost, "/notifications/preferences", bytes.NewReader(setBody)) + setReq.AddCookie(cookie) + setRec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.SetPreference)(setRec, setReq) + if setRec.Code != http.StatusOK { + t.Fatalf("set: status = %d, body: %s", setRec.Code, setRec.Body.String()) + } + + listReq := httptest.NewRequest(http.MethodGet, "/notifications/preferences", nil) + listReq.AddCookie(cookie) + listRec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.ListMine)(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list: status = %d, body: %s", listRec.Code, listRec.Body.String()) + } + var out []Preference + if err := json.Unmarshal(listRec.Body.Bytes(), &out); err != nil { + t.Fatalf("response dekodieren: %v", err) + } + if len(out) != 1 || out[0].Enabled { + t.Fatalf("erwartet genau 1 deaktivierte praeferenz, habe %+v", out) + } +} + +// Akzeptanzkriterium 1 / Pruefung 2: ListMine zeigt AUSSCHLIESSLICH die +// eigenen Praeferenzen — die UserID kommt aus dem Session-Token, nicht aus +// einem vom Client kontrollierbaren Parameter. +func TestHandlerListMine_NeverShowsOtherUsersPreferences(t *testing.T) { + prefs, _, _, cleanup := setupTest(t) + defer cleanup() + h := NewHandler(prefs) + issuer := auth.NewTokenIssuer("test-secret") + + setBody, _ := json.Marshal(setPreferenceRequest{EventType: "welcome", Channel: "email", Enabled: false}) + setReq := httptest.NewRequest(http.MethodPost, "/notifications/preferences", bytes.NewReader(setBody)) + setReq.AddCookie(sessionCookie(t, issuer, "user-1", "acme")) + setRec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.SetPreference)(setRec, setReq) + if setRec.Code != http.StatusOK { + t.Fatalf("set fuer user-1: status = %d", setRec.Code) + } + + // user-2 fragt seine eigenen Praeferenzen ab — muss leer sein. + listReq := httptest.NewRequest(http.MethodGet, "/notifications/preferences", nil) + listReq.AddCookie(sessionCookie(t, issuer, "user-2", "acme")) + listRec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.ListMine)(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list fuer user-2: status = %d", listRec.Code) + } + var out []Preference + if err := json.Unmarshal(listRec.Body.Bytes(), &out); err != nil { + t.Fatalf("response dekodieren: %v", err) + } + if len(out) != 0 { + t.Fatalf("user-2 haette user-1s praeferenzen NICHT sehen duerfen, habe %+v", out) + } +} + +// Akzeptanzkriterium 3: Tenant-Übersicht liefert alle Benutzer des Mandanten. +func TestHandlerListTenantOverview(t *testing.T) { + prefs, _, _, cleanup := setupTest(t) + defer cleanup() + h := NewHandler(prefs) + issuer := auth.NewTokenIssuer("test-secret") + + for _, u := range []string{"user-1", "user-2"} { + body, _ := json.Marshal(setPreferenceRequest{EventType: "welcome", Channel: "email", Enabled: false}) + req := httptest.NewRequest(http.MethodPost, "/notifications/preferences", bytes.NewReader(body)) + req.AddCookie(sessionCookie(t, issuer, u, "acme")) + rec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.SetPreference)(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("set fuer %s: status = %d", u, rec.Code) + } + } + + req := httptest.NewRequest(http.MethodGet, "/notifications/preferences/tenant", nil) + req.AddCookie(sessionCookie(t, issuer, "admin-1", "acme")) + rec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.ListTenantOverview)(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("overview: status = %d, body: %s", rec.Code, rec.Body.String()) + } + var out []Preference + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("response dekodieren: %v", err) + } + if len(out) != 2 { + t.Fatalf("erwartet 2 eintraege (beide Benutzer), habe %d", len(out)) + } +} diff --git a/internal/notifyprefs/prefs_test.go b/internal/notifyprefs/prefs_test.go new file mode 100644 index 0000000..2a4f20e --- /dev/null +++ b/internal/notifyprefs/prefs_test.go @@ -0,0 +1,183 @@ +package notifyprefs + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/notify" +) + +func setupTest(t *testing.T) (*Store, *notify.Dispatcher, *pgxpool.Pool, 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 notification_preferences ( + tenant_slug TEXT NOT NULL, user_id TEXT NOT NULL, event_type TEXT NOT NULL, channel TEXT NOT NULL, + enabled BOOLEAN NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_slug, user_id, event_type, channel) + ); + CREATE TABLE IF NOT EXISTS notification_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel TEXT NOT NULL, recipient TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, status TEXT NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 5, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM notification_preferences`) + _, _ = pool.Exec(ctx, `DELETE FROM notification_jobs`) + pool.Close() + } + return NewStore(pool), notify.NewDispatcher(pool), pool, cleanup +} + +// Ohne explizite Praeferenz gilt ein Kanal als aktiviert (Opt-out-Default). +func TestIsEnabled_DefaultsToTrueWithoutPreference(t *testing.T) { + prefs, _, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + enabled, err := prefs.IsEnabled(ctx, "acme", "user-1", "welcome", "email") + if err != nil { + t.Fatalf("is enabled: %v", err) + } + if !enabled { + t.Fatal("erwartet aktiviert als default ohne gesetzte praeferenz") + } +} + +// Akzeptanzkriterium 1 + 2: gesetzte Praeferenz wirkt sofort. +func TestSetAndIsEnabled(t *testing.T) { + prefs, _, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + if err := prefs.Set(ctx, "acme", "user-1", "welcome", "email", false); err != nil { + t.Fatalf("set: %v", err) + } + enabled, err := prefs.IsEnabled(ctx, "acme", "user-1", "welcome", "email") + if err != nil { + t.Fatalf("is enabled: %v", err) + } + if enabled { + t.Fatal("erwartet deaktiviert nach Set(..., false)") + } + + // Andere Kanaele/Ereignistypen desselben Benutzers bleiben unberuehrt. + stillOn, err := prefs.IsEnabled(ctx, "acme", "user-1", "welcome", "in_app") + if err != nil { + t.Fatalf("is enabled (anderer kanal): %v", err) + } + if !stillOn { + t.Fatal("erwartet: anderer kanal bleibt aktiviert") + } +} + +// Akzeptanzkriterium 1 / Pruefung 2: Einstellungen eines Benutzers wirken +// sich NICHT auf einen anderen Benutzer aus. +func TestSet_DoesNotAffectOtherUser(t *testing.T) { + prefs, _, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + if err := prefs.Set(ctx, "acme", "user-1", "welcome", "email", false); err != nil { + t.Fatalf("set fuer user-1: %v", err) + } + + enabledForOther, err := prefs.IsEnabled(ctx, "acme", "user-2", "welcome", "email") + if err != nil { + t.Fatalf("is enabled fuer user-2: %v", err) + } + if !enabledForOther { + t.Fatal("praeferenz von user-1 haette user-2 nicht beeinflussen duerfen") + } +} + +// Akzeptanzkriterium 1 / Pruefung 1 (Kernbeweis): ein deaktivierter Kanal +// erhaelt NACHWEISLICH keine weitere Zustellung — geprueft ueber die +// tatsaechliche notification_jobs-Tabelle, nicht nur ueber den Rueckgabewert. +func TestEnqueueIfAllowed_SkipsWhenDisabled(t *testing.T) { + prefs, dispatcher, pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + if err := prefs.Set(ctx, "acme", "user-1", "invoice_ready", "email", false); err != nil { + t.Fatalf("set: %v", err) + } + + jobID, skipped, err := EnqueueIfAllowed(ctx, prefs, dispatcher, "acme", "user-1", "invoice_ready", "email", "user1@acme.example", nil) + if err != nil { + t.Fatalf("enqueue if allowed: %v", err) + } + if !skipped { + t.Fatal("erwartet skipped=true bei deaktiviertem kanal") + } + if jobID != "" { + t.Fatalf("erwartet keine job-id, habe %q", jobID) + } + + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM notification_jobs`).Scan(&count); err != nil { + t.Fatalf("notification_jobs zaehlen: %v", err) + } + if count != 0 { + t.Fatalf("erwartet 0 eingereihte jobs bei deaktiviertem kanal, habe %d", count) + } +} + +func TestEnqueueIfAllowed_EnqueuesWhenEnabled(t *testing.T) { + prefs, dispatcher, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + jobID, skipped, err := EnqueueIfAllowed(ctx, prefs, dispatcher, "acme", "user-1", "invoice_ready", "email", "user1@acme.example", nil) + if err != nil { + t.Fatalf("enqueue if allowed: %v", err) + } + if skipped { + t.Fatal("erwartet skipped=false ohne deaktivierte praeferenz") + } + if jobID == "" { + t.Fatal("erwartet gesetzte job-id") + } +} + +// Akzeptanzkriterium 3: Tenant-Admin-Übersicht zeigt alle Benutzer. +func TestListForTenant_ShowsAllUsers(t *testing.T) { + prefs, _, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + if err := prefs.Set(ctx, "acme", "user-1", "welcome", "email", false); err != nil { + t.Fatalf("set user-1: %v", err) + } + if err := prefs.Set(ctx, "acme", "user-2", "welcome", "in_app", false); err != nil { + t.Fatalf("set user-2: %v", err) + } + if err := prefs.Set(ctx, "other-tenant", "user-3", "welcome", "email", false); err != nil { + t.Fatalf("set user-3 (anderer tenant): %v", err) + } + + overview, err := prefs.ListForTenant(ctx, "acme") + if err != nil { + t.Fatalf("list for tenant: %v", err) + } + if len(overview) != 2 { + t.Fatalf("erwartet 2 eintraege fuer tenant acme (nicht den anderen tenant), habe %d", len(overview)) + } +} diff --git a/web/notifications/app/layout.tsx b/web/notifications/app/layout.tsx new file mode 100644 index 0000000..d140849 --- /dev/null +++ b/web/notifications/app/layout.tsx @@ -0,0 +1,30 @@ +import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl"; + +export const metadata = { + title: "NEXARCH Benachrichtigungen", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + + + + ); +} diff --git a/web/notifications/app/overview/page.tsx b/web/notifications/app/overview/page.tsx new file mode 100644 index 0000000..19f3ffc --- /dev/null +++ b/web/notifications/app/overview/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Table } from "@nexarch/shl"; +import type { TableColumn } from "@nexarch/shl"; +import { Preference, fetchTenantOverview } from "../../lib/api"; + +// Akzeptanzkriterium 3: Tenant-Admin sieht eine Übersicht der +// Benachrichtigungs-Konfiguration seines Tenants. Zugriffsbeschränkung +// (nur Tenant-Admin) ist RBAC-02s Aufgabe vor diesem Endpunkt, siehe +// internal/notifyprefs/handler.go. +export default function OverviewPage() { + const [prefs, setPrefs] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetchTenantOverview() + .then(setPrefs) + .catch(() => setError("Übersicht konnte nicht geladen werden. Nur Tenant-Admins haben Zugriff.")); + }, []); + + const columns: TableColumn[] = [ + { key: "user", header: "Benutzer-ID", render: (p) => p.UserID }, + { key: "event", header: "Ereignistyp", render: (p) => p.EventType }, + { key: "channel", header: "Kanal", render: (p) => p.Channel }, + { key: "enabled", header: "Aktiviert", render: (p) => (p.Enabled ? "Ja" : "Nein") }, + ]; + + return ( +
+

Benachrichtigungs-Übersicht (Mandant)

+ {error &&

{error}

} + {prefs && ( + `${p.UserID}-${p.EventType}-${p.Channel}`} + caption="Von der Standardeinstellung abweichende Benachrichtigungspräferenzen aller Benutzer" + /> + )} + + ); +} diff --git a/web/notifications/app/page.tsx b/web/notifications/app/page.tsx new file mode 100644 index 0000000..d493c32 --- /dev/null +++ b/web/notifications/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function IndexPage() { + redirect("/settings"); +} diff --git a/web/notifications/app/settings/page.tsx b/web/notifications/app/settings/page.tsx new file mode 100644 index 0000000..04da330 --- /dev/null +++ b/web/notifications/app/settings/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { CheckboxField, useToast } from "@nexarch/shl"; +import { + ApiError, + KNOWN_CHANNELS, + KNOWN_EVENT_TYPES, + Preference, + fetchMyPreferences, + setPreference, +} from "../../lib/api"; + +const EVENT_LABELS: Record = { + welcome: "Willkommen", + invoice_ready: "Rechnung verfügbar", + password_reset: "Passwort-Zurücksetzung", + security_alert: "Sicherheitshinweis", +}; + +const CHANNEL_LABELS: Record = { + email: "E-Mail", + in_app: "In-App", +}; + +export default function SettingsPage() { + const { push } = useToast(); + const [prefs, setPrefs] = useState(null); + const [loadError, setLoadError] = useState(null); + const [pending, setPending] = useState(null); + + useEffect(() => { + fetchMyPreferences() + .then(setPrefs) + .catch(() => setLoadError("Einstellungen konnten nicht geladen werden. Bitte melden Sie sich an.")); + }, []); + + // Default: aktiviert, solange keine explizite Praeferenz existiert + // (Akzeptanzkriterium 1) — gleicher Opt-out-Default wie im Backend. + function isEnabled(eventType: string, channel: string): boolean { + const explicit = prefs?.find((p) => p.EventType === eventType && p.Channel === channel); + return explicit ? explicit.Enabled : true; + } + + async function onToggle(eventType: string, channel: string, nextEnabled: boolean) { + const key = `${eventType}:${channel}`; + setPending(key); + try { + // Akzeptanzkriterium 2: sofort speichern, kein Sammel-Speichern-Button — + // wirkt unmittelbar auf künftige Zustellungen (EnqueueIfAllowed prüft + // bei jedem Aufruf live, kein Cache dazwischen). + await setPreference(eventType, channel, nextEnabled); + setPrefs((current) => { + const withoutThis = (current ?? []).filter((p) => !(p.EventType === eventType && p.Channel === channel)); + return [...withoutThis, { TenantSlug: "", UserID: "", EventType: eventType, Channel: channel, Enabled: nextEnabled }]; + }); + push(nextEnabled ? "Benachrichtigung aktiviert." : "Benachrichtigung deaktiviert.", "success"); + } catch (err) { + push(err instanceof ApiError ? err.message : "Einstellung konnte nicht gespeichert werden.", "danger"); + } finally { + setPending(null); + } + } + + if (loadError) { + return ( +
+

{loadError}

+
+ ); + } + + return ( +
+

Benachrichtigungseinstellungen

+

Wählen Sie je Ereignis, über welche Kanäle Sie benachrichtigt werden möchten.

+ + {KNOWN_EVENT_TYPES.map((eventType) => ( +
+ {EVENT_LABELS[eventType] ?? eventType} + {KNOWN_CHANNELS.map((channel) => { + const key = `${eventType}:${channel}`; + return ( + onToggle(eventType, channel, e.target.checked)} + /> + ); + })} +
+ ))} +
+ ); +} diff --git a/web/notifications/lib/api.ts b/web/notifications/lib/api.ts new file mode 100644 index 0000000..ec12f97 --- /dev/null +++ b/web/notifications/lib/api.ts @@ -0,0 +1,45 @@ +const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? ""; + +export class ApiError extends Error {} + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API_BASE}${path}`, { + credentials: "include", + headers: init?.body ? { "Content-Type": "application/json" } : undefined, + ...init, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler"); + } + return data as T; +} + +export interface Preference { + TenantSlug: string; + UserID: string; + EventType: string; + Channel: string; + Enabled: boolean; +} + +// Bekannte Ereignistypen/Kanäle — das Backend erzwingt keine feste Liste +// (jedes Modul kann eigene event_type-Werte an EnqueueIfAllowed übergeben), +// diese Liste ist der aktuell bekannte Stand fürs Frontend-Formular. +export const KNOWN_EVENT_TYPES = ["welcome", "invoice_ready", "password_reset", "security_alert"] as const; +export const KNOWN_CHANNELS = ["email", "in_app"] as const; + +export function fetchMyPreferences(): Promise { + return req("/notifications/preferences"); +} + +export function setPreference(eventType: string, channel: string, enabled: boolean): Promise { + return req("/notifications/preferences", { + method: "POST", + body: JSON.stringify({ event_type: eventType, channel, enabled }), + }); +} + +export function fetchTenantOverview(): Promise { + return req("/notifications/preferences/tenant"); +} diff --git a/web/notifications/next.config.mjs b/web/notifications/next.config.mjs new file mode 100644 index 0000000..aa96725 --- /dev/null +++ b/web/notifications/next.config.mjs @@ -0,0 +1,5 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ["@nexarch/shl"], +}; +export default nextConfig; diff --git a/web/notifications/package.json b/web/notifications/package.json new file mode 100644 index 0000000..2d15ce4 --- /dev/null +++ b/web/notifications/package.json @@ -0,0 +1,22 @@ +{ + "name": "nexarch-notifications", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@nexarch/shl": "file:../shl", + "next": "14.2.35", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.14.9", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.5.3" + } +} diff --git a/web/notifications/tsconfig.json b/web/notifications/tsconfig.json new file mode 100644 index 0000000..8b1f373 --- /dev/null +++ b/web/notifications/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From 1e1a8359cb233c811a2105fcdfbf6f7a8119281a Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 23:53:39 +0200 Subject: [PATCH 6/7] CFG-04: go.sum neu erzeugen (go mod tidy nach IAM-02-Merge) --- go.sum | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 go.sum diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d1d04a2 --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From b1601c578ad3f6718cb571f7b5903cc85917f3e6 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 23:54:01 +0200 Subject: [PATCH 7/7] DEVLOG: Sessionlog-Eintrag (Auto-Hook) --- DEVLOG.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/DEVLOG.md b/DEVLOG.md index 7ab7076..ed7fff5 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -159,3 +159,69 @@ Keine Commits in dieser Session. - web/shl/vitest.setup.ts | 1 + --- +## 2026-08-28 23:45 – 23:51 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +- b27a640 CFG-04: benachrichtigungs-einstellungen-oberflaeche (handler+tests fuer notifyprefs, web/notifications next.js-frontend auf shl-01) +- 81ff8c1 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/cfg-04-benachrichtigungs-einstellungen-oberflaeche +- 22b3349 Merge branch 'feature/iam-02-login-session-jwt-grundgeruest' into feature/cfg-04-benachrichtigungs-einstellungen-oberflaeche +- 2df3f93 CFG-04: backend teil 1 — internal/notifyprefs (praeferenz-store + enqueueifallowed-filter vor dispatcher) + +### Geänderte Dateien +- internal/notifyprefs/handler.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- internal/notifyprefs/handler_test.go | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- internal/notifyprefs/prefs_test.go | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/app/layout.tsx | 30 ++++++++++++++++++++++++++++++ +- web/notifications/app/overview/page.tsx | 43 +++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/app/page.tsx | 5 +++++ +- web/notifications/app/settings/page.tsx | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/lib/api.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/next.config.mjs | 5 +++++ +- web/notifications/package.json | 22 ++++++++++++++++++++++ +- web/notifications/tsconfig.json | 21 +++++++++++++++++++++ + +--- +## 2026-08-28 23:51 – 23:51 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- internal/notifyprefs/handler.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- internal/notifyprefs/handler_test.go | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- internal/notifyprefs/prefs_test.go | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/app/layout.tsx | 30 ++++++++++++++++++++++++++++++ +- web/notifications/app/overview/page.tsx | 43 +++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/app/page.tsx | 5 +++++ +- web/notifications/app/settings/page.tsx | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/lib/api.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/next.config.mjs | 5 +++++ +- web/notifications/package.json | 22 ++++++++++++++++++++++ +- web/notifications/tsconfig.json | 21 +++++++++++++++++++++ + +--- +## 2026-08-28 23:52 – 23:52 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- internal/notifyprefs/handler.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- internal/notifyprefs/handler_test.go | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- internal/notifyprefs/prefs_test.go | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/app/layout.tsx | 30 ++++++++++++++++++++++++++++++ +- web/notifications/app/overview/page.tsx | 43 +++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/app/page.tsx | 5 +++++ +- web/notifications/app/settings/page.tsx | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/lib/api.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ +- web/notifications/next.config.mjs | 5 +++++ +- web/notifications/package.json | 22 ++++++++++++++++++++++ +- web/notifications/tsconfig.json | 21 +++++++++++++++++++++ + +---