# Route events

> request-triggered

---

A complementary, *immediate* detection mechanism: instead of polling state on a schedule, the module observes the HTTP request itself. When a request to a **mapped mutating route** has been handled successfully (2xx/3xx), it queues the matching event into the same `cj_event` queue — a listening event job then reacts at the next tick (~60 s), with no polling latency.

**Which routes are mapped** — two rules, both derived from the live webtrees route table. Common prerequisites: path starts with `/tree/{tree}/`, the handler is in webtrees' request-handler namespace (`RequestHandlers` in 2.2.6, `Controllers` in 2.3), and the request is a **POST** mutation. In 2.2.6 this is enforced at map time (GET page routes are not mapped); in 2.3 the route carries no HTTP method, so the POST check happens at fire-time and the GET form-load of the same route is ignored.

1. **Record routes** (rule-based — core updates are picked up automatically): the path contains `{xref}` (the target record) *and* the handler class name starts with `Edit`, `Delete`, `Add`, `Create`, `Link`, `Paste`, `Reorder` or `Pending`. The event name is the third path segment, namespaced `_route:<segment>`; when several mapped routes share a segment (`delete`, `edit-raw`, `accept`, `reject`), the following path parameters are appended, hyphen-separated.
2. **Tree-level routes** (small curated allowlist): the significant tree-wide mutations that carry no `{xref}` in the URL (import, merge, renumber, …). Each allowlist entry has an explicit event name. New tree-wide mutations in a core update need a module update (the record-route rule stays fully automatic).

| Event | Route (under `/tree/{tree}/`) | Payload |
| :--- | :--- | :--- |
| `_route:edit-note-object` | POST `edit-note-object/{xref}` | `tree`, `xref` |
| `_route:update-record` | POST `update-record/{xref}` | `tree`, `xref` |
| `_route:update-fact` | POST `update-fact/{xref}{/fact_id}` | `tree`, `xref` (, `fact_id`) |
| `_route:delete-xref` / `_route:delete-xref-fact_id` | POST `delete/{xref}{/fact_id}` | `tree`, `xref` (, `fact_id`) |
| `_route:edit-raw-xref` / `_route:edit-raw-xref-fact_id` | POST `edit-raw/{xref}{/fact_id}` | `tree`, `xref` (, `fact_id`) |
| `_route:add-child-to-family`, `_route:add-spouse-to-family`, `_route:add-media-file` | POST `add-…/{xref}` | `tree`, `xref` |
| `_route:add-child-to-individual`, `_route:add-parent-to-individual`, `_route:add-spouse-to-individual` | POST `add-…/{xref}` | `tree`, `xref` |
| `_route:paste-fact` | POST `paste-fact/{xref}` | `tree`, `xref` |
| `_route:link-media-to-record` | POST `link-media-to-record/{xref}` | `tree`, `xref` — the **media** object; the target record is a form field, not in the URL |
| `_route:link-child-to-family`, `_route:link-spouse-to-individual` | POST `link-…/{xref}` | `tree`, `xref` |
| `_route:reorder-children`, `_route:reorder-spouses`, `_route:reorder-media`, `_route:reorder-media-files`, `_route:reorder-names` | POST `reorder-…/{xref}` | `tree`, `xref` |
| `_route:accept-xref` / `_route:accept-xref-change` | POST `accept/{xref}{/change}` | `tree`, `xref` (, `change`) |
| `_route:reject-xref` / `_route:reject-xref-change` | POST `reject/{xref}{/change}` | `tree`, `xref` (, `change`) |
| `_route:import` | POST `import` | `tree` |
| `_route:load` | POST `load` | `tree` — the chunked import continuation fires **once per chunk** |
| `_route:merge-step1` / `_route:merge-step2` | POST `merge-step1` / `merge-step2` | `tree` |
| `_route:search-replace` | POST `search-replace` | `tree` |
| `_route:renumber` | POST `renumber` | `tree` |
| `_route:data-fix-update` / `_route:data-fix-update-all` | POST `data-fix/{data_fix}/update[-all]` | `tree`, `data_fix` |
| `_route:accept` / `_route:reject` | POST `accept` / `reject` (bulk) | `tree` |
| `_route:change-family-members` | POST `change-family-members` | `tree` — the family xref is a form field |

In total 38 events (27 record routes + 11 tree-level routes). The payload always contains `tree` (id) + `tree_name` plus all matched route parameters. **Record routes** (with `{xref}`) additionally carry `change_pending` (bool): whether the change is still pending in the `change` table after this request (the user's auto-accept preference is off) or already applied (auto-accept on). A pending change is applied when an admin accepts it — which fires its own `_route:accept*` events. Tree-level routes write directly (no `change` rows) and carry no such flag.

> **Breaking change (this version).** Record routes *without* `{xref}` in the URL — all `_route:create-*` events plus `_route:add-unlinked-individual` — are no longer mapped: they carry no target record. Jobs listening to those events will never fire again; the admin table flags such triggers with an **unknown route event** badge (a generic check over all jobs' `_route:*` triggers against the live map), so re-pointing them is visible instead of silent.
>
> `_route:accept*` / `_route:reject*` fire when a *pending change* is accepted or rejected — accepting a pending deletion is when the record is actually removed.

**How it works**

- **Gated twice, off by default.** An event fires only while the admin toggle "Route events" (Events section) is on *and* at least one enabled event-triggered job listens for that event name. A request that is not mapped costs nothing (one in-memory lookup); only a successful request on a mapped route does two tiny indexed DB reads.
- **Transactional.** The `cj_event` insert runs in the *same DB transaction* as the mutation (the module middleware sits inside webtrees' request transaction), so the queued event commits or rolls back together with the change — no drift, no double fire on a failed save.
- **Never blocks.** Detection runs *after* the handler, is exception-wrapped, and can never alter the response.
- **Robust to core upgrades.** The map is rebuilt from the route table (keyed by handler class *strings*, no `::class`), so a renamed/removed handler simply stops matching instead of fatalling. If the route table is not available (e.g. CLI), a static fallback map with the note route keeps the pilot working. The record-route rule is fully automatic; only the small tree-level allowlist is curated (see above), and jobs pointing at an event that no longer exists — a removed route event or a removed built-in pseudo-event — are flagged in the admin table (**unknown event** badge).

To act on one, create an event-triggered job whose **Event name** matches (e.g. `_route:update-record`) and enable the "Route events" toggle. Note the overlap with the `cronjob:log-edit-update` / `cronjob:log-edit-delete` pollers: every record edit/delete is also written to the log table, so the matching log event follows at the next poll as well — pick the mechanism whose latency/precision fits the job (route events are near-instant and precise per object, and carry `change_pending`; log pollers lag ~5 min but also catch changes made outside the web UI).
