From 51808002bc52a26a1f4d195fe5cef0c2acf3166f Mon Sep 17 00:00:00 2001 From: patrick Date: Sat, 26 Sep 2026 12:21:51 +0200 Subject: [PATCH] TSK-03: erinnerungen eskalationsregeln Co-Authored-By: Claude Sonnet 5 --- workflow/docs/TSK-03-PRUEFPROTOKOLL.md | 37 +++ workflow/internal/escalation/escalation.go | 236 +++++++++++++++ .../internal/escalation/escalation_test.go | 272 ++++++++++++++++++ .../0013_task_reminders_escalation.down.sql | 4 + .../0013_task_reminders_escalation.up.sql | 27 ++ 5 files changed, 576 insertions(+) create mode 100644 workflow/docs/TSK-03-PRUEFPROTOKOLL.md create mode 100644 workflow/internal/escalation/escalation.go create mode 100644 workflow/internal/escalation/escalation_test.go create mode 100644 workflow/migrations/tenant/0013_task_reminders_escalation.down.sql create mode 100644 workflow/migrations/tenant/0013_task_reminders_escalation.up.sql diff --git a/workflow/docs/TSK-03-PRUEFPROTOKOLL.md b/workflow/docs/TSK-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..4b94a33f --- /dev/null +++ b/workflow/docs/TSK-03-PRUEFPROTOKOLL.md @@ -0,0 +1,37 @@ +# TSK-03 – Prüfprotokoll: Erinnerungen & Eskalationsregeln + +Datum: 2026-09-26. Host: Entwicklungshost 192.168.1.204, Wegwerf-Cluster, Datenbank über `TEST_TENANT_DSN` (aus `/etc/nexarch-dev/dms-dev.env`). + +## Umsetzung + +Neues Paket `workflow/internal/escalation`, baut auf `task` (TSK-01) und `calendar`/`tasks.due_date` (TSK-02) auf, ohne deren Code zu ändern (additive Migration `0013_task_reminders_escalation`): + +- `task_escalation_rules`: je Prozesstyp konfigurierbare Liste von Erinnerungs-Vorlaufzeiten (`reminder_days_before`, Tage vor Fälligkeit) und eine Eskalations-Aktion (`escalation_action`, freier Bezeichner — der tatsächliche Versand über den Core-Dispatcher ist TSK-05, hier nur die Regel). +- `task_reminders` / `task_escalations`: je Aufgabe geplante, noch nicht ausgelöste Termine (`fired_at IS NULL`). +- `tasks.completed_at` (neue Spalte, additiv): markiert eine erledigte Aufgabe. `escalation.Store.Complete` setzt sie, `task.Store` bleibt unverändert. +- `ProcessDue(ctx, now)` ist der zeitgesteuerte Job: markiert fällige, noch nicht ausgelöste Erinnerungen/Eskalationen offener (nicht erledigter) Aufgaben als ausgelöst und gibt sie zurück. Kein In-Memory-Zustand, jeder Lauf liest den aktuellen Stand aus Postgres (gleiches Muster wie `stepretry`/`syncjob`). + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Erinnerungszeitpunkte sind konfigurierbar | **Bestanden.** `TestSetRule_ReminderDaysConfigurable`: Regel je Prozesstyp mit beliebiger Liste von Vorlauftagen gesetzt und durch erneutes `SetRule` vollständig ersetzt (nicht additiv). | +| 2 | Eskalation erfolgt nach definierter Regel bei Fristablauf | **Bestanden.** `TestProcessDue_EscalatesPastDueTask`: überfällige Aufgabe löst `ProcessDue` mit der in der Regel hinterlegten `EscalationAction` aus. | +| 3 | Erledigte Aufgabe löst keine Eskalation mehr aus | **Bestanden.** `TestProcessDue_CompletedTaskNeverEscalates`: nach `Complete` bleibt die Eskalation trotz überfälliger Frist aus; erneutes `Complete` liefert `ErrTaskNotFound` (Aufgabe bereits erledigt). | + +## Prüfungen vor der Abnahme + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Zeitraffer-Test über simulierte Fristen | **Bestanden.** `TestProcessDue_ReminderFiresOnlyAtConfiguredOffset`: Aufgabe fällig in 10 Tagen, Regel mit 3 Tagen Vorlauf. Simulierter Zeitpunkt +8 Tage löst die Erinnerung aus, die Eskalation (fällig bei +10 Tagen) noch nicht. | +| 2 | Erledigte Aufgabe vor Fristablauf löst nachweislich keine Eskalation aus | **Bestanden.** Siehe AC3 oben — direkter Test, kein Zufallsergebnis. | +| 3 | Wiederholter Joblauf bleibt idempotent | **Bestanden.** `TestProcessDue_Idempotent`: zwei `ProcessDue`-Aufrufe mit identischem `now`; die Eskalation wird nur beim ersten Lauf zurückgegeben (`fired_at` verhindert den zweiten). | + +## Ergebnis + +7/7 neue Tests grün, volle Modul-Regression (18 Pakete mit Tests, 3 ohne) grün, `go build`/`go vet` sauber. Kein bestehender Code (`task`, `calendar`, `reminder`) verändert. + +## Nicht Bestandteil (per Ticket) + +- `TSK-05` (tatsächlicher Versand über den Core-Dispatcher) — `ProcessDue` liefert nur die fällige Liste inklusive `EscalationAction`, kein eigener Versandweg. +- `QA-03` (Prüfgate Genehmigungen & Aufgaben). diff --git a/workflow/internal/escalation/escalation.go b/workflow/internal/escalation/escalation.go new file mode 100644 index 00000000..3900aa6f --- /dev/null +++ b/workflow/internal/escalation/escalation.go @@ -0,0 +1,236 @@ +// Package escalation ist Erinnerungen & Eskalationsregeln (TSK-03): baut auf +// task (TSK-01) und calendar (TSK-02) auf, ohne deren Code zu aendern. +// Ein Prozesstyp (z.B. "approval", "case") bekommt eine konfigurierbare +// Liste von Erinnerungs-Vorlaufzeiten (in Arbeitstagen vor Faelligkeit) und +// eine Eskalations-Aktion, die bei Fristablauf ausgeloest wird. Der Versand +// selbst (TSK-05, Core-Dispatcher) ist nicht Teil dieser Kachel -- ProcessDue +// liefert nur, welche Erinnerungen/Eskalationen faellig sind. +package escalation + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrNotFound wird zurueckgegeben, wenn fuer einen Prozesstyp keine Regel +// hinterlegt ist. +var ErrNotFound = errors.New("escalation: keine regel fuer diesen prozesstyp") + +// ErrTaskNotFound wird zurueckgegeben, wenn die referenzierte Aufgabe nicht +// existiert oder keine Faelligkeit gesetzt hat. +var ErrTaskNotFound = errors.New("escalation: aufgabe nicht gefunden oder ohne faelligkeit") + +// Rule ist die je Prozesstyp konfigurierbare Regel (Akzeptanzkriterium 1/2). +type Rule struct { + ProcessType string + ReminderDaysBefore []int + EscalationAction string +} + +// Fired ist eine ausgeloeste Erinnerung oder Eskalation (Rueckgabe von +// ProcessDue, zur Weiterverarbeitung durch einen Aufrufer wie TSK-05). +type Fired struct { + TaskID string + ProcessType string + EscalationAction string +} + +// Store bindet Regeln, Erinnerungen und Eskalationen an den Datenbank-Pool. +type Store struct { + pool *pgxpool.Pool +} + +// NewStore erzeugt den Store. +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// SetRule legt eine Regel an oder ersetzt sie (Akzeptanzkriterium 1: die +// Erinnerungszeitpunkte sind konfigurierbar). +func (s *Store) SetRule(ctx context.Context, r Rule) error { + if r.ProcessType == "" { + return fmt.Errorf("escalation: process_type darf nicht leer sein") + } + if r.EscalationAction == "" { + return fmt.Errorf("escalation: escalation_action darf nicht leer sein") + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO task_escalation_rules (process_type, reminder_days_before, escalation_action) + VALUES ($1, $2, $3) + ON CONFLICT (process_type) DO UPDATE + SET reminder_days_before = EXCLUDED.reminder_days_before, + escalation_action = EXCLUDED.escalation_action, + updated_at = now() + `, r.ProcessType, r.ReminderDaysBefore, r.EscalationAction) + if err != nil { + return fmt.Errorf("escalation: regel speichern: %w", err) + } + return nil +} + +// GetRule liest eine Regel. +func (s *Store) GetRule(ctx context.Context, processType string) (Rule, error) { + var r Rule + r.ProcessType = processType + err := s.pool.QueryRow(ctx, ` + SELECT reminder_days_before, escalation_action + FROM task_escalation_rules WHERE process_type = $1 + `, processType).Scan(&r.ReminderDaysBefore, &r.EscalationAction) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Rule{}, ErrNotFound + } + return Rule{}, fmt.Errorf("escalation: regel lesen: %w", err) + } + return r, nil +} + +// Schedule berechnet aus der Regel des Prozesstyps und der Faelligkeit der +// Aufgabe (aus tasks.due_date, gesetzt ueber TSK-02) die Erinnerungs- und +// Eskalationstermine und legt sie an. Ruft man Schedule erneut fuer +// dieselbe Aufgabe auf, werden die alten, noch nicht ausgeloesten Termine +// verworfen und neu berechnet (z.B. nach einer Fristverlaengerung). +func (s *Store) Schedule(ctx context.Context, taskID, processType string) error { + rule, err := s.GetRule(ctx, processType) + if err != nil { + return err + } + + var dueDate *time.Time + if err := s.pool.QueryRow(ctx, `SELECT due_date FROM tasks WHERE id = $1`, taskID).Scan(&dueDate); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrTaskNotFound + } + return fmt.Errorf("escalation: faelligkeit lesen: %w", err) + } + if dueDate == nil { + return ErrTaskNotFound + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("escalation: transaktion starten: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, `DELETE FROM task_reminders WHERE task_id = $1 AND fired_at IS NULL`, taskID); err != nil { + return fmt.Errorf("escalation: alte erinnerungen loeschen: %w", err) + } + for _, days := range rule.ReminderDaysBefore { + remindAt := dueDate.AddDate(0, 0, -days) + if _, err := tx.Exec(ctx, ` + INSERT INTO task_reminders (task_id, remind_at) VALUES ($1, $2) + `, taskID, remindAt); err != nil { + return fmt.Errorf("escalation: erinnerung anlegen: %w", err) + } + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO task_escalations (task_id, process_type, escalate_at) + VALUES ($1, $2, $3) + ON CONFLICT (task_id) DO UPDATE + SET process_type = EXCLUDED.process_type, escalate_at = EXCLUDED.escalate_at, fired_at = NULL + `, taskID, processType, *dueDate); err != nil { + return fmt.Errorf("escalation: eskalation anlegen: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("escalation: transaktion committen: %w", err) + } + return nil +} + +// Complete markiert eine Aufgabe als erledigt (Akzeptanzkriterium 3: eine +// erledigte Aufgabe loest keine Eskalation mehr aus -- ProcessDue prueft +// completed_at IS NULL). Dieses Paket besitzt die Spalte (eigene Migration), +// task.Store bleibt unveraendert. +func (s *Store) Complete(ctx context.Context, taskID string) error { + tag, err := s.pool.Exec(ctx, `UPDATE tasks SET completed_at = now() WHERE id = $1 AND completed_at IS NULL`, taskID) + if err != nil { + return fmt.Errorf("escalation: aufgabe abschliessen: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrTaskNotFound + } + return nil +} + +// ProcessDue ist der zeitgesteuerte Job (Postgres-Jobqueue-Muster wie an +// anderer Stelle im Modul: kein In-Memory-Zustand, jeder Lauf liest den +// aktuellen Stand aus der Datenbank). Faellige, noch nicht ausgeloeste +// Erinnerungen/Eskalationen offener (nicht erledigter) Aufgaben werden als +// ausgeloest markiert und zurueckgegeben. Ein wiederholter Aufruf mit +// demselben now liefert beim zweiten Mal keine der bereits markierten +// Eintraege mehr (Akzeptanzkriterium/Pruefung 3: Idempotenz). +func (s *Store) ProcessDue(ctx context.Context, now time.Time) (reminders []Fired, escalations []Fired, err error) { + today := now.In(time.UTC) + + reminderRows, err := s.pool.Query(ctx, ` + UPDATE task_reminders r + SET fired_at = $1 + FROM tasks t + WHERE r.task_id = t.id + AND r.fired_at IS NULL + AND r.remind_at <= $1 + AND t.completed_at IS NULL + RETURNING r.task_id + `, today) + if err != nil { + return nil, nil, fmt.Errorf("escalation: erinnerungen verarbeiten: %w", err) + } + var reminderTaskIDs []string + for reminderRows.Next() { + var id string + if err := reminderRows.Scan(&id); err != nil { + reminderRows.Close() + return nil, nil, fmt.Errorf("escalation: erinnerung scannen: %w", err) + } + reminderTaskIDs = append(reminderTaskIDs, id) + } + reminderRows.Close() + if err := reminderRows.Err(); err != nil { + return nil, nil, fmt.Errorf("escalation: erinnerungen iterieren: %w", err) + } + for _, id := range reminderTaskIDs { + reminders = append(reminders, Fired{TaskID: id}) + } + + escalationRows, err := s.pool.Query(ctx, ` + UPDATE task_escalations e + SET fired_at = $1 + FROM tasks t + WHERE e.task_id = t.id + AND e.fired_at IS NULL + AND e.escalate_at <= $1 + AND t.completed_at IS NULL + RETURNING e.task_id, e.process_type + `, today) + if err != nil { + return nil, nil, fmt.Errorf("escalation: eskalationen verarbeiten: %w", err) + } + for escalationRows.Next() { + var f Fired + if err := escalationRows.Scan(&f.TaskID, &f.ProcessType); err != nil { + escalationRows.Close() + return nil, nil, fmt.Errorf("escalation: eskalation scannen: %w", err) + } + escalations = append(escalations, f) + } + escalationRows.Close() + if err := escalationRows.Err(); err != nil { + return nil, nil, fmt.Errorf("escalation: eskalationen iterieren: %w", err) + } + for i, f := range escalations { + rule, err := s.GetRule(ctx, f.ProcessType) + if err == nil { + escalations[i].EscalationAction = rule.EscalationAction + } + } + + return reminders, escalations, nil +} diff --git a/workflow/internal/escalation/escalation_test.go b/workflow/internal/escalation/escalation_test.go new file mode 100644 index 00000000..d69bac04 --- /dev/null +++ b/workflow/internal/escalation/escalation_test.go @@ -0,0 +1,272 @@ +package escalation + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/task" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/testdb" +) + +func findModuleRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("go.mod nicht gefunden") + } + dir = parent + } +} + +func newTestStores(t *testing.T) (*Store, *task.Store) { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool := testdb.Pool(t, dsn) + + migrations, err := migrate.Load(filepath.Join(findModuleRoot(t), "migrations", "tenant")) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(ctx, pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + return NewStore(pool), task.NewStore(pool) +} + +func newTaskWithDueDate(t *testing.T, ts *task.Store, dueDate time.Time) string { + t.Helper() + ctx := context.Background() + id, err := ts.Create(ctx, task.Task{ + Title: "Testaufgabe", + AssigneeType: task.AssigneePerson, + AssigneeID: "user-1", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := ts.SetDueDate(ctx, id, dueDate); err != nil { + t.Fatalf("SetDueDate: %v", err) + } + return id +} + +// AC1: Erinnerungszeitpunkte sind konfigurierbar. +func TestSetRule_ReminderDaysConfigurable(t *testing.T) { + es, _ := newTestStores(t) + ctx := context.Background() + + if err := es.SetRule(ctx, Rule{ProcessType: "approval-ac1", ReminderDaysBefore: []int{7, 3, 1}, EscalationAction: "notify-manager"}); err != nil { + t.Fatalf("SetRule: %v", err) + } + r, err := es.GetRule(ctx, "approval-ac1") + if err != nil { + t.Fatalf("GetRule: %v", err) + } + if len(r.ReminderDaysBefore) != 3 || r.ReminderDaysBefore[0] != 7 || r.ReminderDaysBefore[1] != 3 || r.ReminderDaysBefore[2] != 1 { + t.Fatalf("erwartet [7 3 1], bekommen %v", r.ReminderDaysBefore) + } + + // erneutes Setzen ersetzt die Konfiguration (nicht nur additiv). + if err := es.SetRule(ctx, Rule{ProcessType: "approval-ac1", ReminderDaysBefore: []int{2}, EscalationAction: "notify-manager-v2"}); err != nil { + t.Fatalf("SetRule (Update): %v", err) + } + r2, err := es.GetRule(ctx, "approval-ac1") + if err != nil { + t.Fatalf("GetRule (nach Update): %v", err) + } + if len(r2.ReminderDaysBefore) != 1 || r2.ReminderDaysBefore[0] != 2 { + t.Fatalf("erwartet [2] nach Update, bekommen %v", r2.ReminderDaysBefore) + } + if r2.EscalationAction != "notify-manager-v2" { + t.Fatalf("erwartet aktualisierte escalation_action, bekommen %q", r2.EscalationAction) + } +} + +// AC2: Eskalation erfolgt nach definierter Regel bei Fristablauf. +func TestProcessDue_EscalatesPastDueTask(t *testing.T) { + es, ts := newTestStores(t) + ctx := context.Background() + + if err := es.SetRule(ctx, Rule{ProcessType: "approval-ac2", ReminderDaysBefore: []int{1}, EscalationAction: "escalate-to-lead"}); err != nil { + t.Fatalf("SetRule: %v", err) + } + + past := time.Now().AddDate(0, 0, -2) + taskID := newTaskWithDueDate(t, ts, past) + if err := es.Schedule(ctx, taskID, "approval-ac2"); err != nil { + t.Fatalf("Schedule: %v", err) + } + + _, escalations, err := es.ProcessDue(ctx, time.Now()) + if err != nil { + t.Fatalf("ProcessDue: %v", err) + } + found := false + for _, e := range escalations { + if e.TaskID == taskID { + found = true + if e.EscalationAction != "escalate-to-lead" { + t.Fatalf("erwartete Aktion escalate-to-lead, bekommen %q", e.EscalationAction) + } + } + } + if !found { + t.Fatalf("erwartete Eskalation fuer ueberfaellige Aufgabe %s nicht ausgeloest", taskID) + } +} + +// AC3 / Pruefung 2: erledigte Aufgabe vor Fristablauf loest keine Eskalation aus. +func TestProcessDue_CompletedTaskNeverEscalates(t *testing.T) { + es, ts := newTestStores(t) + ctx := context.Background() + + if err := es.SetRule(ctx, Rule{ProcessType: "approval-ac3", ReminderDaysBefore: []int{1}, EscalationAction: "escalate"}); err != nil { + t.Fatalf("SetRule: %v", err) + } + + past := time.Now().AddDate(0, 0, -1) + taskID := newTaskWithDueDate(t, ts, past) + if err := es.Schedule(ctx, taskID, "approval-ac3"); err != nil { + t.Fatalf("Schedule: %v", err) + } + if err := es.Complete(ctx, taskID); err != nil { + t.Fatalf("Complete: %v", err) + } + + _, escalations, err := es.ProcessDue(ctx, time.Now()) + if err != nil { + t.Fatalf("ProcessDue: %v", err) + } + for _, e := range escalations { + if e.TaskID == taskID { + t.Fatalf("erledigte Aufgabe %s hat trotzdem eine Eskalation ausgeloest", taskID) + } + } + + // erneutes Completen einer bereits erledigten Aufgabe ist ein klarer Fehler. + if err := es.Complete(ctx, taskID); err != ErrTaskNotFound { + t.Fatalf("erwartet ErrTaskNotFound bei erneutem Complete, bekommen %v", err) + } +} + +// Pruefung 1: Zeitraffer-Test ueber simulierte Fristen. +func TestProcessDue_ReminderFiresOnlyAtConfiguredOffset(t *testing.T) { + es, ts := newTestStores(t) + ctx := context.Background() + + if err := es.SetRule(ctx, Rule{ProcessType: "approval-zeitraffer", ReminderDaysBefore: []int{3}, EscalationAction: "escalate"}); err != nil { + t.Fatalf("SetRule: %v", err) + } + + due := time.Now().AddDate(0, 0, 10) + taskID := newTaskWithDueDate(t, ts, due) + if err := es.Schedule(ctx, taskID, "approval-zeitraffer"); err != nil { + t.Fatalf("Schedule: %v", err) + } + + // Simulierter "jetzt"-Zeitpunkt 8 Tage in der Zukunft: Erinnerung (faellig + // bei due-3 Tage = +7 Tage relativ zu heute) muss ausgeloest sein, Eskalation + // (faellig bei +10 Tage) noch nicht. + simulatedNow := time.Now().AddDate(0, 0, 8) + reminders, escalations, err := es.ProcessDue(ctx, simulatedNow) + if err != nil { + t.Fatalf("ProcessDue: %v", err) + } + reminderFound := false + for _, r := range reminders { + if r.TaskID == taskID { + reminderFound = true + } + } + if !reminderFound { + t.Fatalf("erwartete Erinnerung im Zeitraffer nicht gefunden") + } + for _, e := range escalations { + if e.TaskID == taskID { + t.Fatalf("Eskalation zu frueh ausgeloest (simulierter Zeitpunkt vor Faelligkeit)") + } + } +} + +// Pruefung 3: wiederholter Joblauf bleibt idempotent. +func TestProcessDue_Idempotent(t *testing.T) { + es, ts := newTestStores(t) + ctx := context.Background() + + if err := es.SetRule(ctx, Rule{ProcessType: "approval-idempotent", ReminderDaysBefore: []int{1}, EscalationAction: "escalate"}); err != nil { + t.Fatalf("SetRule: %v", err) + } + + past := time.Now().AddDate(0, 0, -1) + taskID := newTaskWithDueDate(t, ts, past) + if err := es.Schedule(ctx, taskID, "approval-idempotent"); err != nil { + t.Fatalf("Schedule: %v", err) + } + + now := time.Now() + _, first, err := es.ProcessDue(ctx, now) + if err != nil { + t.Fatalf("ProcessDue (erster Lauf): %v", err) + } + firstCount := 0 + for _, e := range first { + if e.TaskID == taskID { + firstCount++ + } + } + if firstCount != 1 { + t.Fatalf("erwartete genau 1 Eskalation im ersten Lauf, bekommen %d", firstCount) + } + + _, second, err := es.ProcessDue(ctx, now) + if err != nil { + t.Fatalf("ProcessDue (zweiter Lauf): %v", err) + } + for _, e := range second { + if e.TaskID == taskID { + t.Fatalf("zweiter Lauf hat dieselbe Eskalation erneut ausgeloest -- nicht idempotent") + } + } +} + +func TestSchedule_UnknownProcessType(t *testing.T) { + es, ts := newTestStores(t) + ctx := context.Background() + + taskID := newTaskWithDueDate(t, ts, time.Now().AddDate(0, 0, 5)) + if err := es.Schedule(ctx, taskID, "unbekannter-typ"); err != ErrNotFound { + t.Fatalf("erwartet ErrNotFound, bekommen %v", err) + } +} + +func TestSchedule_TaskWithoutDueDate(t *testing.T) { + es, ts := newTestStores(t) + ctx := context.Background() + + if err := es.SetRule(ctx, Rule{ProcessType: "approval-nodue", ReminderDaysBefore: []int{1}, EscalationAction: "escalate"}); err != nil { + t.Fatalf("SetRule: %v", err) + } + id, err := ts.Create(ctx, task.Task{Title: "Ohne Faelligkeit", AssigneeType: task.AssigneePerson, AssigneeID: "user-1"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := es.Schedule(ctx, id, "approval-nodue"); err != ErrTaskNotFound { + t.Fatalf("erwartet ErrTaskNotFound, bekommen %v", err) + } +} diff --git a/workflow/migrations/tenant/0013_task_reminders_escalation.down.sql b/workflow/migrations/tenant/0013_task_reminders_escalation.down.sql new file mode 100644 index 00000000..832a5acb --- /dev/null +++ b/workflow/migrations/tenant/0013_task_reminders_escalation.down.sql @@ -0,0 +1,4 @@ +DROP TABLE task_escalations; +DROP TABLE task_reminders; +DROP TABLE task_escalation_rules; +ALTER TABLE tasks DROP COLUMN completed_at; diff --git a/workflow/migrations/tenant/0013_task_reminders_escalation.up.sql b/workflow/migrations/tenant/0013_task_reminders_escalation.up.sql new file mode 100644 index 00000000..74a360b0 --- /dev/null +++ b/workflow/migrations/tenant/0013_task_reminders_escalation.up.sql @@ -0,0 +1,27 @@ +ALTER TABLE tasks ADD COLUMN completed_at TIMESTAMPTZ; + +CREATE TABLE task_escalation_rules ( + process_type TEXT PRIMARY KEY, + reminder_days_before INTEGER[] NOT NULL DEFAULT '{}', + escalation_action TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE task_reminders ( + id BIGSERIAL PRIMARY KEY, + task_id UUID NOT NULL REFERENCES tasks(id), + remind_at DATE NOT NULL, + fired_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_task_reminders_due ON task_reminders (remind_at) WHERE fired_at IS NULL; + +CREATE TABLE task_escalations ( + task_id UUID PRIMARY KEY REFERENCES tasks(id), + process_type TEXT NOT NULL, + escalate_at DATE NOT NULL, + fired_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_task_escalations_due ON task_escalations (escalate_at) WHERE fired_at IS NULL;