Files
nexarch/workflow/internal/task/task.go
patrickandClaude Sonnet 5 56ab1a2d6d
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
TSK-04: aufgaben oberflaeche
Neue additive status-Spalte (open/in_progress/done) auf tasks,
status.go (SetStatus/ByStatus/GetStatus/ListAll) haelt completed_at
synchron fuer TSK-03s escalation.Store. Neuer HTTP-Layer taskapi
(GET /tasks, PATCH /tasks/{id}/status), Tenant-Kontext-Durchsetzung
wie backchannelapi (VOR-06). Neue Next.js-Oberflaeche
web/workflow-tasks (Liste + Kanban, Drag&Drop + Tastaturbedienung).
tasks-a11y-fixture fuer den echten Browser-Test, kein Produktions-
binary vorhanden das geaendert werden musste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 23:26:56 +02:00

270 lines
7.8 KiB
Go

// Package task ist das Aufgabenmodell (TSK-01): Aufgaben mit Zustaendigkeit
// (Person oder Gruppe), Prioritaet und Bezug zu beliebigen Modul-Objekten
// (Dokument, Workflow-Schritt, o.ae.), ueber denselben Referenz-Ansatz wie
// caseobj (VOR-01) -- kein Duplizieren von Rohdaten, nur Typ/ID/Herkunft.
// Fristen (TSK-02) und eine Oberflaeche (TSK-04) sind eigene Kacheln.
package task
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// AssigneeType ist Person oder Gruppe (Akzeptanzkriterium 1).
type AssigneeType string
const (
AssigneePerson AssigneeType = "person"
AssigneeGroup AssigneeType = "group"
)
// Priority ist die Dringlichkeit einer Aufgabe.
type Priority string
const (
PriorityLow Priority = "low"
PriorityNormal Priority = "normal"
PriorityHigh Priority = "high"
)
// ErrInvalidAssigneeType wird zurueckgegeben, wenn AssigneeType weder
// "person" noch "group" ist.
var ErrInvalidAssigneeType = errors.New("task: assignee_type muss person oder group sein")
// ErrInvalidPriority wird zurueckgegeben, wenn Priority keinen der drei
// zulaessigen Werte hat.
var ErrInvalidPriority = errors.New("task: unzulaessige prioritaet")
// ErrNotFound wird zurueckgegeben, wenn keine Aufgabe mit der
// angegebenen ID existiert.
var ErrNotFound = errors.New("task: aufgabe nicht gefunden")
// Reference ist ein referenziertes Fremdobjekt (Akzeptanzkriterium 3),
// dasselbe Format wie caseobj.Reference.
type Reference struct {
ModuleName string
ObjectType string
ObjectID string
}
// Task ist eine Aufgabe.
type Task struct {
ID string
Title string
AssigneeType AssigneeType
AssigneeID string
Priority Priority
References []Reference
// DueDate ist die ueber calendar.Store.AddBusinessDays berechnete
// Faelligkeit (TSK-02). Nil, solange keine Faelligkeit gesetzt wurde.
DueDate *time.Time
// Status ist der Kanban-Status (TSK-04, status.go).
Status Status
}
// Store bindet das Aufgabenmodell an den Datenbank-Pool.
type Store struct {
pool *pgxpool.Pool
}
// NewStore erzeugt den Store.
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func validAssigneeType(t AssigneeType) bool {
return t == AssigneePerson || t == AssigneeGroup
}
func validPriority(p Priority) bool {
return p == PriorityLow || p == PriorityNormal || p == PriorityHigh
}
// Create legt eine Aufgabe mit optionalen Referenzen an. Ein leerer
// Priority-Wert wird als PriorityNormal behandelt.
//
// Zuweisung an eine Gruppe legt genau eine Zeile an (Akzeptanzkriterium 2:
// kein Aufteilen in je eine Aufgabe pro Gruppenmitglied hier -- das waere
// Aufgabe eines aufrufenden Dienstes, der Gruppenmitglieder kennt, nicht
// dieses Pakets).
func (s *Store) Create(ctx context.Context, t Task) (string, error) {
if !validAssigneeType(t.AssigneeType) {
return "", ErrInvalidAssigneeType
}
if t.Priority == "" {
t.Priority = PriorityNormal
}
if !validPriority(t.Priority) {
return "", ErrInvalidPriority
}
if t.AssigneeID == "" {
return "", fmt.Errorf("task: assignee_id darf nicht leer sein")
}
if t.Title == "" {
return "", fmt.Errorf("task: title darf nicht leer sein")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return "", fmt.Errorf("task: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO tasks (title, assignee_type, assignee_id, priority)
VALUES ($1, $2, $3, $4)
RETURNING id
`, t.Title, string(t.AssigneeType), t.AssigneeID, string(t.Priority)).Scan(&id); err != nil {
return "", fmt.Errorf("task: anlegen: %w", err)
}
for _, ref := range t.References {
if ref.ModuleName == "" || ref.ObjectType == "" || ref.ObjectID == "" {
return "", fmt.Errorf("task: referenz unvollstaendig")
}
if _, err := tx.Exec(ctx, `
INSERT INTO task_references (task_id, module_name, object_type, object_id)
VALUES ($1, $2, $3, $4)
`, id, ref.ModuleName, ref.ObjectType, ref.ObjectID); err != nil {
return "", fmt.Errorf("task: referenz anlegen: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return "", fmt.Errorf("task: transaktion committen: %w", err)
}
return id, nil
}
// Get liest eine Aufgabe mit ihren Referenzen.
func (s *Store) Get(ctx context.Context, id string) (Task, error) {
var t Task
var assigneeType, priority, status string
var dueDate *time.Time
if err := s.pool.QueryRow(ctx, `
SELECT id, title, assignee_type, assignee_id, priority, due_date, status
FROM tasks WHERE id = $1
`, id).Scan(&t.ID, &t.Title, &assigneeType, &t.AssigneeID, &priority, &dueDate, &status); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Task{}, ErrNotFound
}
return Task{}, fmt.Errorf("task: lesen: %w", err)
}
t.AssigneeType = AssigneeType(assigneeType)
t.Priority = Priority(priority)
t.DueDate = dueDate
t.Status = Status(status)
rows, err := s.pool.Query(ctx, `
SELECT module_name, object_type, object_id
FROM task_references WHERE task_id = $1
ORDER BY created_at
`, id)
if err != nil {
return Task{}, fmt.Errorf("task: referenzen lesen: %w", err)
}
defer rows.Close()
for rows.Next() {
var r Reference
if err := rows.Scan(&r.ModuleName, &r.ObjectType, &r.ObjectID); err != nil {
return Task{}, fmt.Errorf("task: referenz scannen: %w", err)
}
t.References = append(t.References, r)
}
if err := rows.Err(); err != nil {
return Task{}, fmt.Errorf("task: referenzen iterieren: %w", err)
}
return t, nil
}
// SetDueDate setzt die Faelligkeit einer Aufgabe (TSK-02). dueDate wird
// unveraendert gespeichert -- die Arbeitstagsberechnung erfolgt vorher
// in calendar.Store.AddBusinessDays, dieses Paket kennt keine
// Kalenderlogik.
func (s *Store) SetDueDate(ctx context.Context, id string, dueDate time.Time) error {
tag, err := s.pool.Exec(ctx, `UPDATE tasks SET due_date = $2 WHERE id = $1`, id, dueDate)
if err != nil {
return fmt.Errorf("task: faelligkeit setzen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ByAssignee liefert alle Aufgaben einer Person oder Gruppe.
func (s *Store) ByAssignee(ctx context.Context, assigneeType AssigneeType, assigneeID string) ([]Task, error) {
rows, err := s.pool.Query(ctx, `
SELECT id FROM tasks WHERE assignee_type = $1 AND assignee_id = $2
ORDER BY created_at
`, string(assigneeType), assigneeID)
if err != nil {
return nil, fmt.Errorf("task: nach zustaendigkeit lesen: %w", err)
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, fmt.Errorf("task: id scannen: %w", err)
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("task: iterieren: %w", err)
}
out := make([]Task, 0, len(ids))
for _, id := range ids {
t, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, nil
}
// ByReference liefert alle Aufgaben, die ein bestimmtes Fremdobjekt
// referenzieren (Akzeptanzkriterium 3, Vorbereitung fuer eine spaetere
// Rueckkanal-API wie caseobj.ByReference).
func (s *Store) ByReference(ctx context.Context, moduleName, objectType, objectID string) ([]Task, error) {
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT task_id FROM task_references
WHERE module_name = $1 AND object_type = $2 AND object_id = $3
`, moduleName, objectType, objectID)
if err != nil {
return nil, fmt.Errorf("task: nach referenz lesen: %w", err)
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, fmt.Errorf("task: id scannen: %w", err)
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("task: iterieren: %w", err)
}
out := make([]Task, 0, len(ids))
for _, id := range ids {
t, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, nil
}