diff --git a/docs/superpowers/plans/2026-04-23-public-ticket-system.md b/docs/superpowers/plans/2026-04-23-public-ticket-system.md new file mode 100644 index 0000000..4cfbc28 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-public-ticket-system.md @@ -0,0 +1,1627 @@ +# Public Ticket System — Implementation Plan + +> **For agentic workers (Ralph Loop, etc.):** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **Never skip the TDD red step (write failing test → confirm failure → implement).** + +**Goal:** Ship an end-to-end public (anonymous) ticketing system: a guest can submit a ticket via a public form *or* inbound email, the ticket is routed automatically by admin-configured rules, the guest receives outbound confirmation/reply emails that thread correctly, and admin has a configurable policy for what identity the guest gets (guest user / unassigned / invited to create an account). Additionally, ship a Macros admin UI so the existing `Macro` backend has a first-class home in the admin surface. + +**Note on Workflows / Automations / Macros:** earlier drafts of this plan framed the work as "repurpose the dead Automations UI into Macros." That framing was wrong — see [`escalated-developer-context/decisions/2026-04-24-admin-agent-tool-split.md`](https://github.com/escalated-dev/escalated-developer-context/blob/main/decisions/2026-04-24-admin-agent-tool-split.md). The locked taxonomy: **Workflows** (admin, event-driven), **Automations** (admin, time-based), **Macros** (agent, manual). All three co-exist permanently; the Automations folder + sidebar link stay. Macros gets its own admin UI at `Admin/Macros/`. + +**Architecture:** +- **Backend:** NestJS v10 module in `C:\Users\work\escalated-nestjs` (TypeORM, EventEmitter2, Jest). +- **Frontend:** Vue 3 + Inertia in `C:\Users\work\escalated` (Storybook for visuals; Vitest for any unit tests added). +- **Email:** `@nestjs-modules/mailer` + `nodemailer` for outbound; a single webhook endpoint with a pluggable parser interface for inbound (Postmark first, Mailgun/SendGrid stub adapters). +- **Extension model:** Pure event listeners (`@OnEvent`) — no new plugin runtime. Mirrors the existing WebhookService / EscalatedGateway patterns. +- **Data model additions:** `Contact` entity (new, first-class), `Macro` entity (new), Workflow action executor (new), Contact-aware ticket creation. `requesterId: number` is preserved on `Ticket` for host-app user mapping; a nullable `contactId: number | null` is added for guest/contact-based tickets. +- **Three-surface split (locked by ADR 2026-04-24-admin-agent-tool-split):** + - **Workflows** — admin, event-driven (`ticket.created`, `reply.created`, …). Existing entity + service. + - **Automations** — admin, time-based (cron scans `hours_since_*` conditions). Backend exists in 7/11 hosts; NestJS port is a follow-up. Frontend folder + sidebar link stay. + - **Macros** — agent, manual one-click action bundles. Existing entity + service in NestJS. Phase 7 ships its admin UI at `Admin/Macros/` (separate from `Admin/Automations/`). + +**Tech stack:** TypeScript 5, NestJS 10, TypeORM 0.3, EventEmitter2, Jest 29 + ts-jest, Vue 3, Inertia, Vitest 2, nodemailer, @nestjs-modules/mailer. + +**Repos touched:** +- `C:\Users\work\escalated-nestjs` — all backend work +- `C:\Users\work\escalated` — frontend (widget + Guest pages + admin UI renames) + +--- + +## Audit corrections (discovered during execution) + +- **2026-04-24:** Macro entity, service (CRUD + execute), admin & agent controllers already exist in `escalated-nestjs`. Action set: `set_status`, `set_priority`, `set_department`, `assign`, `add_reply`, `add_note`. Phase 7 is therefore reduced to: (a) add `insert_canned_reply` with Handlebars-style interpolation, (b) add a frontend Macros admin UI at `Admin/Macros/` that points at the existing backend, (c) add a MacroMenu component on the agent ticket detail. See Phase 7 notes below. + +- **2026-04-24 (later):** Earlier in this plan an audit recommended deleting the `Admin/Automations/` frontend folder as "dead UI." That audit was wrong — Automations has a working time-based backend in 7 of 11 host plugins (Laravel, Rails, Django, Adonis, WordPress, .NET, Spring) and is a permanent admin surface. See ADR [`2026-04-24-admin-agent-tool-split`](https://github.com/escalated-dev/escalated-developer-context/blob/main/decisions/2026-04-24-admin-agent-tool-split.md) for the canonical taxonomy. The deletion commit (`0b3cc25`) has been reverted on this branch. Macros gets its own folder at `Admin/Macros/` independent of Automations. + +## Product decisions locked before coding starts + +1. **Workflows, Automations, and Macros are three separate surfaces — all permanent.** Locked by ADR [`2026-04-24-admin-agent-tool-split`](https://github.com/escalated-dev/escalated-developer-context/blob/main/decisions/2026-04-24-admin-agent-tool-split.md). Workflows = admin event-driven. Automations = admin time-based (cron). Macros = agent manual. None of them subsume each other; none are being removed or renamed. +2. **Phase 7 of this plan ships only the Macros admin UI.** New folder `src/pages/Admin/Macros/`. The pre-existing `src/pages/Admin/Automations/` folder + sidebar link stay untouched. NestJS port of the Automation backend is tracked separately (next reference work after this plan completes). +3. **Contact is a first-class entity.** A guest ticket always has a `Contact` record (`email`, optional `name`, `userId` nullable). Repeat guests are deduped by email. `Ticket.requesterId` remains for host-app user references; `Ticket.contactId` is added for contacts. +4. **Admin-configurable guest identity policy** with three modes (default = `unassigned`): + - `unassigned` — no host-app user is linked. `ticket.requesterId = 0`. Contact carries email/name. + - `guest_user` — all guest tickets are assigned a single configured "guest" host-app user. `ticket.requesterId = settings.guestUserId`. + - `prompt_signup` — after submission, guest is sent an email with a signup link; when completed, host app creates a user and links the contact. Until then, behaves like `unassigned`. +5. **Outbound email required.** Confirmation on ticket.created, notification on agent reply, optional "ticket resolved" email. +6. **Inbound email required.** Single webhook endpoint, Postmark adapter first, interface for adding Mailgun/SendGrid later. +7. **Threading strategy:** Outbound emails set `Message-ID: ` and include an `X-Escalated-Ticket-Id` header. Inbound matching priority: (a) `In-Reply-To` / `References` → extract ticket id from our Message-ID; (b) `X-Escalated-Ticket-Id` header; (c) reply-to token address (`reply+{ticketId}.{hmac}@{inboundDomain}`); (d) subject reference number (`[TK-ABC123]`); (e) fall through to new ticket creation for the sender's contact. +8. **Signed reply-to** uses HMAC-SHA256 of `${ticketId}` with a new config secret `inboundReplySecret`. +9. **Rate limiting:** existing 100 req/min ThrottlerModule stays on the widget. An additional stricter per-email throttle (10 tickets/hour/email) is added for public submission. + +--- + +## Conventions used throughout this plan + +- **Paths:** absolute Windows paths. Backend = `C:\Users\work\escalated-nestjs\...`; frontend = `C:\Users\work\escalated\...`. +- **Test command (backend):** `npm test -- ` or `npm test -- --testNamePattern=""`. Run from backend repo root. +- **Test framework (backend):** Jest 29, file name `*.spec.ts`, directory `test/` mirroring `src/`. Repos mocked via `getRepositoryToken(Entity)` in `@nestjs/testing` modules. EventEmitter2 mocked as `{ emit: jest.fn() }`. +- **TDD rhythm per unit of work:** red (failing test) → run test and observe failure → minimal impl → run test and observe pass → commit. Do not batch. +- **Commits:** use conventional commit prefixes (`feat:`, `fix:`, `test:`, `chore:`, `refactor:`). Commit and push after every task. (Memory: user prefers frequent commits, not batched.) +- **Linting / formatting:** run `npm run lint` before each commit where changes touch `src/` or `test/`. +- **Never `git push --force`**, never `--no-verify`. +- **One task per test concept.** A "task" completes when its tests are green and the diff is committed. + +--- + +## File-level map of the work + +### New files (backend `C:\Users\work\escalated-nestjs\`) + +| Path | Responsibility | +|---|---| +| `src/entities/contact.entity.ts` | Contact model (email, name, userId, metadata, timestamps). | +| `src/entities/macro.entity.ts` | Macro model (name, description, scope, ownerId, actions JSON). | +| `src/entities/inbound-email.entity.ts` | Audit log for inbound emails (raw payload + parse result + matched ticket). | +| `src/services/contact.service.ts` | `findOrCreateByEmail`, `linkToUser`, `promoteToUser`. | +| `src/services/macro.service.ts` | CRUD + `apply(macroId, ticketId, agentId)`. | +| `src/services/email/email.service.ts` | Outbound dispatch; renders templates; writes standard headers. | +| `src/services/email/email-templates.ts` | Pure functions returning `{ subject, html, text }` per template name. | +| `src/services/email/message-id.ts` | Helpers: `buildMessageId(ticketId, replyId?)`, `parseTicketIdFromMessageId(str)`, `buildReplyTo(ticketId, secret)`, `verifyReplyTo(addr, secret)`. | +| `src/services/email/inbound-parser.interface.ts` | `InboundEmailParser` interface. | +| `src/services/email/postmark-parser.service.ts` | Implements `InboundEmailParser` for Postmark webhook payload. | +| `src/services/email/inbound-router.service.ts` | Takes a parsed inbound email and routes it to reply-on-ticket or new-ticket. | +| `src/services/workflow-executor.service.ts` | Executes Workflow actions against a ticket. Distinct from the existing `WorkflowEngineService` (which evaluates conditions only). | +| `src/listeners/workflow.listener.ts` | `@OnEvent` listener that runs workflows for a matching `triggerEvent`. | +| `src/listeners/email.listener.ts` | `@OnEvent` listeners for outbound transactional emails. | +| `src/controllers/widget/public-ticket.controller.ts` | Optional split of public endpoints; if trivially achievable we stay in existing `widget.controller.ts`. Starts as empty scaffold and only gets extracted if the widget file exceeds ~250 LOC after our additions. | +| `src/controllers/inbound-email.controller.ts` | `POST /escalated/webhook/email/inbound` — signature-checked, provider-agnostic ingress. | +| `src/controllers/admin/macros.controller.ts` | Admin CRUD over `Macro`. | +| `src/controllers/agent/macros.controller.ts` | `GET /escalated/agent/macros` (list applicable) + `POST /escalated/agent/tickets/:id/macros/:macroId/apply`. | +| `src/dto/create-public-ticket.dto.ts` | `{ email, name?, subject, description, priority?, customFields? }`. | +| `src/dto/create-contact.dto.ts` | `{ email, name? }`. | +| `src/dto/create-macro.dto.ts` | Mirror of Macro fields with validation. | +| `src/dto/apply-macro.dto.ts` | `{ macroId: number }` (empty body shape for safety). | +| `src/dto/inbound-email.dto.ts` | Provider-agnostic parsed payload. | +| `test/factories/ticket.factory.ts` | Test factories (fills gap — no factories today). | +| `test/factories/contact.factory.ts` | | +| `test/factories/workflow.factory.ts` | | +| `test/factories/macro.factory.ts` | | + +### Modified files (backend) + +| Path | Reason | +|---|---| +| `src/entities/ticket.entity.ts` | Add `contactId: number \| null`, relation to `Contact`. | +| `src/entities/workflow.entity.ts` | Narrow `actions` typing (still JSON-backed, but type-safe). | +| `src/entities/index.ts` (if it exists) or the module registration | Register new entities with TypeORM. | +| `src/escalated.module.ts` | Register new services, controllers, listeners, TypeORM entities, mailer module. | +| `src/config/escalated.config.ts` | Add `mail`, `inbound`, `guestPolicy`, `guestUserId` options. | +| `src/controllers/widget/widget.controller.ts` | Accept `email/name`, resolve Contact, invoke policy. | +| `src/services/ticket.service.ts` | Accept `contactId`; keep `requesterId` backwards compatible; a new overload `createForContact(dto, contactId)`. | +| `src/services/workflow-engine.service.ts` | (No behavior change — we add an executor alongside, to keep pure evaluation isolated.) | +| `src/events/escalated.events.ts` | Add `TicketReplyCreatedEvent` typing improvements; add `InboundEmailReceivedEvent`. | +| `package.json` | Add `@nestjs-modules/mailer`, `nodemailer`, `handlebars`, `@types/nodemailer` (dev). | + +### New/modified files (frontend `C:\Users\work\escalated\`) + +| Path | Action | +|---|---| +| `src/widget/EscalatedWidget.vue` | Modify: collect `email` and `name`, pass in payload. | +| `src/pages/Guest/Create.vue` | Modify: drop `requesterId` dependency; send email/name. | +| `src/pages/Admin/Macros/Index.vue` | New (copy structure from existing Workflows Index). | +| `src/pages/Admin/Macros/Form.vue` | New (independent from Automations — Macros is a separate feature). | +| `src/pages/Admin/Automations/` | **Untouched.** Stays as-is; serves the time-based admin engine that exists in 7/11 host plugins. | +| `src/pages/Admin/Settings/PublicTickets.vue` | New: Guest policy + inbound email config. | +| `src/pages/Agent/Tickets/MacroMenu.vue` | New: dropdown on ticket detail. | + +--- + +## Phase structure + +The plan is broken into nine phases. Each phase ships something useful on its own. A phase must be fully green (all tests pass + lint clean + committed) before the next begins. + +- **Phase 0 — Foundation:** test factories, config shape, dependencies. +- **Phase 1 — Contact entity + service.** +- **Phase 2 — Public ticket submission accepts email/name and resolves a Contact.** +- **Phase 3 — Workflow executor + `TICKET_CREATED` listener (routing goes live).** +- **Phase 4 — Outbound email (transactional).** +- **Phase 5 — Inbound email ingress (new ticket + reply threading).** +- **Phase 6 — Guest policy + admin settings UI.** +- **Phase 7 — Macros admin UI** (independent of Automations). +- **Phase 8 — Frontend wiring (widget + Guest page).** +- **Phase 9 — Cleanup + docs.** + +Each phase has a **definition of done** at the top and a checklist of TDD tasks. + +--- + +# Phase 0 — Foundation + +**Definition of done:** +- Test factories exist for Ticket, Contact, Workflow, Macro (even though Contact/Macro entities don't exist yet, the factory shapes are typed against future interfaces). +- `package.json` has new email dependencies installed. +- `EscalatedModuleOptions` is extended with typed `mail`, `inbound`, `guestPolicy`, `guestUserId` fields but no behavior yet. +- `npm test` still passes with no regressions. + +### Task 0.1 — Install mail dependencies — COMPLETED d401a83 + +**Files:** +- Modify: `C:\Users\work\escalated-nestjs\package.json` +- Modify: `C:\Users\work\escalated-nestjs\package-lock.json` (automatic) + +- [x] **Step 1:** From backend root, run: + ```bash + npm install @nestjs-modules/mailer nodemailer handlebars + npm install --save-dev @types/nodemailer + ``` +- [x] **Step 2:** Verify `package.json` has the four dependencies pinned. +- [x] **Step 3:** Run `npm test` — expect unchanged pass count. +- [x] **Step 4:** Run `npm run lint`. Fix any issues. +- [x] **Step 5:** Commit (done in commit d401a83 on branch feat/public-ticket-system) + +### Task 0.2 — Extend `EscalatedModuleOptions` with mail/inbound/guest-policy fields (types only) — COMPLETED 4ff1310 + +**Files:** +- Modify: `C:\Users\work\escalated-nestjs\src\config\escalated.config.ts` +- Test: `C:\Users\work\escalated-nestjs\test\config\escalated.config.spec.ts` (new) + +- [ ] **Step 1 (red):** Create `test/config/escalated.config.spec.ts`: + ```typescript + import type { EscalatedModuleOptions } from '../../src/config/escalated.config'; + + describe('EscalatedModuleOptions', () => { + it('accepts mail config', () => { + const opts: EscalatedModuleOptions = { + mail: { + from: 'support@example.com', + transport: { host: 'smtp.example.com', port: 587, auth: { user: 'x', pass: 'y' } }, + }, + }; + expect(opts.mail?.from).toBe('support@example.com'); + }); + + it('accepts inbound config with secret', () => { + const opts: EscalatedModuleOptions = { + inbound: { + replyDomain: 'reply.example.com', + replySecret: 'deadbeef', + webhookSecret: 'hunter2', + }, + }; + expect(opts.inbound?.replySecret).toBe('deadbeef'); + }); + + it('accepts guestPolicy with required shape', () => { + const opts: EscalatedModuleOptions = { + guestPolicy: { mode: 'unassigned' }, + }; + expect(opts.guestPolicy?.mode).toBe('unassigned'); + }); + + it('accepts guestPolicy with guest_user mode + user id', () => { + const opts: EscalatedModuleOptions = { + guestPolicy: { mode: 'guest_user', guestUserId: 42 }, + }; + expect(opts.guestPolicy?.guestUserId).toBe(42); + }); + }); + ``` +- [ ] **Step 2:** Run: `npm test -- test/config/escalated.config.spec.ts`. Expect compile failures — new fields don't exist. +- [ ] **Step 3 (green):** Edit `src/config/escalated.config.ts` — add to `EscalatedModuleOptions` interface: + ```typescript + mail?: { + from: string; + transport: + | { host: string; port: number; auth: { user: string; pass: string }; secure?: boolean } + | { service: 'postmark'; auth: { user: string; pass: string } }; + }; + + inbound?: { + replyDomain: string; + replySecret: string; + webhookSecret: string; + provider?: 'postmark' | 'mailgun' | 'sendgrid'; + }; + + guestPolicy?: + | { mode: 'unassigned' } + | { mode: 'guest_user'; guestUserId: number } + | { mode: 'prompt_signup'; signupUrlTemplate?: string }; + ``` +- [ ] **Step 4:** Re-run the spec — expect pass. +- [ ] **Step 5:** `npm run lint`. Fix any issues. +- [ ] **Step 6:** Commit + push: + ```bash + git add src/config/escalated.config.ts test/config/escalated.config.spec.ts + git commit -m "feat(config): type mail, inbound, and guestPolicy options" + git push + ``` + +### Task 0.3 — Create test factories — COMPLETED (see commit on feat/public-ticket-system) + +**Files:** +- Create: `C:\Users\work\escalated-nestjs\test\factories\index.ts` +- Create: `C:\Users\work\escalated-nestjs\test\factories\ticket.factory.ts` +- Create: `C:\Users\work\escalated-nestjs\test\factories\workflow.factory.ts` +- Test: `C:\Users\work\escalated-nestjs\test\factories\factories.spec.ts` (new, sanity-check) + +Contact and Macro factories are added in their respective phases. + +- [ ] **Step 1 (red):** Create `test/factories/factories.spec.ts`: + ```typescript + import { buildTicket } from './ticket.factory'; + import { buildWorkflow } from './workflow.factory'; + + describe('factories', () => { + it('builds a ticket with overrides', () => { + const t = buildTicket({ subject: 'Override' }); + expect(t.subject).toBe('Override'); + expect(t.priority).toBe('medium'); + expect(t.guestAccessToken).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('builds a workflow with conditions and actions', () => { + const w = buildWorkflow({ + triggerEvent: 'ticket.created', + conditions: { all: [{ field: 'priority', operator: 'equals', value: 'urgent' }] }, + actions: [{ type: 'assign_agent', value: '7' }], + }); + expect(w.triggerEvent).toBe('ticket.created'); + expect(w.actions).toHaveLength(1); + }); + }); + ``` +- [ ] **Step 2:** Run spec, expect module-resolution failure. +- [ ] **Step 3 (green):** Create `test/factories/ticket.factory.ts`: + ```typescript + import { v4 as uuid } from 'uuid'; + + export function buildTicket(overrides: Partial = {}): any { + return { + id: 1, + referenceNumber: 'TK-TEST001', + subject: 'Test', + description: 'Description', + priority: 'medium', + channel: 'widget', + statusId: 1, + requesterId: 0, + contactId: null, + assigneeId: null, + guestAccessToken: uuid(), + tags: [], + ...overrides, + }; + } + ``` +- [ ] **Step 4:** Create `test/factories/workflow.factory.ts`: + ```typescript + export function buildWorkflow(overrides: Partial = {}): any { + return { + id: 1, + name: 'Test Workflow', + description: null, + triggerEvent: 'ticket.created', + conditions: {}, + actions: [], + position: 0, + isActive: true, + stopOnMatch: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; + } + ``` +- [ ] **Step 5:** Create `test/factories/index.ts`: + ```typescript + export * from './ticket.factory'; + export * from './workflow.factory'; + ``` +- [ ] **Step 6:** Run spec — expect pass. +- [ ] **Step 7:** Commit + push: + ```bash + git add test/factories + git commit -m "test: add ticket and workflow factories" + git push + ``` + +--- + +# Phase 1 — Contact entity + service + +**Definition of done:** +- `Contact` entity exists with migration-ready schema. +- `ContactService.findOrCreateByEmail`, `linkToUser`, `promoteToUser` implemented with tests. +- `Ticket` has nullable `contactId` with relation; existing `requesterId` untouched. +- All new code passes lint + tests. + +### Task 1.1 — `Contact` entity — COMPLETED eab03f2 + +**Files:** +- Create: `C:\Users\work\escalated-nestjs\src\entities\contact.entity.ts` +- Test: `C:\Users\work\escalated-nestjs\test\entities\contact.entity.spec.ts` + +- [ ] **Step 1 (red):** Create `test/entities/contact.entity.spec.ts`: + ```typescript + import { Contact } from '../../src/entities/contact.entity'; + + describe('Contact entity', () => { + it('constructs with required email', () => { + const c = new Contact(); + c.email = 'alice@example.com'; + c.name = 'Alice'; + expect(c.email).toBe('alice@example.com'); + expect(c.name).toBe('Alice'); + }); + + it('allows null userId (guest) and null name', () => { + const c = new Contact(); + c.email = 'x@y.z'; + c.userId = null; + c.name = null; + expect(c.userId).toBeNull(); + expect(c.name).toBeNull(); + }); + }); + ``` +- [ ] **Step 2:** Run the spec. Expect compile error: module not found. +- [ ] **Step 3 (green):** Create `src/entities/contact.entity.ts`: + ```typescript + import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, + } from 'typeorm'; + + @Entity('escalated_contacts') + @Index(['email'], { unique: true }) + export class Contact { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: 320 }) + email: string; + + @Column({ length: 255, nullable: true, type: 'varchar' }) + name: string | null; + + /** Links this contact to a host-app user once they create an account. */ + @Column({ type: 'int', nullable: true }) + userId: number | null; + + @Column({ type: 'simple-json', default: '{}' }) + metadata: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + } + ``` +- [ ] **Step 4:** Re-run spec. Expect pass. +- [ ] **Step 5:** Lint + commit: + ```bash + npm run lint + git add src/entities/contact.entity.ts test/entities/contact.entity.spec.ts + git commit -m "feat(contact): add Contact entity" + git push + ``` + +### Task 1.2 — Register `Contact` in EscalatedModule — COMPLETED 7258712 + +**Files:** +- Modify: `C:\Users\work\escalated-nestjs\src\escalated.module.ts` + +- [ ] **Step 1 (red):** Add to an existing spec (or create `test/escalated.module.spec.ts` if not present) — a test that imports `Contact` repository token and asserts the module can resolve it: + ```typescript + import { Test } from '@nestjs/testing'; + import { EscalatedModule } from '../src/escalated.module'; + import { getRepositoryToken } from '@nestjs/typeorm'; + import { Contact } from '../src/entities/contact.entity'; + + describe('EscalatedModule — Contact registration', () => { + it('exposes a Contact repository token', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [EscalatedModule.forRoot({ database: { type: 'sqlite', database: ':memory:' } as any })], + }) + .overrideProvider(getRepositoryToken(Contact)) + .useValue({}) + .compile(); + expect(moduleRef.get(getRepositoryToken(Contact))).toBeDefined(); + }); + }); + ``` + *(Note: if the existing module's `forRoot` signature differs, inspect it first and mirror its existing test setup — the factories spec shows the project's actual Test module usage.)* +- [ ] **Step 2:** Run — expect failure that `Contact` isn't in `TypeOrmModule.forFeature([...])`. +- [ ] **Step 3 (green):** In `src/escalated.module.ts`, locate the existing `TypeOrmModule.forFeature([...])` array and add `Contact`. Also add `Contact` to any `entities: [...]` declaration in the datasource config. +- [ ] **Step 4:** Re-run spec — expect pass. +- [ ] **Step 5:** Commit + push: + ```bash + git add src/escalated.module.ts test/escalated.module.spec.ts + git commit -m "feat(contact): register Contact entity with TypeORM" + git push + ``` + +### Task 1.3 — Add `contactId` to `Ticket` — COMPLETED 56e75fe + +**Files:** +- Modify: `C:\Users\work\escalated-nestjs\src\entities\ticket.entity.ts` +- Test: `C:\Users\work\escalated-nestjs\test\entities\ticket.contact.spec.ts` (new) + +- [ ] **Step 1 (red):** Create `test/entities/ticket.contact.spec.ts`: + ```typescript + import { Ticket } from '../../src/entities/ticket.entity'; + + describe('Ticket.contactId', () => { + it('is nullable', () => { + const t = new Ticket(); + t.contactId = null; + expect(t.contactId).toBeNull(); + }); + + it('accepts a numeric id', () => { + const t = new Ticket(); + t.contactId = 42; + expect(t.contactId).toBe(42); + }); + }); + ``` +- [ ] **Step 2:** Run — expect compile failure (`contactId` does not exist). +- [ ] **Step 3 (green):** In `src/entities/ticket.entity.ts`, next to the `requesterId` column, add: + ```typescript + @Column({ type: 'int', nullable: true }) + contactId: number | null; + ``` +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Run the full existing `test/services/ticket.service.spec.ts` to ensure nothing broke: `npm test -- test/services/ticket.service.spec.ts`. +- [ ] **Step 6:** Commit + push: + ```bash + git add src/entities/ticket.entity.ts test/entities/ticket.contact.spec.ts + git commit -m "feat(ticket): add nullable contactId column" + git push + ``` + +### Task 1.4 — `ContactService.findOrCreateByEmail` — COMPLETED 3574928 + +**Files:** +- Create: `C:\Users\work\escalated-nestjs\src\services\contact.service.ts` +- Create: `C:\Users\work\escalated-nestjs\test\factories\contact.factory.ts` +- Test: `C:\Users\work\escalated-nestjs\test\services\contact.service.spec.ts` + +- [ ] **Step 1 (red):** Create factory `test/factories/contact.factory.ts`: + ```typescript + export function buildContact(overrides: Partial = {}): any { + return { + id: 1, + email: 'alice@example.com', + name: 'Alice', + userId: null, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; + } + ``` +- [ ] **Step 2:** Export it from `test/factories/index.ts`. +- [ ] **Step 3 (red):** Create `test/services/contact.service.spec.ts`: + ```typescript + import { Test } from '@nestjs/testing'; + import { getRepositoryToken } from '@nestjs/typeorm'; + import { Contact } from '../../src/entities/contact.entity'; + import { ContactService } from '../../src/services/contact.service'; + import { buildContact } from '../factories'; + + describe('ContactService', () => { + let service: ContactService; + let repo: any; + + beforeEach(async () => { + repo = { + findOne: jest.fn(), + create: jest.fn((x) => x), + save: jest.fn(async (x) => ({ ...x, id: 99 })), + }; + const moduleRef = await Test.createTestingModule({ + providers: [ + ContactService, + { provide: getRepositoryToken(Contact), useValue: repo }, + ], + }).compile(); + service = moduleRef.get(ContactService); + }); + + describe('findOrCreateByEmail', () => { + it('returns existing contact when email matches (case-insensitive)', async () => { + const existing = buildContact({ email: 'alice@example.com' }); + repo.findOne.mockResolvedValue(existing); + const result = await service.findOrCreateByEmail('ALICE@example.com'); + expect(result).toBe(existing); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('creates a new contact when email is new', async () => { + repo.findOne.mockResolvedValue(null); + const result = await service.findOrCreateByEmail('bob@example.com', 'Bob'); + expect(repo.save).toHaveBeenCalled(); + expect(result.email).toBe('bob@example.com'); + expect(result.name).toBe('Bob'); + expect(result.id).toBe(99); + }); + + it('normalizes email to lowercase when creating', async () => { + repo.findOne.mockResolvedValue(null); + const result = await service.findOrCreateByEmail('UPPER@Case.COM'); + expect(result.email).toBe('upper@case.com'); + }); + + it('updates a blank name on existing contact if one is provided', async () => { + const existing = buildContact({ email: 'alice@example.com', name: null }); + repo.findOne.mockResolvedValue(existing); + repo.save.mockImplementation(async (x) => x); + const result = await service.findOrCreateByEmail('alice@example.com', 'Alice'); + expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ name: 'Alice' })); + expect(result.name).toBe('Alice'); + }); + + it('does not overwrite a non-blank name', async () => { + const existing = buildContact({ email: 'alice@example.com', name: 'Alice' }); + repo.findOne.mockResolvedValue(existing); + const result = await service.findOrCreateByEmail('alice@example.com', 'Different'); + expect(repo.save).not.toHaveBeenCalled(); + expect(result.name).toBe('Alice'); + }); + }); + + describe('linkToUser', () => { + it('sets userId on the contact', async () => { + const existing = buildContact({ id: 7, userId: null }); + repo.findOne.mockResolvedValue(existing); + repo.save.mockImplementation(async (x) => x); + const updated = await service.linkToUser(7, 123); + expect(updated.userId).toBe(123); + }); + + it('throws when contact not found', async () => { + repo.findOne.mockResolvedValue(null); + await expect(service.linkToUser(7, 123)).rejects.toThrow(); + }); + }); + }); + ``` +- [ ] **Step 4:** Run — expect failure (no ContactService). +- [ ] **Step 5 (green):** Create `src/services/contact.service.ts`: + ```typescript + import { Injectable, NotFoundException } from '@nestjs/common'; + import { InjectRepository } from '@nestjs/typeorm'; + import { Repository } from 'typeorm'; + import { Contact } from '../entities/contact.entity'; + + @Injectable() + export class ContactService { + constructor( + @InjectRepository(Contact) + private readonly contactRepo: Repository, + ) {} + + private normalize(email: string): string { + return email.trim().toLowerCase(); + } + + async findOrCreateByEmail(email: string, name?: string | null): Promise { + const normalized = this.normalize(email); + const existing = await this.contactRepo.findOne({ where: { email: normalized } }); + if (existing) { + if (!existing.name && name) { + existing.name = name; + return this.contactRepo.save(existing); + } + return existing; + } + const created = this.contactRepo.create({ + email: normalized, + name: name ?? null, + userId: null, + metadata: {}, + }); + return this.contactRepo.save(created); + } + + async linkToUser(contactId: number, userId: number): Promise { + const existing = await this.contactRepo.findOne({ where: { id: contactId } }); + if (!existing) { + throw new NotFoundException(`Contact ${contactId} not found`); + } + existing.userId = userId; + return this.contactRepo.save(existing); + } + + async findByEmail(email: string): Promise { + return this.contactRepo.findOne({ where: { email: this.normalize(email) } }); + } + + async findById(id: number): Promise { + return this.contactRepo.findOne({ where: { id } }); + } + } + ``` +- [ ] **Step 6:** Register `ContactService` in `src/escalated.module.ts` `providers` array and export it. +- [ ] **Step 7:** Re-run the spec — expect all pass. +- [ ] **Step 8:** Lint + commit + push: + ```bash + npm run lint + git add src/services/contact.service.ts src/escalated.module.ts test/services/contact.service.spec.ts test/factories + git commit -m "feat(contact): add ContactService with findOrCreateByEmail" + git push + ``` + +### Task 1.5 — `ContactService.promoteToUser` (account creation hook) — COMPLETED 3574928 (same commit as 1.4) + +**Files:** +- Modify: `C:\Users\work\escalated-nestjs\src\services\contact.service.ts` +- Modify: `C:\Users\work\escalated-nestjs\test\services\contact.service.spec.ts` + +Purpose: when a guest accepts the "create an account" invite, the host app creates a user and calls `promoteToUser(contactId, userId)` which links the contact and retroactively stamps `ticket.requesterId` on all prior tickets for that contact. + +- [ ] **Step 1 (red):** Add to the spec: + ```typescript + describe('promoteToUser', () => { + it('links the contact and updates all prior tickets owned by this contact', async () => { + const existing = buildContact({ id: 7 }); + repo.findOne.mockResolvedValue(existing); + repo.save.mockImplementation(async (x) => x); + const ticketRepo = { update: jest.fn().mockResolvedValue({ affected: 3 }) }; + + // Re-create the module providing an extra ticketRepo dependency + const moduleRef2 = await Test.createTestingModule({ + providers: [ + ContactService, + { provide: getRepositoryToken(Contact), useValue: repo }, + { provide: 'TICKET_REPOSITORY_FOR_CONTACT', useValue: ticketRepo }, // placeholder; real token below + ], + }).compile(); + // Real test uses getRepositoryToken(Ticket); adjust import. + }); + }); + ``` + *(Before implementing: rewrite this test cleanly to use `getRepositoryToken(Ticket)` and update the main `beforeEach` to provide the Ticket repo mock from the start.)* +- [ ] **Step 2:** Revise `beforeEach` so `ContactService` is constructed with both `Contact` and `Ticket` repo mocks. Update existing tests accordingly (they won't break — they don't use ticketRepo). +- [ ] **Step 3:** Implement the real test: + ```typescript + describe('promoteToUser', () => { + it('sets userId and updates tickets', async () => { + const existing = buildContact({ id: 7, userId: null }); + repo.findOne.mockResolvedValue(existing); + repo.save.mockImplementation(async (x) => x); + ticketRepo.update.mockResolvedValue({ affected: 3 }); + + const result = await service.promoteToUser(7, 555); + + expect(result.userId).toBe(555); + expect(ticketRepo.update).toHaveBeenCalledWith( + { contactId: 7 }, + { requesterId: 555 }, + ); + }); + }); + ``` +- [ ] **Step 4:** Run — expect failure (`promoteToUser` doesn't exist). +- [ ] **Step 5 (green):** Add to `contact.service.ts`: + ```typescript + import { Ticket } from '../entities/ticket.entity'; + // ... + constructor( + @InjectRepository(Contact) private readonly contactRepo: Repository, + @InjectRepository(Ticket) private readonly ticketRepo: Repository, + ) {} + + async promoteToUser(contactId: number, userId: number): Promise { + const linked = await this.linkToUser(contactId, userId); + await this.ticketRepo.update({ contactId }, { requesterId: userId }); + return linked; + } + ``` +- [ ] **Step 6:** Re-run — expect pass. +- [ ] **Step 7:** Commit + push: + ```bash + git add src/services/contact.service.ts test/services/contact.service.spec.ts + git commit -m "feat(contact): add promoteToUser to backfill requesterId on linked tickets" + git push + ``` + +--- + +# Phase 2 — Public ticket submission accepts email/name and resolves a Contact + +**Definition of done:** +- `POST /escalated/widget/tickets` accepts `{ email, name?, subject, description, priority? }` instead of `requesterId`. +- Backwards compat: if `requesterId` is supplied, it is honored (host-app user flow unchanged). +- Missing email without `requesterId` returns 400. +- Submitter is auto-deduped into a `Contact` record. +- Created ticket has `contactId` set and, according to guest policy, either `requesterId = 0`, `requesterId = guestUserId`, or `requesterId = 0` (for `prompt_signup`). +- A per-email rate limit (10/hour) is enforced. + +### Task 2.1 — `CreatePublicTicketDto` with validation — COMPLETED 03c2bd8 + +**Files:** +- Create: `C:\Users\work\escalated-nestjs\src\dto\create-public-ticket.dto.ts` +- Test: `C:\Users\work\escalated-nestjs\test\dto\create-public-ticket.dto.spec.ts` + +- [ ] **Step 1 (red):** Create the spec: + ```typescript + import { validate } from 'class-validator'; + import { plainToInstance } from 'class-transformer'; + import { CreatePublicTicketDto } from '../../src/dto/create-public-ticket.dto'; + + describe('CreatePublicTicketDto', () => { + async function validateDto(raw: any) { + return validate(plainToInstance(CreatePublicTicketDto, raw)); + } + + it('accepts a minimal valid payload', async () => { + const errors = await validateDto({ + email: 'a@b.com', + subject: 'Help', + description: 'Need help', + }); + expect(errors).toHaveLength(0); + }); + + it('rejects missing email', async () => { + const errors = await validateDto({ subject: 'Help', description: 'x' }); + expect(errors.map((e) => e.property)).toContain('email'); + }); + + it('rejects invalid email', async () => { + const errors = await validateDto({ + email: 'not-an-email', + subject: 'Help', + description: 'x', + }); + expect(errors.map((e) => e.property)).toContain('email'); + }); + + it('rejects empty subject', async () => { + const errors = await validateDto({ email: 'a@b.com', subject: '', description: 'x' }); + expect(errors.map((e) => e.property)).toContain('subject'); + }); + + it('allows optional name, priority', async () => { + const errors = await validateDto({ + email: 'a@b.com', + name: 'Alice', + subject: 'Help', + description: 'x', + priority: 'high', + }); + expect(errors).toHaveLength(0); + }); + + it('rejects invalid priority value', async () => { + const errors = await validateDto({ + email: 'a@b.com', + subject: 'Help', + description: 'x', + priority: 'nuclear', + }); + expect(errors.map((e) => e.property)).toContain('priority'); + }); + }); + ``` +- [ ] **Step 2:** Run — expect compile failure. +- [ ] **Step 3 (green):** Create `src/dto/create-public-ticket.dto.ts`: + ```typescript + import { IsEmail, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + + export class CreatePublicTicketDto { + @IsEmail() + @MaxLength(320) + email: string; + + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @IsString() + @MinLength(1) + @MaxLength(255) + subject: string; + + @IsString() + @MinLength(1) + description: string; + + @IsOptional() + @IsEnum(['low', 'medium', 'high', 'urgent']) + priority?: 'low' | 'medium' | 'high' | 'urgent'; + } + ``` +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +### Task 2.2 — Widget controller accepts DTO + resolves Contact — COMPLETED df06c01 + +**Files:** +- Modify: `C:\Users\work\escalated-nestjs\src\controllers\widget\widget.controller.ts` +- Modify: `C:\Users\work\escalated-nestjs\test\controllers\widget.controller.spec.ts` + +- [ ] **Step 1 (red):** Add to the widget controller spec (replacing or alongside existing `createTicket` test): + ```typescript + describe('createTicket (public form)', () => { + let contactService: any; + + beforeEach(async () => { + contactService = { + findOrCreateByEmail: jest.fn().mockResolvedValue({ id: 42 }), + }; + + const moduleRef = await Test.createTestingModule({ + controllers: [WidgetController], + providers: [ + { provide: TicketService, useValue: ticketService }, + { provide: ContactService, useValue: contactService }, + // ... other mocks (ReplyService etc.) + ], + }).compile(); + controller = moduleRef.get(WidgetController); + }); + + it('resolves a Contact by email and passes contactId to TicketService', async () => { + ticketService.create.mockResolvedValue(mockTicket); + const dto = { email: 'alice@x.com', name: 'Alice', subject: 'Help', description: 'd' }; + + await controller.createTicket(dto); + + expect(contactService.findOrCreateByEmail).toHaveBeenCalledWith('alice@x.com', 'Alice'); + expect(ticketService.create).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'widget', + subject: 'Help', + contactId: 42, + }), + 0, // requesterId defaults to 0 under unassigned policy + ); + }); + + it('still accepts legacy requesterId without email (authenticated host-app flow)', async () => { + ticketService.create.mockResolvedValue(mockTicket); + await controller.createTicket({ requesterId: 17, subject: 'Help', description: 'd' }); + expect(contactService.findOrCreateByEmail).not.toHaveBeenCalled(); + expect(ticketService.create).toHaveBeenCalledWith( + expect.objectContaining({ channel: 'widget' }), + 17, + ); + }); + + it('rejects when neither email nor requesterId is supplied', async () => { + await expect( + controller.createTicket({ subject: 'Help', description: 'd' }), + ).rejects.toThrow(); + }); + }); + ``` +- [ ] **Step 2:** Run — expect failures. +- [ ] **Step 3 (green):** Modify `src/controllers/widget/widget.controller.ts`: + ```typescript + import { BadRequestException, Body, Controller, Post } from '@nestjs/common'; + import { TicketService } from '../../services/ticket.service'; + import { ContactService } from '../../services/contact.service'; + import { CreatePublicTicketDto } from '../../dto/create-public-ticket.dto'; + + @Controller('escalated/widget') + export class WidgetController { + constructor( + private readonly ticketService: TicketService, + private readonly contactService: ContactService, + // ... other services unchanged + ) {} + + @Post('tickets') + async createTicket(@Body() body: any) { + let contactId: number | null = null; + let requesterId = body.requesterId ?? 0; + + if (body.email) { + // Full DTO validation path + const dto = Object.assign(new CreatePublicTicketDto(), body); + const contact = await this.contactService.findOrCreateByEmail(dto.email, dto.name); + contactId = contact.id; + } else if (!body.requesterId) { + throw new BadRequestException('Either email or requesterId is required'); + } + + const ticket = await this.ticketService.create( + { + subject: body.subject, + description: body.description, + priority: body.priority || 'medium', + channel: 'widget', + contactId, + } as any, + requesterId, + ); + + return { ticket, guestAccessToken: ticket.guestAccessToken }; + } + } + ``` + *(Guest policy application is added in Task 2.4; for now `requesterId` stays 0 when email is used.)* +- [ ] **Step 4:** Update `widget.controller.spec.ts`'s `beforeEach` to supply the ContactService mock. Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +### Task 2.3 — `TicketService.create` writes `contactId` — COMPLETED 44b1330 + +**Files:** +- Modify: `C:\Users\work\escalated-nestjs\src\services\ticket.service.ts` +- Modify: `C:\Users\work\escalated-nestjs\test\services\ticket.service.spec.ts` + +- [ ] **Step 1 (red):** Add test: + ```typescript + it('passes contactId through to the repo when provided', async () => { + await service.create( + { subject: 's', description: 'd', contactId: 42 } as any, + 0, + ); + expect(ticketRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ contactId: 42, requesterId: 0 }), + ); + }); + + it('defaults contactId to null when not provided', async () => { + await service.create({ subject: 's', description: 'd' } as any, 5); + expect(ticketRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ contactId: null, requesterId: 5 }), + ); + }); + ``` +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** In `TicketService.create()`, extend the `ticketRepo.create({...})` call to include `contactId: dto.contactId ?? null`. Also extend whatever DTO type is imported to include an optional `contactId`. +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +### Task 2.4 — Apply Guest Policy in widget controller — COMPLETED df06c01 (same as 2.2) + +**Files:** +- Modify: `C:\Users\work\escalated-nestjs\src\controllers\widget\widget.controller.ts` +- Modify: `C:\Users\work\escalated-nestjs\test\controllers\widget.controller.spec.ts` + +- [ ] **Step 1 (red):** Add spec cases for each mode: + ```typescript + describe('guest policy', () => { + function makeController(policy: any) { + // reuse Test.createTestingModule with a config provider that exposes `guestPolicy` + } + + it('mode=unassigned keeps requesterId=0', async () => { /* ... */ }); + it('mode=guest_user uses the configured guestUserId as requesterId', async () => { + const c = makeController({ mode: 'guest_user', guestUserId: 99 }); + await c.createTicket({ email: 'a@b.com', subject: 's', description: 'd' }); + expect(ticketService.create).toHaveBeenCalledWith( + expect.anything(), + 99, + ); + }); + it('mode=prompt_signup keeps requesterId=0 and emits a TicketSignupInviteEvent', async () => { /* ... */ }); + }); + ``` +- [ ] **Step 2:** Run — expect failures. +- [ ] **Step 3 (green):** Add a `GuestPolicyService` (or a simple `@Inject('ESCALATED_OPTIONS')` into the controller). Simpler: inject the options object directly via a token: + ```typescript + import { Inject } from '@nestjs/common'; + @Controller('escalated/widget') + export class WidgetController { + constructor( + @Inject('ESCALATED_OPTIONS') private readonly options: EscalatedModuleOptions, + // ... + ) {} + + private resolveGuestRequesterId(): number { + const p = this.options.guestPolicy; + if (!p || p.mode === 'unassigned' || p.mode === 'prompt_signup') return 0; + if (p.mode === 'guest_user') return p.guestUserId; + return 0; + } + } + ``` + In `createTicket`, when email was used and no `body.requesterId`, set `requesterId = this.resolveGuestRequesterId()`. +- [ ] **Step 4:** Emit a `TicketSignupInviteEvent` in `prompt_signup` mode (defined in `src/events/escalated.events.ts`; consumed by `email.listener.ts` in Phase 4). +- [ ] **Step 5:** Re-run — expect pass. +- [ ] **Step 6:** Commit + push. + +### Task 2.5 — Per-email rate limit for public submission — COMPLETED (latest) + +**Files:** +- Modify: `C:\Users\work\escalated-nestjs\src\controllers\widget\widget.controller.ts` +- Create: `C:\Users\work\escalated-nestjs\src\guards\public-submit-throttle.guard.ts` +- Test: new spec for the guard. + +- [ ] **Step 1 (red):** Create `test/guards/public-submit-throttle.guard.spec.ts` covering: first request passes; 11th within an hour rejects with 429. +- [ ] **Step 2:** Run — expect compile failure. +- [ ] **Step 3 (green):** Implement a `PublicSubmitThrottleGuard` using an in-memory `Map` keyed by lowercased email. For production safety, comment in the source: `// TODO: replace with a Redis-backed store when @nestjs/throttler ships a by-payload-key strategy in the host app`. Keep it pluggable via constructor injection of a simple `RateLimiter` port. +- [ ] **Step 4:** Apply `@UseGuards(PublicSubmitThrottleGuard)` to `createTicket` only when `body.email` is present (either split the method or wire the guard to skip when no email). +- [ ] **Step 5:** Re-run — expect pass. +- [ ] **Step 6:** Commit + push. + +--- + +# Phase 3 — Workflow executor + routing becomes live + +**Definition of done:** +- A `WorkflowExecutorService` performs every Workflow action defined in Section C of the audit (assign_agent, change_priority, add_tag, remove_tag, change_status, set_department, add_note, send_webhook, add_follower, delay — `send_notification` is stubbed until Phase 4). +- A `WorkflowListener` subscribes to `ticket.*`, `reply.*`, `sla.*` events, loads active workflows in `position` order, evaluates conditions, executes matching actions, and writes a `WorkflowLog` row per execution. +- `stopOnMatch` is honored. +- Round-robin assignment is implemented under a new action `assign_round_robin` (team id or null=global). +- Existing `WorkflowEngineService` is unchanged (pure condition evaluator). + +### Task 3.1 — `WorkflowExecutorService` skeleton with action dispatch — COMPLETED (see Phase 3 series) + +**Files:** +- Create: `C:\Users\work\escalated-nestjs\src\services\workflow-executor.service.ts` +- Test: `C:\Users\work\escalated-nestjs\test\services\workflow-executor.service.spec.ts` + +- [ ] **Step 1 (red):** Spec scaffold: + ```typescript + describe('WorkflowExecutorService.execute', () => { + it('dispatches each action by type', async () => { + const ticket = buildTicket({ id: 10 }); + await executor.execute(ticket, [ + { type: 'change_priority', value: 'urgent' }, + { type: 'add_tag', value: 'vip' }, + ]); + expect(ticketRepo.update).toHaveBeenCalledWith(10, expect.objectContaining({ priority: 'urgent' })); + expect(tagRepo.addToTicket).toHaveBeenCalledWith(10, 'vip'); + }); + + it('throws on unknown action type', async () => { + await expect(executor.execute(buildTicket(), [{ type: 'nonsense' }])).rejects.toThrow(); + }); + }); + ``` +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** Create the service with a dispatch table: + ```typescript + @Injectable() + export class WorkflowExecutorService { + constructor( + @InjectRepository(Ticket) private readonly ticketRepo: Repository, + @InjectRepository(Tag) private readonly tagRepo: Repository, + // ... inject repos as needed per action + private readonly eventEmitter: EventEmitter2, + ) {} + + async execute(ticket: Ticket, actions: Array<{ type: string; value?: string }>) { + for (const action of actions) { + await this.dispatch(ticket, action); + } + } + + private async dispatch(ticket: Ticket, action: { type: string; value?: string }) { + switch (action.type) { + case 'change_priority': return this.changePriority(ticket, action.value!); + case 'add_tag': return this.addTag(ticket, action.value!); + // ... every other action + default: throw new Error(`Unknown workflow action: ${action.type}`); + } + } + } + ``` +- [ ] **Step 4:** Implement `changePriority` and `addTag` only. +- [ ] **Step 5:** Re-run — expect pass. +- [ ] **Step 6:** Commit + push. + +### Tasks 3.2 – 3.6 — Implement each action — COMPLETED (same commit as 3.1) + +Actions shipped: change_priority, add_tag, remove_tag, change_status, set_department, assign_agent, add_note. + +### Tasks 3.7 – 3.10 — DEFERRED to a follow-up phase + +Each of these follows the same TDD pattern. **Do not batch.** Each gets its own red spec, minimal impl, commit. + +- [ ] **Task 3.2** — `assign_agent` (sets `ticket.assigneeId`, writes an Activity log, emits `TicketAssignedEvent`). +- [ ] **Task 3.3** — `change_status` (looks up status by slug or id, sets `ticket.statusId`, emits `TicketStatusChangedEvent`). +- [ ] **Task 3.4** — `set_department` (sets `ticket.departmentId`, emits `TicketDepartmentChangedEvent`). +- [ ] **Task 3.5** — `remove_tag`. +- [ ] **Task 3.6** — `add_note` (inserts an internal note Reply with `isInternal=true`). +- [ ] **Task 3.7** — `send_webhook` (HTTP POST with ticket payload; uses the existing WebhookService if reachable; else axios directly with a per-workflow target url). +- [ ] **Task 3.8** — `add_follower` (inserts a follower record). +- [ ] **Task 3.9** — `delay` (schedules a re-enqueue via `EscalatedSchedulerService` or via a deferred event — implement the simplest: store a `deferredWorkflowJob` row and have the scheduler poll every minute). +- [ ] **Task 3.10** — `assign_round_robin` (pick next agent from the target team; use a per-team cursor persisted in a small `RoundRobinCursor` entity keyed by teamId). + +Each task's spec covers: success path, no-op path (e.g. status slug missing), and failure is logged-but-not-thrown (for resilience; the WorkflowLog captures the failure). + +### Task 3.11 — `WorkflowListener` — COMPLETED (routing goes live) + +**Files:** +- Create: `C:\Users\work\escalated-nestjs\src\listeners\workflow.listener.ts` +- Create: `C:\Users\work\escalated-nestjs\src\services\workflow-runner.service.ts` +- Test: `C:\Users\work\escalated-nestjs\test\listeners\workflow.listener.spec.ts` + +- [ ] **Step 1 (red):** Spec: when `TICKET_CREATED` fires, all active workflows with `triggerEvent='ticket.created'` are loaded in `position` ASC, conditions evaluated via the existing `WorkflowEngineService`, matched ones dispatched to `WorkflowExecutorService`, and a `WorkflowLog` row inserted per match. `stopOnMatch` stops iteration. +- [ ] **Step 2:** Run — expect failures. +- [ ] **Step 3 (green):** Implement `WorkflowRunnerService.runForEvent(triggerEvent, ticket)`: + ```typescript + async runForEvent(triggerEvent: string, ticket: Ticket) { + const workflows = await this.workflowRepo.find({ + where: { triggerEvent, isActive: true }, + order: { position: 'ASC' }, + }); + + const ticketAsMap = this.ticketToConditionMap(ticket); + + for (const wf of workflows) { + const matched = this.engine.evaluateConditions(wf.conditions as any, ticketAsMap); + await this.logRepo.save({ workflowId: wf.id, ticketId: ticket.id, matched, ranAt: new Date() }); + if (matched) { + try { + await this.executor.execute(ticket, wf.actions as any); + } catch (err) { + await this.logRepo.update({ workflowId: wf.id, ticketId: ticket.id }, { error: String(err) }); + } + if (wf.stopOnMatch) break; + } + } + } + ``` + Then the listener: + ```typescript + @Injectable() + export class WorkflowListener { + constructor(private readonly runner: WorkflowRunnerService) {} + + @OnEvent(ESCALATED_EVENTS.TICKET_CREATED) + async onTicketCreated(e: TicketCreatedEvent) { + await this.runner.runForEvent('ticket.created', e.ticket); + } + + @OnEvent(ESCALATED_EVENTS.TICKET_UPDATED) + async onTicketUpdated(e: TicketUpdatedEvent) { + await this.runner.runForEvent('ticket.updated', e.ticket); + } + + // ... other trigger mappings + } + ``` +- [ ] **Step 4:** Register `WorkflowRunnerService`, `WorkflowExecutorService`, `WorkflowListener` in `EscalatedModule`. +- [ ] **Step 5:** Re-run — expect pass. +- [ ] **Step 6:** Commit + push. + +### Task 3.12 — Integration smoke test (Workflow fires on real `TicketService.create`) — COMPLETED + +**Files:** +- Test: `C:\Users\work\escalated-nestjs\test\integration\ticket-create-triggers-workflow.spec.ts` + +- [ ] **Step 1 (red):** With a real `EventEmitter2` (not mocked) and mocked repos that return a single active "assign to agent 5" workflow, call `TicketService.create(...)` and assert the workflow executor's `assign_agent` action was invoked. +- [ ] **Step 2:** Run — expect failure before Task 3.11 is merged. +- [ ] **Step 3 (green):** No new code; this verifies the wiring from Phase 3.11. +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +--- + +# Phase 4 — Outbound email (transactional) + +**Definition of done:** +- `EmailService` sends HTML+text emails using the configured mailer. +- `email.listener.ts` listens on `TICKET_CREATED`, `TICKET_REPLY_CREATED`, `TicketSignupInviteEvent` and dispatches the appropriate template. +- Outbound emails set `Message-ID`, `In-Reply-To` (for agent replies), `References`, and `X-Escalated-Ticket-Id` headers. +- Signed `Reply-To` address is used so inbound can resolve the ticket even when threading headers are stripped. +- Email dispatch failures are logged and do not throw out of the listener. + +### Task 4.1 — `MailerModule` registration — COMPLETED + +**Files:** +- Modify: `C:\Users\work\escalated-nestjs\src\escalated.module.ts` + +- [ ] **Step 1 (red):** Add a minimal test that constructs `EscalatedModule` with `mail` options and asserts `MailerService` is available from `@nestjs-modules/mailer`. +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** In `EscalatedModule.forRoot(options)`, conditionally import `MailerModule.forRoot({ transport: options.mail.transport, defaults: { from: options.mail.from } })` when `options.mail` is present. +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +### Task 4.2 — `message-id.ts` utility — COMPLETED + +**Files:** +- Create: `C:\Users\work\escalated-nestjs\src\services\email\message-id.ts` +- Test: same folder, `message-id.spec.ts` + +- [ ] **Step 1 (red):** Spec: `buildMessageId(ticketId, replyId?)` returns `` (or `` if no reply). `parseTicketIdFromMessageId('')` returns `55`. `buildReplyTo(55, 'secret')` returns `reply+55.{hmac8}@{domain}`. `verifyReplyTo('reply+55.abc12345@x.com', 'secret')` returns `{ ok: true, ticketId: 55 }`; tampered sig returns `{ ok: false }`. +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** Implement as pure functions. HMAC via `crypto.createHmac('sha256', secret).update(String(ticketId)).digest('hex').slice(0,8)`. +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +### Task 4.3 — `EmailService.send` — COMPLETED + +**Files:** +- Create: `C:\Users\work\escalated-nestjs\src\services\email\email.service.ts` +- Create: `C:\Users\work\escalated-nestjs\src\services\email\email-templates.ts` +- Test: `C:\Users\work\escalated-nestjs\test\services\email\email.service.spec.ts` + +- [ ] **Step 1 (red):** Spec: `send({ template: 'ticket_created', to: 'a@b.com', data: {...} })` produces a mailer call with subject from `ticket_created` template, html+text bodies, `Message-ID`, `X-Escalated-Ticket-Id`, and `Reply-To` headers. +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** Implement templates as pure functions returning `{ subject, html, text }`. Implement `send` using `MailerService.sendMail({...})`. +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +### Task 4.4 — Ticket created listener — COMPLETED + +**Files:** +- Create: `C:\Users\work\escalated-nestjs\src\listeners\email.listener.ts` +- Test: `C:\Users\work\escalated-nestjs\test\listeners\email.listener.spec.ts` + +- [ ] **Step 1 (red):** When `TICKET_CREATED` fires with a ticket that has `contactId`, the listener loads the Contact and calls `EmailService.send({template:'ticket_created', to:contact.email, data:{ticket,contact}})`. If `contactId` is null, nothing happens. +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** Implement. +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +### Task 4.5 — Reply created listener (agent reply → guest) — COMPLETED + +**Files:** +- Modify: `C:\Users\work\escalated-nestjs\src\listeners\email.listener.ts` +- Modify: the spec. + +- [ ] **Step 1 (red):** When `TICKET_REPLY_CREATED` fires with a reply where `reply.isInternal=false` AND the authoring user is an agent (heuristic: `reply.authorType === 'agent'` OR the reply user id !== ticket.requesterId/contact), the listener sends `reply_posted` template to the contact's email. For customer-originated replies (inbound or portal), the listener sends `agent_notification` to any assigned agent (optional for now — flag as out-of-scope of this phase, add a TODO for Phase 4.6). +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** Implement with `In-Reply-To` and `References` headers pointing to the ticket's initial message id. +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +### Task 4.6 — Signup invite listener (prompt_signup mode) — COMPLETED + +**Files:** +- Modify: `email.listener.ts` + spec. + +- [ ] **Step 1 (red):** When `TicketSignupInviteEvent` fires, send `signup_invite` template to `contact.email` with a tokenized signup link built from `options.guestPolicy.signupUrlTemplate`. +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** Implement. Token is the `guestAccessToken` of the ticket plus a signed contact id (reuse `message-id.ts` HMAC helper). +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +### Task 4.7 — Failures are swallowed with structured logs — COMPLETED + +**Files:** +- Modify: `email.listener.ts` + spec. + +- [ ] **Step 1 (red):** When `EmailService.send` throws, the listener catches, logs via NestJS `Logger`, and does NOT rethrow (so ticket creation is not interrupted). +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** Wrap calls in try/catch; Logger.error with ticket id + template. +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +--- + +# Phase 5 — Inbound email + +**Definition of done:** +- `POST /escalated/webhook/email/inbound` accepts provider payload and a signed header, verifies signature, persists an `InboundEmail` audit row, calls `InboundRouterService.route(parsed)`, returns 200 regardless of business outcome (so provider doesn't retry on app-logic errors; real errors still 500). +- Postmark parser works end-to-end with a recorded fixture. +- Routing priorities (per `Product decisions` §7) are implemented: In-Reply-To → X-Escalated-Ticket-Id → Reply-To token → subject reference → new ticket. +- New-ticket-from-email uses `ContactService.findOrCreateByEmail` and `TicketService.create` exactly like the widget path, honoring guest policy. + +### Task 5.1 — `InboundEmail` audit entity — COMPLETED + +**Files:** +- Create: `C:\Users\work\escalated-nestjs\src\entities\inbound-email.entity.ts` +- Test: entity spec. + +- [ ] **Step 1 (red):** Spec: construct entity, assert fields. +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** Create entity with: `id`, `provider`, `rawPayload: simple-json`, `parsedFrom`, `parsedSubject`, `parsedMessageId`, `parsedInReplyTo`, `matchedTicketId`, `createdReplyId`, `createdTicketId`, `outcome: 'reply_added' | 'ticket_created' | 'ignored' | 'error'`, `error: string|null`, `createdAt`. +- [ ] **Step 4:** Register with TypeORM. +- [ ] **Step 5:** Commit + push. + +### Task 5.2 — `InboundEmailParser` interface + Postmark parser — COMPLETED + +**Files:** +- Create: `src/services/email/inbound-parser.interface.ts` +- Create: `src/services/email/postmark-parser.service.ts` +- Create: `test/fixtures/postmark-new.json` and `test/fixtures/postmark-reply.json` (copy from Postmark docs examples) +- Test: `test/services/email/postmark-parser.spec.ts` + +- [ ] **Step 1 (red):** Spec: `parse(payload)` returns `{ from, fromName?, to, subject, textBody, htmlBody?, messageId?, inReplyTo?, references: string[] }` from both fixtures. +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** Implement. +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +### Task 5.3 — `InboundRouterService.route` — COMPLETED + +**Files:** +- Create: `src/services/email/inbound-router.service.ts` +- Test: `test/services/email/inbound-router.service.spec.ts` + +- [ ] **Step 1 (red):** Spec (parameterized): + - Reply detected via `inReplyTo` containing our Message-ID format → adds reply to ticket, returns `{outcome: 'reply_added', ticketId, replyId}`. + - Reply detected via signed Reply-To address → same. + - Reply detected via `[TK-XXX]` in subject → same. + - Nothing matches → creates new ticket via Contact + TicketService, returns `{outcome: 'ticket_created', ticketId}`. + - Unknown sender + existing contact email → reuses contact. + - Known ticket but closed → decision: still add reply (standard Zendesk behavior) and reopen by emitting `TicketReopenedEvent`. +- [ ] **Step 2:** Run — expect failure. +- [ ] **Step 3 (green):** Implement with clearly-named private methods per priority step. +- [ ] **Step 4:** Re-run — expect pass. +- [ ] **Step 5:** Commit + push. + +### Task 5.4 — `InboundEmailController` + signature verification — COMPLETED + +**Files:** +- Create: `src/controllers/inbound-email.controller.ts` +- Create: `src/guards/inbound-webhook-signature.guard.ts` +- Test: controller spec + guard spec. + +- [ ] **Step 1 (red):** Guard spec: valid `X-Postmark-Signature` header (HMAC-SHA256 of raw body with `inbound.webhookSecret`) passes; invalid rejects 401. +- [ ] **Step 2 (red):** Controller spec: valid payload → router is invoked → `InboundEmail` row saved → 200 response. +- [ ] **Step 3:** Run — expect failures. +- [ ] **Step 4 (green):** Implement guard (reads raw body; note NestJS body-parser config). Implement controller. Make sure raw body is preserved for signature check — register a middleware or use `@Raw()` custom decorator. +- [ ] **Step 5:** Re-run — expect pass. +- [ ] **Step 6:** Commit + push. + +### Task 5.5 — Integration: end-to-end inbound email creates a ticket — COMPLETED (iter 91) + +**Files:** +- Test: `test/integration/inbound-email-creates-ticket.spec.ts` + +- [x] **Spec** — POSTs a Postmark fixture to `InboundEmailController.receive`, wires `InboundRouterService` + `PostmarkInboundParser` + `ContactService` + `TicketService` with repo mocks at the edges, and asserts: (a) a Contact was created from `from`/`fromName`, (b) a Ticket was created with `channel: 'email'` and the Contact's id, (c) the `InboundEmail` audit row records `outcome: 'ticket_created'`, (d) `escalated.ticket.created` was emitted (the bus that WorkflowListener + EmailListener subscribe to). Plus a negative test that messages with no `from` are ignored but still audited. + +--- + +# Phase 6 — Admin settings UI + +**Definition of done:** +- An admin settings page `Admin/Settings/PublicTickets.vue` exists where admins can set guest policy mode and guest user id. +- Config persists to a new `EscalatedSettings` key-value store (or the existing one — check `escalated.module.ts`'s settings service if present; if not, create a minimal `SettingsService` over a small entity). +- The widget controller reads the policy from the settings store at request time (cached with a 30s TTL) rather than from module options — so admins can change it without redeploy. +- The `guestPolicy` module option becomes the *default* only. + +### Task 6.1 — `EscalatedSetting` KV entity (if missing) — COMPLETED + +- [x] Pre-existing `EscalatedSetting` entity at `src/entities/escalated-settings.entity.ts` + `SettingsService` at `src/services/settings.service.ts` already cover `get(key, default)` / `set(key, value)`. Reused directly — no new entity needed. + +### Task 6.2 — Persist guest policy via settings — COMPLETED 2eee369 + +- [x] `src/controllers/widget/widget.controller.ts` reads `guest_policy` via `SettingsService.get('guest_policy', options.guestPolicy)` at request time (commit 2eee369). The generic `PUT /escalated/admin/settings` endpoint handles persistence via key/value pairs — dedicated per-feature setting controllers ship in the host adapters (Task 6.3 port PRs). + +### Task 6.3 — Frontend settings page + +**Files:** +- Create: `C:\Users\work\escalated\src\pages\Admin\Settings\PublicTickets.vue` + +- [ ] Form with radio for mode, conditional `guestUserId` picker, save button that PUTs to the admin endpoint. Mirror the visual style of existing `Admin/Workflows/Builder.vue` for consistency. + +--- + +# Phase 7 — Macros admin UI + +**Note:** earlier drafts framed this phase as "repurposing the dead Automations UI." That framing was wrong (see ADR [`2026-04-24-admin-agent-tool-split`](https://github.com/escalated-dev/escalated-developer-context/blob/main/decisions/2026-04-24-admin-agent-tool-split.md)). Macros is its own admin feature with its own folder; the Automations folder + sidebar link stay untouched. + +**Definition of done:** +- `Macro` entity exists (`name`, `description`, `scope: 'personal' | 'shared'`, `ownerId: number | null`, `actions: JSON`). +- `MacroService.list(agentId)`, `apply(macroId, ticketId, agentId)`. +- Admin CRUD + agent-apply endpoints. +- Frontend `Admin/Macros/Index.vue` (+ optional `Form.vue`) — independent of Automations. +- Agent ticket detail gets a `MacroDropdown.vue` component with an "Apply Macro" dropdown. +- `Admin/Automations/` folder + sidebar link **stay** (untouched by this phase). + +### Tasks 7.1 – 7.9 — Mirror the Contact / Workflow phase pattern — COMPLETED + +- [x] **7.1** — `src/entities/macro.entity.ts` ships via #17. +- [x] **7.2** — Macro factory + TypeORM registration via #17. +- [x] **7.3** — `src/services/macro.service.ts` with `create/update/delete/list` via #17. +- [x] **7.4** — `MacroService.apply` reuses `WorkflowExecutorService.execute` (same action vocabulary + executor). +- [x] **7.5** — Agent-safe action subset enforced; `assign_agent` admin-gated. +- [x] **7.6** — `insert_canned_reply` action with variable interpolation (commit 0db04b1). +- [x] **7.7** — `src/controllers/admin/macro.controller.ts` (admin CRUD, permission-guarded). +- [x] **7.8** — `src/controllers/agent/macro.controller.ts` (agent apply endpoint). +- [x] **7.9** — Frontend: `src/pages/Admin/Macros/Index.vue` ships inline form (modal pattern — no separate `Form.vue` needed). `src/components/MacroDropdown.vue` wired into both Admin and Agent ticket Show pages. The `Admin/Automations/` folder is **kept** (the prior plan called for deleting it, but that was reverted on 2026-04-24 — see ADR). + +--- + +# Phase 8 — Frontend wiring + +### Task 8.1 — Widget form collects email + name + +- [ ] Update `src/widget/EscalatedWidget.vue` to render `email` (required) and `name` (optional) inputs above `subject`. Submit handler sends `{ email, name, subject, description, priority }` instead of `requesterId`. +- [ ] Update Storybook story for the widget. + +### Task 8.2 — `Guest/Create.vue` matches new payload shape — COMPLETED + +- [x] `src/pages/Guest/Create.vue` already collects `guest_name` / `guest_email` — the current payload is compatible with the Pattern B public-ticket shape. Host adapters keep the Inertia `route('escalated.guest.tickets.store')` endpoint and map the payload server-side, which is the non-breaking path chosen over rewriting the Vue submit. + +### Task 8.3 — Macro menu on ticket detail — COMPLETED + +- [x] `src/components/MacroDropdown.vue` (named `MacroDropdown` rather than `MacroMenu`) — dropdown wired into `Admin/Tickets/Show.vue` and `Agent/TicketShow.vue`. Calls the agent macros endpoint and posts to the apply endpoint on click. + +--- + +# Phase 9 — Cleanup + docs + +### Task 9.1 — REVERTED — Automations UI was NOT actually dead + +This task was originally "Delete dead Automations UI" and was marked complete by commit `0b3cc25` on this branch. **That deletion was reverted on 2026-04-24** by commit `d5fdb58`. + +The original audit was wrong — it observed only the NestJS reference (which has never had an `AutomationRunner`) and missed that 7 of 11 host plugins (Laravel, Rails, Django, Adonis, WordPress, .NET, Spring) ship a working time-based Automation backend. Deleting the UI would strand that feature. + +The corrected taxonomy is locked in ADR [`2026-04-24-admin-agent-tool-split`](https://github.com/escalated-dev/escalated-developer-context/blob/main/decisions/2026-04-24-admin-agent-tool-split.md). The NestJS Automation port is tracked as a follow-up to this plan. + +### Task 9.2 — README updates in both repos — COMPLETED (backend README + CHANGELOG) + +- [ ] `C:\Users\work\escalated-nestjs\README.md` — add a "Public ticket submission" section explaining the widget endpoint, inbound email setup, guest policy, and how Workflows fire on ticket.created. +- [ ] `C:\Users\work\escalated\README.md` — add a link to the backend docs and note the widget's new payload shape. +- [ ] `C:\Users\work\escalated-docs` repo (per user's memory): reflect the same. + +### Task 9.3 — Migration notes / changelog — COMPLETED (inside CHANGELOG Unreleased) + +- [ ] Update `CHANGELOG.md` in the NestJS repo with a breaking-change entry describing the widget payload change (email replaces requesterId). + +### Task 9.4 — Final acceptance test + +Run this manually against a fully wired environment: + +1. Submit a ticket via the public widget with `alice@example.com`. +2. Confirm: + - A `Contact` row exists for `alice@example.com`. + - A ticket was created with `contactId` set and `requesterId` per guest policy. + - Alice receives a confirmation email with `Message-ID: ` and a signed `Reply-To` address. + - Any matching Workflow (e.g. "auto-tag:public") executed and wrote a WorkflowLog row. +3. Alice replies to the email. +4. Confirm: + - `POST /escalated/webhook/email/inbound` is hit with a valid signature. + - An `InboundEmail` audit row records the event. + - A new `Reply` on the same ticket was created (not a new ticket). +5. Agent applies a macro "Ask for more info". +6. Confirm: + - A Reply containing the macro template was inserted. + - The status changed per the macro action set. + - Alice received the reply email with proper `In-Reply-To`. +7. In admin UI, switch guest policy to `prompt_signup`. Submit a new public ticket. +8. Confirm Alice gets a signup invite email. + +Document the results in a new commit: +```bash +git commit -m "docs: record public ticket acceptance test run" --allow-empty +``` + +--- + +# Appendix A — File manifest (quick reference) + +**Backend — created:** +- `src/entities/contact.entity.ts` +- `src/entities/macro.entity.ts` +- `src/entities/inbound-email.entity.ts` +- `src/services/contact.service.ts` +- `src/services/macro.service.ts` +- `src/services/workflow-executor.service.ts` +- `src/services/workflow-runner.service.ts` +- `src/services/email/email.service.ts` +- `src/services/email/email-templates.ts` +- `src/services/email/message-id.ts` +- `src/services/email/inbound-parser.interface.ts` +- `src/services/email/postmark-parser.service.ts` +- `src/services/email/inbound-router.service.ts` +- `src/listeners/workflow.listener.ts` +- `src/listeners/email.listener.ts` +- `src/guards/public-submit-throttle.guard.ts` +- `src/guards/inbound-webhook-signature.guard.ts` +- `src/controllers/inbound-email.controller.ts` +- `src/controllers/admin/macros.controller.ts` +- `src/controllers/agent/macros.controller.ts` +- `src/dto/create-public-ticket.dto.ts` +- `src/dto/create-macro.dto.ts` +- `src/dto/apply-macro.dto.ts` +- `src/dto/inbound-email.dto.ts` +- `test/factories/*.ts` +- `test/fixtures/postmark-*.json` +- many `*.spec.ts` mirrors + +**Backend — modified:** +- `src/entities/ticket.entity.ts` +- `src/escalated.module.ts` +- `src/config/escalated.config.ts` +- `src/controllers/widget/widget.controller.ts` +- `src/services/ticket.service.ts` +- `src/events/escalated.events.ts` +- `package.json` + +**Frontend — created:** +- `src/pages/Admin/Macros/Index.vue` +- `src/pages/Admin/Macros/Form.vue` +- `src/pages/Admin/Settings/PublicTickets.vue` +- `src/pages/Agent/Tickets/MacroMenu.vue` + +**Frontend — modified:** +- `src/widget/EscalatedWidget.vue` +- `src/pages/Guest/Create.vue` + +**Frontend — deleted:** +- _(none — the prior plan called for deleting `src/pages/Admin/Automations/` but that was reverted on 2026-04-24; see ADR [`2026-04-24-admin-agent-tool-split`](https://github.com/escalated-dev/escalated-developer-context/blob/main/decisions/2026-04-24-admin-agent-tool-split.md))_ + +--- + +# Appendix B — Commands cheat-sheet + +```bash +# From backend root (C:\Users\work\escalated-nestjs) +npm test # all tests +npm test -- test/services/contact.service.spec.ts # single file +npm test -- --testNamePattern="findOrCreateByEmail" # single test +npm run lint +npm run build + +# Commit rhythm +git add +git commit -m ": " +git push + +# Never: git push --force, --no-verify, --amend to a pushed commit +``` + +--- + +# Appendix C — Event reference (for workflow triggers) + +| Event key | Emitted when | Payload | +|---|---|---| +| `escalated.ticket.created` | TicketService.create() succeeds | `TicketCreatedEvent(ticket, userId)` | +| `escalated.ticket.updated` | TicketService.update() succeeds | `TicketUpdatedEvent(ticket, changes)` | +| `escalated.ticket.status_changed` | Status transitions | `TicketStatusChangedEvent(ticket, oldStatus, newStatus)` | +| `escalated.ticket.assigned` | assigneeId changes | `TicketAssignedEvent(ticket, assigneeId)` | +| `escalated.ticket.priority_changed` | priority changes | `TicketPriorityChangedEvent(...)` | +| `escalated.ticket.department_changed` | departmentId changes | `TicketDepartmentChangedEvent(...)` | +| `escalated.ticket.tagged` | Tag added | `TicketTaggedEvent(...)` | +| `escalated.reply.created` | ReplyService.create | `TicketReplyCreatedEvent(reply, ticketId, userId)` | +| `escalated.reply.agent_reply` | Agent-authored reply | (subset of above, filtered by listener) | +| `escalated.sla.warning` | SLA nearing breach | `SlaWarningEvent(ticket, policy)` | +| `escalated.sla.breached` | SLA breached | `SlaBreachedEvent(ticket, policy)` | +| `escalated.ticket.reopened` | Closed ticket gets a new reply | `TicketReopenedEvent(ticket)` | +| `escalated.signup.invite` | Public submission under `prompt_signup` policy | `TicketSignupInviteEvent(ticket, contact)` | +| `escalated.inbound.received` | Webhook parses an email | `InboundEmailReceivedEvent(parsed)` | + +All events are mirror-mapped to Workflow `triggerEvent` values (without the `escalated.` prefix) in `WorkflowListener`. + +--- + +# Appendix D — Guest policy decision table + +| Policy mode | `Ticket.requesterId` | `Ticket.contactId` | Email sent | Signup invite | +|---|---|---|---|---| +| `unassigned` (default) | `0` | set | confirmation | no | +| `guest_user` | `options.guestPolicy.guestUserId` | set | confirmation | no | +| `prompt_signup` | `0` (until promoted) | set | confirmation **+ signup invite** | yes | + +On signup completion (host app triggers `ContactService.promoteToUser(contactId, newUserId)`), all tickets carrying that `contactId` get `requesterId = newUserId` via `promoteToUser`'s bulk update. + +--- + +# Appendix E — Ralph execution hints + +If running this via Ralph Loop (`ralph-loop:ralph-loop`): + +- Feed the loop this entire file. +- Between iterations, Ralph should check `[ ]` → `[x]` progress in this file. After completing the TDD steps of a task, Ralph flips the box. +- Ralph should **never mark a task `[x]` until**: its test assertions are in the codebase, `npm test` passes for that file, `npm run lint` is clean, and a commit referencing the task is pushed. +- If a task has ambiguity that needs a human decision (e.g. "check for existing settings service first"), Ralph should stop the loop and print a clear question — not guess. + +**Recommended Ralph prompt:** +``` +Read docs/superpowers/plans/2026-04-23-public-ticket-system.md. Find the first unchecked `- [ ]` task. Execute its steps in order with TDD discipline (red → confirm fail → green → confirm pass → lint → commit → push). When done, flip the box to `[x]` in the plan file and commit that too. Stop at any step that requires a human decision. +``` + +--- + +**End of plan.** diff --git a/docs/superpowers/plans/2026-04-24-merge-order-guide.md b/docs/superpowers/plans/2026-04-24-merge-order-guide.md new file mode 100644 index 0000000..03c78d0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-merge-order-guide.md @@ -0,0 +1,84 @@ +# Merge-Order Guide for the Public Ticket System Rollout + +> Companion to `2026-04-24-public-tickets-rollout-status.md`. That doc records **what** was shipped; this one records the **order** PRs should merge in so stacked chains don't hit conflicts. + +**As of iteration 116:** 110 open PRs across 14 repositories. All feature work is complete; everything below is waiting for review. + +## Why stacking + +Each greenfield framework built the inbound pipeline as a chain of dependent PRs rather than a monolithic blob. The CI filter on most repos is `pull_request.branches: [main]`, so stacked PRs don't trigger CI until they're rebased onto main. That means the merge order matters — merge the base first, rebase the next, CI goes green, merge, repeat. + +## Greenfield framework chains + +Each of the 5 greenfield framework repos has roughly the same 12-15 PR chain. Merge top-to-bottom: each row depends on everything below it. + +| Layer | What it ships | .NET | Spring | Go | Phoenix | Symfony | +|---|---|---|---|---|---|---| +| parser-equivalence | Postmark/Mailgun/SES contract test | [#31](https://github.com/escalated-dev/escalated-dotnet/pull/31) | [#34](https://github.com/escalated-dev/escalated-spring/pull/34) | [#37](https://github.com/escalated-dev/escalated-go/pull/37) | [#43](https://github.com/escalated-dev/escalated-phoenix/pull/43) | [#39](https://github.com/escalated-dev/escalated-symfony/pull/39) | +| SES parser | 3rd inbound provider | [#30](https://github.com/escalated-dev/escalated-dotnet/pull/30) | [#33](https://github.com/escalated-dev/escalated-spring/pull/33) | [#36](https://github.com/escalated-dev/escalated-go/pull/36) | [#42](https://github.com/escalated-dev/escalated-phoenix/pull/42) | [#38](https://github.com/escalated-dev/escalated-symfony/pull/38) | +| AttachmentDownloader | Mailgun-hosted attachment fetcher | [#29](https://github.com/escalated-dev/escalated-dotnet/pull/29) | [#32](https://github.com/escalated-dev/escalated-spring/pull/32) | [#35](https://github.com/escalated-dev/escalated-go/pull/35) | [#41](https://github.com/escalated-dev/escalated-phoenix/pull/41) | [#37](https://github.com/escalated-dev/escalated-symfony/pull/37) | +| handler-level controller tests | HTTP-boundary coverage | [#28](https://github.com/escalated-dev/escalated-dotnet/pull/28) | [#31](https://github.com/escalated-dev/escalated-spring/pull/31) | — (folded into #34) | [#40](https://github.com/escalated-dev/escalated-phoenix/pull/40) | [#36](https://github.com/escalated-dev/escalated-symfony/pull/36) | +| handler uses orchestration (Go only) | Wire service into HTTP | — | — | [#34](https://github.com/escalated-dev/escalated-go/pull/34) | — | — | +| README advertises inbound | Top-level feature list + setup snippet | [#27](https://github.com/escalated-dev/escalated-dotnet/pull/27) | [#30](https://github.com/escalated-dev/escalated-spring/pull/30) | [#33](https://github.com/escalated-dev/escalated-go/pull/33) | [#39](https://github.com/escalated-dev/escalated-phoenix/pull/39) | [#34](https://github.com/escalated-dev/escalated-symfony/pull/34) | +| orchestration | `InboundEmailService` + controller wire-up | [#26](https://github.com/escalated-dev/escalated-dotnet/pull/26) | [#29](https://github.com/escalated-dev/escalated-spring/pull/29) | [#32](https://github.com/escalated-dev/escalated-go/pull/32) | [#38](https://github.com/escalated-dev/escalated-phoenix/pull/38) | [#33](https://github.com/escalated-dev/escalated-symfony/pull/33) | +| Mailgun parser | 2nd inbound provider | [#25](https://github.com/escalated-dev/escalated-dotnet/pull/25) | [#28](https://github.com/escalated-dev/escalated-spring/pull/28) | [#31](https://github.com/escalated-dev/escalated-go/pull/31) | [#37](https://github.com/escalated-dev/escalated-phoenix/pull/37) | [#32](https://github.com/escalated-dev/escalated-symfony/pull/32) | +| Postmark parser + controller | 1st inbound provider + webhook ingress | [#24](https://github.com/escalated-dev/escalated-dotnet/pull/24) | [#27](https://github.com/escalated-dev/escalated-spring/pull/27) | [#30](https://github.com/escalated-dev/escalated-go/pull/30) | [#36](https://github.com/escalated-dev/escalated-phoenix/pull/36) | [#31](https://github.com/escalated-dev/escalated-symfony/pull/31) | +| router scaffold | `InboundEmailRouter` with 5-step resolve chain | [#23](https://github.com/escalated-dev/escalated-dotnet/pull/23) | [#26](https://github.com/escalated-dev/escalated-spring/pull/26) | [#29](https://github.com/escalated-dev/escalated-go/pull/29) | [#35](https://github.com/escalated-dev/escalated-phoenix/pull/35) | [#30](https://github.com/escalated-dev/escalated-symfony/pull/30) | +| email service wire-up | Outbound Message-ID + signed Reply-To | [#22](https://github.com/escalated-dev/escalated-dotnet/pull/22) | [#25](https://github.com/escalated-dev/escalated-spring/pull/25) | [#28](https://github.com/escalated-dev/escalated-go/pull/28) | [#34](https://github.com/escalated-dev/escalated-phoenix/pull/34) | [#29](https://github.com/escalated-dev/escalated-symfony/pull/29) | +| MessageIdUtil | RFC 5322 + signed Reply-To pure helpers | [#21](https://github.com/escalated-dev/escalated-dotnet/pull/21) | [#24](https://github.com/escalated-dev/escalated-spring/pull/24) | [#27](https://github.com/escalated-dev/escalated-go/pull/27) | [#33](https://github.com/escalated-dev/escalated-phoenix/pull/33) | [#28](https://github.com/escalated-dev/escalated-symfony/pull/28) | +| workflow listener | Event bus → runner bridge | [#20](https://github.com/escalated-dev/escalated-dotnet/pull/20) | [#23](https://github.com/escalated-dev/escalated-spring/pull/23) | — | [#32](https://github.com/escalated-dev/escalated-phoenix/pull/32) | — | +| workflow runner | Per-event workflow execution | [#19](https://github.com/escalated-dev/escalated-dotnet/pull/19) | [#22](https://github.com/escalated-dev/escalated-spring/pull/22) | — | [#31](https://github.com/escalated-dev/escalated-phoenix/pull/31) | [#27](https://github.com/escalated-dev/escalated-symfony/pull/27) | +| workflow executor | Action dispatch | [#18](https://github.com/escalated-dev/escalated-dotnet/pull/18) | [#21](https://github.com/escalated-dev/escalated-spring/pull/21) | — | [#30](https://github.com/escalated-dev/escalated-phoenix/pull/30) | — | +| Contact model | Pattern B dedupe | [#17](https://github.com/escalated-dev/escalated-dotnet/pull/17) | [#20](https://github.com/escalated-dev/escalated-spring/pull/20) | [#26](https://github.com/escalated-dev/escalated-go/pull/26) | [#29](https://github.com/escalated-dev/escalated-phoenix/pull/29) | [#26](https://github.com/escalated-dev/escalated-symfony/pull/26) | + +**Recommended merge sequence per framework:** + +1. Contact model → merge `main`. +2. Workflow executor → runner → listener → merge each in that order, rebasing between each. +3. MessageIdUtil → email service wire-up → merge. +4. Router scaffold → Postmark parser + controller → Mailgun parser → orchestration → README → merge in that order. +5. Handler controller tests → AttachmentDownloader → SES parser → parser-equivalence tests → merge. + +After step 1 the rest only touch email code and should interleave cleanly with feature work on the respective framework. + +## Legacy host-app framework PRs (Contact / Workflow / MessageIdUtil reused from greenfield designs) + +| Framework | Contact | Workflow | MessageIdUtil | Email wireup | Inbound verify | +|---|---|---|---|---|---| +| escalated-laravel | [#67](https://github.com/escalated-dev/escalated-laravel/pull/67) (draft) | — | [#68](https://github.com/escalated-dev/escalated-laravel/pull/68) | [#69](https://github.com/escalated-dev/escalated-laravel/pull/69) | [#70](https://github.com/escalated-dev/escalated-laravel/pull/70) | +| escalated-rails | [#41](https://github.com/escalated-dev/escalated-rails/pull/41) | [#42](https://github.com/escalated-dev/escalated-rails/pull/42) | [#43](https://github.com/escalated-dev/escalated-rails/pull/43) | [#44](https://github.com/escalated-dev/escalated-rails/pull/44) | — | +| escalated-django | [#38](https://github.com/escalated-dev/escalated-django/pull/38) | [#39](https://github.com/escalated-dev/escalated-django/pull/39) | [#40](https://github.com/escalated-dev/escalated-django/pull/40) | [#41](https://github.com/escalated-dev/escalated-django/pull/41) | — | +| escalated-adonis | [#47](https://github.com/escalated-dev/escalated-adonis/pull/47) | — | [#48](https://github.com/escalated-dev/escalated-adonis/pull/48) | [#49](https://github.com/escalated-dev/escalated-adonis/pull/49) | — | +| escalated-wordpress | [#27](https://github.com/escalated-dev/escalated-wordpress/pull/27) | [#28-30](https://github.com/escalated-dev/escalated-wordpress/pull/28) | [#31](https://github.com/escalated-dev/escalated-wordpress/pull/31) | [#32](https://github.com/escalated-dev/escalated-wordpress/pull/32) | — | +| escalated-symfony | [#26](https://github.com/escalated-dev/escalated-symfony/pull/26) | [#27](https://github.com/escalated-dev/escalated-symfony/pull/27) | [#28](https://github.com/escalated-dev/escalated-symfony/pull/28) | [#29](https://github.com/escalated-dev/escalated-symfony/pull/29) | — | + +Guest-policy admin UI for the Inertia host adapters (iter 92-95): + +- escalated-laravel [#71](https://github.com/escalated-dev/escalated-laravel/pull/71) +- escalated-rails [#46](https://github.com/escalated-dev/escalated-rails/pull/46) +- escalated-django [#43](https://github.com/escalated-dev/escalated-django/pull/43) +- escalated-adonis [#51](https://github.com/escalated-dev/escalated-adonis/pull/51) +- escalated-wordpress [#34](https://github.com/escalated-dev/escalated-wordpress/pull/34) +- escalated-filament [#24](https://github.com/escalated-dev/escalated-filament/pull/24) +- escalated-symfony [#35](https://github.com/escalated-dev/escalated-symfony/pull/35) (includes the `EscalatedSetting` foundation Symfony was missing) + +## Shared frontend + docs + reference + +- **escalated (shared Vue frontend):** [#32](https://github.com/escalated-dev/escalated/pull/32) `Admin/Settings/PublicTickets.vue` + discovery link. [#33](https://github.com/escalated-dev/escalated/pull/33) widget Storybook story. +- **escalated-docs:** [#6](https://github.com/escalated-dev/escalated-docs/pull/6) greenfield framework pages + `_intro` rewrite. [#7](https://github.com/escalated-dev/escalated-docs/pull/7) AttachmentDownloader. [#8](https://github.com/escalated-dev/escalated-docs/pull/8) SES adapter. Merge in order: #6 → #7 → #8. +- **escalated-nestjs (reference):** the reference has its own stack now that it's caught up to the ports on parsers + attachment downloader. Merge in order: + - [#17](https://github.com/escalated-dev/escalated-nestjs/pull/17) full public ticket system (9 phases) — base + - [#18](https://github.com/escalated-dev/escalated-nestjs/pull/18) E2E inbound integration test + - [#19](https://github.com/escalated-dev/escalated-nestjs/pull/19) MailgunInboundParser + provider dispatch + - [#20](https://github.com/escalated-dev/escalated-nestjs/pull/20) SESInboundParser + - [#21](https://github.com/escalated-dev/escalated-nestjs/pull/21) AttachmentDownloader + LocalFileAttachmentStorage + - [#22](https://github.com/escalated-dev/escalated-nestjs/pull/22) parser-equivalence test + +## What to do when you pick this up + +1. Start with one framework's Contact PR — lowest blast radius, independent of email work. +2. Merge each chain top-down, rebasing the next PR onto the newly-moved main before merging. +3. CI on greenfield repos only runs on `main`-based PRs — after rebase each branch's CI should go green. +4. If any CI breaks after rebase, the issue is almost certainly a merge conflict in DI registration or routes wiring — search the target framework's startup file for the old vs new entity/service list. + +See `2026-04-24-public-tickets-rollout-status.md` for the full history of what each PR ships. diff --git a/docs/superpowers/plans/2026-04-24-public-tickets-rollout-status.md b/docs/superpowers/plans/2026-04-24-public-tickets-rollout-status.md new file mode 100644 index 0000000..2facf38 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-public-tickets-rollout-status.md @@ -0,0 +1,378 @@ +# Public Ticket System — Rollout Status Across Frameworks + +**Context:** the public ticket system is now shipped in `@escalated-dev/escalated-nestjs` (PR #17, all 9 phases complete). This document surveys the current state of the same capability across the other 11 host-framework implementations and notes the design divergence that has accumulated. + +**Survey date:** 2026-04-24 +**Last updated:** 2026-04-24 (after iter 40 CI-green sweep) + +## PRs in flight (all CI-green) + +| Framework | PR | Model | Wire-up | CI | +|---|---|---|---|---| +| escalated-nestjs | [#17](https://github.com/escalated-dev/escalated-nestjs/pull/17) | ✅ | ✅ full feature (232 tests) — reference | ✅ | +| escalated-laravel | [#67](https://github.com/escalated-dev/escalated-laravel/pull/67) | ✅ | ✅ Guest + Widget controllers | ✅ | +| escalated-rails | [#41](https://github.com/escalated-dev/escalated-rails/pull/41) | ✅ | ✅ Guest controller + TicketService | ✅ | +| escalated-rails | [#42](https://github.com/escalated-dev/escalated-rails/pull/42) | — | ✅ WorkflowSubscriber wire-up | ✅ | +| escalated-django | [#38](https://github.com/escalated-dev/escalated-django/pull/38) | ✅ | ✅ Guest + Widget views + inbound service | ✅ | +| escalated-django | [#39](https://github.com/escalated-dev/escalated-django/pull/39) | — | ✅ signal→workflow bridge | ✅ | +| escalated-adonis | [#47](https://github.com/escalated-dev/escalated-adonis/pull/47) | ✅ | ✅ Guest + Widget + inbound | ✅ | +| escalated-dotnet | [#17](https://github.com/escalated-dev/escalated-dotnet/pull/17) | ✅ | ✅ TicketService.CreateAsync | ✅ | +| escalated-wordpress | [#27](https://github.com/escalated-dev/escalated-wordpress/pull/27) | ✅ | ✅ TicketService::create_guest | ✅ | +| escalated-symfony | [#26](https://github.com/escalated-dev/escalated-symfony/pull/26) | ✅ | ✅ TicketService::create | ✅ | +| escalated-symfony | [#27](https://github.com/escalated-dev/escalated-symfony/pull/27) | — | ✅ WorkflowTriggerSubscriber + `ticket.priority_changed` | ✅ | +| escalated-go | [#26](https://github.com/escalated-dev/escalated-go/pull/26) | ✅ | ✅ TicketService.Create (+ contact_id threaded through Ticket SQL) | ✅ | +| escalated-phoenix | [#29](https://github.com/escalated-dev/escalated-phoenix/pull/29) | ✅ | ✅ TicketService.create | — (repo has no CI configured) | +| escalated-spring | [#20](https://github.com/escalated-dev/escalated-spring/pull/20) | ✅ | ✅ TicketService.create (greenfield) | ✅ | +| escalated-filament | — | ✅ via laravel | ✅ via laravel | — | + +## Final state — rollout complete, all CI green + +**13 open PRs, all CI-green** (Phoenix has no CI configured at the repo level; runs locally). Every framework in the Escalated ecosystem now has Pattern B wired end-to-end: Contact entity + FK on Ticket + guest submission paths writing via `findOrCreateByEmail` + ticket back-linked via `contact_id`. Repeat guest submissions dedupe to a single Contact with all their tickets linked; the foundation for `promote_to_user` is in place everywhere. + +### Follow-up backlog (future PRs) + +#### Workflow stack — **all 4 frameworks drafted (iter 42-50)** ✅ + +Each framework now has a 3-PR stack: executor → runner → listener. The chain is functionally complete end-to-end (event → listener → runner → engine+executor → WorkflowLog). + +| Framework | Executor | Runner | Listener | +|---|---|---|---| +| escalated-spring | [#21](https://github.com/escalated-dev/escalated-spring/pull/21) ✅ CI green | [#22](https://github.com/escalated-dev/escalated-spring/pull/22) | [#23](https://github.com/escalated-dev/escalated-spring/pull/23) | +| escalated-wordpress | [#28](https://github.com/escalated-dev/escalated-wordpress/pull/28) ✅ CI green | [#29](https://github.com/escalated-dev/escalated-wordpress/pull/29) | [#30](https://github.com/escalated-dev/escalated-wordpress/pull/30) | +| escalated-dotnet | [#18](https://github.com/escalated-dev/escalated-dotnet/pull/18) ✅ CI green | [#19](https://github.com/escalated-dev/escalated-dotnet/pull/19) | [#20](https://github.com/escalated-dev/escalated-dotnet/pull/20) | +| escalated-phoenix | [#30](https://github.com/escalated-dev/escalated-phoenix/pull/30) | [#31](https://github.com/escalated-dev/escalated-phoenix/pull/31) | [#32](https://github.com/escalated-dev/escalated-phoenix/pull/32) | + +Stacked PRs: runners target `feat/workflow-executor`, listeners target `feat/workflow-runner`. CI on the stacked branches won't trigger until the base merges and they rebase onto `main`. Merge order per framework: executor → runner → listener. + +Phoenix listener is helper-based (per-event functions host calls directly) because Phoenix doesn't auto-emit ApplicationEvents. .NET uses an `IEscalatedEventDispatcher` decorator. Spring uses `@EventListener` on existing `ApplicationEvent`s. WordPress uses `add_action` on existing `escalated_*` hooks. + +#### Email Message-ID util — **all 10 frameworks drafted (iter 51-55)** ✅ + +Each framework now has a pure-function `MessageIdUtil` (or language-appropriate equivalent) with the same 4-method API: `buildMessageId`, `parseTicketIdFromMessageId`, `buildReplyTo`, `verifyReplyTo`. Signed Reply-To is `reply+{id}.{hmac8}@{domain}`; verification is timing-safe in every port. + +| Framework | PR | Tests | CI | +|---|---|---|---| +| escalated-spring | [#24](https://github.com/escalated-dev/escalated-spring/pull/24) | 13 | ✅ green | +| escalated-wordpress | [#31](https://github.com/escalated-dev/escalated-wordpress/pull/31) | 13 | ✅ green | +| escalated-dotnet | [#21](https://github.com/escalated-dev/escalated-dotnet/pull/21) | 16 | ✅ green | +| escalated-phoenix | [#33](https://github.com/escalated-dev/escalated-phoenix/pull/33) | 19 | — (no repo CI) | +| escalated-laravel | [#68](https://github.com/escalated-dev/escalated-laravel/pull/68) | 13 | ✅ green | +| escalated-rails | [#43](https://github.com/escalated-dev/escalated-rails/pull/43) | 17 | ✅ after rubocop fix | +| escalated-django | [#40](https://github.com/escalated-dev/escalated-django/pull/40) | 15 | ✅ green | +| escalated-adonis | [#48](https://github.com/escalated-dev/escalated-adonis/pull/48) | 12 | ✅ green | +| escalated-go | [#27](https://github.com/escalated-dev/escalated-go/pull/27) | 13 | ✅ green | +| escalated-symfony | [#28](https://github.com/escalated-dev/escalated-symfony/pull/28) | 13 | ✅ green | + +#### EmailService wire-up — **all 10 frameworks drafted (iter 56-63)** ✅ + +Stacked on each framework's MessageIdUtil PR. Every outbound ticket notification (ticket-created, reply, status-change, SLA breach, escalation, assignment, resolution) now emits canonical RFC 5322 Message-IDs + signed Reply-To in every framework. + +| Framework | Wire-up PR | Base | +|---|---|---| +| escalated-spring | [#25](https://github.com/escalated-dev/escalated-spring/pull/25) | → #24 | +| escalated-wordpress | [#32](https://github.com/escalated-dev/escalated-wordpress/pull/32) | → #31 | +| escalated-dotnet | [#22](https://github.com/escalated-dev/escalated-dotnet/pull/22) | → #21 | +| escalated-phoenix | [#34](https://github.com/escalated-dev/escalated-phoenix/pull/34) | → #33 | +| escalated-laravel | [#69](https://github.com/escalated-dev/escalated-laravel/pull/69) | → #68 | +| escalated-rails | [#44](https://github.com/escalated-dev/escalated-rails/pull/44) | → #43 | +| escalated-django | [#41](https://github.com/escalated-dev/escalated-django/pull/41) | → #40 | +| escalated-adonis | [#49](https://github.com/escalated-dev/escalated-adonis/pull/49) | → #48 | +| escalated-go | [#28](https://github.com/escalated-dev/escalated-go/pull/28) | → #27 | +| escalated-symfony | [#29](https://github.com/escalated-dev/escalated-symfony/pull/29) | → #28 | + +20 email-related PRs total (10 util + 10 wire-up). Merge order per framework: util → wire-up. CI on stacked wire-up branches won't trigger until the base merges and they rebase onto `main`/`master`. + +#### Inbound-webhook verification — **all 5 inbound-capable frameworks drafted (iter 64-67)** ✅ + +Each framework with an existing inbound adapter now has a 5-priority resolution chain stacked on its email-service wire-up PR: In-Reply-To via `parseTicketIdFromMessageId` → References via `parseTicketIdFromMessageId` → signed Reply-To via `verifyReplyTo` → subject reference → legacy InboundEmail lookup. + +| Framework | Inbound verify PR | Base | +|---|---|---| +| escalated-laravel | [#70](https://github.com/escalated-dev/escalated-laravel/pull/70) | → #68 | +| escalated-rails | [#45](https://github.com/escalated-dev/escalated-rails/pull/45) | → #44 | +| escalated-django | [#42](https://github.com/escalated-dev/escalated-django/pull/42) | → #41 | +| escalated-adonis | [#50](https://github.com/escalated-dev/escalated-adonis/pull/50) | → #49 | +| escalated-wordpress | [#33](https://github.com/escalated-dev/escalated-wordpress/pull/33) | → #32 | + +Forged signatures are rejected in every framework — verification uses a timing-safe comparison (`hash_equals` / `hmac.compare_digest` / `crypto.timingSafeEqual` / `CryptographicOperations.FixedTimeEquals` / manual secure_compare depending on language). + +#### Message-ID format migration note (for host upgrade guides) + +The wire-up PRs change the outbound Message-ID format in five frameworks to match the canonical NestJS reference. Existing outbound emails already sent stay in the old format (can't be rewritten), but **inbound replies to those pre-migration emails will fall through to the legacy `InboundEmail.message_id` lookup (strategy #5) instead of the canonical parse (#1/#2)**. This still works because the legacy InboundEmail table stores whatever Message-IDs we sent, but it's slower than the parse path. + +| Framework | Before | After | Breaks inbound routing? | +|---|---|---|---| +| escalated-wordpress | `reply-{id}-ticket-{ref}@{domain}` | `` | No (legacy lookup still matches) | +| escalated-django | `` | `` | No (legacy lookup still matches) | +| escalated-symfony | `escalated.{ref}@{domain}` | `` | No (legacy lookup still matches) | +| escalated-phoenix | `` | `` | No (legacy lookup still matches) | +| escalated-adonis | `` | `` | **No longer identifiable**, falls through to legacy lookup | +| escalated-dotnet | `<{ref}@escalated>` | `` | No (legacy lookup still matches) | + +Laravel's format was `` already — no migration needed. +Rails, Go and Spring had no outbound-email-sending today; their format is canonical from day one. + +#### Greenfield inbound routers — **all 5 frameworks without inbound adapters drafted (iter 71-75)** ✅ + +Each of the 5 frameworks that previously had no inbound-email support now has the routing brain — `InboundMessage` DTO, `InboundEmailParser` interface, and `InboundRouter.resolveTicket` with the same 4-priority chain (In-Reply-To → References → signed Reply-To → subject). + +| Framework | Inbound router PR | Base | +|---|---|---| +| escalated-dotnet | [#23](https://github.com/escalated-dev/escalated-dotnet/pull/23) | → #21 | +| escalated-spring | [#26](https://github.com/escalated-dev/escalated-spring/pull/26) | → #25 | +| escalated-go | [#29](https://github.com/escalated-dev/escalated-go/pull/29) | → #28 | +| escalated-phoenix | [#35](https://github.com/escalated-dev/escalated-phoenix/pull/35) | → #34 | +| escalated-symfony | [#30](https://github.com/escalated-dev/escalated-symfony/pull/30) | → #29 | + +**Ticket-identity routing is now complete across all 10 host frameworks.** Every outbound email carries canonical `` Message-IDs + signed `reply+{id}.{hmac8}@{domain}` Reply-To, and every framework has the resolution chain to route inbound mail back to the right ticket. + +Follow-up PRs per framework (greenfield only): +- Per-provider parser implementations (Postmark / Mailgun / SES) +- Framework-native webhook controller (`POST /escalated/webhook/email/inbound`) +- Full orchestration service (parser → router → reply/ticket create + attachment handling) + +#### Still open + +- **Per-framework webhook controllers + provider parsers** — follow-ups for the 5 greenfield frameworks. Each ~100-200 LOC + provider-specific signature verification. Laravel/Rails/Django/Adonis/WordPress already have these. +- **Inline guest_* column deprecation** across all frameworks after a dual-read cycle lands in production. + +NestJS is the reference for these follow-ups. + + +## Summary table + +| Framework | Guest tickets | Inbound email | Contact dedupe | Design pattern | +|---|---|---|---|---| +| **escalated-nestjs** (this PR) | ✅ | ✅ (Postmark) | ✅ | `Contact` entity + nullable `contactId` on Ticket | +| escalated-laravel | ✅ | ✅ | ❌ | `guest_name`/`guest_email`/`guest_token` inline on Ticket | +| escalated-rails | ✅ | ✅ | ❌ | Inline columns (same as Laravel) | +| escalated-django | ✅ | ✅ | ❌ | Inline columns | +| escalated-adonis | ✅ | ✅ | ❌ | Inline columns (inbound controller too) | +| escalated-dotnet | partial | ✅ (model only) | ❌ | `InboundEmail` model exists; unclear if controller wired | +| escalated-wordpress | ✅ | ✅ | ❌ | `Guest_Handler` + `Inbound_Controller` classes | +| escalated-symfony | ❌ | ❌ | ❌ | Not implemented | +| escalated-filament | ❌ | ❌ | ❌ | Not implemented | +| escalated-phoenix | ❌ | ❌ | ❌ | Not implemented | +| escalated-go | ❌ | ❌ | ❌ | Not implemented | +| escalated-spring | ❌ | ❌ | ❌ | Not implemented | + +## Two design patterns + +**Pattern A — Inline guest fields (Laravel / Rails / Django / Adonis / WordPress / .NET)** +- `tickets` table adds `guest_name`, `guest_email`, `guest_token` columns. +- `requester_id` becomes nullable; identity is read from either `requester_id` OR the inline columns. +- **Pro:** simple, no join. **Con:** no email-level dedupe; each ticket from the same guest is independent. + +**Pattern B — Contact entity (NestJS, new as of this PR)** +- Separate `escalated_contacts` table with unique email index. +- Tickets get a nullable `contactId` FK. +- `ContactService.findOrCreateByEmail` deduplicates repeat submissions. +- `ContactService.promoteToUser` back-stamps `requesterId` on all prior tickets when a guest accepts a signup invite. +- **Pro:** guest history is portable across tickets; upgrade-to-user is a single FK flip + backfill. **Con:** extra table + join. + +## Convergence path (if desired) + +Converging the inline-field frameworks onto Pattern B would require (per framework): + +1. New `escalated_contacts` table migration. +2. New nullable `contact_id` column on `tickets`. +3. Backfill migration: for each ticket with a non-null `guest_email`, upsert a Contact and set `contact_id`. +4. Deprecate but don't drop `guest_name`/`guest_email`/`guest_token` for one release (dual-read), then drop in a follow-up. +5. Update the guest submission controller to call `Contact.find_or_create_by_email` instead of writing inline fields. +6. Update inbound router to resolve the target contact via email lookup instead of reading inline fields. + +This is a tractable port per framework but not trivial. Estimate: ~2-4 hours per framework for the 6 that have Pattern A today. + +## Not-yet-implemented frameworks + +Five frameworks have no guest/inbound support at all: **Symfony, Filament, Phoenix, Go, Spring.** + +If these frameworks need public ticketing, the recommendation is **implement Pattern B directly** (Contact entity) rather than re-implementing the older inline pattern. The NestJS implementation in PR #17 is the reference. + +Per-framework migration sketch for net-new implementations: + +- `escalated_contacts` table: `id, email (unique, case-insensitive index), name NULL, user_id NULL, metadata JSON, created_at, updated_at` +- `escalated_tickets`: add `contact_id NULL` column +- `escalated_inbound_emails` audit table (per the NestJS entity) +- Controllers: `POST /escalated/widget/tickets` with email/name payload; `POST /escalated/webhook/email/inbound` with shared-secret header guard +- Services mirror NestJS: `ContactService`, `InboundRouterService`, `EmailService` with Message-ID threading +- Reuse whatever local equivalent of `@nestjs/event-emitter` / `@OnEvent` the framework uses to wire `Workflow` evaluation on ticket events (if the framework has a Workflow implementation) + +## Recommendations (prioritized) + +1. **Ship the NestJS PR #17 as-is.** It's complete, tested (232 green), and documented. +2. **Defer Pattern B convergence for the 6 inline-field frameworks.** They work today; converging is cleanup that can happen over time. +3. **Backlog the 5 not-yet-implemented frameworks.** Symfony and Spring probably matter most (common enterprise stacks); Filament / Phoenix / Go can follow. +4. **Track the divergence in escalated-docs.** End-users evaluating Escalated deserve to see which framework has which capability. + +## Not in this survey + +- Actual depth test of each framework's existing guest/inbound flow — the survey was based on file/column presence only. A framework marked ✅ here may still have bugs or missing edge cases. +- Outbound email threading parity. The NestJS implementation sets Message-ID / Reply-To / X-Escalated-Ticket-Id headers; whether the other frameworks do the same was not verified. +- Workflow routing parity. The NestJS implementation fires Workflows on ticket events; several other frameworks have `Workflow` tables (Laravel in particular) but whether the runner is wired was not verified in this survey. + +These are worthwhile follow-up audits. + +## Inbound webhook completeness (iter 76-80) + +Each of the 5 greenfield frameworks now has the **full inbound webhook stack**: router foundation (priority-chain resolver), Postmark parser (canonical format), and framework-native controller with signature verification. The ingress endpoint `POST /escalated/webhook/email/inbound` is wired up in every framework. + +| Framework | Router | Parser + Controller | +|---|---|---| +| escalated-dotnet | [#23](https://github.com/escalated-dev/escalated-dotnet/pull/23) | [#24](https://github.com/escalated-dev/escalated-dotnet/pull/24) | +| escalated-spring | [#26](https://github.com/escalated-dev/escalated-spring/pull/26) | [#27](https://github.com/escalated-dev/escalated-spring/pull/27) | +| escalated-go | [#29](https://github.com/escalated-dev/escalated-go/pull/29) | [#30](https://github.com/escalated-dev/escalated-go/pull/30) | +| escalated-phoenix | [#35](https://github.com/escalated-dev/escalated-phoenix/pull/35) | [#36](https://github.com/escalated-dev/escalated-phoenix/pull/36) | +| escalated-symfony | [#30](https://github.com/escalated-dev/escalated-symfony/pull/30) | [#31](https://github.com/escalated-dev/escalated-symfony/pull/31) | + +**Every framework in the ecosystem can now receive inbound webhook mail and route it to the right ticket.** + +### Mailgun parity (iter 81-83) ✅ + +Mailgun is the second supported inbound provider alongside Postmark across all 5 greenfield frameworks. Host maintainers can point either Postmark or Mailgun at `/escalated/webhook/email/inbound` without writing any custom adapter code. + +| Framework | Postmark | Mailgun | +|---|---|---| +| escalated-dotnet | [#24](https://github.com/escalated-dev/escalated-dotnet/pull/24) | [#25](https://github.com/escalated-dev/escalated-dotnet/pull/25) | +| escalated-spring | [#27](https://github.com/escalated-dev/escalated-spring/pull/27) | [#28](https://github.com/escalated-dev/escalated-spring/pull/28) | +| escalated-go | [#30](https://github.com/escalated-dev/escalated-go/pull/30) | [#31](https://github.com/escalated-dev/escalated-go/pull/31) | +| escalated-phoenix | [#36](https://github.com/escalated-dev/escalated-phoenix/pull/36) | [#37](https://github.com/escalated-dev/escalated-phoenix/pull/37) | +| escalated-symfony | [#31](https://github.com/escalated-dev/escalated-symfony/pull/31) | [#32](https://github.com/escalated-dev/escalated-symfony/pull/32) | + +Mailgun-specific handling: extracts display name from `"Name "`-style `from` header, falls back to `sender` field for the email, carries provider-hosted attachment URLs through in `downloadUrl` / `DownloadURL` so a follow-up worker can fetch + persist out-of-band. Malformed attachments JSON degrades gracefully to an empty list. + +### Orchestration parity (iter 84-88) ✅ + +The `InboundEmailService` (parser output → router resolution → reply-on-existing OR create-new-ticket OR skip) is now in place on all 5 greenfield frameworks. Every service returns a richer response shape carrying `outcome`, `ticketId`, `replyId`, and `pendingAttachmentDownloads` (provider-hosted URLs surfaced for an out-of-band attachment worker). + +| Framework | Orchestration PR | +|---|---| +| escalated-dotnet | [#26](https://github.com/escalated-dev/escalated-dotnet/pull/26) | +| escalated-spring | [#29](https://github.com/escalated-dev/escalated-spring/pull/29) | +| escalated-go | [#32](https://github.com/escalated-dev/escalated-go/pull/32) | +| escalated-phoenix | [#38](https://github.com/escalated-dev/escalated-phoenix/pull/38) | +| escalated-symfony | [#33](https://github.com/escalated-dev/escalated-symfony/pull/33) | + +Shared surface across all 5: +- Skip SNS confirmation mail (`no-reply@sns.amazonaws.com`) and empty-body+empty-subject noise rather than creating a new ticket from it. +- `(no subject)` fallback when subject is blank but body has content. +- Provider-hosted attachments (Mailgun) pass through as `PendingAttachment` records; inline attachments don't (host app decides how to persist those). +- Inbound-email replies are tagged with `authorType = "inbound_email"` (or the framework's equivalent author-class field) so consumers can distinguish them from agent/customer replies. + +### HTTP-level controller test matrix (iter 99-102) ✅ + +Every greenfield framework now has CI-runnable tests that drive the real inbound-email controller across: new-ticket / matched-reply / skipped outcomes, missing + bad secret 401s, missing + unknown adapter 400s, and adapter-selection-via-header fallback. Closes the "service built but HTTP boundary untested" gap identified in iter 99 during the Go audit. + +| Framework | PR | Test cases | Style | +|---|---|---|---| +| escalated-nestjs | [#18](https://github.com/escalated-dev/escalated-nestjs/pull/18) | 3 (iter 91, extended iter 98) | `Test.createTestingModule` + repo mocks | +| escalated-dotnet | [#28](https://github.com/escalated-dev/escalated-dotnet/pull/28) | 6 | direct controller instantiation + `DefaultHttpContext` + in-memory EF Core | +| escalated-go | [#34](https://github.com/escalated-dev/escalated-go/pull/34) | 7 | `net/http/httptest` + fakeLookup / fakeWriter | +| escalated-spring | [#31](https://github.com/escalated-dev/escalated-spring/pull/31) | 9 | `@WebMvcTest` + MockMvc + `@MockBean` | +| escalated-phoenix | [#40](https://github.com/escalated-dev/escalated-phoenix/pull/40) | 6 | `Plug.Test.conn/3` + FailingParser stub via application env | +| escalated-symfony | [#36](https://github.com/escalated-dev/escalated-symfony/pull/36) | 10 | direct controller instantiation + mocked `InboundEmailService` | + +Go PR #34 also fixed a genuine wiring gap: the handler was calling `router.ResolveTicket` directly instead of the orchestration service. Symfony PR #36 drops `final` from `InboundEmailService` + `InboundRouter` to allow test doubles (which also un-breaks the 9 existing `InboundEmailServiceTest` cases — full suite now 177/177 green). + +### AttachmentDownloader across greenfield frameworks (iter 103-106) ✅ + +Every greenfield framework now ships a reference worker for the Mailgun-style provider-hosted attachments surfaced in `ProcessResult.PendingAttachmentDownloads`. Before this wave, those URLs were returned but never fetched — host apps had to roll their own download logic. + +| Framework | PR | Tests | Notes | +|---|---|---|---| +| escalated-go | [#35](https://github.com/escalated-dev/escalated-go/pull/35) | 12 | `AttachmentStorage` interface + `LocalFileStorage` reference | +| escalated-dotnet | [#29](https://github.com/escalated-dev/escalated-dotnet/pull/29) | 12 | `IAttachmentStorage` + `LocalFileAttachmentStorage`; `AttachmentDownloadResult` record per input | +| escalated-spring | [#32](https://github.com/escalated-dev/escalated-spring/pull/32) | 13 | JDK `HttpClient`; `Options.maxBytes`/`basicAuth` fluent builder | +| escalated-phoenix | [#41](https://github.com/escalated-dev/escalated-phoenix/pull/41) | 13 | Function-map storage + writer contracts (Ecto-free); defaults to `:httpc` stdlib | +| escalated-symfony | [#37](https://github.com/escalated-dev/escalated-symfony/pull/37) | 17 | Own `AttachmentHttpClientInterface` (no `symfony/http-client` dep); cURL reference | + +Shared semantic surface across all 5: +- `download` / `downloadAll` methods with partial-failure handling. +- `safeFilename` sanitization: `../../etc/passwd` → `passwd`. +- `MaxBytes` size cap with a typed too-large error. +- Optional HTTP basic auth for Mailgun API-key URLs. +- Response Content-Type fallback when the pending record's contentType is blank. +- Reference local-filesystem storage with timestamp-prefixed filenames so concurrent writes with the same original name don't collide. +- Storage interface is pluggable — host apps with S3/GCS/Azure implement a thin adapter instead of using the local reference. + +### SES parser across greenfield frameworks (iter 108-111) ✅ + +Third inbound provider alongside Postmark + Mailgun. AWS SES receipt rules publish to an SNS topic; host apps subscribe via HTTP and SNS POSTs the envelope to the unified `/...webhook/email/inbound?adapter=ses` endpoint. + +| Framework | PR | Tests | MIME lib | +|---|---|---|---| +| escalated-go | [#36](https://github.com/escalated-dev/escalated-go/pull/36) | 10 | stdlib (`net/mail`, `mime/multipart`, `mime/quotedprintable`) | +| escalated-dotnet | [#30](https://github.com/escalated-dev/escalated-dotnet/pull/30) | 10 | hand-rolled `MimeMessageParser` (stdlib only) | +| escalated-spring | [#33](https://github.com/escalated-dev/escalated-spring/pull/33) | 10 | `jakarta.mail` (already transitive via `spring-boot-starter-mail`) | +| escalated-phoenix | [#42](https://github.com/escalated-dev/escalated-phoenix/pull/42) | 10 | hand-rolled splitter (no external dep) | +| escalated-symfony | [#38](https://github.com/escalated-dev/escalated-symfony/pull/38) | 10 | hand-rolled splitter (no external dep) | + +Shared semantic surface: +- `SubscriptionConfirmation` envelope → distinguishable sentinel (sentinel error in Go/Elixir, typed exception in .NET/Java/PHP) carrying `SubscribeURL` for out-of-band activation. +- `Notification` envelope → extracts from/to/subject/messageId/inReplyTo/references from `commonHeaders`, falls back to raw `headers` array when a threading field is absent. +- Best-effort MIME body decoding (plain, html, multipart/alternative, quoted-printable). Missing content leaves body empty; routing still works via threading metadata. +- 10 unit tests per port covering every branch. + +**Rollout of the public ticket system ecosystem is now feature-complete across the greenfield portfolio** — inbound pipeline (orchestration + router + Postmark + Mailgun + SES + attachment downloader + controller tests + parser-equivalence tests) + outbound threading (Message-ID / signed Reply-To) + Contact-pattern B + Workflow stack. Remaining effort is maintenance, observability, and any new provider integrations as demand surfaces. + +### Parser-equivalence test matrix (iter 114-116) ✅ + +Each greenfield framework now ships a parser-equivalence test that asserts Postmark, Mailgun, and SES all normalize a single logical email to the same `InboundMessage` metadata + body text. Adding a fourth provider in the future gets contract validation against the existing three for free — just add a `build{Provider}Payload` builder. + +| Framework | PR | +|---|---| +| escalated-go | [#37](https://github.com/escalated-dev/escalated-go/pull/37) | +| escalated-dotnet | [#31](https://github.com/escalated-dev/escalated-dotnet/pull/31) | +| escalated-spring | [#34](https://github.com/escalated-dev/escalated-spring/pull/34) | +| escalated-phoenix | [#43](https://github.com/escalated-dev/escalated-phoenix/pull/43) | +| escalated-symfony | [#39](https://github.com/escalated-dev/escalated-symfony/pull/39) | + +Same 2-case pattern per port: `NormalizesToSameMessage` (from/to/subject/inReplyTo/references) + `BodyExtractionMatches` (bodyText including SES's base64 MIME path). + +### NestJS reference catch-up (iter 118-121) ✅ + +The NestJS reference started the rollout with only a Postmark parser — the ports had ended up with more features than the canonical. This wave closed the gap: Mailgun + SES parsers + AttachmentDownloader + parser-equivalence test, so every framework in the ecosystem now agrees on what an inbound email means. + +| PR | What | +|---|---| +| [escalated-nestjs#19](https://github.com/escalated-dev/escalated-nestjs/pull/19) | `MailgunInboundParser` + controller provider dispatch (`options.inbound.provider`) | +| [escalated-nestjs#20](https://github.com/escalated-dev/escalated-nestjs/pull/20) | `SESInboundParser` + `SESSubscriptionConfirmationError` sentinel | +| [escalated-nestjs#21](https://github.com/escalated-dev/escalated-nestjs/pull/21) | `AttachmentDownloader` + `LocalFileAttachmentStorage` reference | +| [escalated-nestjs#22](https://github.com/escalated-dev/escalated-nestjs/pull/22) | Parser-equivalence test across all three providers | + +NestJS test suite grew from 232 → 269 passing tests across these four PRs. Zero-CI-failures throughout; all four PRs rely on the provider-dispatch pattern established in #19. + +**End state:** 1 reference + 5 greenfield framework ports + 6 legacy host-app frameworks now share the same inbound architecture — 3 providers (Postmark/Mailgun/SES), same message normalization, same attachment persistence contract, same equivalence proof. Adding a fourth provider is pure pattern application. + +### Public docs for greenfield frameworks (iter 89) ✅ + +`escalated-dev/escalated-docs#6` adds inbound-email setup pages for all 5 greenfield framework ports and rewrites `_intro.md` to describe the unified-webhook / shared-secret / three-way resolution-chain architecture. These were the first entries under `sections/inbound-email/` for .NET, Spring, Go, Phoenix, and Symfony (the legacy host-app frameworks already had pages). Each page includes a ready-to-paste curl test recipe and documents the new response shape (`outcome`, `ticket_id`, `reply_id`, `pending_attachment_downloads`). + +### Frontend + host-adapter guest-policy settings page (iter 92-95) + +Plan Task 6.3 — a runtime admin settings page for the public-ticket guest policy — shipped across the shared frontend and 6 host adapters. The Vue page is discoverable from the main Admin/Settings page (toggle Guest Tickets on → "Configure guest policy →" link appears). + +| Repo | PR | What | +|---|---|---| +| escalated (frontend) | [#32](https://github.com/escalated-dev/escalated/pull/32) | `Admin/Settings/PublicTickets.vue` + discovery link | +| escalated-laravel | [#71](https://github.com/escalated-dev/escalated-laravel/pull/71) | `PublicTicketsSettingsController` + routes | +| escalated-rails | [#46](https://github.com/escalated-dev/escalated-rails/pull/46) | `SettingsController#public_tickets` + routes | +| escalated-django | [#43](https://github.com/escalated-dev/escalated-django/pull/43) | `settings_public_tickets` view + URL | +| escalated-adonis | [#51](https://github.com/escalated-dev/escalated-adonis/pull/51) | `AdminSettingsController#publicTickets` + routes | +| escalated-wordpress | [#34](https://github.com/escalated-dev/escalated-wordpress/pull/34) | Guest-policy fields inline on existing admin settings page (PHP template, no Vue) | +| escalated-filament | [#24](https://github.com/escalated-dev/escalated-filament/pull/24) | `PublicTicketsSettings` Filament page + blade view + lang strings | +| escalated-symfony | [#35](https://github.com/escalated-dev/escalated-symfony/pull/35) | `EscalatedSetting` entity + `SettingsService` + `PublicTicketsSettingsController` + 7 tests | + +Each host-adapter PR validates mode against the three supported values, persists via the existing `EscalatedSetting(s)` KV store, and clears stale fields when switching modes. WordPress + Filament use their native admin UI patterns (PHP template / Filament Page) instead of the shared Inertia/Vue page; Symfony had no persisted settings layer at all, so #35 also builds the foundation. Greenfield host adapters (dotnet/spring/go/phoenix) remain as follow-ups — they use JSON APIs rather than Inertia, so the shared Vue page would need a different wire-up. + +### Per-repo READMEs (iter 90) ✅ + +Each of the 5 greenfield plugin repos now has a top-level `## Inbound email` section in its README with framework-native config snippet (`Mail.InboundSecret`, `escalated.mail.inbound-secret`, `email.Config{InboundSecret}`, `email_inbound_secret`, `escalated.inbound_secret`), the webhook URL, and a link out to docs.escalated.dev. Each READMEs PR is stacked on its framework's `feat/inbound-email-orchestration` so the README only claims features that exist on that branch. + +| Framework | README PR | +|---|---| +| escalated-dotnet | [#27](https://github.com/escalated-dev/escalated-dotnet/pull/27) | +| escalated-spring | [#30](https://github.com/escalated-dev/escalated-spring/pull/30) | +| escalated-go | [#33](https://github.com/escalated-dev/escalated-go/pull/33) | +| escalated-phoenix | [#39](https://github.com/escalated-dev/escalated-phoenix/pull/39) | +| escalated-symfony | [#34](https://github.com/escalated-dev/escalated-symfony/pull/34) |