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

188 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_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`:
```ts
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.ts`
- `updateTicketInTransaction``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:
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 <date> 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.