// 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 }