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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
188 lines
11 KiB
Markdown
188 lines
11 KiB
Markdown
# 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.
|