Skip to content

docs: add Public Tickets section#10

Open
mpge wants to merge 15 commits intomainfrom
docs/public-tickets
Open

docs: add Public Tickets section#10
mpge wants to merge 15 commits intomainfrom
docs/public-tickets

Conversation

@mpge
Copy link
Copy Markdown
Member

@mpge mpge commented Apr 24, 2026

Summary

Adds `sections/public-tickets.md` covering the feature that shipped across every host-framework plugin during the public-ticket rollout — until now it only had scattered mentions in the NestJS CHANGELOG and the per-framework inbound-email pages. Public Tickets is arguably one of the bigger user-visible features in the plugin set and deserves its own docs page.

What it covers

  • Three guest-policy modes — decision table for `unassigned` / `guest_user` / `prompt_signup` (requester_id / contact_id / email behavior)
  • Admin settings page — how to flip modes at runtime via `Admin → Settings → Public Tickets`, backed by the `GET/PUT /escalated/admin/settings/public-tickets` endpoints that landed in escalated-dotnet#32, escalated-go#38, escalated-spring#36, and escalated-phoenix#45, plus the legacy-framework PRs from iter 92-95
  • Runtime validation semantics — unknown-mode coercion, mode-switch cleanup (so stale `guest_user_id` doesn't leak into `prompt_signup`), 500-char template truncation, zero-user-id handling
  • Widget submission flow — snippet, payload shape, per-email rate limit (10 / hour)
  • Inbound email routing — the 5-priority resolution chain (In-Reply-To → References → signed Reply-To → subject ref → legacy lookup)
  • Workflow integration — public submissions fire `ticket.created` / `reply.created` same as authenticated flows, so existing Workflows you configure automatically fire against them
  • `promoteToUser` back-stamp — what happens when a `prompt_signup` guest accepts the invite
  • Data model — Contact entity + `contact_id` FK

Wires the new page into `docs.json` between Tickets and Bulk Actions so the sidebar follows the natural reading order.

Test plan

  • `python -c "import json; json.load(open('docs.json'))"` — docs.json still parses
  • Markdown renders as valid (no unclosed fences, tables align, links resolve within the doc set)
  • Reviewer: cross-check the per-framework behavior descriptions against the shipped PRs — particularly the `guest_policy_signup_url_template` 500-char truncation (shipped in all four greenfield PRs and the Symfony foundation) and the case-insensitive email uniqueness on Contact (shipped in the Contact convergence PRs)

mpge added 11 commits April 24, 2026 10:03
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.
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.
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.
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.
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).
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.
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.
…tions

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.
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.
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.
… endpoint

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.
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.
mpge added 2 commits April 24, 2026 12:12
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.
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.
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 (<widget-path>/config, <widget-path>/tickets) since the
behavior is now path-parametric.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant