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.