Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
11 KiB
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:
- Pre-close validation gates — conditions a ticket must satisfy before a human can move it to a closed status, with a permissioned override.
- 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.
- 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_rules — template_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:
updateTicket—packages/tickets/src/actions/ticketActions.tsupdateTicketInTransaction—packages/tickets/src/actions/optimizedTicketActions.ts(covers board view, bulk actions, move-to-board, TicketDetails)TicketService.update—server/src/lib/api/services/TicketService.ts(REST v1, mobile)- client portal
updateTicketStatus—packages/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:
- Match. Find open tickets whose current status has an enabled
board_auto_close_rulesrow. Computelast_activity_at(latest of comments, status changes, customer replies) andscheduled_close_at = last_activity_at + inactivity_days. Upsertticket_auto_close_state; newer activity recomputes the row, resetting the timer and cancelling any pending warning/close. - Warn. Where
warning_days_beforeis set andnow ≥ scheduled_close_at − warning, send the newticket-auto-close-warningnotification template to the ticket contact (respecting tenant notification settings) and stampwarning_sent_at. - Close. Where
now ≥ scheduled_close_at, close viaupdateTicketInTransactionwith a system actor, the bypass flag, and an automatic comment ("Closed automatically after N days of inactivity"). Riding the normal path meansTICKET_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_overrideget "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.closeaction 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.