Files
paliad/internal/services/deadline_service.go
mAi a905911cf4 fix(deadlines): restore /api/events deadline rail after mig 140 column drop (t-paliad-344)
Two SELECTs still referenced paliad.deadlines.rule_id after mig 140
(Slice B.4) dropped that column in favour of sequencing_rule_id:

  - internal/services/deadline_service.go:268 — DeadlineService.
    ListVisibleForUser. Powers /api/events?type=deadline (dashboard
    deadline rail, /deadlines page, every status bucket). Threw
    `pq: column f.rule_id does not exist` on every request → 500
    for any authenticated user hitting the dashboard.

  - internal/services/projection_service.go:1250 — collectActualsForOverrides.
    Same column on `paliad.deadlines d`. Logged once per projection
    pass (`ERROR service: projection: deadlines: ...`); aliased the
    rename to `rule_id` so the receiving struct tag still scans.

Live container logs confirmed the failure mode — a 60-row burst of
`pq: column f.rule_id does not exist at position 3:36 (42703)` starting
the minute the post-B0 container came up (mig 140 had applied to the
DB but the SELECT still used the dropped name). EXPLAIN against the
live schema after the edit plans cleanly; the LEFT JOIN to
paliad.deadline_rules_unified on sequencing_rule_id was already correct
(only the SELECT projection was stale).

Root cause: mig 140 commit (1129bab) renamed the JOIN to
`f.sequencing_rule_id` but left the SELECT clause on the older name.
The model tag is already `db:"sequencing_rule_id" json:"rule_id"`, so
the wire shape is unchanged — only the column reference flips.

bun build clean, go vet ./... clean, go test ./... green.
2026-05-28 00:47:08 +02:00

1176 lines
43 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// DeadlineService reads and writes paliad.deadlines. Visibility inherits from the
// parent Project via ProjectService.GetByID — every read or write goes through
// that gate first.
//
// Audit: every mutation appends a paliad.project_events row via
// insertProjectEvent so the Project verlauf shows what changed.
//
// EventTypes: every Deadline can carry 0..N paliad.event_types via the
// paliad.deadline_event_types junction. The dependency is optional so
// the service stays runnable in isolated tests; in production main.go
// always wires it.
type DeadlineService struct {
db *sqlx.DB
projects *ProjectService
eventTypes *EventTypeService
approvals *ApprovalService
}
// NewDeadlineService wires the service. eventTypes and approvals may be
// nil in tests that don't exercise those features; production wires both.
func NewDeadlineService(db *sqlx.DB, projects *ProjectService, eventTypes *EventTypeService) *DeadlineService {
return &DeadlineService{db: db, projects: projects, eventTypes: eventTypes}
}
// SetApprovalService wires the optional 4-eye approval workflow
// (t-paliad-138). When set, every Create/Update/Complete/Delete consults
// paliad.approval_policies and may stage the change as a pending request
// instead of applying it directly. main.go wires this in production;
// tests that don't exercise the workflow can leave it unset.
func (s *DeadlineService) SetApprovalService(a *ApprovalService) {
s.approvals = a
}
// pendingApprovalErr enriches ErrConcurrentPending with the in-flight
// approval_request id + required_role for an entity, so handlers can
// render a 409 body that points the UI at the blocking request. Falls
// back to the bare ErrConcurrentPending if approvals isn't wired or the
// lookup fails — the user still gets a 409, just without the structured
// hint.
func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uuid.UUID) error {
if s.approvals == nil {
return ErrConcurrentPending
}
rid, role, err := s.approvals.PendingRequestForEntity(ctx, EntityTypeDeadline, deadlineID)
if err != nil || rid == "" {
return ErrConcurrentPending
}
return NewPendingApprovalError(rid, role)
}
// Slice B.4 (mig 140, t-paliad-305): rule_id column dropped from
// paliad.deadlines. sequencing_rule_id holds the same UUID and is the
// FK to paliad.sequencing_rules. SELECT-column lists below pull
// sequencing_rule_id into the Deadline.RuleID field (db tag adjusted in
// internal/models/models.go).
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
warning_date, source, sequencing_rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
notes, created_by, created_at, updated_at,
approval_status, pending_request_id, approved_by, approved_at`
// CreateDeadlineInput is the payload for Create / bulk create entries.
type CreateDeadlineInput struct {
Title string `json:"title"`
Description *string `json:"description,omitempty"`
DueDate string `json:"due_date"` // YYYY-MM-DD
OriginalDueDate *string `json:"original_due_date,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
// RuleCode is the legal citation ("RoP.023") to display in REGEL.
// Sent by the Fristenrechner save flow so the title can stay clean
// instead of carrying the citation as a prefix.
RuleCode *string `json:"rule_code,omitempty"`
// CustomRuleText is the lawyer's free-text rule label when the
// deadline form is in Custom mode (t-paliad-258). Mutually exclusive
// with RuleID at the application layer; the service trims and treats
// an all-whitespace value as nil.
CustomRuleText *string `json:"custom_rule_text,omitempty"`
Source string `json:"source,omitempty"` // default "manual"
Notes *string `json:"notes,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
// AgentTurnID, when non-nil, marks the create as a Paliadin-drafted
// suggestion (t-paliad-161). The deadline lands as approval_status='pending'
// with requester_kind='agent' on the approval_request, regardless of
// whether a (project, deadline, create) policy applies. Default-zero
// behaviour matches the user-direct path.
AgentTurnID *uuid.UUID `json:"agent_turn_id,omitempty"`
}
// UpdateDeadlineInput is the partial-update payload for PATCH.
// EventTypeIDs uses pointer-to-slice semantics: nil = leave existing
// attachments untouched; non-nil (including empty) = replace.
//
// ProjectID, when non-nil, moves the deadline under a different project
// (t-paliad-140). The caller must be able to see the new project; the
// service emits a deadline_project_changed audit row on both the old and
// new project so each side's Verlauf still shows the move.
type UpdateDeadlineInput struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
Notes *string `json:"notes,omitempty"`
Status *string `json:"status,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
// Rule pointer pair (t-paliad-258 / m/paliad#89). Three valid
// shapes; the service rejects "both set":
// - RuleSet=true, RuleID non-nil, CustomRuleText nil → Auto:
// bind to the catalog rule, clear custom_rule_text.
// - RuleSet=true, RuleID nil, CustomRuleText non-nil → Custom:
// store free text, clear rule_id.
// - RuleSet=true, RuleID nil, CustomRuleText nil → No rule:
// clear both columns.
// RuleSet=false leaves both columns untouched (the rest of the
// PATCH body doesn't carry rule changes).
RuleSet bool `json:"rule_set,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
CustomRuleText *string `json:"custom_rule_text,omitempty"`
}
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
//
// Bucket math (t-paliad-106): Heute / Diese Woche / Nächste Woche are disjoint
// by date; "this_week" runs from tomorrow through the upcoming Sunday
// inclusive, "next_week" from the following Monday through that Sunday
// inclusive. Items further out than next Sunday are not in any of the four
// active buckets — they only show up under "all" / "pending" / "upcoming"
// (the latter is kept as a server-side back-compat alias for "everything
// pending after this Sunday", but the dashboard + /deadlines summary cards
// no longer expose it).
type DeadlineStatusFilter string
const (
DeadlineFilterAll DeadlineStatusFilter = "all"
DeadlineFilterOverdue DeadlineStatusFilter = "overdue"
DeadlineFilterToday DeadlineStatusFilter = "today"
DeadlineFilterThisWeek DeadlineStatusFilter = "this_week"
DeadlineFilterNextWeek DeadlineStatusFilter = "next_week"
DeadlineFilterLater DeadlineStatusFilter = "later"
DeadlineFilterUpcoming DeadlineStatusFilter = "upcoming"
DeadlineFilterCompleted DeadlineStatusFilter = "completed"
DeadlineFilterPending DeadlineStatusFilter = "pending"
)
// ListFilter narrows ListVisibleForUser results.
//
// EventTypeIDs / IncludeUntyped form the multi-select Typ filter on
// /deadlines and /agenda (t-paliad-088). The two flags compose with OR:
// a deadline matches if it has at least one of EventTypeIDs attached
// OR (IncludeUntyped && it has none). When BOTH are zero/false the
// filter is inactive.
//
// CreatedBy narrows to deadlines whose `created_by = id`. Backs the
// "Nur persönliche" filter on /events (t-paliad-128) — applied on top of
// the team-visibility predicate so a deadline a user created on a team
// they have since left still doesn't leak through.
//
// DirectOnly narrows ProjectID from "this project + every descendant" (the
// t-paliad-139 subtree default) to "this project only" (t-paliad-152).
// Has no effect when ProjectID is nil.
type ListFilter struct {
Status DeadlineStatusFilter
ProjectID *uuid.UUID
EventTypeIDs []uuid.UUID
IncludeUntyped bool
CreatedBy *uuid.UUID
DirectOnly bool
}
// ListVisibleForUser returns Deadlines on every Project the user can see,
// joined with parent-Project display fields. Sorted by due_date ascending.
func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter ListFilter) ([]models.DeadlineWithProject, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.DeadlineWithProject{}, nil
}
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
}
if filter.ProjectID != nil {
if filter.DirectOnly {
conds = append(conds, `f.project_id = :project_id`)
} else {
conds = append(conds, projectDescendantPredicate("p"))
}
args["project_id"] = *filter.ProjectID
}
if filter.CreatedBy != nil {
conds = append(conds, `f.created_by = :created_by`)
args["created_by"] = *filter.CreatedBy
}
if etCond := buildEventTypeFilterClause(filter, args); etCond != "" {
conds = append(conds, etCond)
}
b := computeDeadlineBucketBounds(time.Now().UTC())
switch filter.Status {
case DeadlineFilterOverdue:
conds = append(conds, `f.status = 'pending' AND f.due_date < :today`)
args["today"] = b.today
case DeadlineFilterToday:
// "Heute" includes pending due today AND items that were completed
// today (rendered strikethrough/green client-side). m, 2026-05-22:
// previously filtered out the moment a row was checked off, so a
// user couldn't see their own progress on the day's deadlines.
// Items completed on earlier days still drop out — the bucket
// stays scoped to "today's work".
// date(...) cast instead of `::date` — sqlx's named-parameter parser
// reads `::date` as `::` + `:date` placeholder and rewrites it into
// a syntax error against Postgres.
conds = append(conds, `f.due_date = :today AND (f.status = 'pending' OR date(f.completed_at) = :today)`)
args["today"] = b.today
case DeadlineFilterThisWeek:
conds = append(conds, `f.status = 'pending' AND f.due_date > :today AND f.due_date < :next_monday`)
args["today"] = b.today
args["next_monday"] = b.nextMonday
case DeadlineFilterNextWeek:
conds = append(conds, `f.status = 'pending' AND f.due_date >= :next_monday AND f.due_date < :week_after`)
args["next_monday"] = b.nextMonday
args["week_after"] = b.weekAfter
case DeadlineFilterLater:
// "Später" — pending deadlines past next Sunday (t-paliad-110).
// The card on /deadlines clicks through to this filter; the dropdown
// also exposes it as the always-future-of-bucketed-window option.
conds = append(conds, `f.status = 'pending' AND f.due_date >= :week_after`)
args["week_after"] = b.weekAfter
case DeadlineFilterUpcoming:
// Back-compat: "upcoming" used to mean "anything pending past this week".
// Kept so legacy bookmarks / third-party links don't 4xx; the new UI
// surfaces the disjoint Heute / Diese Woche / Nächste Woche buckets
// instead.
conds = append(conds, `f.status = 'pending' AND f.due_date >= :next_monday`)
args["next_monday"] = b.nextMonday
case DeadlineFilterCompleted:
conds = append(conds, `f.status = 'completed'`)
case DeadlineFilterPending:
conds = append(conds, `f.status = 'pending'`)
case DeadlineFilterAll, "":
// no-op
default:
return nil, fmt.Errorf("%w: unknown status filter %q", ErrInvalidInput, filter.Status)
}
query := `
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
f.warning_date, f.source, f.sequencing_rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
f.created_at, f.updated_at,
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
p.reference AS project_reference,
p.title AS project_title,
p.type AS project_type,
r.name AS rule_name,
r.name_en AS rule_name_en,
ar.requester_kind AS requester_kind
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
LEFT JOIN paliad.deadline_rules_unified r ON r.id = f.sequencing_rule_id
LEFT JOIN paliad.approval_requests ar ON ar.id = f.pending_request_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY f.due_date ASC, f.created_at DESC`
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare list deadlines: %w", err)
}
defer stmt.Close()
rows := []models.DeadlineWithProject{}
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list deadlines: %w", err)
}
if len(rows) > 0 {
ids := make([]uuid.UUID, len(rows))
for i := range rows {
ids[i] = rows[i].ID
}
etByID, err := s.hydrateEventTypes(ctx, ids)
if err != nil {
return nil, err
}
for i := range rows {
rows[i].EventTypeIDs = etByID[rows[i].ID]
}
}
return rows, nil
}
// ListForProject returns Deadlines for a Project (visibility-checked).
//
// When directOnly is false (default), the result aggregates deadlines from
// the Project itself AND every descendant Project (per the t-paliad-139
// hierarchy aggregation contract). When directOnly is true, only deadlines
// whose project_id exactly equals the filter are returned — useful for
// edit / attribution surfaces that want exact narrowing.
//
// The descendant aggregation reuses the materialised path on
// paliad.projects (text-shaped, t-paliad-018). The visibility check on
// the filter Project is sufficient: paliad.can_see_project walks ancestors,
// so a user who can see Project P can see every descendant of P.
func (s *DeadlineService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Deadline, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
rows := []models.Deadline{}
var filter string
if directOnly {
filter = `WHERE project_id = $1`
} else {
filter = `WHERE project_id IN (
SELECT p.id FROM paliad.projects p
WHERE $1 = ANY(string_to_array(p.path, '.')::uuid[]))`
}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+deadlineColumns+`
FROM paliad.deadlines
`+filter+`
ORDER BY due_date ASC, created_at DESC`, projectID); err != nil {
return nil, fmt.Errorf("list deadlines for project: %w", err)
}
if len(rows) > 0 {
ids := make([]uuid.UUID, len(rows))
for i := range rows {
ids[i] = rows[i].ID
}
etByID, err := s.hydrateEventTypes(ctx, ids)
if err != nil {
return nil, err
}
for i := range rows {
rows[i].EventTypeIDs = etByID[rows[i].ID]
}
}
return rows, nil
}
// GetByID returns a single Deadline, with parent Project visibility checked.
func (s *DeadlineService) GetByID(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
projectID, err := s.parentProjectID(ctx, deadlineID)
if err != nil {
return nil, err
}
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
var f models.Deadline
if err := s.db.GetContext(ctx, &f,
`SELECT `+deadlineColumns+` FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
return nil, fmt.Errorf("fetch deadline: %w", err)
}
etIDs, err := s.hydrateEventTypes(ctx, []uuid.UUID{f.ID})
if err != nil {
return nil, err
}
f.EventTypeIDs = etIDs[f.ID]
return &f, nil
}
// Create inserts a single Deadline under a Project.
func (s *DeadlineService) Create(ctx context.Context, userID, projectID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
if err := s.validateEventTypeIDs(ctx, userID, input.EventTypeIDs); err != nil {
return nil, err
}
id, err := s.insert(ctx, userID, projectID, input)
if err != nil {
return nil, err
}
return s.GetByID(ctx, userID, id)
}
// validateEventTypeIDs returns nil if every id is visible to userID, or
// ErrNotVisible / ErrInvalidInput otherwise. No-op when ids is empty
// or when the eventTypes service is unwired (test harness).
func (s *DeadlineService) validateEventTypeIDs(ctx context.Context, userID uuid.UUID, ids []uuid.UUID) error {
if len(ids) == 0 || s.eventTypes == nil {
return nil
}
if _, err := s.eventTypes.ValidateForUser(ctx, userID, ids); err != nil {
return err
}
return nil
}
// CreateBulk inserts multiple Deadlines under one Project in a single
// transaction (Fristenrechner "Als Deadline(en) speichern" flow).
func (s *DeadlineService) CreateBulk(ctx context.Context, userID, projectID uuid.UUID, inputs []CreateDeadlineInput) ([]models.Deadline, error) {
if len(inputs) == 0 {
return nil, fmt.Errorf("%w: at least one Deadline is required", ErrInvalidInput)
}
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
ids := make([]uuid.UUID, 0, len(inputs))
for _, in := range inputs {
if err := s.validateEventTypeIDs(ctx, userID, in.EventTypeIDs); err != nil {
return nil, err
}
id, err := s.insertTx(ctx, tx, userID, projectID, in)
if err != nil {
return nil, err
}
if s.eventTypes != nil && len(in.EventTypeIDs) > 0 {
if err := s.eventTypes.AttachToDeadlineTx(ctx, tx, id, in.EventTypeIDs); err != nil {
return nil, err
}
}
ids = append(ids, id)
}
// Description carries the value-only payload (the import count); the
// frontend renders via the localized event.description.deadlines_imported
// template.
desc := strconv.Itoa(len(inputs))
descPtr := &desc
if err := insertProjectEvent(ctx, tx, projectID, userID, "deadlines_imported", "Deadlines imported", descPtr); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit bulk create: %w", err)
}
out := make([]models.Deadline, 0, len(ids))
for _, id := range ids {
f, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
out = append(out, *f)
}
return out, nil
}
// Update applies a partial update to a Deadline.
//
// Approval gate (t-paliad-138): if any date-bearing field actually changes
// (due_date / original_due_date / warning_date — Q4 allowlist), the change
// is applied immediately AND parked in paliad.approval_requests with
// approval_status='pending' on the row. Approver flips it to 'approved'
// or rejects (which reverts the row from the snapshotted pre_image).
//
// Refuses to mutate a row whose approval_status is already 'pending'
// (a different request is in flight) — caller must wait or revoke.
func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, deadlineID)
if err != nil {
return nil, err
}
if current.ApprovalStatus == ApprovalStatusPending {
return nil, s.pendingApprovalErr(ctx, deadlineID)
}
sets := []string{}
args := []any{}
next := 1
appendSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
// Capture pre_image / payload for the date-bearing allowlist as fields
// are about to be set. Only populated when a field actually changes —
// SubmitUpdate skips the approval flow entirely when nothing in the
// allowlist moved.
preImage := map[string]any{}
payload := map[string]any{}
if input.Title != nil {
title := strings.TrimSpace(*input.Title)
if title == "" {
return nil, fmt.Errorf("%w: title cannot be empty", ErrInvalidInput)
}
appendSet("title", title)
}
if input.Description != nil {
appendSet("description", *input.Description)
}
if input.DueDate != nil {
due, err := time.Parse("2006-01-02", *input.DueDate)
if err != nil {
return nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
}
if !due.Equal(current.DueDate) {
preImage["due_date"] = current.DueDate.Format("2006-01-02")
payload["due_date"] = *input.DueDate
}
appendSet("due_date", due)
}
if input.Notes != nil {
appendSet("notes", *input.Notes)
}
if input.Status != nil {
if !isValidDeadlineStatus(*input.Status) {
return nil, fmt.Errorf("%w: invalid status %q", ErrInvalidInput, *input.Status)
}
appendSet("status", *input.Status)
if *input.Status == "completed" && current.CompletedAt == nil {
appendSet("completed_at", time.Now().UTC())
} else if *input.Status != "completed" {
appendSet("completed_at", nil)
}
}
if input.EventTypeIDs != nil {
if err := s.validateEventTypeIDs(ctx, userID, *input.EventTypeIDs); err != nil {
return nil, err
}
}
// Auto/Custom rule swap (t-paliad-258). Mutually exclusive at the
// persistence boundary: setting one column NULLs the other.
if input.RuleSet {
if input.RuleID != nil && input.CustomRuleText != nil {
return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
}
// Slice B.4 (t-paliad-305): rule_id column dropped; the FK
// back-link now lives on sequencing_rule_id. Same UUID value.
// The procedural_event_id mirror is derived in
// syncDeadlineDualLinks below after the primary UPDATE lands.
appendSet("sequencing_rule_id", input.RuleID)
var customText *string
if input.CustomRuleText != nil {
trimmed := strings.TrimSpace(*input.CustomRuleText)
if trimmed != "" {
customText = &trimmed
}
}
appendSet("custom_rule_text", customText)
}
// Project move (t-paliad-140). Visibility on the destination is enforced
// the same way as on Create — a GetByID round-trip through ProjectService
// returns ErrNotVisible if the user can't see the target. Same-project
// "moves" are silently dropped so a UI that always sends project_id in
// the PATCH payload doesn't churn the Verlauf.
var movedFromProject *uuid.UUID
if input.ProjectID != nil && *input.ProjectID != current.ProjectID {
if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
return nil, err
}
appendSet("project_id", *input.ProjectID)
from := current.ProjectID
movedFromProject = &from
}
if len(sets) == 0 && input.EventTypeIDs == nil {
return current, nil
}
if len(sets) > 0 {
appendSet("updated_at", time.Now().UTC())
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if len(sets) > 0 {
args = append(args, deadlineID)
query := fmt.Sprintf("UPDATE paliad.deadlines SET %s WHERE id = $%d",
strings.Join(sets, ", "), next)
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update deadline: %w", err)
}
// Slice B.4 (mig 140, t-paliad-305): rule_id column gone;
// sequencing_rule_id holds the back-link. When the patch updated
// it (auto/custom swap from t-paliad-258), mirror the FK onto
// procedural_event_id so the joined view continues to resolve.
// Idempotent: no-op when sequencing_rule_id is unchanged.
if input.RuleSet {
if err := syncDeadlineProceduralEventID(ctx, tx, deadlineID); err != nil {
return nil, err
}
}
}
if input.EventTypeIDs != nil && s.eventTypes != nil {
if err := s.eventTypes.AttachToDeadlineTx(ctx, tx, deadlineID, *input.EventTypeIDs); err != nil {
return nil, err
}
}
// Description carries value-only payload (the deadline title); frontend
// renders via the localized event.description.deadline_updated template.
// Same pattern below for completed/reopened/deleted/created.
//
// Audit shape for project moves (t-paliad-140): emit
// deadline_project_changed on both old and new project rows so each
// side's Verlauf still shows the move (the row is gone from the old
// project, but its history shouldn't be). If the same PATCH also
// touched other fields, the new project additionally records a
// deadline_updated covering those edits.
desc := current.Title
descPtr := &desc
if movedFromProject != nil {
moveMeta := map[string]any{
"deadline_id": deadlineID,
"from_project_id": *movedFromProject,
"to_project_id": *input.ProjectID,
}
if err := insertProjectEventWithMeta(ctx, tx, *movedFromProject, userID,
"deadline_project_changed", "Deadline project changed", descPtr, moveMeta); err != nil {
return nil, err
}
if err := insertProjectEventWithMeta(ctx, tx, *input.ProjectID, userID,
"deadline_project_changed", "Deadline project changed", descPtr, moveMeta); err != nil {
return nil, err
}
}
// Did the PATCH touch anything beyond the project move?
otherFieldsTouched := input.Title != nil || input.Description != nil ||
input.DueDate != nil || input.Notes != nil || input.Status != nil ||
input.EventTypeIDs != nil || input.RuleSet
if otherFieldsTouched {
auditProject := current.ProjectID
if movedFromProject != nil {
auditProject = *input.ProjectID
}
if err := insertProjectEventWithMeta(ctx, tx, auditProject, userID, "deadline_updated", "Deadline updated", descPtr,
map[string]any{"deadline_id": deadlineID}); err != nil {
return nil, err
}
}
// Approval gate (Q4 = date-bearing allowlist only). When preImage is
// empty (no allowlisted field changed), SubmitUpdate is a no-op.
if s.approvals != nil {
if _, err := s.approvals.SubmitUpdate(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage, payload); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update deadline: %w", err)
}
return s.GetByID(ctx, userID, deadlineID)
}
// Complete marks a Deadline as completed.
//
// Approval gate (t-paliad-138): if a (project, deadline, complete) policy
// applies, the row is flipped to status='completed' immediately AND
// parked in approval_requests with approval_status='pending'. Reject
// reverts (status back to 'pending', completed_at cleared).
func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, deadlineID)
if err != nil {
return nil, err
}
if current.Status == "completed" {
return current, nil
}
if current.ApprovalStatus == ApprovalStatusPending {
return nil, s.pendingApprovalErr(ctx, deadlineID)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines
SET status = 'completed', completed_at = $1, updated_at = $1
WHERE id = $2`, now, deadlineID); err != nil {
return nil, fmt.Errorf("complete deadline: %w", err)
}
desc := current.Title
descPtr := &desc
if err := insertProjectEventWithMeta(ctx, tx, current.ProjectID, userID, "deadline_completed", "Deadline completed", descPtr,
map[string]any{"deadline_id": deadlineID}); err != nil {
return nil, err
}
if s.approvals != nil {
preImage := map[string]any{
"status": current.Status,
"completed_at": nil,
}
if _, err := s.approvals.SubmitComplete(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage, nil); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit complete: %w", err)
}
return s.GetByID(ctx, userID, deadlineID)
}
// Reopen flips a completed Deadline back to pending and clears completed_at.
// Authorization: global admin OR a member of the Project (or any ancestor)
// with project_teams.responsibility = 'lead'. Other authenticated viewers
// can see the Deadline but cannot reopen it.
func (s *DeadlineService) Reopen(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, deadlineID)
if err != nil {
return nil, err
}
if err := s.assertCanAdminProject(ctx, userID, current.ProjectID); err != nil {
return nil, err
}
if current.Status != "completed" {
return current, nil
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines
SET status = 'pending', completed_at = NULL, updated_at = $1
WHERE id = $2`, now, deadlineID); err != nil {
return nil, fmt.Errorf("reopen deadline: %w", err)
}
desc := current.Title
descPtr := &desc
if err := insertProjectEventWithMeta(ctx, tx, current.ProjectID, userID, "deadline_reopened", "Deadline reopened", descPtr,
map[string]any{"deadline_id": deadlineID}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit reopen: %w", err)
}
return s.GetByID(ctx, userID, deadlineID)
}
// assertCanAdminProject returns nil if the user may perform admin-level
// actions on the Project (reopen, future bulk ops). Pass-conditions:
// - users.global_role = 'global_admin', or
// - direct/inherited project_teams membership with responsibility = 'lead'.
//
// Returns ErrForbidden otherwise. Visibility must be checked separately
// (callers do this via GetByID before calling here).
//
// t-paliad-148: switched from `role IN ('admin','lead')` to
// `responsibility = 'lead'`. The legacy 'admin' value was already dead
// since t-paliad-051 (project_teams.role never had an 'admin' value;
// only the legacy users.role enum did, before it was split into
// global_role).
func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, projectID uuid.UUID) error {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrNotVisible
}
if user.GlobalRole == "global_admin" {
return nil
}
var ok bool
err = s.db.GetContext(ctx, &ok,
`SELECT EXISTS (
SELECT 1
FROM paliad.projects p
JOIN paliad.project_teams pt
ON pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
WHERE p.id = $1
AND pt.user_id = $2
AND pt.responsibility = 'lead'
)`, projectID, userID)
if err != nil {
return fmt.Errorf("check project admin: %w", err)
}
if !ok {
return fmt.Errorf("%w: only project leads can reopen Deadlines", ErrForbidden)
}
return nil
}
// Delete removes a Deadline. Partner/admin only.
//
// Approval gate (t-paliad-138): if a (project, deadline, delete) policy
// applies, this is the one stage-then-write exception in the otherwise
// write-then-approve architecture. The row stays alive with
// approval_status='pending' until the approver hard-deletes (approve) or
// restores it (reject).
func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UUID) error {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrNotVisible
}
if user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only partners/admins can delete Deadlines", ErrForbidden)
}
current, err := s.GetByID(ctx, userID, deadlineID)
if err != nil {
return err
}
if current.ApprovalStatus == ApprovalStatusPending {
return s.pendingApprovalErr(ctx, deadlineID)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// Approval gate runs FIRST (before the actual delete). If a policy
// applies, SubmitDelete returns a non-nil request id and we skip the
// hard delete — the row is now flagged pending. The approver's
// Approve flips it to a real delete; their Reject clears the marker.
var pendingRequest *uuid.UUID
if s.approvals != nil {
preImage := map[string]any{
"title": current.Title,
"due_date": current.DueDate.Format("2006-01-02"),
"status": current.Status,
}
req, err := s.approvals.SubmitDelete(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage)
if err != nil {
return err
}
pendingRequest = req
}
if pendingRequest == nil {
// No policy applied — proceed with the immediate hard-delete.
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
return fmt.Errorf("delete deadline: %w", err)
}
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_deleted", "Deadline deleted", descPtr); err != nil {
return err
}
}
return tx.Commit()
}
// SummaryCounts returns traffic-light counts across the user's visible Deadlines.
//
// The five buckets are disjoint (t-paliad-106):
// - Overdue: pending AND due_date < today
// - Today: pending AND due_date == today
// - ThisWeek: pending AND today < due_date <= upcoming Sunday (inclusive)
// - NextWeek: pending AND Mon next week <= due_date <= Sun next week
// - Completed: status = 'completed' (no time bound — full history)
//
// "Total" is the count of all visible Deadlines (any status). Items with a
// pending due_date past next Sunday are not in any of the four pending buckets;
// they're still counted in Total and in the all-pending list.
type SummaryCounts struct {
Overdue int `json:"overdue" db:"overdue"`
Today int `json:"today" db:"today"`
ThisWeek int `json:"this_week" db:"this_week"`
NextWeek int `json:"next_week" db:"next_week"`
Completed int `json:"completed" db:"completed"`
Total int `json:"total" db:"total"`
}
// SummaryCounts aggregates Deadlines by due-date bucket for the user's visible
// projects, optionally scoped to a single Project.
func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID) (*SummaryCounts, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return &SummaryCounts{}, nil
}
b := computeDeadlineBucketBounds(time.Now().UTC())
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
"today": b.today,
"tomorrow": b.tomorrow,
"next_monday": b.nextMonday,
"week_after": b.weekAfter,
}
if projectID != nil {
conds = append(conds, projectDescendantPredicate("p"))
args["project_id"] = *projectID
}
query := `
SELECT
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < :today) AS overdue,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date = :today) AS today,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :tomorrow AND f.due_date < :next_monday) AS this_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :next_monday AND f.due_date < :week_after) AS next_week,
COUNT(*) FILTER (WHERE f.status = 'completed') AS completed,
COUNT(*) AS total
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
WHERE ` + strings.Join(conds, " AND ")
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare summary: %w", err)
}
defer stmt.Close()
var c SummaryCounts
if err := stmt.GetContext(ctx, &c, args); err != nil {
return nil, fmt.Errorf("deadline summary: %w", err)
}
return &c, nil
}
// deadlineBucketBounds carries the date pivots used by both
// SummaryCounts and ListVisibleForUser to keep the cutoff math in
// exactly one place. All values are UTC-day boundaries (00:00:00 UTC),
// matching the existing convention — see the package-level note on
// timezone in the original SummaryCounts implementation.
type deadlineBucketBounds struct {
today time.Time
tomorrow time.Time
nextMonday time.Time // Monday of next week (= Sunday-of-this-week + 1d)
weekAfter time.Time // Monday of the week after next (exclusive end of NextWeek)
}
// computeDeadlineBucketBounds derives the four pivots from `now`.
//
// Encoding of weekdays in Go: 0=Sunday, 1=Monday, …, 6=Saturday.
// "Sunday of this week" is the upcoming Sunday including today, so when
// today is Sunday daysToSunday = 0 (Diese Woche has no future-eligible
// entries beyond Heute, which is exactly what the spec calls for).
func computeDeadlineBucketBounds(now time.Time) deadlineBucketBounds {
today := now.Truncate(24 * time.Hour)
tomorrow := today.AddDate(0, 0, 1)
daysToSunday := (7 - int(today.Weekday())) % 7
sunday := today.AddDate(0, 0, daysToSunday)
nextMonday := sunday.AddDate(0, 0, 1)
weekAfter := nextMonday.AddDate(0, 0, 7)
return deadlineBucketBounds{
today: today,
tomorrow: tomorrow,
nextMonday: nextMonday,
weekAfter: weekAfter,
}
}
// insert performs one INSERT in its own transaction.
func (s *DeadlineService) insert(ctx context.Context, userID, projectID uuid.UUID, input CreateDeadlineInput) (uuid.UUID, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return uuid.Nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
id, err := s.insertTx(ctx, tx, userID, projectID, input)
if err != nil {
return uuid.Nil, err
}
if s.eventTypes != nil && len(input.EventTypeIDs) > 0 {
if err := s.eventTypes.AttachToDeadlineTx(ctx, tx, id, input.EventTypeIDs); err != nil {
return uuid.Nil, err
}
}
desc := strings.TrimSpace(input.Title)
descPtr := &desc
if err := insertProjectEventWithMeta(ctx, tx, projectID, userID, "deadline_created", "Deadline created", descPtr,
map[string]any{"deadline_id": id}); err != nil {
return uuid.Nil, err
}
// Approval gate: if a (project, deadline, create) policy applies, this
// flips the just-inserted row's approval_status to 'pending' and emits
// a 'deadline_approval_requested' audit event. No-op when no policy is
// configured or when the approval service isn't wired (test harness).
//
// Agent-suggested path (t-paliad-161): when input.AgentTurnID is set,
// the row goes through the agent-create variant which always creates
// a request (bypassing the policy gate) and stamps the request with
// requester_kind='agent' + the originating turn id.
if s.approvals != nil {
payload := map[string]any{
"title": desc,
"due_date": input.DueDate,
}
if input.AgentTurnID != nil {
if _, err := s.approvals.SubmitAgentCreate(ctx, tx, projectID, id, userID, *input.AgentTurnID, EntityTypeDeadline, payload); err != nil {
return uuid.Nil, err
}
} else {
if _, err := s.approvals.SubmitCreate(ctx, tx, projectID, id, userID, EntityTypeDeadline, payload); err != nil {
return uuid.Nil, err
}
}
}
if err := tx.Commit(); err != nil {
return uuid.Nil, fmt.Errorf("commit insert deadline: %w", err)
}
return id, nil
}
// insertTx writes one deadlines row in an existing transaction.
func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, projectID uuid.UUID, input CreateDeadlineInput) (uuid.UUID, error) {
title := strings.TrimSpace(input.Title)
if title == "" {
return uuid.Nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
if input.DueDate == "" {
return uuid.Nil, fmt.Errorf("%w: due_date is required", ErrInvalidInput)
}
due, err := time.Parse("2006-01-02", input.DueDate)
if err != nil {
return uuid.Nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
}
var orig *time.Time
if input.OriginalDueDate != nil && *input.OriginalDueDate != "" {
o, err := time.Parse("2006-01-02", *input.OriginalDueDate)
if err != nil {
return uuid.Nil, fmt.Errorf("%w: original_due_date must be YYYY-MM-DD", ErrInvalidInput)
}
orig = &o
}
source := input.Source
if source == "" {
source = "manual"
}
var ruleCode *string
if input.RuleCode != nil {
trimmed := strings.TrimSpace(*input.RuleCode)
if trimmed != "" {
ruleCode = &trimmed
}
}
// Auto vs Custom (t-paliad-258): RuleID and CustomRuleText are
// mutually exclusive. If the caller passes both, the catalog rule
// wins and the free-text is dropped — keeps the invariant simple at
// the persistence boundary.
var customRuleText *string
if input.CustomRuleText != nil && input.RuleID == nil {
trimmed := strings.TrimSpace(*input.CustomRuleText)
if trimmed != "" {
customRuleText = &trimmed
}
}
id := uuid.New()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(id, project_id, title, description, due_date, original_due_date,
source, rule_id, rule_code, custom_rule_text, status, notes, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11, $12, $13, $13)`,
id, projectID, title, input.Description, due, orig,
source, input.RuleID, ruleCode, customRuleText, input.Notes, userID, now,
); err != nil {
return uuid.Nil, fmt.Errorf("insert deadline: %w", err)
}
return id, nil
}
// parentProjectID resolves a Deadline's parent Project ID without a visibility
// check. Internal only — callers must then gate via ProjectService.GetByID.
func (s *DeadlineService) parentProjectID(ctx context.Context, deadlineID uuid.UUID) (uuid.UUID, error) {
var projectID uuid.UUID
err := s.db.GetContext(ctx, &projectID,
`SELECT project_id FROM paliad.deadlines WHERE id = $1`, deadlineID)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrNotVisible
}
if err != nil {
return uuid.Nil, fmt.Errorf("lookup deadline parent: %w", err)
}
return projectID, nil
}
// users returns the shared user service via the ProjectService handle.
func (s *DeadlineService) users() *UserService {
return s.projects.Users()
}
func isValidDeadlineStatus(st string) bool {
switch st {
case "pending", "completed", "cancelled", "waived":
return true
}
return false
}
// buildEventTypeFilterClause returns the WHERE-fragment that enforces
// ListFilter.EventTypeIDs / ListFilter.IncludeUntyped against the
// `paliad.deadlines` row aliased `f`. Caller is using a sqlx named-args
// map; this function injects the params directly into that map and
// returns a fragment usable with the named-statement compiler. Returns
// empty when no Typ filter is active.
func buildEventTypeFilterClause(filter ListFilter, args map[string]any) string {
if len(filter.EventTypeIDs) == 0 && !filter.IncludeUntyped {
return ""
}
parts := []string{}
if len(filter.EventTypeIDs) > 0 {
// sqlx.PrepareNamedContext doesn't expand IN-with-slice; build
// per-element named placeholders manually instead.
phs := make([]string, 0, len(filter.EventTypeIDs))
for i, id := range filter.EventTypeIDs {
key := fmt.Sprintf("event_type_id_%d", i)
args[key] = id
phs = append(phs, ":"+key)
}
parts = append(parts, `EXISTS (
SELECT 1 FROM paliad.deadline_event_types det
WHERE det.deadline_id = f.id
AND det.event_type_id IN (`+strings.Join(phs, ", ")+`)
)`)
}
if filter.IncludeUntyped {
parts = append(parts, `NOT EXISTS (
SELECT 1 FROM paliad.deadline_event_types det
WHERE det.deadline_id = f.id
)`)
}
if len(parts) == 1 {
return parts[0]
}
return "(" + strings.Join(parts, " OR ") + ")"
}
// hydrateEventTypes loads the attached event_type_ids for each Deadline
// (or DeadlineWithProject) in rows. No-op when eventTypes service is
// unset (test fixtures).
func (s *DeadlineService) hydrateEventTypes(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) {
if s.eventTypes == nil {
out := make(map[uuid.UUID][]uuid.UUID, len(ids))
for _, id := range ids {
out[id] = []uuid.UUID{}
}
return out, nil
}
return s.eventTypes.ListForDeadlines(ctx, ids)
}