diff --git a/docs.json b/docs.json index 7891a9d..471d00a 100644 --- a/docs.json +++ b/docs.json @@ -90,6 +90,12 @@ "anchor": "automations", "type": "single" }, + { + "slug": "workflows", + "label": "Workflows", + "anchor": "workflows", + "type": "single" + }, { "slug": "followers", "label": "Followers", diff --git a/sections/workflows.md b/sections/workflows.md new file mode 100644 index 0000000..97527bb --- /dev/null +++ b/sections/workflows.md @@ -0,0 +1,238 @@ +# Workflows + +Workflows are event-driven automation rules that run in response to ticket lifecycle events (new ticket, status change, SLA breach, inbound reply, etc.). Unlike [automations](automations.md), which run on a recurring scheduler, workflows fire **the moment the triggering event happens** -- an agent assigning a ticket, a customer replying, an SLA tipping over. + +Use workflows when you want reactive side effects. Use automations when you want periodic sweeps of the ticket database. + +## How workflows work + +Every workflow has three parts: + +1. **Trigger event** -- the event that starts evaluation (e.g. `ticket.created`, `reply.created`, `sla.breached`). +2. **Conditions** -- a filter evaluated on the ticket when the event fires. All conditions must match (AND logic). +3. **Actions** -- the side effects applied when conditions match. Actions run in order; a failure in one does not abort the rest (the error is logged to the workflow run history). + +When an event fires, Escalated dispatches it to the workflow engine, which looks up every active workflow subscribed to that event, evaluates conditions against the current ticket state, and executes each matched workflow's actions. The run is recorded in the workflow log for auditability. + +## Trigger events + +| Event | Fires when | +|---|---| +| `ticket.created` | A new ticket is created (via API, inbound email, widget, or agent UI) | +| `ticket.updated` | Any field on the ticket changes | +| `ticket.assigned` | The `assignee_id` changes | +| `ticket.status_changed` | The ticket status transitions (e.g. `open -> solved`) | +| `reply.created` | Any reply is added (agent or customer) | + +A workflow subscribes to exactly one trigger event. For rules that should react to multiple events, define a workflow per event. + +Fine-grained event subtypes (priority changed, tagged, reopened, SLA warnings, inbound received, signup invites) all fire on the Escalated event bus too, but today they are not bridged into the Workflow runner — they are consumed by the built-in email + activity-log listeners instead. Use the five events above combined with per-field conditions to approximate per-subtype behavior. + +## Conditions + +A condition is a `{ field, operator, value }` triple. `field` names any top-level ticket column (framework-specific column names apply — see [Template variables](#template-variables) for the naming convention on your framework). The engine looks up `ticket[field]`, coerces to string, and applies `operator` against `value`. + +**Operators:** + +| Operator | Meaning | +|---|---| +| `equals` / `not_equals` | String equality | +| `contains` / `not_contains` | Substring match (case-sensitive) | +| `starts_with` / `ends_with` | String prefix / suffix | +| `greater_than` / `less_than` | Numeric comparison (value coerced via `Number()`) | +| `greater_or_equal` / `less_or_equal` | Numeric comparison | +| `is_empty` / `is_not_empty` | Trim + length check (value arg ignored) | + +**Example:** to match high-priority tickets mentioning "refund" in the subject: + +```json +{ + "all": [ + { "field": "priority", "operator": "equals", "value": "urgent" }, + { "field": "subject", "operator": "contains", "value": "refund" } + ] +} +``` + +Conditions combine via `all` (AND) or `any` (OR); a bare array is treated as `all`. If you need disjoint OR-semantics, define separate workflows on the same trigger with different condition sets. + +> **Scope of accessible fields.** The engine flattens the ticket entity into a string map of top-level scalar columns only (id, referenceNumber, subject, description, priority, channel, statusId, departmentId, requesterId, assigneeId, contactId, …). Relationship data (`tags`, joined `Department`, joined `Contact.email`) is **not** in the map, so filters like "has tag vip" or "requester email ends with @foo.com" don't work out-of-the-box. If you need those, either (a) denormalize the field you care about onto the ticket (e.g. a `customer_tier` column populated at creation) or (b) use a first-pass workflow to tag the ticket, then filter downstream workflows by the new scalar-populated state. + +## Actions + +Workflows can perform any of the following actions. Actions execute in the order they are declared: + +| Action | Purpose | +|---|---| +| `change_priority` | Set the ticket priority | +| `change_status` | Set the ticket status (accepts status slug or numeric id) | +| `set_department` | Move the ticket to a department | +| `add_tag` | Attach a tag (by slug or id); no-op if already present | +| `remove_tag` | Detach a tag; no-op if not present | +| `assign_agent` | Assign to a specific agent by id, writing a ticket-activity audit row | +| `assign_round_robin` | Assign to the least-loaded active agent in a department (see below) | +| `add_note` | Add an internal note visible only to agents | +| `insert_canned_reply` | Add a public reply with `{{field}}` template interpolation | +| `add_follower` | Add a user as a ticket follower (idempotent) | +| `send_webhook` | POST a JSON payload to a configured webhook | +| `delay` | Pause the workflow and resume remaining actions after N seconds (see below) | + +### Template variables + +`insert_canned_reply` supports `{{variable}}` interpolation against the ticket. The engine flattens every top-level scalar column on the ticket row into the template context, so the variable names follow your framework's column naming convention. + +For the NestJS reference (camelCase): + +- `{{subject}}`, `{{description}}`, `{{priority}}`, `{{channel}}` +- `{{referenceNumber}}`, `{{id}}` +- `{{requesterId}}`, `{{contactId}}`, `{{assigneeId}}`, `{{departmentId}}`, `{{statusId}}` + +Frameworks whose columns are snake_case (Laravel, Rails, Django, WordPress, Symfony, Phoenix) expose the same fields under snake_case names — e.g. `{{reference_number}}`, `{{requester_id}}`. Check your framework's ticket schema for the exact list. + +Non-scalar relationships (the loaded `Tag[]` array, nested `Department`, etc.) are skipped — reach for a workflow hook or a dedicated action if you need to interpolate relation data. + +Unknown variable names are left as literal `{{name}}` in the output so gaps are visible in the rendered reply rather than silently disappearing. + +### Round-robin assignment + +`assign_round_robin` takes a department id as its `value` and picks the **least-loaded active + available agent** in that department — the one with the fewest currently-open tickets assigned to them. This favors the agent most likely to be idle rather than rotating strictly. If two agents tie on open-ticket count, the one with the lower user id wins for determinism. Ineligible inputs (non-numeric or zero department id, empty eligible-agent list) log a warning and skip without assigning. + +### Delayed actions + +The `delay` action splits a workflow run into two halves. Actions before the delay run inline. Remaining actions are persisted to a deferred-job queue with `run_at = now + N` and picked up by a scheduled poller (runs once per minute by default) after the wait elapses. + +**`N` units differ by framework.** NestJS, Spring, and WordPress interpret `delay.value` as **seconds**; Laravel, Rails, Django, Adonis, .NET, Go, Phoenix, and Symfony interpret it as **minutes** (matching the pre-existing `delayed_actions` schema those plugins had before this rollout). Check your plugin's workflow-executor source if you're unsure. + +Example: "When a ticket is created by a caller from an urgent priority, tag it, wait 5 minutes, then leave a note to page on-call." + +```json +{ + "trigger_event": "ticket.created", + "conditions": [ + { "field": "priority", "operator": "equals", "value": "urgent" } + ], + "actions": [ + { "type": "add_tag", "value": "needs-fast-response" }, + { "type": "delay", "value": "300" }, + { "type": "add_note", "value": "Still open after 5 min -- page on-call." } + ] +} +``` + +> `"value": "300"` is 5 minutes on the seconds-unit frameworks (NestJS / Spring / WordPress). On the minutes-unit frameworks you'd write `"value": "5"` for the same behavior. + +The first two actions happen immediately. If the ticket is still open 5 minutes later, the note is added; if the ticket was already closed in the meantime, the note still fires (the delay does not re-evaluate conditions -- it resumes the saved action sequence verbatim). To make conditional resume-time behavior, fan out to a separate workflow trigger. + +The deferred-job queue retains rows after completion (status flips from `pending` to `done` or `failed`) so you can audit when and why each delay fired. + +## Webhook action + +`send_webhook` takes a webhook id (the numeric id of a row you've configured under `Admin -> Webhooks`) and POSTs a payload to that webhook's configured URL. The HTTP body: + +```json +{ + "event": "workflow.triggered", + "data": { + "ticket": { "id": 1234, "subject": "...", "priority": "high", ... } + }, + "timestamp": "2026-04-24T14:00:00.000Z" +} +``` + +The full ticket entity is passed through as `data.ticket` — relationships present on the in-memory entity at dispatch time (tags, department) are included. Delivery follows the same HMAC-SHA256 signing + retry rules as every other Escalated webhook. Each request carries two headers: + +- `X-Escalated-Signature`: the hex-encoded HMAC-SHA256 of the raw payload body, using the webhook's configured secret. Verify on your side by recomputing `hex(hmac_sha256(secret, body))` and comparing with a timing-safe equality check. +- `X-Escalated-Event`: the event name (`workflow.triggered` for this action; other events use their own names). + +Delivery failures are recorded on `WebhookDelivery` for the retry scheduler to pick up; they do not block subsequent actions in the same workflow. + +## Example workflows + +### Auto-tag and route billing tickets + +```json +{ + "trigger_event": "ticket.created", + "conditions": { + "all": [ + { "field": "subject", "operator": "contains", "value": "billing" } + ] + }, + "actions": [ + { "type": "change_priority", "value": "high" }, + { "type": "add_tag", "value": "billing" }, + { "type": "assign_round_robin", "value": "7" }, + { "type": "send_webhook", "value": "9" } + ] +} +``` + +The `assign_round_robin` value `"7"` is the id of the Billing department; `"send_webhook"` value `"9"` is the id of a webhook you've configured under `Admin → Webhooks` (typically pointing at PagerDuty or similar). + +### Chase stale tickets + +```json +{ + "trigger_event": "ticket.status_changed", + "conditions": [ + { "field": "status", "operator": "equals", "value": "pending" } + ], + "actions": [ + { "type": "delay", "value": "86400" }, + { "type": "add_note", "value": "Pending > 24h. Consider following up." } + ] +} +``` + +> `"value": "86400"` is 24h on the seconds-unit frameworks; on minutes-unit frameworks use `"value": "1440"`. + +### Triage by subject + +```json +{ + "trigger_event": "ticket.created", + "conditions": [ + { "field": "subject", "operator": "contains", "value": "refund" } + ], + "actions": [ + { "type": "set_department", "value": "5" }, + { "type": "add_tag", "value": "refund-request" }, + { "type": "insert_canned_reply", + "value": "Hi, we've received your refund request (#{{referenceNumber}}) and will respond within 1 business day." } + ] +} +``` + +The canned-reply template uses `{{referenceNumber}}` because the NestJS reference stores ticket reference as a camelCase scalar column. Frameworks with snake_case columns would use `{{reference_number}}`. There is no `{{requester_name}}` variable — contact name is a separate related row that the interpolator doesn't traverse. + +## Workflow logs + +Every workflow *considered* -- match or no match -- writes a row to `escalated_workflow_logs`, surfaced in the UI at `Admin -> Workflows -> Logs`. Each row records: + +- Trigger event string, triggering ticket id (FK), workflow id (FK) +- `conditions_matched` boolean — whether the workflow actually fired +- `actions_executed_raw` JSON — the actions array as stored on the workflow when it fired (not per-action status; re-read the workflow to see current config) +- `error_message` — top-level error from the executor if the whole run threw, else null. Individual action failures within the executor are log-warn and do NOT surface here +- `started_at` / `completed_at` timestamps so you can derive duration + +Deferred (`delay`) actions do not get their own log row — inspect `escalated_deferred_workflow_jobs` directly (status + last_error columns) to audit what fired when. + +## Managing workflows + +Admins create and edit workflows at `Admin -> Workflows`. The UI mirrors the macro builder: pick a trigger, add conditions, add actions, reorder as needed. Workflows can be toggled inactive without deleting them. + +## Scheduler dependency + +The `delay` action and any other deferred behaviors depend on the application scheduler being live. See [Scheduling](scheduling.md) for how to enable the cron or queue worker your framework uses. If the scheduler is not running, delayed actions will sit in `pending` indefinitely. + +## Automations vs. workflows -- when to use which + +| Use case | Choice | +|---|---| +| "When a ticket is created, do X" | Workflow (`ticket.created`) | +| "Every 15 minutes, check all pending tickets and close > 7 days" | Automation | +| "When *any* reply is added, send a webhook" | Workflow (`reply.created`) — note the event fires with the TICKET as payload, not the reply, so you can't filter by `author_type` in conditions; the webhook receives the ticket and your downstream handler decides | +| "Nightly: add the `stale` tag to tickets idle > 72h" | Automation | +| "When a high-priority ticket is created, wait 5 min then leave an escalation note" | Workflow (`ticket.created` + priority condition + `delay`) | + +Workflows are reactive and surgical. Automations are periodic and sweeping. Both can coexist on the same ticket; they read and write the same tables.