Benachrichtigungs-Übersicht (Mandant)
+ {error &&{error}
} + {prefs && ( +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 ( + +
+{error}
} + {prefs && ( +