Compare commits

...
Author SHA1 Message Date
sysops 0db8fa1377 RET-07: fristablauf-benachrichtigungen
- archive/internal/notifyclient: HTTP-Client fuer Core CFG-05 (gleiches
  Muster wie rbacclient/RET-08 fuer RBAC-06)
- archive/internal/retentionnotify.Run: ermittelt faellige Objekte ueber
  dieselbe Funktion wie RET-02-Job/RET-06-API-Preview, filtert je
  Klasse nach Vorlauf+Ein-Aus-Schalter, Postgres-persistente Dedupe
  (retention_notifications), Fehlschlag wird protokolliert statt
  verworfen (kein Eintrag -> Retry beim naechsten Durchlauf)
- archive/cmd/retention-notify-job: systemd-Timer-CLI, analog scrub-cli
- Abweichung vom urspruenglichen Ticket-Text dokumentiert: CFG-05 statt
  direktem CFG-02-Import (Modul-Trennung), konfigurierte zustaendige
  Rolle statt Objekt-Owner (RET-01 fuehrt keinen)
- real deployed auf 131 (timer taeglich 07:00 UTC), end-zu-ende
  bewiesen: echte notification_jobs-Zeile in Core-DB, zweiter
  Dienststart ohne Doppelversand

Pruefungen siehe archive/docs/RET-07-PRUEFPROTOKOLL.md
2026-08-30 09:13:01 +02:00
sysops 36d1cf5e7f RET-06: aufbewahrungsfristen-konfigurationsoberfläche
- web/retention-admin: eigenstaendige Next.js/React/TypeScript-App
  (kein Backend-Annex), auf web/shl (SHL-01) aufbauend
- classes: Aufbewahrungsklasse anlegen/aendern/deaktivieren (AC1)
- preview: Vorschauliste 30 Tage, nutzt denselben Endpunkt wie der
  periodische Job (AC2)
- lib/api.ts: ForbiddenError bei 403, getrennt behandelt, keine
  eigene Autorisierungslogik im Frontend (RBAC-Entscheidung liegt
  bei RET-08/RBAC-06)
- expliziter 403-Nachweis (nicht nur der 200-Fall): lib/api.test.ts,
  ClassesPage/PreviewPage zeigen 'Zugriff verweigert' statt leerer Seite
- lokal getestet: tsc clean, next build clean, vitest 3/3 gruen

Pruefungen siehe archive/docs/RET-06-PRUEFPROTOKOLL.md
2026-08-30 08:49:24 +02:00
sysops 1dc70be933 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ret-06-aufbewahrungsfristen-konfigurationsoberflaeche 2026-08-30 08:45:23 +02:00
sysops bfa5c61db5 SHL-01: fix — Test-Cleanup zwischen Dialog-Tests (afterEach(cleanup), sonst stapeln sich gerenderte DOM-Bäume) 2026-08-28 22:01:56 +02:00
sysops 3c226dab12 SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl) 2026-08-28 21:55:34 +02:00
sysops 584cefa388 DEVLOG: Sessionlog-Eintrag (Auto-Hook) 2026-08-28 21:45:55 +02:00
sysops 00665592f6 SHL-01: ui-shell-design-system-zentral (tokens, theming, i18n-rahmen, basis-komponenten) 2026-08-28 21:41:56 +02:00
38 changed files with 2027 additions and 0 deletions
+2
View File
@@ -1,2 +1,4 @@
*.log
.env
web/*/node_modules/
web/*/.next/
+38
View File
@@ -44,3 +44,41 @@ Keine Commits in dieser Session.
Keine Änderungen ermittelbar.
---
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/index.ts | 25 +++++++++++++++++++++++++
- web/shl/package.json | 23 +++++++++++++++++++++++
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tsconfig.json | 18 ++++++++++++++++++
---
## 2026-08-28 21:51 21:57 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
### Geänderte Dateien
- web/shl/package.json | 2 ++
- web/shl/vitest.config.ts | 8 ++++++++
- web/shl/vitest.setup.ts | 1 +
---
+63
View File
@@ -0,0 +1,63 @@
// retention-notify-job ist der Aufrufpunkt fuer RET-07 (systemd-Timer,
// konfigurierbare Kadenz, analog scrub-cli/BAK-08): ein Durchlauf pro
// Aufruf, ermittelt bald ablaufende Objekte (RET-02) und loest fuer noch
// nicht benachrichtigte je ein Ereignis ueber Core CFG-05 aus. Versendet
// selbst nichts, protokolliert Fehlschlaege explizit statt sie zu
// verwerfen (Pflichtpruefung 3).
package main
import (
"context"
"log"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/notifyclient"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionnotify"
)
func requireEnv(name string) string {
v := os.Getenv(name)
if v == "" {
log.Fatalf("%s muss gesetzt sein", name)
}
return v
}
func main() {
dsn := requireEnv("NEXARCH_RETENTION_TENANT_DSN")
notifyBaseURL := requireEnv("NEXARCH_RETENTION_NOTIFY_BASE_URL")
notifyServiceToken := requireEnv("NEXARCH_RETENTION_NOTIFY_SERVICE_TOKEN")
recipient := retentionnotify.Recipient{
TenantSlug: requireEnv("NEXARCH_RETENTION_NOTIFY_TENANT_SLUG"),
UserID: requireEnv("NEXARCH_RETENTION_NOTIFY_ADMIN_USER_ID"),
Email: requireEnv("NEXARCH_RETENTION_NOTIFY_ADMIN_EMAIL"),
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("datenbankverbindung: %v", err)
}
defer pool.Close()
client := notifyclient.New(notifyBaseURL, notifyServiceToken)
results, err := retentionnotify.Run(ctx, pool, client, time.Now().UTC(), recipient)
if err != nil {
log.Fatalf("retention-notify-job: durchlauf fehlgeschlagen: %v", err)
}
failed := 0
for _, r := range results {
if r.Err != nil {
failed++
log.Printf("retention-notify-job: FEHLER bei objekt %s (klasse %s): %v", r.RetentionObjectID, r.RetentionClass, r.Err)
continue
}
log.Printf("retention-notify-job: objekt %s (klasse %s) benachrichtigt, job_id=%s skipped=%t", r.RetentionObjectID, r.RetentionClass, r.JobID, r.Skipped)
}
log.Printf("retention-notify-job: durchlauf abgeschlossen, %d ergebnis(se), %d fehlgeschlagen", len(results), failed)
}
+58
View File
@@ -0,0 +1,58 @@
# RET-06 Prüfprotokoll: Aufbewahrungsfristen-Konfigurationsoberfläche
Voraussetzung RET-02, RET-06-API, RET-08 (RBAC-Migration) alle
bereits Fertig. RET-08 hat das Header-Provisorium in RET-06-API bereits
durch einen echten Aufruf von Core RBAC-06 ersetzt dieses Ticket
testet daher von Anfang an gegen echte RBAC-Autorisierung, nicht gegen
ein Provisorium (siehe RET-08-Prüfprotokoll).
## Umsetzung
- `web/retention-admin` eigenständige Next.js/React/TypeScript-App
(kein `/admin`-Annex im Go-Backend), analog zu `web/notifications`
(CFG-04) und `web/lic-admin` (LIC-04), aufbauend auf `web/shl`
(SHL-01, gemeinsames Design-System).
- `lib/api.ts` dünner Client für RET-06-API. Reicht die vom Nutzer
beanspruchte Rolle über `X-User-Role` durch (RET-08 prüft sie gegen
RBAC-06), trifft selbst keine Autorisierungsentscheidung. Wirft
`ForbiddenError` bei HTTP 403, getrennt von generischen `ApiError`s.
- `app/classes/page.tsx` Akzeptanzkriterium 1: Aufbewahrungsklasse
anlegen/ändern (ein Formular, Backend-UPSERT) und deaktivieren.
Akzeptanzkriterium 3: `ForbiddenError` führt zu einer expliziten
"Zugriff verweigert"-Anzeige, nicht zu einer leeren Tabelle.
- `app/preview/page.tsx` Akzeptanzkriterium 2: Vorschauliste über
`/retention-classes/preview`, dieselbe Funktion wie der periodische
Job (RET-02/RET-06-API), keine eigene Berechnung im Frontend.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Änderung einer Frist wirkt nur auf künftige Berechnungen, nicht rückwirkend | **bestanden (Backend-seitig bereits durch RET-02/RET-06-API bewiesen)** Frontend ruft ausschließlich den bestehenden UPSERT-Endpunkt auf, keine eigene Berechnungslogik im Frontend, die dies unterlaufen könnte |
| 2 | Nicht berechtigte Rolle erhält keinen Zugriff auf die Konfiguration | **bestanden expliziter 403-Nachweis, nicht nur der 200-Fall:** `lib/api.test.ts`, `describe("api client - 403-Nachweis (keine berechtigte Rolle)")` zwei Tests (`fetchClassRules`, `configureClassRule`) mit real gemockter 403-HTTP-Antwort, beide werfen `ForbiddenError`; `ClassesPage`/`PreviewPage` fangen `ForbiddenError` ab und zeigen `role="alert"` "Zugriff verweigert" statt einer leeren/stillen Seite. Der 403-Vertrag selbst (RET-06-API antwortet real mit 403 bei fehlender RBAC-06-Berechtigung) ist bereits in RET-08 end-zu-ende gegen den laufenden Dienst auf 131 bewiesen (`curl` ohne Policy-Rule → 403) dieses Ticket prüft, dass das Frontend diesen real existierenden Vertrag korrekt behandelt, nicht das Backend erneut |
| 3 | Vorschauliste stimmt mit dem Ergebnis des periodischen Jobs überein | **bestanden (Backend-seitig bereits durch RET-06-API bewiesen)** `PreviewPage` ruft exakt denselben `/retention-classes/preview`-Endpunkt auf, der intern `retentionengine.ListExpiringObjects` verwendet (identische Funktion wie der periodische Job), keine zweite Implementierung im Frontend |
## Build/Test-Ergebnis (lokal, node v22.16.0/npm 10.9.2 bereits installiert)
```
npx tsc --noEmit -> clean (eigener Code; siehe Hinweis)
npx next build -> Compiled successfully, 3 Routen (/, /classes, /preview)
npx vitest run -> 3/3 Tests bestanden
```
**Hinweis:** `web/shl` (SHL-01, bereits Fertig) hatte kein eigenes
`node_modules` im Checkout ohne `npm install` dort lieferte `tsc`
kaskadierende "Cannot find module 'react'"-Fehler in shl-eigenen
Dateien, nicht durch RET-06 verursacht. Für den lokalen Testlauf wurde
`npm install` in `web/shl` ausgeführt (kein Code-Umbau, nur Abhängigkeiten
installiert); `web/shl/package-lock.json` wurde dadurch neu erzeugt,
aber bewusst NICHT mitcommittet (gehört zu SHL-01, nicht zu diesem
Ticket kein Umbau angrenzender Bereiche).
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien real erfüllt, insbesondere
der explizit geforderte negative 403-Fall (nicht nur der Erfolgsfall)
sowie die eigenständige Next.js-App-Struktur (kein Backend-Annex).
Frontend testet von Anfang an gegen die durch RET-08 real hergestellte
RBAC-06-Autorisierung, nicht gegen ein Provisorium.
+91
View File
@@ -0,0 +1,91 @@
# RET-07 Prüfprotokoll: Fristablauf-Benachrichtigungen
Voraussetzung RET-02, CFG-05 beide bereits Fertig.
## Abweichung vom ursprünglichen Ticket-Text (bewusst, dokumentiert)
Der ursprüngliche Ticket-Text sprach von einer direkten "Kopplung an
Core CFG-02 (Postgres-Job-Queue, E-Mail-Versand)". Zum Zeitpunkt der
Umsetzung war CFG-05 (HTTP-Wrapper für CFG-02/CFG-04) bereits Fertig
und der korrekte, tatsächlich nutzbare Weg — CFG-02s
`internal/notify.Dispatcher` ist reiner Go-Code im Core-Modul, Archive
kann ihn als physisch getrenntes Modul nicht direkt importieren (siehe
CFG-05-Prüfprotokoll). RET-07 ruft daher `POST /notify/enqueue`
(CFG-05) auf, nicht `internal/notify` direkt. Board-Text (`dependsOn`,
Beschreibung) wurde vor Umsetzung entsprechend aktualisiert.
**Empfänger-Klarstellung:** Der ursprüngliche Ticket-Text sprach von
"verantwortlichen Personen". `retention_objects` (RET-01) führt bewusst
KEINE Objekt-Owner-Beziehung. Die Benachrichtigung geht daher an eine
je Tenant konfigurierte zuständige Rolle (Tenant-Admin,
`NEXARCH_RETENTION_NOTIFY_ADMIN_EMAIL`), nicht an einen individuellen
Objekt-Owner. Board-Text wurde vor Umsetzung entsprechend präzisiert
(Akzeptanzkriterium 1).
## Umsetzung
- `archive/migrations/0006_retention_notify.up/down.sql`
`retention_class_rules.notify_lead_days`/`notify_enabled`
(Akzeptanzkriterium 3) und `retention_notifications`
(Postgres-persistente Dedupe-Tabelle, Akzeptanzkriterium 2 übersteht
Job-Neustarts).
- `archive/internal/notifyclient` schlanker HTTP-Client für CFG-05
(gleiches Muster wie `rbacclient`/RET-08 für RBAC-06).
- `archive/internal/retentionnotify.Run` EIN Durchlauf: lädt
Klassenregeln, ermittelt fällige Objekte über
`retentionengine.ListExpiringObjects` (DIESELBE Funktion wie
RET-02-Job/RET-06-API-Preview, kein zweiter Ermittlungspfad), filtert
je Klasse nach deren eigenem Vorlauf und Ein/Aus-Schalter,
überspringt bereits benachrichtigte Objekte, löst pro verbleibendem
Objekt EIN CFG-05-Ereignis aus. Bei Zustellfehler: KEIN Eintrag in
`retention_notifications` (Retry beim nächsten Durchlauf), Fehler wird
im `Result` zurückgegeben, nicht verworfen.
- `archive/cmd/retention-notify-job` one-shot CLI (systemd-Timer,
analog `scrub-cli`/BAK-08), protokolliert jedes Ergebnis inkl.
Fehler über `log.Printf`.
- `deploy/systemd/nexarch-archive-retention-notify.{service,timer}.tmpl`.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Testfrist mit kurzem Vorlauf löst genau eine Benachrichtigung aus | **bestanden** `TestRun_ShortLeadTimeTriggersExactlyOneNotification`: fake-CFG-05-Server zählt Aufrufe, genau 1; real auf 131: Testobjekt mit 1-Tage-Frist/1-Tage-Vorlauf, Job manuell gestartet, `journalctl` zeigt genau eine Benachrichtigung mit echter `job_id`, echte Zeile in Core-`notification_jobs` (Status `pending`) bestätigt |
| 2 | Deaktivierte Benachrichtigung verschickt nachweislich nichts | **bestanden** `TestRun_DisabledNotificationSendsNothing`: `notify_enabled=false`, 0 Ergebnisse, 0 CFG-05-Aufrufe (Zähler geprüft, nicht nur "kein Fehler") |
| 3 | Fehlgeschlagener Versand wird protokolliert und nicht stillschweigend verworfen | **bestanden** `TestRun_FailedDeliveryIsReportedNotSwallowed`: fake-CFG-05-Server liefert 500, `Result.Err` gesetzt, KEIN Eintrag in `retention_notifications` (Objekt bleibt für Retry offen); `cmd/retention-notify-job` protokolliert jeden Fehler explizit über `log.Printf` |
**Akzeptanzkriterium 2 zusätzlich real auf 131 bewiesen:** Job zweimal
hintereinander gestartet (simulierter Neustart, kein In-Memory-Zustand
zwischen den systemd-Aufrufen) — zweiter Lauf liefert 0 Ergebnisse,
`journalctl` bestätigt, kein zweiter CFG-05-Aufruf.
## Echte Verdrahtung auf 192.168.1.131
- Migration `0006_retention_notify` real auf `dms_tenant_test`
angewendet.
- `retention-notify-job` gebaut nach `/opt/nexarch-archive/bin/`,
`/etc/nexarch/archive-retention-notify.env` (0600).
- `nexarch-archive-retention-notify.timer` installiert/aktiviert
(täglich 07:00 UTC, `Persistent=true`), zugehöriger
`nexarch-archive-retention-notify.service` (`Type=oneshot`).
- Realer End-zu-Ende-Nachweis: Testklasse mit 1-Tage-Vorlauf, fälliges
Testobjekt angelegt, Dienst manuell gestartet → echte Benachrichtigung
über CFG-05, echte `notification_jobs`-Zeile in der Core-Registry-DB,
echte `retention_notifications`-Zeile in der Tenant-DB, zweiter
Dienststart → 0 Ergebnisse. Alle Testdaten anschließend entfernt.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -> alle Archive-Pakete bestanden (inkl. retentionnotify, objectbackup, restoretest)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt, inklusive echtem systemd-Timer-Deploy
und End-zu-Ende-Nachweis über zwei physisch getrennte Module (Archive
→ CFG-05 → Core-Queue) sowie eines simulierten Job-Neustarts ohne
Doppelversand.
@@ -0,0 +1,84 @@
// Package notifyclient ist ein schlanker HTTP-Client für Core CFG-05
// (internal/notifyapi, POST /notify/enqueue). Archive ist ein physisch
// getrenntes Go-Modul und kann Core CFG-02/CFG-04 daher nicht direkt
// importieren — nur über die HTTP-Schnittstelle von CFG-05 aufrufen.
// Gleiches Muster wie archive/internal/rbacclient (RET-08) für RBAC-06.
package notifyclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
type Client struct {
BaseURL string
ServiceToken string
HTTPClient *http.Client
}
func New(baseURL, serviceToken string) *Client {
return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: http.DefaultClient}
}
type enqueueRequest struct {
TenantSlug string `json:"tenant_slug"`
UserID string `json:"user_id"`
EventType string `json:"event_type"`
Channel string `json:"channel"`
Recipient string `json:"recipient"`
Payload map[string]any `json:"payload"`
}
// EnqueueResult spiegelt CFG-05s enqueueResponse.
type EnqueueResult struct {
JobID string
Skipped bool
}
// Enqueue löst ein Benachrichtigungs-Ereignis über CFG-05 aus. Jeder
// Fehler (Transport, Timeout, unerwarteter Status) wird als Fehler
// zurückgegeben — der Aufrufer entscheidet, ob/wie er das protokolliert
// (Pflichtprüfung RET-07: fehlgeschlagener Versand wird protokolliert,
// nicht stillschweigend verworfen).
func (c *Client) Enqueue(ctx context.Context, tenantSlug, userID, eventType, channel, recipient string, payload map[string]any) (EnqueueResult, error) {
body, err := json.Marshal(enqueueRequest{
TenantSlug: tenantSlug, UserID: userID, EventType: eventType, Channel: channel, Recipient: recipient, Payload: payload,
})
if err != nil {
return EnqueueResult{}, fmt.Errorf("notifyclient: request kodieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/notify/enqueue", bytes.NewReader(body))
if err != nil {
return EnqueueResult{}, fmt.Errorf("notifyclient: request bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Service-Token", c.ServiceToken)
resp, err := c.httpClient().Do(req)
if err != nil {
return EnqueueResult{}, fmt.Errorf("notifyclient: aufruf fehlgeschlagen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return EnqueueResult{}, fmt.Errorf("notifyclient: unerwarteter status %d", resp.StatusCode)
}
var out struct {
JobID string `json:"job_id"`
Skipped bool `json:"skipped"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return EnqueueResult{}, fmt.Errorf("notifyclient: antwort dekodieren: %w", err)
}
return EnqueueResult{JobID: out.JobID, Skipped: out.Skipped}, nil
}
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return http.DefaultClient
}
@@ -0,0 +1,158 @@
// Package retentionnotify implementiert RET-07: Benachrichtigung einer
// konfigurierten zuständigen Rolle (Tenant-Admin — RET-01 führt bewusst
// keine Objekt-Owner-Beziehung) vor Ablauf einer Aufbewahrungsfrist,
// konfigurierbarer Vorlauf je Aufbewahrungsklasse. Erzeugt NUR das
// Ereignis über Core CFG-05 (archive/internal/notifyclient) — versendet
// selbst keine E-Mail.
package retentionnotify
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/notifyclient"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
)
// EventType ist der an CFG-05 übergebene Ereignistyp — muss mit dem im
// Frontend/Core bekannten Namen übereinstimmen (siehe CFG-05-Tests).
const EventType = "retention_due_soon"
// Recipient benennt Tenant-Slug, User-ID und E-Mail-Adresse der
// konfigurierten zuständigen Rolle (Tenant-Admin), an die alle
// Fristablauf-Benachrichtigungen dieses Tenants gehen.
type Recipient struct {
TenantSlug string
UserID string
Email string
}
// Result ist das Ergebnis EINES benachrichtigten (oder fehlgeschlagenen)
// Objekts — der Aufrufer (cmd/retention-notify-job) protokolliert Err
// explizit, kein stillschweigendes Verwerfen (Pflichtprüfung 3).
type Result struct {
RetentionObjectID string
RetentionClass string
JobID string
Skipped bool
Err error
}
type classRuleLead struct {
leadDays int
enabled bool
}
// Run führt EINEN Durchlauf des Benachrichtigungs-Jobs aus: ermittelt je
// aktiver, benachrichtigungs-aktivierter Aufbewahrungsklasse die Objekte,
// deren Stichtag innerhalb des konfigurierten Vorlaufs liegt, überspringt
// bereits benachrichtigte Objekte (Akzeptanzkriterium 2, Postgres-
// persistent — übersteht einen Job-Neustart) und löst für den Rest je ein
// Ereignis über CFG-05 aus.
func Run(ctx context.Context, pool *pgxpool.Pool, client *notifyclient.Client, now time.Time, recipient Recipient) ([]Result, error) {
// notify_lead_days/notify_enabled sind nicht Teil von
// retentionengine.ClassRule (RET-02/RET-06 kennen sie nicht) — direkt
// gelesen, um retentionengine nicht um RET-07-eigene Felder zu
// erweitern (kein Umbau angrenzender Bereiche).
leadByClass := make(map[string]classRuleLead)
maxLeadDays := 0
leadRows, err := pool.Query(ctx, `SELECT retention_class, notify_lead_days, notify_enabled FROM retention_class_rules WHERE active`)
if err != nil {
return nil, fmt.Errorf("retentionnotify: benachrichtigungs-konfiguration laden: %w", err)
}
for leadRows.Next() {
var class string
var lead classRuleLead
if err := leadRows.Scan(&class, &lead.leadDays, &lead.enabled); err != nil {
leadRows.Close()
return nil, fmt.Errorf("retentionnotify: konfigurationszeile lesen: %w", err)
}
leadByClass[class] = lead
if lead.leadDays > maxLeadDays {
maxLeadDays = lead.leadDays
}
}
if err := leadRows.Err(); err != nil {
return nil, fmt.Errorf("retentionnotify: benachrichtigungs-konfiguration lesen: %w", err)
}
leadRows.Close()
if maxLeadDays == 0 {
return nil, nil
}
// Nutzt DIESELBE Funktion wie der RET-02-Job/RET-06-API-Preview
// (kein zweiter Ermittlungspfad) — asOf auf den größten konfigurierten
// Vorlauf gesetzt, je Klasse wird unten mit deren EIGENEM Vorlauf
// gefiltert.
candidates, err := retentionengine.ListExpiringObjects(ctx, pool, now.AddDate(0, 0, maxLeadDays))
if err != nil {
return nil, fmt.Errorf("retentionnotify: ablaufende objekte ermitteln: %w", err)
}
alreadyNotified, err := loadAlreadyNotified(ctx, pool)
if err != nil {
return nil, err
}
var results []Result
for _, obj := range candidates {
lead, known := leadByClass[obj.RetentionClass]
if !known || !lead.enabled {
continue
}
if !obj.DueDate.Before(now.AddDate(0, 0, lead.leadDays+1)) {
// Ausserhalb des klassen-eigenen Vorlaufs (nur mit dem
// globalen maxLeadDays vorselektiert).
continue
}
if alreadyNotified[obj.RetentionObjectID] {
continue
}
res := Result{RetentionObjectID: obj.RetentionObjectID, RetentionClass: obj.RetentionClass}
enq, err := client.Enqueue(ctx, recipient.TenantSlug, recipient.UserID, EventType, "email", recipient.Email, map[string]any{
"object_type": obj.ObjectType,
"object_reference": obj.ObjectReference,
"retention_class": obj.RetentionClass,
"due_date": obj.DueDate.Format(time.RFC3339),
})
if err != nil {
res.Err = err
results = append(results, res)
// Kein INSERT in retention_notifications bei Fehler — das
// Objekt wird beim naechsten Durchlauf erneut versucht,
// statt stillschweigend als erledigt zu gelten.
continue
}
res.JobID = enq.JobID
res.Skipped = enq.Skipped
if _, err := pool.Exec(ctx, `INSERT INTO retention_notifications (retention_object_id) VALUES ($1)`, obj.RetentionObjectID); err != nil {
res.Err = fmt.Errorf("retentionnotify: benachrichtigung als versendet markieren: %w", err)
}
results = append(results, res)
}
return results, nil
}
func loadAlreadyNotified(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) {
rows, err := pool.Query(ctx, `SELECT retention_object_id FROM retention_notifications`)
if err != nil {
return nil, fmt.Errorf("retentionnotify: bereits benachrichtigte objekte laden: %w", err)
}
defer rows.Close()
out := make(map[string]bool)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("retentionnotify: zeile lesen: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
@@ -0,0 +1,223 @@
package retentionnotify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/notifyclient"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionengine"
)
func requireTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS retention_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
object_reference TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (object_type, object_reference)
);
CREATE TABLE IF NOT EXISTS retention_class_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS retention_class_rules (
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL,
active BOOLEAN NOT NULL DEFAULT true
);
ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS notify_lead_days INT NOT NULL DEFAULT 7;
ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS notify_enabled BOOLEAN NOT NULL DEFAULT true;
CREATE TABLE IF NOT EXISTS retention_notifications (
retention_object_id UUID PRIMARY KEY REFERENCES retention_objects(id) ON DELETE CASCADE,
notified_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_notifications, retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`)
})
return pool
}
// fakeCFG05Server zaehlt Aufrufe und liefert eine feste Antwort - simuliert
// den echten CFG-05-Endpunkt, ohne das Core-Modul einzubinden (Archive kann
// es nicht direkt importieren, siehe notifyclient).
func fakeCFG05Server(t *testing.T, fail bool) (*notifyclient.Client, *int32) {
t.Helper()
var calls int32
mux := http.NewServeMux()
mux.HandleFunc("POST /notify/enqueue", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
if fail {
http.Error(w, "simulierter zustellfehler", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"job_id": "fake-job-id", "skipped": false})
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return notifyclient.New(server.URL, "test-token"), &calls
}
// insertDueObject legt ein Objekt an, dessen Stichtag (assigned_at +
// Klassen-Frist) genau dueIn ab jetzt liegt — die Subtraktion der Frist
// erfolgt IN Postgres (dieselbe INTERVAL-Arithmetik wie ComputeDueDate),
// keine eigene Kalenderrechnung in Go.
func insertDueObject(t *testing.T, ctx context.Context, pool *pgxpool.Pool, objectRef, class string, dueIn time.Duration) string {
t.Helper()
var objID string
if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', $1) RETURNING id`, objectRef).Scan(&objID); err != nil {
t.Fatalf("objekt anlegen: %v", err)
}
desiredDue := time.Now().UTC().Add(dueIn)
if _, err := pool.Exec(ctx, `
INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at)
SELECT $1, $2, $3::timestamptz - r.duration FROM retention_class_rules r WHERE r.retention_class = $2
`, objID, class, desiredDue); err != nil {
t.Fatalf("zuordnung anlegen: %v", err)
}
return objID
}
var testRecipient = Recipient{TenantSlug: "acme", UserID: "tenant-admin", Email: "admin@acme.example"}
// TestRun_ShortLeadTimeTriggersExactlyOneNotification ist die geforderte
// Pflichtpruefung 1.
func TestRun_ShortLeadTimeTriggersExactlyOneNotification(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-kurz", "1 day"); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE retention_class_rules SET notify_lead_days = 1, notify_enabled = true WHERE retention_class = 'klasse-kurz'`); err != nil {
t.Fatal(err)
}
insertDueObject(t, ctx, pool, "kurz-doc", "klasse-kurz", 12*time.Hour)
client, calls := fakeCFG05Server(t, false)
results, err := Run(ctx, pool, client, time.Now().UTC(), testRecipient)
if err != nil {
t.Fatalf("run: %v", err)
}
if len(results) != 1 || results[0].Err != nil {
t.Fatalf("erwartet genau ein ergebnis ohne fehler, habe: %+v", results)
}
if atomic.LoadInt32(calls) != 1 {
t.Fatalf("erwartet genau einen cfg-05-aufruf, habe %d", *calls)
}
}
// TestRun_DisabledNotificationSendsNothing ist die geforderte
// Pflichtpruefung 2.
func TestRun_DisabledNotificationSendsNothing(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-deaktiviert", "1 day"); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE retention_class_rules SET notify_lead_days = 1, notify_enabled = false WHERE retention_class = 'klasse-deaktiviert'`); err != nil {
t.Fatal(err)
}
insertDueObject(t, ctx, pool, "deaktiviert-doc", "klasse-deaktiviert", 12*time.Hour)
client, calls := fakeCFG05Server(t, false)
results, err := Run(ctx, pool, client, time.Now().UTC(), testRecipient)
if err != nil {
t.Fatalf("run: %v", err)
}
if len(results) != 0 {
t.Fatalf("erwartet keine benachrichtigung bei deaktivierter klasse, habe: %+v", results)
}
if atomic.LoadInt32(calls) != 0 {
t.Fatalf("erwartet keinen cfg-05-aufruf, habe %d", *calls)
}
}
// TestRun_FailedDeliveryIsReportedNotSwallowed ist die geforderte
// Pflichtpruefung 3.
func TestRun_FailedDeliveryIsReportedNotSwallowed(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-fehler", "1 day"); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE retention_class_rules SET notify_lead_days = 1, notify_enabled = true WHERE retention_class = 'klasse-fehler'`); err != nil {
t.Fatal(err)
}
objID := insertDueObject(t, ctx, pool, "fehler-doc", "klasse-fehler", 12*time.Hour)
client, _ := fakeCFG05Server(t, true)
results, err := Run(ctx, pool, client, time.Now().UTC(), testRecipient)
if err != nil {
t.Fatalf("run: %v", err)
}
if len(results) != 1 || results[0].Err == nil {
t.Fatalf("erwartet ein ergebnis MIT protokolliertem fehler, habe: %+v", results)
}
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM retention_notifications WHERE retention_object_id = $1`, objID).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("fehlgeschlagener versand darf NICHT als benachrichtigt markiert werden (sonst kein retry)")
}
}
// TestRun_PreventsDoubleNotificationAcrossRuns ist Akzeptanzkriterium 2 -
// Mehrfachversand wird verhindert, auch nach einem simulierten Neustart
// (zweiter Run() mit frischem Aufruf, kein In-Memory-Zustand zwischen den
// Durchlaeufen).
func TestRun_PreventsDoubleNotificationAcrossRuns(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
if err := retentionengine.ConfigureClassRule(ctx, pool, "klasse-doppelt", "1 day"); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE retention_class_rules SET notify_lead_days = 1, notify_enabled = true WHERE retention_class = 'klasse-doppelt'`); err != nil {
t.Fatal(err)
}
insertDueObject(t, ctx, pool, "doppelt-doc", "klasse-doppelt", 12*time.Hour)
client, calls := fakeCFG05Server(t, false)
now := time.Now().UTC()
if _, err := Run(ctx, pool, client, now, testRecipient); err != nil {
t.Fatalf("erster run: %v", err)
}
if _, err := Run(ctx, pool, client, now, testRecipient); err != nil {
t.Fatalf("zweiter run (simulierter neustart): %v", err)
}
if atomic.LoadInt32(calls) != 1 {
t.Fatalf("erwartet genau einen cfg-05-aufruf ueber beide durchlaeufe, habe %d", *calls)
}
}
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS retention_notifications;
ALTER TABLE retention_class_rules DROP COLUMN IF EXISTS notify_lead_days;
ALTER TABLE retention_class_rules DROP COLUMN IF EXISTS notify_enabled;
@@ -0,0 +1,11 @@
-- RET-07: Fristablauf-Benachrichtigungen. Zwei neue Spalten je
-- Aufbewahrungsklasse (Vorlauf, Ein/Aus-Schalter — Akzeptanzkriterium 3)
-- und eine Dedupe-Tabelle (Akzeptanzkriterium 2: kein Mehrfachversand,
-- auch nicht nach Job-Neustart, da Postgres-persistent statt In-Memory).
ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS notify_lead_days INT NOT NULL DEFAULT 7;
ALTER TABLE retention_class_rules ADD COLUMN IF NOT EXISTS notify_enabled BOOLEAN NOT NULL DEFAULT true;
CREATE TABLE IF NOT EXISTS retention_notifications (
retention_object_id UUID PRIMARY KEY REFERENCES retention_objects(id) ON DELETE CASCADE,
notified_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,10 @@
[Unit]
Description=NEXARCH Archive - Fristablauf-Benachrichtigungen (RET-07)
After=network.target postgresql.service
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-retention-notify.env
ExecStart=__INSTALL_DIR__/bin/retention-notify-job
StandardOutput=journal
@@ -0,0 +1,9 @@
[Unit]
Description=Zeitplan fuer NEXARCH Archive Fristablauf-Benachrichtigungen (RET-07)
[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true
[Install]
WantedBy=timers.target
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
import { Table, TextField, useToast, type TableColumn } from "@nexarch/shl";
import {
ApiError,
ClassRule,
ForbiddenError,
configureClassRule,
deactivateClassRule,
fetchClassRules,
} from "../../lib/api";
export default function ClassesPage() {
const { push } = useToast();
const [rules, setRules] = useState<ClassRule[] | null>(null);
const [forbidden, setForbidden] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [newClass, setNewClass] = useState("");
const [newDuration, setNewDuration] = useState("");
const [saving, setSaving] = useState(false);
function load() {
fetchClassRules()
.then((r) => {
setRules(r);
setForbidden(false);
})
.catch((err) => {
if (err instanceof ForbiddenError) {
setForbidden(true);
} else {
setLoadError(err instanceof Error ? err.message : "Aufbewahrungsklassen konnten nicht geladen werden.");
}
});
}
useEffect(load, []);
// Akzeptanzkriterium 3: nicht berechtigte Rolle sieht explizit den
// Zugriff-verweigert-Zustand, keine leere Tabelle ohne Erklaerung.
if (forbidden) {
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">Zugriff verweigert: Ihre Rolle ist nicht zur Fristenverwaltung berechtigt.</p>
</main>
);
}
if (loadError) {
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
</main>
);
}
async function onCreateOrChange(e: FormEvent) {
e.preventDefault();
setSaving(true);
try {
// Akzeptanzkriterium 1: gleicher Endpunkt legt an ODER aendert
// (UPSERT im Backend, retentionengine.ConfigureClassRule).
await configureClassRule(newClass, newDuration);
push(`Aufbewahrungsklasse "${newClass}" gespeichert.`, "success");
setNewClass("");
setNewDuration("");
load();
} catch (err) {
if (err instanceof ForbiddenError) {
setForbidden(true);
} else {
push(err instanceof ApiError ? err.message : "Speichern fehlgeschlagen.", "danger");
}
} finally {
setSaving(false);
}
}
async function onDeactivate(retentionClass: string) {
try {
await deactivateClassRule(retentionClass);
push(`Aufbewahrungsklasse "${retentionClass}" deaktiviert.`, "success");
load();
} catch (err) {
if (err instanceof ForbiddenError) {
setForbidden(true);
} else {
push(err instanceof ApiError ? err.message : "Deaktivieren fehlgeschlagen.", "danger");
}
}
}
const columns: TableColumn<ClassRule>[] = [
{ key: "class", header: "Aufbewahrungsklasse", render: (r) => r.RetentionClass },
{ key: "duration", header: "Frist", render: (r) => r.Duration },
{ key: "active", header: "Status", render: (r) => (r.Active ? "aktiv" : "deaktiviert") },
{
key: "actions",
header: "Aktion",
render: (r) =>
r.Active ? (
<button type="button" onClick={() => onDeactivate(r.RetentionClass)}>
Deaktivieren
</button>
) : null,
},
];
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<h1>Aufbewahrungsfristen-Konfiguration</h1>
<form onSubmit={onCreateOrChange} style={{ marginBottom: 24 }}>
<TextField
label="Aufbewahrungsklasse"
value={newClass}
onChange={(e: ChangeEvent<HTMLInputElement>) => setNewClass(e.target.value)}
required
/>
<TextField
label="Frist (z. B. 5 years)"
value={newDuration}
onChange={(e: ChangeEvent<HTMLInputElement>) => setNewDuration(e.target.value)}
required
/>
<button type="submit" disabled={saving}>
Speichern
</button>
</form>
{rules && (
<Table columns={columns} rows={rules} rowKey={(r) => r.RetentionClass} caption="Konfigurierte Aufbewahrungsklassen" />
)}
</main>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Aufbewahrungsfristen",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<body
style={{
fontFamily: typography.fontFamily,
margin: 0,
background: "var(--shl-color-background, #ffffff)",
color: "var(--shl-color-text-primary, #14181f)",
}}
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function IndexPage() {
redirect("/classes");
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import { Table, type TableColumn } from "@nexarch/shl";
import { ExpiringObject, ForbiddenError, fetchExpiringPreview } from "../../lib/api";
// Akzeptanzkriterium 2: Vorschauliste bald ablaufender Objekte (naechste 30
// Tage) - ruft dieselbe RET-06-API /retention-classes/preview auf, die
// intern dieselbe Funktion wie der periodische Job nutzt (RET-02), keine
// eigene Berechnung im Frontend.
export default function PreviewPage() {
const [objects, setObjects] = useState<ExpiringObject[] | null>(null);
const [forbidden, setForbidden] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
fetchExpiringPreview()
.then(setObjects)
.catch((err) => {
if (err instanceof ForbiddenError) {
setForbidden(true);
} else {
setLoadError(err instanceof Error ? err.message : "Vorschauliste konnte nicht geladen werden.");
}
});
}, []);
if (forbidden) {
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">Zugriff verweigert: Ihre Rolle ist nicht zur Fristenverwaltung berechtigt.</p>
</main>
);
}
if (loadError) {
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
</main>
);
}
const columns: TableColumn<ExpiringObject>[] = [
{ key: "type", header: "Objekttyp", render: (o) => o.ObjectType },
{ key: "ref", header: "Referenz", render: (o) => o.ObjectReference },
{ key: "class", header: "Aufbewahrungsklasse", render: (o) => o.RetentionClass },
{ key: "due", header: "Fällig am", render: (o) => o.DueDate },
];
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<h1>In den nächsten 30 Tagen ablaufende Objekte</h1>
{objects && (
<Table columns={columns} rows={objects} rowKey={(o) => o.RetentionObjectID} caption="Bald ablaufende Objekte" />
)}
</main>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ForbiddenError, fetchClassRules, configureClassRule } from "./api";
const ORIGINAL_ENV = process.env;
beforeEach(() => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_RETENTION_API_URL: "http://backend.test" };
});
afterEach(() => {
process.env = ORIGINAL_ENV;
vi.restoreAllMocks();
});
// Akzeptanzkriterium 3 ("nur berechtigten Rollen zugaenglich"): der
// Negativfall MUSS explizit gepruft werden, nicht nur der Erfolgsfall -
// Frontend wirft ForbiddenError, wenn RET-06-API (real gegen RBAC-06,
// siehe RET-08) mit 403 antwortet.
describe("api client - 403-Nachweis (keine berechtigte Rolle)", () => {
it("fetchClassRules wirft ForbiddenError bei 403-Antwort", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(null, { status: 403 }))
);
await expect(fetchClassRules()).rejects.toBeInstanceOf(ForbiddenError);
});
it("configureClassRule wirft ForbiddenError bei 403-Antwort", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(null, { status: 403 }))
);
await expect(configureClassRule("klasse-x", "1 year")).rejects.toBeInstanceOf(ForbiddenError);
});
});
describe("api client - erlaubte Rolle", () => {
it("fetchClassRules liefert die Liste bei 200-Antwort", async () => {
const rules = [{ RetentionClass: "klasse-a", Duration: "5 years", Active: true }];
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify(rules), { status: 200, headers: { "Content-Type": "application/json" } })
)
);
await expect(fetchClassRules()).resolves.toEqual(rules);
});
});
+81
View File
@@ -0,0 +1,81 @@
// Duenner Client fuer RET-06-API (archive/internal/retentionapi) — enthaelt
// keine eigene Fristen-/RBAC-Logik, nur Datenabruf und -weitergabe. Der
// RBAC-Check selbst passiert serverseitig ueber RET-08 (echter RBAC-06-Aufruf),
// dieser Client meldet nur, ob der Zugriff erlaubt war (403) oder nicht.
export class ForbiddenError extends Error {}
export class ApiError extends Error {}
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_RETENTION_API_URL;
if (!base) {
throw new Error("NEXT_PUBLIC_RETENTION_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base;
}
// userRole wird bis zu einer zentralen Session-/Identitaets-Loesung im
// Frontend als Umgebungsvariable/Query mitgegeben und als X-User-Role-Header
// an RET-06-API durchgereicht, das ihn seinerseits gegen RBAC-06 prueft
// (RET-08) - das Frontend trifft selbst KEINE Autorisierungsentscheidung.
function userRoleHeader(): Record<string, string> {
const role = process.env.NEXT_PUBLIC_USER_ROLE;
return role ? { "X-User-Role": role } : {};
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${apiBase()}${path}`, {
...init,
headers: {
...userRoleHeader(),
...(init?.body ? { "Content-Type": "application/json" } : {}),
...init?.headers,
},
});
if (res.status === 403) {
throw new ForbiddenError("Zugriff verweigert: Ihre Rolle ist nicht zur Fristenverwaltung berechtigt.");
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(body.error ?? `Anfrage fehlgeschlagen (${res.status})`);
}
if (res.status === 200 && res.headers.get("content-length") === "0") {
return undefined as T;
}
return res.json().catch(() => undefined as T);
}
export interface ClassRule {
RetentionClass: string;
Duration: string;
Active: boolean;
}
export interface ExpiringObject {
RetentionObjectID: string;
ObjectType: string;
ObjectReference: string;
RetentionClass: string;
DueDate: string;
}
export function fetchClassRules(): Promise<ClassRule[]> {
return req("/retention-classes");
}
export function configureClassRule(retentionClass: string, duration: string): Promise<void> {
return req("/retention-classes", {
method: "POST",
body: JSON.stringify({ retention_class: retentionClass, duration }),
});
}
export function deactivateClassRule(retentionClass: string): Promise<void> {
return req(`/retention-classes/${encodeURIComponent(retentionClass)}/deactivate`, {
method: "POST",
});
}
export function fetchExpiringPreview(): Promise<ExpiringObject[]> {
return req("/retention-classes/preview");
}
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
+26
View File
@@ -0,0 +1,26 @@
{
"name": "nexarch-retention-admin",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run"
},
"dependencies": {
"@nexarch/shl": "file:../shl",
"next": "14.2.35",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@testing-library/react": "16.0.0",
"@types/node": "20.14.9",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"jsdom": "24.1.0",
"typescript": "5.5.3",
"vitest": "2.0.5"
}
}
+21
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
},
});
+45
View File
@@ -0,0 +1,45 @@
# @nexarch/shl — UI-Shell & Design-System (Core SHL-01)
Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect).
Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal.
## Enthält
- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`).
- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`.
- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen.
- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management).
## Verwendung in einem Modul-Frontend
```tsx
import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>
<Shell modules={[]} tenantLabel="Mandant XY">
{children}
</Shell>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
);
}
```
## Bekannter offener Punkt
Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`.
## Tests
Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur):
```bash
npm install
npm test
npm run typecheck
```
+38
View File
@@ -0,0 +1,38 @@
// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2).
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { Dialog } from "../components/Dialog";
import { I18nProvider } from "../i18n/i18n";
function renderDialog(onClose: () => void) {
return render(
<I18nProvider>
<Dialog open titleId="test-title" title="Test-Dialog" onClose={onClose}>
<button type="button">Erste Aktion</button>
<button type="button">Zweite Aktion</button>
</Dialog>
</I18nProvider>,
);
}
describe("Dialog: Tastaturbedienung", () => {
it("schließt sich bei ESC", () => {
const onClose = vi.fn();
renderDialog(onClose);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledOnce();
});
it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => {
renderDialog(vi.fn());
const closeButton = screen.getByRole("button", { name: /schließen/i });
expect(document.activeElement).toBe(closeButton);
});
it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => {
renderDialog(vi.fn());
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(dialog).toHaveAttribute("aria-labelledby", "test-title");
});
});
+39
View File
@@ -0,0 +1,39 @@
// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4).
import { describe, expect, it } from "vitest";
import { colorTokens } from "../tokens/tokens";
// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig.
function relLuminance(hex: string): number {
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrastRatio(a: string, b: string): number {
const l1 = relLuminance(a);
const l2 = relLuminance(b);
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
return (lighter + 0.05) / (darker + 0.05);
}
describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => {
for (const scheme of ["light", "dark"] as const) {
const c = colorTokens[scheme];
it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5);
});
}
});
+88
View File
@@ -0,0 +1,88 @@
"use client";
// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig.
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
export interface DialogProps {
open: boolean;
onClose: () => void;
titleId: string;
title: string;
children: ReactNode;
}
export function Dialog({ open, onClose, titleId, title, children }: DialogProps) {
const { t } = useI18n();
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const node = dialogRef.current;
const focusables = node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
focusables?.[0]?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key !== "Tab" || !node) return;
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="shl-dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
className="shl-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="shl-dialog-header">
<h2 id={titleId}>{title}</h2>
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
×
</button>
</div>
<div className="shl-dialog-body">{children}</div>
</div>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes <label>,
// Fehler werden per aria-describedby + aria-invalid angebunden, nicht nur farblich markiert.
import { useId } from "react";
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
interface FieldWrapperProps {
label: string;
error?: string;
hint?: string;
children: (ids: { inputId: string; describedBy: string | undefined }) => ReactNode;
}
function FieldWrapper({ label, error, hint, children }: FieldWrapperProps) {
const inputId = useId();
const hintId = hint ? `${inputId}-hint` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="shl-field">
<label htmlFor={inputId}>{label}</label>
{children({ inputId, describedBy })}
{hint && (
<p id={hintId} className="shl-field-hint">
{hint}
</p>
)}
{error && (
<p id={errorId} className="shl-field-error" role="alert">
{error}
</p>
)}
</div>
);
}
export interface TextFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
}
export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<input
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...inputProps}
/>
)}
</FieldWrapper>
);
}
export interface SelectFieldProps
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
children: ReactNode;
}
export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<select
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...selectProps}
>
{children}
</select>
)}
</FieldWrapper>
);
}
export interface CheckboxFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> {
label: string;
}
export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) {
const inputId = useId();
return (
<div className="shl-field shl-field-checkbox">
<input id={inputId} type="checkbox" {...inputProps} />
<label htmlFor={inputId}>{label}</label>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
import { useTheme } from "../theme/ThemeProvider";
export interface ModuleLink {
key: string;
label: string;
href: string;
active?: boolean;
}
export interface ShellProps {
modules: ModuleLink[];
tenantLabel: string;
children: ReactNode;
}
export function Shell({ modules, tenantLabel, children }: ShellProps) {
const { scheme, toggle } = useTheme();
const { t } = useI18n();
return (
<div className="shl-shell">
<a className="shl-skip-link" href="#shl-main-content">
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
</a>
<header className="shl-shell-header">
<nav aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}>
<ul>
{modules.map((mod) => (
<li key={mod.key}>
<a href={mod.href} aria-current={mod.active ? "page" : undefined}>
{mod.label}
</a>
</li>
))}
</ul>
</nav>
<div className="shl-shell-context">
<span className="shl-tenant-context">{tenantLabel}</span>
<button
type="button"
onClick={toggle}
aria-label={
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
}
>
{scheme === "light" ? "🌙" : "☀️"}
</button>
</div>
</header>
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
{children}
</main>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
// Table-Basis-Komponente — SHL-01. WCAG: semantische <table>, scope auf Kopfzellen, sortierbare Spalten per Tastatur.
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export interface TableColumn<Row> {
key: string;
header: string;
render: (row: Row) => ReactNode;
sortable?: boolean;
}
export interface TableProps<Row> {
columns: TableColumn<Row>[];
rows: Row[];
rowKey: (row: Row) => string;
sortKey?: string;
sortDirection?: "asc" | "desc";
onSort?: (key: string) => void;
caption?: string;
}
export function Table<Row>({
columns,
rows,
rowKey,
sortKey,
sortDirection,
onSort,
caption,
}: TableProps<Row>) {
const { t } = useI18n();
return (
<table className="shl-table">
{caption && <caption>{caption}</caption>}
<thead>
<tr>
{columns.map((column) => {
const isSorted = column.key === sortKey;
const ariaSort = column.sortable
? isSorted
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
: undefined;
return (
<th key={column.key} scope="col" aria-sort={ariaSort}>
{column.sortable ? (
<button
type="button"
onClick={() => onSort?.(column.key)}
className="shl-table-sort-button"
>
{column.header}
</button>
) : (
column.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={columns.length}>{t("shl.table.noRows")}</td>
</tr>
) : (
rows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((column) => (
<td key={column.key}>{column.render(row)}</td>
))}
</tr>
))
)}
</tbody>
</table>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
// Toast-Basis-Komponente — SHL-01. WCAG: aria-live sorgt dafür, dass Screenreader Meldungen ansagen.
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export type ToastVariant = "info" | "success" | "danger" | "warning";
export interface ToastMessage {
id: string;
text: string;
variant: ToastVariant;
}
interface ToastContextValue {
toasts: ToastMessage[];
push: (text: string, variant?: ToastVariant) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const { t } = useI18n();
const dismiss = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const push = useCallback((text: string, variant: ToastVariant = "info") => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((current) => [...current, { id, text, variant }]);
}, []);
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="shl-toast-region" role="status" aria-live="polite" aria-atomic="false">
{toasts.map((toast) => (
<div key={toast.id} className={`shl-toast shl-toast-${toast.variant}`}>
<span>{toast.text}</span>
<button
type="button"
onClick={() => dismiss(toast.id)}
aria-label={t("shl.toast.dismiss")}
>
×
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error("useToast muss innerhalb von <ToastProvider> aufgerufen werden");
}
return ctx;
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
// i18n-Rahmen (mind. Deutsch/Englisch) — SHL-01 Akzeptanzkriterium 5.
// Liefert nur den Umschaltmechanismus + Basis-Komponenten-Texte.
// Modul-Frontends liefern ihre eigenen fachlichen Textbausteine über registerMessages(),
// statt einen eigenen i18n-Mechanismus zu bauen (siehe UI-UX-KONZEPT.md Abschnitt 4).
import { createContext, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
export type Locale = "de" | "en";
type MessageDict = Record<string, string>;
type MessageBundle = Record<Locale, MessageDict>;
const baseMessages: MessageBundle = {
de: {
"shl.dialog.close": "Schließen",
"shl.toast.dismiss": "Meldung schließen",
"shl.table.noRows": "Keine Einträge vorhanden",
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
},
en: {
"shl.dialog.close": "Close",
"shl.toast.dismiss": "Dismiss message",
"shl.table.noRows": "No entries",
"shl.theme.toggleToLight": "Switch to light theme",
"shl.theme.toggleToDark": "Switch to dark theme",
},
};
// Registry, in die Modul-Frontends ihre eigenen Textbausteine einhängen.
const registry: MessageBundle = { de: { ...baseMessages.de }, en: { ...baseMessages.en } };
export function registerMessages(locale: Locale, messages: MessageDict): void {
registry[locale] = { ...registry[locale], ...messages };
}
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string, fallback?: string) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({
initialLocale = "de",
children,
}: {
initialLocale?: Locale;
children: ReactNode;
}) {
const [locale, setLocale] = useState<Locale>(initialLocale);
const value = useMemo<I18nContextValue>(
() => ({
locale,
setLocale,
t: (key: string, fallback?: string) => registry[locale][key] ?? fallback ?? key,
}),
[locale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useI18n muss innerhalb von <I18nProvider> aufgerufen werden");
}
return ctx;
}
+25
View File
@@ -0,0 +1,25 @@
// Öffentliche Schnittstelle des Pakets @nexarch/shl — Modul-Frontends importieren ausschließlich hierüber,
// nicht aus internen Unterpfaden (SHL-01 Akzeptanzkriterium 2: dokumentiert, versioniert, importierbar statt kopiert).
export { colorTokens, spacing, breakpoints, typography, cssVariables } from "./tokens/tokens";
export type { ColorScheme, ColorTokens } from "./tokens/tokens";
export { ThemeProvider, useTheme, currentColors } from "./theme/ThemeProvider";
export { I18nProvider, useI18n, registerMessages } from "./i18n/i18n";
export type { Locale } from "./i18n/i18n";
export { Shell } from "./components/Shell";
export type { ShellProps, ModuleLink } from "./components/Shell";
export { Dialog } from "./components/Dialog";
export type { DialogProps } from "./components/Dialog";
export { Table } from "./components/Table";
export type { TableProps, TableColumn } from "./components/Table";
export { TextField, SelectField, CheckboxField } from "./components/FormElements";
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
export { ToastProvider, useToast } from "./components/Toast";
export type { ToastMessage, ToastVariant } from "./components/Toast";
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@nexarch/shl",
"version": "0.1.0",
"private": true,
"description": "NEXARCH UI-Shell & Design-System (Core SHL-01) — gemeinsames Paket für alle Modul-Frontends.",
"main": "index.ts",
"types": "index.ts",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"jsdom": "^24.1.1",
"typescript": "5.5.3",
"vitest": "^2.0.0"
}
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
// Zentrales Theming (Hell/Dunkel) — SHL-01 Akzeptanzkriterium 6.
// Einzige Quelle für Hell/Dunkel-Werte; Modul-Frontends schalten nur um, bauen kein eigenes Theming.
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { colorTokens, cssVariables, type ColorScheme } from "../tokens/tokens";
const STORAGE_KEY = "nexarch-shl-theme";
interface ThemeContextValue {
scheme: ColorScheme;
setScheme: (scheme: ColorScheme) => void;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function readStoredScheme(): ColorScheme | null {
if (typeof window === "undefined") return null;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === "light" || stored === "dark" ? stored : null;
} catch {
// localStorage kann in privaten Fenstern/eingeschränkten Kontexten fehlschlagen — kein Absturz, nur kein persistierter Zustand.
return null;
}
}
function systemPrefersDark(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [scheme, setSchemeState] = useState<ColorScheme>("light");
useEffect(() => {
const stored = readStoredScheme();
setSchemeState(stored ?? (systemPrefersDark() ? "dark" : "light"));
}, []);
const setScheme = useCallback((next: ColorScheme) => {
setSchemeState(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Speichern optional — Umschaltung funktioniert auch ohne Persistenz.
}
}, []);
const toggle = useCallback(() => {
setScheme(scheme === "light" ? "dark" : "light");
}, [scheme, setScheme]);
useEffect(() => {
const vars = cssVariables(scheme);
const root = document.documentElement;
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value);
}
root.dataset.shlTheme = scheme;
}, [scheme]);
const value = useMemo(() => ({ scheme, setScheme, toggle }), [scheme, setScheme, toggle]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme muss innerhalb von <ThemeProvider> aufgerufen werden");
}
return ctx;
}
export function currentColors(scheme: ColorScheme) {
return colorTokens[scheme];
}
+96
View File
@@ -0,0 +1,96 @@
// Design-Tokens: einzige Quelle für Farbe, Abstand, Typografie im gesamten Frontend-Verbund.
// Modul-Frontends importieren diese Tokens, überschreiben sie nicht lokal (SHL-01 Akzeptanzkriterium 3).
// Kontrastwerte sind gegen WCAG 2.1 AA geprüft (Akzeptanzkriterium 1/4): mindestens 4.5:1 für Fließtext.
export type ColorScheme = "light" | "dark";
export interface ColorTokens {
background: string;
surface: string;
surfaceRaised: string;
border: string;
textPrimary: string;
textSecondary: string;
accent: string;
accentContrast: string;
danger: string;
dangerContrast: string;
success: string;
warning: string;
focusRing: string;
}
// Kontrastwerte geprüft: textPrimary auf background/surface >= 7:1, textSecondary >= 4.5:1,
// accentContrast auf accent >= 4.5:1 (WCAG AA, siehe SHL-01 Prüfung 3).
export const colorTokens: Record<ColorScheme, ColorTokens> = {
light: {
background: "#FFFFFF",
surface: "#F5F6F8",
surfaceRaised: "#FFFFFF",
border: "#D7DBE0",
textPrimary: "#14181F",
textSecondary: "#4B5563",
accent: "#1D4ED8",
accentContrast: "#FFFFFF",
danger: "#B91C1C",
dangerContrast: "#FFFFFF",
success: "#15803D",
warning: "#B45309",
focusRing: "#1D4ED8",
},
dark: {
background: "#0F1115",
surface: "#181B21",
surfaceRaised: "#20242C",
border: "#333944",
textPrimary: "#F2F4F7",
textSecondary: "#B4BAC4",
accent: "#5B8DEF",
accentContrast: "#0F1115",
danger: "#F87171",
dangerContrast: "#0F1115",
success: "#4ADE80",
warning: "#FBBF24",
focusRing: "#5B8DEF",
},
};
export const spacing = {
xs: "4px",
sm: "8px",
md: "16px",
lg: "24px",
xl: "32px",
xxl: "48px",
} as const;
export const breakpoints = {
mobile: "0px",
tablet: "768px",
desktop: "1200px",
} as const;
export const typography = {
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
fontFamilyMono: "'JetBrains Mono', ui-monospace, monospace",
sizeSm: "13px",
sizeMd: "15px",
sizeLg: "18px",
sizeXl: "24px",
lineHeight: 1.5,
weightRegular: 400,
weightMedium: 500,
weightBold: 600,
} as const;
export function cssVariables(scheme: ColorScheme): Record<string, string> {
const c = colorTokens[scheme];
const vars: Record<string, string> = {};
for (const [key, value] of Object.entries(c)) {
vars[`--shl-color-${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`] = value;
}
for (const [key, value] of Object.entries(spacing)) {
vars[`--shl-spacing-${key}`] = value;
}
return vars;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});
+9
View File
@@ -0,0 +1,9 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Ohne explizites Cleanup bleiben zwischen den it()-Blöcken gerenderte Dialoge im DOM stehen
// (mehrere <html>/<body>-Bäume stapeln sich), wodurch getByRole() mehrere Treffer statt einen findet.
afterEach(() => {
cleanup();
});