PSA/docs/plans/2026-06-10-ticket-close-rules-design.md
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

11 KiB
Raw Blame History

Ticket Close Rules — Design

  • Status: Approved design
  • Created: 2026-06-10
  • Branch: feature/ticket-close-rules

1. Problem statement

Today nothing governs how a ticket gets closed. Any status update that lands on a status with is_closed = true closes the ticket — no required resolution, no time-entry check, no completion checklist — and abandoned tickets sit in "Waiting for Customer" forever unless a human remembers them. The only closure-adjacent checks in the codebase are idempotency guards in the workflow tickets.close action (shared/workflow/runtime/actions/businessOperations/tickets.ts) and a config constraint that a closed status cannot be a board default (packages/reference-data/src/actions/status-actions/statusActions.ts).

This feature adds three capabilities, all configured per board:

  1. Pre-close validation gates — conditions a ticket must satisfy before a human can move it to a closed status, with a permissioned override.
  2. Ticket checklists — first-class checklist items on tickets (ad-hoc, template-applied, rule-applied, or workflow-applied), where checking an item permanently records and displays who checked it and when. Required-item completion is one of the close gates.
  3. Auto-close rules — tickets sitting in a configured status with no activity for N days close automatically, with an optional warning notification beforehand.

2. Decisions (with rationale)

Decision Choice
Rule scope Per board, matching how statuses, SLAs, and email settings are board-scoped
Gate behavior Hard block with a permissioned override (ticket.close_override); overrides audit-logged with the failure list
Gate types (v1) Resolution comment, ≥1 time entry, required checklist items complete, no open bundled children, admin-chosen required fields
Enforcement coverage All human paths: MSP UI single + bulk, REST API. Exempt: workflow tickets.close, CSV import, auto-close engine, and the client portal (customers cannot satisfy internal-hygiene gates like time entry; blocking them dead-ends the conversation). Exempt closures are audit-logged as bypasses
Checklist accountability completed_by + completed_at stored permanently and displayed inline (name + timestamp); check/uncheck written to the ticket audit log. No confirmation friction at click time
Checklist sources Ad-hoc per ticket, reusable admin templates, auto-apply matchers (board/category/subcategory/priority), and a workflow action
Auto-close model Status + inactivity timer per board, optional warning M days before close
Auto-close engine Single recurring scan job (15 min) via the IJobRunner abstraction (server/src/lib/jobs/JobRunnerFactory.ts) — Temporal on EE/appliance/on-prem, pg-boss otherwise. A per-ticket Temporal workflow was considered and rejected: CE would still need the scan (two implementations), inactivity resets would require signal plumbing across every comment/reply path (the SLA Temporal workflow already needed a self-healing poll for exactly this drift), and config changes would invalidate in-flight timers. Day-granularity timers gain nothing from event-driven precision. ticket_auto_close_state is engine-agnostic, so the engine can be swapped later without a data-model change

3. Data model

All tables tenant-scoped with composite (tenant, id) primary keys per the Citus conventions (server/migrations/20250804000001_fix_primary_keys_for_citus.cjs).

board_close_rules — one row per board: require_resolution_comment, require_time_entry, require_checklist_complete, require_no_open_children (booleans), required_fields (jsonb array from an allowed set: category, subcategory, priority, assignee, …), is_enabled. A fixed v1 gate set means toggles, not a generic rule-row model.

ticket_checklist_items — live checklist on a ticket: item_name, description, order_number, assigned_to, is_required (default true; only required items gate closure), completed, completed_by, completed_at, provenance (source: manual | template | workflow; nullable template_id). Modeled after task_checklist_items (server/migrations/20241009225600_create_task_checklist_items.cjs) plus the accountability and provenance fields that table lacks.

checklist_templates / checklist_template_items — admin-defined templates; items carry name/description/order/is_required. Items are copied onto tickets, never referenced, so later template edits do not mutate history.

checklist_template_apply_rulestemplate_id + nullable board_id / category_id / subcategory_id / priority_id (null = match any). Evaluated on ticket creation and on board/category change; application is additive and idempotent — a template never applies twice to the same ticket.

board_auto_close_rules — multiple rows per board: trigger_status_id, inactivity_days, nullable warning_days_before, close_to_status_id (must be an is_closed status), is_enabled.

ticket_auto_close_state — engine scratchpad per ticket: matched rule_id, scheduled_close_at, warning_sent_at. Recomputed/cleared whenever activity resets the timer; prevents duplicate warnings and drives the "will auto-close on …" banner.

Permission: ticket.close_override added to the RBAC seeds, granted to Admin by default.

4. Enforcement flow

A single shared function in packages/tickets:

validateTicketClosure(trx, tenant, ticketId, user, options)
   { allowed, failures: [{ rule, message, meta }], overridden? }

It loads the board's board_close_rules row and evaluates each enabled gate inside the caller's transaction. Gate checks are cheap queries: resolution comment → a comment tagged as resolution exists; time entry → any time_entries row for the ticket; checklist → no incomplete is_required items; open children → no open tickets bundled under this master; required fields → null checks on the ticket row.

Call sites are the four places that already detect the open→closed status flip:

  • updateTicketpackages/tickets/src/actions/ticketActions.ts
  • updateTicketInTransactionpackages/tickets/src/actions/optimizedTicketActions.ts (covers board view, bulk actions, move-to-board, TicketDetails)
  • TicketService.updateserver/src/lib/api/services/TicketService.ts (REST v1, mobile)
  • client portal updateTicketStatuspackages/client-portal/src/actions/client-portal-actions/client-tickets.ts (exempt from gates, but instrumented for the closure-recording fix below)

On failure the chokepoint throws a typed TicketCloseValidationError carrying the failures array — rendered as a blocked-close dialog in the UI and a 422 with structured details from the API. Bulk close validates per ticket and reports per-ticket results instead of failing the batch.

Override: a request with overrideCloseRules: true is honored only when the server confirms ticket.close_override; the override and its failure list are written to ticket_audit_logs as TICKET_CLOSE_RULES_OVERRIDDEN.

Exemptions (workflow tickets.close, CSV import, auto-close engine, client portal) pass a system bypass flag and are audit-logged as bypassed.

Portal closure-recording fix (in scope): the portal status-update path currently neither sets is_closed / closed_at / closed_by nor publishes TICKET_CLOSED. As part of wiring the chokepoint through it, it gains the same closure side effects as every other path.

5. Auto-close engine

One recurring job, auto-close-tickets, every 15 minutes, registered through IJobRunner.scheduleRecurringJob. Each run, per tenant:

  1. Match. Find open tickets whose current status has an enabled board_auto_close_rules row. Compute last_activity_at (latest of comments, status changes, customer replies) and scheduled_close_at = last_activity_at + inactivity_days. Upsert ticket_auto_close_state; newer activity recomputes the row, resetting the timer and cancelling any pending warning/close.
  2. Warn. Where warning_days_before is set and now ≥ scheduled_close_at warning, send the new ticket-auto-close-warning notification template to the ticket contact (respecting tenant notification settings) and stamp warning_sent_at.
  3. Close. Where now ≥ scheduled_close_at, close via updateTicketInTransaction with a system actor, the bypass flag, and an automatic comment ("Closed automatically after N days of inactivity"). Riding the normal path means TICKET_CLOSED, closure emails, SLA resolution recording, surveys, webhooks, and search reindexing all fire exactly as a manual close would.

The close step re-validates inactivity inside the closing transaction, so activity racing the scan cannot cause a wrongful close, and re-runs are idempotent. The job is per-tenant, per-ticket try/catch — one bad ticket never stalls the sweep.

6. UI surfaces

  • Board settings → Close Rules: gate toggles, required-fields multi-select, and the board's auto-close rules list (trigger status, inactivity days, warning lead time, target closed status).
  • Settings → Checklist Templates: template CRUD, drag-ordered items with per-item is_required, and each template's auto-apply matchers.
  • Ticket details — Checklist section: checkboxes, inline add, "Apply template" picker. A checked item permanently shows the checker's avatar/name and timestamp inline. A progress chip ("3 of 5 required done") sits near the status control. Unchecking is allowed but clears who/when and writes an audit entry recording the uncheck and the prior signoff.
  • Blocked-close dialog: lists each unmet condition with a quick action (jump to checklist, add time, …). Holders of ticket.close_override get "Close anyway" with an optional reason.
  • Auto-close banner: "Will close automatically on unless there's new activity", driven by ticket_auto_close_state.

7. Workflow integration

  • New workflow action: apply a checklist template to a ticket.
  • The existing tickets.close action keeps its bypass but logs it.

8. Testing

  • Unit: each gate in validateTicketClosure (pass/fail/override paths).
  • Integration (repo integration-test patterns): blocked/allowed/override closes across all four entry paths; accountability field writes on check/uncheck; template auto-apply on create and on board/category change; scan match/warn/close behavior with synthetic timestamps; portal closure-recording fix publishes TICKET_CLOSED.
  • Playwright: blocked-close dialog flow; checklist who/when display; board settings and template settings CRUD.