From ed2bb184b7d8aaa1ff21d40d1a6f996fc2cfe74a Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:03:46 -0400 Subject: [PATCH 01/15] docs: add Public Tickets section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the full public-ticket surface that shipped across all 10 host-framework plugins in this rollout: - Three guest-policy modes (unassigned / guest_user / prompt_signup) with the decision table - Admin settings page at Admin → Settings → Public Tickets backed by GET/PUT /escalated/admin/settings/public-tickets endpoints - Runtime validation semantics (unknown-mode coercion, mode-switch cleanup, 500-char template truncation, zero-user-id handling) - Widget submission flow + per-email rate limit - Inbound email 5-priority ticket-resolution chain (In-Reply-To → References → signed Reply-To → subject reference → legacy lookup) - Workflow integration pattern (ticket.created fires against public submissions too) - promoteToUser back-stamp for signup invite flow - Contact-pattern data model (escalated_contacts + nullable contact_id on escalated_tickets) Wires the new page into docs.json between Tickets and Bulk Actions so the sidebar reflects the natural reading order. --- docs.json | 6 ++ sections/public-tickets.md | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 sections/public-tickets.md diff --git a/docs.json b/docs.json index 7891a9d..eb31f2b 100644 --- a/docs.json +++ b/docs.json @@ -54,6 +54,12 @@ "anchor": "tickets", "type": "single" }, + { + "slug": "public-tickets", + "label": "Public Tickets", + "anchor": "public-tickets", + "type": "single" + }, { "slug": "bulk-actions", "label": "Bulk Actions", diff --git a/sections/public-tickets.md b/sections/public-tickets.md new file mode 100644 index 0000000..27b6224 --- /dev/null +++ b/sections/public-tickets.md @@ -0,0 +1,135 @@ +# Public Tickets + +The public ticket system lets unauthenticated users submit tickets without a host-app account. Two entry points share the same pipeline: + +- **Embeddable widget** (`POST /escalated/widget/tickets`) — a support button you drop into marketing pages. +- **Inbound email** (`POST /escalated/webhook/email/inbound`) — a Postmark/Mailgun/SES webhook that routes messages to the right ticket. + +Both paths look up or create a `Contact` by email, so repeat submissions from the same address are deduplicated. Once a contact exists, they can reply to confirmation emails and those replies thread back into the same conversation via RFC 5322 `Message-ID` + a signed `Reply-To` address. + +## Guest policy + +The **guest policy** decides which identity a public ticket is attributed to. Three modes, all runtime-switchable from the admin settings page: + +| Mode | `ticket.requester_id` | `ticket.contact_id` | Behavior | +|---|---|---|---| +| `unassigned` (default) | `0` | set | Ticket has no authenticated requester. Agents see the guest email on the Contact row. | +| `guest_user` | configured shared user id | set | Every public ticket is owned by one pre-created host-app user (typical: `guest@yourcompany.com`). Lets existing authorization rules treat guests uniformly. | +| `prompt_signup` | `0` (until promoted) | set | Same as `unassigned`, plus a signup-invite email goes out with the confirmation. If the guest accepts, `ContactService.promoteToUser` back-stamps `requester_id` on all prior tickets with that `contact_id`. | + +The policy is global — it applies to every public submission. Host apps that need per-department or per-rule routing should combine it with a Workflow that reassigns the ticket after creation. + +## Configuration + +### Boot-time defaults + +Every host-framework plugin accepts a compile-time default in its configuration block (the exact syntax varies per framework — see each plugin's README or the [installation](installation.md) page). + +```json +{ + "guest_policy": { + "mode": "unassigned", + "guest_user_id": null, + "signup_url_template": null + } +} +``` + +### Runtime switching (admin settings page) + +Every host-framework plugin ships an admin page at `Admin → Settings → Public tickets` that writes to the plugin's settings table. The runtime value overrides the compile-time default. + +Under the hood, the page calls: + +``` +GET /escalated/admin/settings/public-tickets +PUT /escalated/admin/settings/public-tickets +``` + +with JSON body (all fields snake_case to match the wire format): + +```json +{ + "guest_policy_mode": "guest_user", + "guest_policy_user_id": 42, + "guest_policy_signup_url_template": "https://app.example.com/signup?email={{email}}" +} +``` + +Validation the API performs for you: + +- Unknown `guest_policy_mode` values coerce silently to `unassigned` — the endpoint never 500s on a bad mode. +- Switching mode clears the other mode's fields so stale values (e.g. a leftover `guest_user_id` from an earlier `guest_user` run) don't leak into `prompt_signup` behavior. +- `guest_policy_user_id` ≤ 0 or non-numeric is stored as empty; GET surfaces that as JSON `null`. +- `guest_policy_signup_url_template` is trimmed and truncated to 500 characters. + +### Template variables in signup URL + +When `mode = prompt_signup`, the `guest_policy_signup_url_template` supports two `{{...}}` placeholders: + +- `{{email}}` — the contact's email, URL-encoded +- `{{token}}` — a host-app-generated single-use signup token, if your host app provides one + +Unknown placeholders are left as literal `{{name}}` so gaps are visible in the rendered URL rather than silently disappearing. + +## Widget submission + +Place the widget snippet on any page: + +```html + + +``` + +The widget collects `email` (required), `name` (optional), `subject`, `description`, and an optional `priority`. On submit it POSTs to `/escalated/widget/tickets`. + +Per-email rate limit: 10 submissions per hour, enforced by `PublicSubmitThrottleGuard`. Requests exceeding the limit get a `429` and no ticket is created. + +## Inbound email + +Point your transactional mail provider's inbound webhook at `/escalated/webhook/email/inbound`. The plugin supports three providers natively: **Postmark**, **Mailgun**, and **AWS SES** (via SNS HTTP subscription). See [Inbound Email](inbound-email.md) for per-framework setup details. + +The inbound router resolves the target ticket in this order: + +1. **`In-Reply-To` header** → ticket id parsed from our outbound `Message-ID` +2. **`References` header** → same parse, for clients that don't set `In-Reply-To` +3. **Envelope `To`** → matches our signed `Reply-To` address `reply+{id}.{hmac8}@{domain}` (HMAC-SHA256, timing-safe compare) +4. **Subject line** → contains a `[TK-XXX]` ticket reference +5. **Legacy `InboundEmail` audit lookup** — for messages that predate the canonical Message-ID format + +If none match, the router creates a new ticket under the current guest policy. + +## Workflow integration + +Public-submission events fire the same `ticket.created` and `reply.created` events as authenticated flows, so [Workflows](workflows.md) you configure will automatically fire against them. Common use cases: + +- **Auto-tag all public tickets** with a `from-widget` or `from-email` tag so agents can filter the queue +- **Assign public tickets to a specific department** (e.g. Billing) when the subject contains keywords +- **Delay auto-close** — close tickets after 7 days of no response using a `delay` action on `reply.created` + +See the [Workflows](workflows.md) page for the full action catalog and decision table. + +## Promoting a guest to a real user + +When a guest accepts a signup invite (only fires under `prompt_signup` mode), the host app calls: + +``` +ContactService.promoteToUser(contactId, userId) +``` + +That single call: + +1. Sets `contact.user_id = userId` on the Contact row +2. Back-stamps every ticket carrying that `contact_id` with `requester_id = userId` + +After promotion, the former guest logs in and sees their full public-ticket history as if they'd been authenticated the whole time. + +## Data model + +Two key relationships: + +- `escalated_contacts.id` (unique email index, case-insensitive) ← `escalated_tickets.contact_id` (nullable FK) +- `escalated_contacts.user_id` (nullable host-app user FK) — set post-promotion + +Existing host-app users submitting via the widget: the widget controller resolves a `Contact` for them but also fills `ticket.requester_id` with their host-app user id, so both paths work. From 1dbeadf4eb7176938a65a54f0dedf88cd2ea1cc7 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:39:56 -0400 Subject: [PATCH 02/15] docs(public-tickets): fix signup-url template var syntax The doc claimed two {{...}} placeholders ({{email}} and {{token}}), but the actual NestJS implementation (src/services/email/email.service.ts:157) only substitutes a single {token} placeholder (single braces) with an HMAC-scoped one-time token derived from the contact id. No {email} substitution exists. Correct the syntax and drop the {email} placeholder that never existed. Also explain how the token is used for verification on the host-app side. --- sections/public-tickets.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index 27b6224..33b4d17 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -63,14 +63,15 @@ Validation the API performs for you: - `guest_policy_user_id` ≤ 0 or non-numeric is stored as empty; GET surfaces that as JSON `null`. - `guest_policy_signup_url_template` is trimmed and truncated to 500 characters. -### Template variables in signup URL +### Template variable in signup URL -When `mode = prompt_signup`, the `guest_policy_signup_url_template` supports two `{{...}}` placeholders: +When `mode = prompt_signup`, the `guest_policy_signup_url_template` supports a single `{token}` placeholder (single braces) that Escalated replaces with a URL-encoded, HMAC-scoped signup token derived from the contact's id and your configured `inbound.webhookSecret`. A typical template: -- `{{email}}` — the contact's email, URL-encoded -- `{{token}}` — a host-app-generated single-use signup token, if your host app provides one +``` +https://app.example.com/signup?from_ticket={token} +``` -Unknown placeholders are left as literal `{{name}}` so gaps are visible in the rendered URL rather than silently disappearing. +The token is one-time use and lets your host app identify the originating `Contact` when the user completes signup. Your app verifies the token by round-tripping it through the same secret that signs Reply-To addresses. ## Widget submission From df4460f5c1e971c7e9c16fbe14ef67d69fb31dda Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:43:57 -0400 Subject: [PATCH 03/15] docs(public-tickets): note in-memory rate limiter won't scale The shipped PublicSubmitThrottleGuard keeps hits in a plain Map. Fine for single-instance + tests, but a multi-instance deployment needs Redis or equivalent to enforce the limit globally. Flag that in the widget section so host maintainers don't discover it the hard way in production. --- sections/public-tickets.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index 33b4d17..f11b84f 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -87,6 +87,8 @@ The widget collects `email` (required), `name` (optional), `subject`, `descripti Per-email rate limit: 10 submissions per hour, enforced by `PublicSubmitThrottleGuard`. Requests exceeding the limit get a `429` and no ticket is created. +> **Deployment note:** The shipped guard keeps its counter in-memory, which is fine for single-instance deployments and tests but lets the limit leak under horizontal scale-out. For multi-instance production, swap the backing store for Redis (or your framework's equivalent shared cache). The guard is a pluggable class — override it when you bind the Escalated module. + ## Inbound email Point your transactional mail provider's inbound webhook at `/escalated/webhook/email/inbound`. The plugin supports three providers natively: **Postmark**, **Mailgun**, and **AWS SES** (via SNS HTTP subscription). See [Inbound Email](inbound-email.md) for per-framework setup details. From 0b75367f1392b95579f6924ecafa020140003eb1 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:48:25 -0400 Subject: [PATCH 04/15] docs(public-tickets): fix widget snippet to match shipped loader The previous snippet invented a window.escalated('init', { tenantId }) pattern that doesn't exist. The real widget loader (src/widget/index.js in the escalated repo) reads config from data-base-url / data-color / data-position attributes on the script tag (or from a pre-set window.EscalatedWidget object) and mounts into a shadow-DOM host. No tenant concept exists. Also noted that the widget fetches /widget/config on load to pick up admin-configured branding overrides, so the snippet's data-color is just a boot-time default. --- sections/public-tickets.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index f11b84f..964b9f6 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -75,15 +75,19 @@ The token is one-time use and lets your host app identify the originating `Conta ## Widget submission -Place the widget snippet on any page: +Place the widget snippet on any page. Config is read from `data-*` attributes on the script tag (or from a `window.EscalatedWidget` object set before the script loads): ```html - - + ``` -The widget collects `email` (required), `name` (optional), `subject`, `description`, and an optional `priority`. On submit it POSTs to `/escalated/widget/tickets`. +Only `data-base-url` is required; `data-color` (hex, defaults to indigo `#4F46E5`) and `data-position` (`bottom-right` | `bottom-left`, defaults to `bottom-right`) are optional. The widget mounts itself into a shadow-DOM host so host-app CSS can't leak in. + +Once open, the widget fetches `/escalated/widget/config` to pick up admin-side branding (primary color + logo override, configured by the host app's runtime settings), then renders a ticket form collecting `email` (required), `name` (optional), `subject`, `description`, and an optional `priority`. On submit it POSTs to `/escalated/widget/tickets`. Per-email rate limit: 10 submissions per hour, enforced by `PublicSubmitThrottleGuard`. Requests exceeding the limit get a `429` and no ticket is created. From 1e8a9e93d2fae8f3f6d31d57da5322f75cfd2135 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:49:10 -0400 Subject: [PATCH 05/15] docs(public-tickets): drop fabricated 5th inbound-routing priority The InboundRouterService resolves in exactly 4 priorities (In-Reply-To, References, signed Reply-To envelope, subject reference). I invented a 5th 'legacy InboundEmail audit lookup' priority that doesn't exist in the code. Dropped it and clarified that References walks the whole chain (the code loops), not just a second parse. Also tightened the fall-through line to mention the Contact resolve-or-create step, which is the real behavior of the fall-through branch (router.ts:101). --- sections/public-tickets.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index 964b9f6..0579a85 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -100,12 +100,11 @@ Point your transactional mail provider's inbound webhook at `/escalated/webhook/ The inbound router resolves the target ticket in this order: 1. **`In-Reply-To` header** → ticket id parsed from our outbound `Message-ID` -2. **`References` header** → same parse, for clients that don't set `In-Reply-To` +2. **`References` header** → same parse, walks the whole chain for clients that drop `In-Reply-To` 3. **Envelope `To`** → matches our signed `Reply-To` address `reply+{id}.{hmac8}@{domain}` (HMAC-SHA256, timing-safe compare) 4. **Subject line** → contains a `[TK-XXX]` ticket reference -5. **Legacy `InboundEmail` audit lookup** — for messages that predate the canonical Message-ID format -If none match, the router creates a new ticket under the current guest policy. +If none match, the router resolves/creates a `Contact` by sender email and creates a new ticket under the current guest policy. ## Workflow integration From eb5db391b047a3acdbb425fd9257e6a09da2f0cd Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:50:19 -0400 Subject: [PATCH 06/15] docs(public-tickets): widget /config endpoint is host-framework optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NestJS WidgetController has no GET /config route. The shared Vue widget calls /support/widget/config with a try/catch, falling back to data-* attributes when the endpoint is absent — so Laravel/Rails/ Django (which do serve /config) get dynamic admin branding, and NestJS stays on the boot-time data-color. Also corrected the tickets endpoint path — /escalated/widget/tickets is NestJS-specific; other frameworks mount under /support/widget. --- sections/public-tickets.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index 0579a85..480fadf 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -87,7 +87,7 @@ Place the widget snippet on any page. Config is read from `data-*` attributes on Only `data-base-url` is required; `data-color` (hex, defaults to indigo `#4F46E5`) and `data-position` (`bottom-right` | `bottom-left`, defaults to `bottom-right`) are optional. The widget mounts itself into a shadow-DOM host so host-app CSS can't leak in. -Once open, the widget fetches `/escalated/widget/config` to pick up admin-side branding (primary color + logo override, configured by the host app's runtime settings), then renders a ticket form collecting `email` (required), `name` (optional), `subject`, `description`, and an optional `priority`. On submit it POSTs to `/escalated/widget/tickets`. +On open, the widget optionally fetches `/support/widget/config` (host-framework-dependent — some plugins implement this to serve admin-configured branding overrides, others fall back to the `data-*` attributes) and renders a ticket form collecting `email` (required), `name` (optional), `subject`, `description`, and an optional `priority`. On submit it POSTs to the host framework's widget tickets endpoint (`/escalated/widget/tickets` on NestJS; `/support/widget/tickets` on Laravel/Rails/Django/etc.). Per-email rate limit: 10 submissions per hour, enforced by `PublicSubmitThrottleGuard`. Requests exceeding the limit get a `429` and no ticket is created. From fd1d0a7870c59b225fd633b7fee57601b5798f06 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:53:17 -0400 Subject: [PATCH 07/15] docs(public-tickets): inbound-email endpoint is framework-specific The intro and Inbound Email section both hardcoded /escalated/webhook/email/inbound as THE endpoint. That's NestJS-only. Laravel exposes /support/inbound/{adapter} (verified in routes/inbound.php), Django has widget/config but a different inbound path, Adonis has its own controller, etc. Route the reader to the framework-specific Inbound Email page for the exact URL shape, and show NestJS + Laravel examples inline so they have a concrete starting point. --- sections/public-tickets.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index 480fadf..f5f4c35 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -2,8 +2,8 @@ The public ticket system lets unauthenticated users submit tickets without a host-app account. Two entry points share the same pipeline: -- **Embeddable widget** (`POST /escalated/widget/tickets`) — a support button you drop into marketing pages. -- **Inbound email** (`POST /escalated/webhook/email/inbound`) — a Postmark/Mailgun/SES webhook that routes messages to the right ticket. +- **Embeddable widget** — a support button you drop into marketing pages. The widget POSTs to the host framework's widget-tickets route (`/escalated/widget/tickets` on NestJS; `/support/widget/tickets` on every other framework). +- **Inbound email** — a Postmark/Mailgun/SES webhook that routes messages to the right ticket. The endpoint path is framework-specific — see [Inbound Email](inbound-email.md) for the URL shape your plugin uses. Both paths look up or create a `Contact` by email, so repeat submissions from the same address are deduplicated. Once a contact exists, they can reply to confirmation emails and those replies thread back into the same conversation via RFC 5322 `Message-ID` + a signed `Reply-To` address. @@ -95,7 +95,7 @@ Per-email rate limit: 10 submissions per hour, enforced by `PublicSubmitThrottle ## Inbound email -Point your transactional mail provider's inbound webhook at `/escalated/webhook/email/inbound`. The plugin supports three providers natively: **Postmark**, **Mailgun**, and **AWS SES** (via SNS HTTP subscription). See [Inbound Email](inbound-email.md) for per-framework setup details. +Point your transactional mail provider's inbound webhook at your plugin's inbound-email endpoint. The exact path depends on the framework (NestJS: `/escalated/webhook/email/inbound`; Laravel: `/support/inbound/{adapter}`; others vary). All plugins support three providers natively: **Postmark**, **Mailgun**, and **AWS SES** (via SNS HTTP subscription). See [Inbound Email](inbound-email.md) for per-framework setup details. The inbound router resolves the target ticket in this order: From eb6f7fd5e4f7c2110bcd6ea908047eda1b17346f Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:54:07 -0400 Subject: [PATCH 08/15] docs(public-tickets): provider coverage is split across plugin generations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claimed all 11 plugins support Postmark, Mailgun, and SES. In reality only the NestJS reference plus the five greenfield plugins have all three — the five legacy host-adapter plugins (Laravel, Rails, Django, Adonis, WordPress) ship Postmark + Mailgun only, no SES. Verified by filesystem grep across each plugin's mail/ adapters directory. Replaced the flat sentence with a table so readers don't implement against SES on a framework that doesn't serve it. --- sections/public-tickets.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index f5f4c35..da9b3b3 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -95,7 +95,18 @@ Per-email rate limit: 10 submissions per hour, enforced by `PublicSubmitThrottle ## Inbound email -Point your transactional mail provider's inbound webhook at your plugin's inbound-email endpoint. The exact path depends on the framework (NestJS: `/escalated/webhook/email/inbound`; Laravel: `/support/inbound/{adapter}`; others vary). All plugins support three providers natively: **Postmark**, **Mailgun**, and **AWS SES** (via SNS HTTP subscription). See [Inbound Email](inbound-email.md) for per-framework setup details. +Point your transactional mail provider's inbound webhook at your plugin's inbound-email endpoint. The exact path depends on the framework (NestJS: `/escalated/webhook/email/inbound`; Laravel: `/support/inbound/{adapter}`; others vary). Provider coverage varies too: + +| Provider | NestJS + greenfield plugins¹ | Legacy plugins² | +|---|:---:|:---:| +| Postmark | ✅ | ✅ | +| Mailgun | ✅ | ✅ | +| AWS SES (via SNS HTTP) | ✅ | — | + +¹ `escalated-nestjs`, `escalated-dotnet`, `escalated-spring`, `escalated-go`, `escalated-phoenix`, `escalated-symfony` +² `escalated-laravel`, `escalated-rails`, `escalated-django`, `escalated-adonis`, `escalated-wordpress` + +See [Inbound Email](inbound-email.md) for per-framework setup details. The inbound router resolves the target ticket in this order: From 9261a1131c0b11d920a22f1d22af51c150f404ef Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:55:05 -0400 Subject: [PATCH 09/15] docs(public-tickets): fix boot-time config shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JSON snippet flattened guest_user_id + signup_url_template into one object with null defaults. The actual type (verified against EscalatedModuleOptions in escalated-nestjs/src/config/escalated.config.ts:75) is a tagged union — { mode: 'unassigned' } | { mode: 'guest_user', guestUserId: number } | { mode: 'prompt_signup', signupUrlTemplate?: string } — where mode-specific fields only appear on the matching variant. Showed all three forms so readers don't construct an invalid mixed object. Also noted the camelCase-vs-snake_case split: NestJS/.NET/Go use camelCase for the boot option; Laravel/Rails/Django/Symfony use snake_case. The runtime settings API (below) always round-trips snake_case regardless of framework. --- sections/public-tickets.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index da9b3b3..9ecb2d5 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -23,18 +23,21 @@ The policy is global — it applies to every public submission. Host apps that n ### Boot-time defaults -Every host-framework plugin accepts a compile-time default in its configuration block (the exact syntax varies per framework — see each plugin's README or the [installation](installation.md) page). +Every host-framework plugin accepts a compile-time default in its configuration block. The exact syntax varies per framework (see each plugin's README or the [installation](installation.md) page), but the semantic shape is a tagged union with one of three modes: ```json -{ - "guest_policy": { - "mode": "unassigned", - "guest_user_id": null, - "signup_url_template": null - } -} +// unassigned (default) +{ "mode": "unassigned" } + +// single shared guest user +{ "mode": "guest_user", "guest_user_id": 42 } + +// prompt to sign up +{ "mode": "prompt_signup", "signup_url_template": "https://app.example.com/signup?from_ticket={token}" } ``` +Mode-specific fields (`guest_user_id`, `signup_url_template`) are only meaningful when the matching mode is selected. NestJS + .NET + Go use camelCase keys (`guestUserId`, `signupUrlTemplate`); Laravel / Rails / Django / Symfony / etc. use snake_case. The runtime settings API below always round-trips snake_case. + ### Runtime switching (admin settings page) Every host-framework plugin ships an admin page at `Admin → Settings → Public tickets` that writes to the plugin's settings table. The runtime value overrides the compile-time default. From 9fbcd7f4d81b2c2379f20080874c60fc31617f9f Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:55:33 -0400 Subject: [PATCH 10/15] docs(public-tickets): clarify where email case-insensitivity is enforced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous line said the unique email index on escalated_contacts.id was 'case-insensitive'. Two mistakes: 1. The index is on the email column, not id (id is the integer PK). 2. Case-insensitivity is enforced at the SERVICE layer (ContactService.findOrCreateByEmail lowercases before query/insert, verified at src/services/contact.service.ts:16-18), not at the DB index level. The SQL index itself is case-sensitive like any standard unique index. Rewrote as two bullets — one about the contact table's unique email index + service-layer normalization, one about the ticket.contact_id FK — so the index location and the case-insensitive behavior both land in the right mental place. --- sections/public-tickets.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index 9ecb2d5..5adc81e 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -149,7 +149,8 @@ After promotion, the former guest logs in and sees their full public-ticket hist Two key relationships: -- `escalated_contacts.id` (unique email index, case-insensitive) ← `escalated_tickets.contact_id` (nullable FK) +- `escalated_contacts` has a unique index on `email` (plus its own integer PK). `ContactService.findOrCreateByEmail` normalizes emails to lowercase at the service boundary before any query or insert, so lookups behave case-insensitively even though the DB index itself is case-sensitive. +- `escalated_tickets.contact_id` is a nullable FK into `escalated_contacts.id`. - `escalated_contacts.user_id` (nullable host-app user FK) — set post-promotion Existing host-app users submitting via the widget: the widget controller resolves a `Contact` for them but also fills `ticket.requester_id` with their host-app user id, so both paths work. From bef1662a380599edb2b62778d687c55b07b44286 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:12:25 -0400 Subject: [PATCH 11/15] docs(public-tickets): NestJS has no dedicated settings/public-tickets endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime-switching section showed GET + PUT /escalated/admin/settings/public-tickets as THE way to update guest policy. That's wrong for the NestJS reference — NestJS's AdminSettingsController only has a generic PUT /escalated/admin/settings that takes [{ key, value, type, group }], and the widget controller reads a single 'guest_policy' key containing a JSON blob. The dedicated /settings/public-tickets endpoints exist on all 10 host- framework plugins (Laravel/Rails/Django/Adonis/WordPress/Symfony/.NET/ Go/Spring/Phoenix) but NOT on the NestJS reference. Also documented the example URL as /app.example.com/signup?from_ticket={token} instead of the invented ?email={{email}} syntax — consistent with the Template variable in signup URL fix from an earlier iteration. --- sections/public-tickets.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index 5adc81e..7ed5aa5 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -42,20 +42,20 @@ Mode-specific fields (`guest_user_id`, `signup_url_template`) are only meaningfu Every host-framework plugin ships an admin page at `Admin → Settings → Public tickets` that writes to the plugin's settings table. The runtime value overrides the compile-time default. -Under the hood, the page calls: +Under the hood the page calls a dedicated GET + PUT pair on the host framework's admin API. Exact path varies per framework: -``` -GET /escalated/admin/settings/public-tickets -PUT /escalated/admin/settings/public-tickets -``` +| Framework | Endpoint | +|---|---| +| Laravel / Rails / Django / Adonis / WordPress / Symfony / .NET / Go / Spring / Phoenix | `GET` + `PUT /admin/settings/public-tickets` (prefix varies: `/support/admin/` for Laravel/Rails/Django/etc., `/escalated/api/admin/` for Spring, `/support/admin/` for .NET + Go) | +| NestJS reference | No dedicated endpoint — the widget controller reads a single `guest_policy` JSON value via the generic `PUT /escalated/admin/settings` endpoint with `[{ "key": "guest_policy", "value": {...}, "type": "json" }]` | -with JSON body (all fields snake_case to match the wire format): +For the 10 host-framework plugins, the PUT body is snake_case to match the wire format the shared Vue page sends: ```json { "guest_policy_mode": "guest_user", "guest_policy_user_id": 42, - "guest_policy_signup_url_template": "https://app.example.com/signup?email={{email}}" + "guest_policy_signup_url_template": "https://app.example.com/signup?from_ticket={token}" } ``` From 76559c4ab85677a540bcbd73431bc689da53ba37 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:17:01 -0400 Subject: [PATCH 12/15] docs(public-tickets): NestJS now has the dedicated endpoint too Removes the 'NestJS reference: no dedicated endpoint' caveat now that escalated-nestjs#27 lands GET+PUT /escalated/admin/settings/public-tickets matching the 10 host-framework plugins. Switched the table from a 2-row framework-vs-NestJS split to a per- framework prefix lookup so readers get the exact URL for their plugin. All 11 frameworks now share the same snake_case wire format at the same relative path. --- sections/public-tickets.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index 7ed5aa5..8490bcc 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -42,14 +42,17 @@ Mode-specific fields (`guest_user_id`, `signup_url_template`) are only meaningfu Every host-framework plugin ships an admin page at `Admin → Settings → Public tickets` that writes to the plugin's settings table. The runtime value overrides the compile-time default. -Under the hood the page calls a dedicated GET + PUT pair on the host framework's admin API. Exact path varies per framework: +Under the hood the page calls a dedicated `GET` + `PUT /admin/settings/public-tickets` pair on the host framework's admin API. Every framework ships this endpoint; only the route prefix varies: -| Framework | Endpoint | +| Framework | Prefix | |---|---| -| Laravel / Rails / Django / Adonis / WordPress / Symfony / .NET / Go / Spring / Phoenix | `GET` + `PUT /admin/settings/public-tickets` (prefix varies: `/support/admin/` for Laravel/Rails/Django/etc., `/escalated/api/admin/` for Spring, `/support/admin/` for .NET + Go) | -| NestJS reference | No dedicated endpoint — the widget controller reads a single `guest_policy` JSON value via the generic `PUT /escalated/admin/settings` endpoint with `[{ "key": "guest_policy", "value": {...}, "type": "json" }]` | +| NestJS reference | `/escalated/admin/settings/public-tickets` | +| Laravel / Rails / Django / Adonis / WordPress / Symfony | `/support/admin/settings/public-tickets` | +| Spring | `/escalated/api/admin/settings/public-tickets` | +| .NET / Go | `/support/admin/settings/public-tickets` | +| Phoenix | `/admin/settings/public-tickets` (inside `/support/...` if you mount it there) | -For the 10 host-framework plugins, the PUT body is snake_case to match the wire format the shared Vue page sends: +The PUT body is snake_case to match the wire format the shared Vue page sends: ```json { From aecae6e6208cae311ba8c4856f72cdd11524eee2 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:12:32 -0400 Subject: [PATCH 13/15] docs(public-tickets): correct widget-for-authenticated-user claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous line said the widget 'resolves a Contact for existing host-app users AND fills requester_id with their host-app user id, so both paths work'. That's wrong. Reading widget.controller.ts createTicket: - If body.email is supplied: resolves/creates a Contact, sets requester_id from the CURRENT GUEST POLICY (not the user's host-app id — the submission is treated as a guest submission). - If body.requesterId is supplied instead: sets requester_id to the supplied value, no Contact is resolved. These are two separate mutually-exclusive paths. Rewrote to describe them as such, with the note that one-or-the-other is required (400 otherwise) and email is the recommended path for new integrations. --- sections/public-tickets.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index 8490bcc..0851d77 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -156,4 +156,4 @@ Two key relationships: - `escalated_tickets.contact_id` is a nullable FK into `escalated_contacts.id`. - `escalated_contacts.user_id` (nullable host-app user FK) — set post-promotion -Existing host-app users submitting via the widget: the widget controller resolves a `Contact` for them but also fills `ticket.requester_id` with their host-app user id, so both paths work. +The widget endpoint accepts either `email` (guest path — creates/reuses a `Contact`, fills `ticket.contact_id`, and sets `ticket.requester_id` from the current guest policy) OR `requester_id` (legacy authenticated path — sets `ticket.requester_id` to the supplied host-app user id, no `Contact` is created). One of the two must be present or the endpoint returns 400. The `email` path is recommended — the legacy `requester_id` shortcut is kept for backwards compatibility with existing host-app integrations. From ed261d4fa057ff0a7cb11b5378831b724916e43a Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:13:36 -0400 Subject: [PATCH 14/15] docs(public-tickets): column names are framework-case, not always snake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data-model section wrote column names as escalated_tickets.contact_id and escalated_contacts.user_id. Those snake_case names are right for Laravel/Rails/Django/Symfony/WordPress but wrong for NestJS/.NET/Go where TypeORM's default uses the property name exactly (camelCase) and no snake-casing strategy is configured. Fix by showing both forms and noting the per-ecosystem convention. Verified against src/entities/contact.entity.ts — no NamingStrategy configured anywhere in the project, so @Column properties literally map to same-name columns. --- sections/public-tickets.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index 0851d77..603816d 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -150,10 +150,10 @@ After promotion, the former guest logs in and sees their full public-ticket hist ## Data model -Two key relationships: +Two key relationships (logical column names shown; actual column case matches the framework's convention — camelCase on NestJS and JPA-mapped plugins, snake_case on ActiveRecord / Eloquent / Django ORM): -- `escalated_contacts` has a unique index on `email` (plus its own integer PK). `ContactService.findOrCreateByEmail` normalizes emails to lowercase at the service boundary before any query or insert, so lookups behave case-insensitively even though the DB index itself is case-sensitive. -- `escalated_tickets.contact_id` is a nullable FK into `escalated_contacts.id`. -- `escalated_contacts.user_id` (nullable host-app user FK) — set post-promotion +- `escalated_contacts` has a unique index on the email column (plus its own integer PK). `ContactService.findOrCreateByEmail` normalizes emails to lowercase at the service boundary before any query or insert, so lookups behave case-insensitively even though the DB index itself is case-sensitive. +- Ticket's `contact_id` / `contactId` is a nullable FK into `escalated_contacts.id` / `.id`. +- Contact's `user_id` / `userId` is a nullable FK back to the host-app user model — set by `ContactService.promoteToUser` once a guest accepts a signup invite. The widget endpoint accepts either `email` (guest path — creates/reuses a `Contact`, fills `ticket.contact_id`, and sets `ticket.requester_id` from the current guest policy) OR `requester_id` (legacy authenticated path — sets `ticket.requester_id` to the supplied host-app user id, no `Contact` is created). One of the two must be present or the endpoint returns 400. The `email` path is recommended — the legacy `requester_id` shortcut is kept for backwards compatibility with existing host-app integrations. From 959e3f73d25a252862f691e367462d5939f3c7ff Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:18:37 -0400 Subject: [PATCH 15/15] docs(public-tickets): note data-widget-path for NestJS embedders The shared Vue widget hardcoded /support/widget and couldn't reach NestJS's /escalated/widget routes. Fixed in escalated#35 by adding a data-widget-path override. Document it here so NestJS users know to set the attribute. Also switched the 'On open' paragraph from hardcoded paths to a template (/config, /tickets) since the behavior is now path-parametric. --- sections/public-tickets.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sections/public-tickets.md b/sections/public-tickets.md index 603816d..cdf1d61 100644 --- a/sections/public-tickets.md +++ b/sections/public-tickets.md @@ -93,7 +93,9 @@ Place the widget snippet on any page. Config is read from `data-*` attributes on Only `data-base-url` is required; `data-color` (hex, defaults to indigo `#4F46E5`) and `data-position` (`bottom-right` | `bottom-left`, defaults to `bottom-right`) are optional. The widget mounts itself into a shadow-DOM host so host-app CSS can't leak in. -On open, the widget optionally fetches `/support/widget/config` (host-framework-dependent — some plugins implement this to serve admin-configured branding overrides, others fall back to the `data-*` attributes) and renders a ticket form collecting `email` (required), `name` (optional), `subject`, `description`, and an optional `priority`. On submit it POSTs to the host framework's widget tickets endpoint (`/escalated/widget/tickets` on NestJS; `/support/widget/tickets` on Laravel/Rails/Django/etc.). +**For NestJS backends, add one more attribute:** `data-widget-path="/escalated/widget"`. The NestJS reference mounts its WidgetController at `/escalated/widget` (not `/support/widget` like every other host adapter), so embedders need to tell the shared widget which prefix to use. Every non-NestJS backend works with the default and doesn't need this attribute. + +On open, the widget optionally fetches `/config` (host-framework-dependent — some plugins implement this to serve admin-configured branding overrides, others fall back to the `data-*` attributes) and renders a ticket form collecting `email` (required), `name` (optional), `subject`, `description`, and an optional `priority`. On submit it POSTs to `/tickets` on the host framework. Per-email rate limit: 10 submissions per hour, enforced by `PublicSubmitThrottleGuard`. Requests exceeding the limit get a `429` and no ticket is created.