From b27a6401163d5c2a111698f05a6b45e3dde79b72 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 23:50:54 +0200 Subject: [PATCH] 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"] +}