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.
1176 lines
43 KiB
Go
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)
|
|
}
|