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..cdf1d61 --- /dev/null +++ b/sections/public-tickets.md @@ -0,0 +1,161 @@ +# 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** — 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. + +## 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), but the semantic shape is a tagged union with one of three modes: + +```json +// 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. + +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 | Prefix | +|---|---| +| 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) | + +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?from_ticket={token}" +} +``` + +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 variable in signup URL + +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: + +``` +https://app.example.com/signup?from_ticket={token} +``` + +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 + +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 + +``` + +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. + +**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. + +> **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 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: + +1. **`In-Reply-To` header** → ticket id parsed from our outbound `Message-ID` +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 + +If none match, the router resolves/creates a `Contact` by sender email and 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 (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 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.