Merge remote-tracking branch 'origin/feature/tsk-03-erinnerungen-eskalationsregeln' into merge-batch-4

This commit is contained in:
patrick committed 2026-09-26 12:38:09 +02:00
commit 01c60539df
5 files changed
+576

No files matched your search

+37
View File
@@ -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).
+236
View File
@@ -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
}
@@ -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)
}
}
@@ -0,0 +1,4 @@
DROP TABLE task_escalations;
DROP TABLE task_reminders;
DROP TABLE task_escalation_rules;
ALTER TABLE tasks DROP COLUMN completed_at;
@@ -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;