From 0db8fa13770d84c8fa6681f8df2f6a5febdb0004 Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 30 Aug 2026 09:13:01 +0200 Subject: [PATCH] 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 --- archive/cmd/retention-notify-job/main.go | 63 +++++ archive/docs/RET-07-PRUEFPROTOKOLL.md | 91 +++++++ archive/internal/notifyclient/notifyclient.go | 84 +++++++ .../retentionnotify/retentionnotify.go | 158 +++++++++++++ .../retentionnotify/retentionnotify_test.go | 223 ++++++++++++++++++ .../migrations/0006_retention_notify.down.sql | 3 + .../migrations/0006_retention_notify.up.sql | 11 + ...arch-archive-retention-notify.service.tmpl | 10 + ...exarch-archive-retention-notify.timer.tmpl | 9 + 9 files changed, 652 insertions(+) create mode 100644 archive/cmd/retention-notify-job/main.go create mode 100644 archive/docs/RET-07-PRUEFPROTOKOLL.md create mode 100644 archive/internal/notifyclient/notifyclient.go create mode 100644 archive/internal/retentionnotify/retentionnotify.go create mode 100644 archive/internal/retentionnotify/retentionnotify_test.go create mode 100644 archive/migrations/0006_retention_notify.down.sql create mode 100644 archive/migrations/0006_retention_notify.up.sql create mode 100644 deploy/systemd/nexarch-archive-retention-notify.service.tmpl create mode 100644 deploy/systemd/nexarch-archive-retention-notify.timer.tmpl diff --git a/archive/cmd/retention-notify-job/main.go b/archive/cmd/retention-notify-job/main.go new file mode 100644 index 0000000..3602a0e --- /dev/null +++ b/archive/cmd/retention-notify-job/main.go @@ -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) +} diff --git a/archive/docs/RET-07-PRUEFPROTOKOLL.md b/archive/docs/RET-07-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..23777c8 --- /dev/null +++ b/archive/docs/RET-07-PRUEFPROTOKOLL.md @@ -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. diff --git a/archive/internal/notifyclient/notifyclient.go b/archive/internal/notifyclient/notifyclient.go new file mode 100644 index 0000000..09a2d6f --- /dev/null +++ b/archive/internal/notifyclient/notifyclient.go @@ -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 +} diff --git a/archive/internal/retentionnotify/retentionnotify.go b/archive/internal/retentionnotify/retentionnotify.go new file mode 100644 index 0000000..dc60ebc --- /dev/null +++ b/archive/internal/retentionnotify/retentionnotify.go @@ -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() +} diff --git a/archive/internal/retentionnotify/retentionnotify_test.go b/archive/internal/retentionnotify/retentionnotify_test.go new file mode 100644 index 0000000..916cedb --- /dev/null +++ b/archive/internal/retentionnotify/retentionnotify_test.go @@ -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) + } +} diff --git a/archive/migrations/0006_retention_notify.down.sql b/archive/migrations/0006_retention_notify.down.sql new file mode 100644 index 0000000..bd51593 --- /dev/null +++ b/archive/migrations/0006_retention_notify.down.sql @@ -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; diff --git a/archive/migrations/0006_retention_notify.up.sql b/archive/migrations/0006_retention_notify.up.sql new file mode 100644 index 0000000..c96adbc --- /dev/null +++ b/archive/migrations/0006_retention_notify.up.sql @@ -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() +); diff --git a/deploy/systemd/nexarch-archive-retention-notify.service.tmpl b/deploy/systemd/nexarch-archive-retention-notify.service.tmpl new file mode 100644 index 0000000..1d2a873 --- /dev/null +++ b/deploy/systemd/nexarch-archive-retention-notify.service.tmpl @@ -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 diff --git a/deploy/systemd/nexarch-archive-retention-notify.timer.tmpl b/deploy/systemd/nexarch-archive-retention-notify.timer.tmpl new file mode 100644 index 0000000..e417da2 --- /dev/null +++ b/deploy/systemd/nexarch-archive-retention-notify.timer.tmpl @@ -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