RFC Summary: Embeddable Deal & Ticket Widgets — Index + Deal Create + Ticket Create
Full RFC: Embeddable Deal & Ticket Widgets — Index + Deal Create + Ticket Create (see
scratch/rfc/embed-deal-ticket-index-deal-create-ticket-create-rfc.md) Status: IDEA | Type: full-stack / enhancement Author: Engineering (RFC author) | Target: 2026-Q3 PRD: Create Deals & Tickets While Viewing Chat
Problem
Omnichannel agents handling live conversations cannot see existing deals or tickets associated with the room, or create new ones, without leaving the conversation context. The current full-screen bottom sheet overlays hide the chat during form entry, forcing agents to repeatedly close and reopen forms to reference conversation details. Unsaved form data is silently discarded on accidental close, causing data loss and discouraging record creation.
What's Being Built
- Deal index embed page (
/embed/deals/room/:room_id) — shows the most recent deal for a room, or empty state with create button - Deal create embed page (
/embed/deals/new) — new wrapper component reusing existingDealsCreate*form components, with proper error toast, cancel confirm, and typed postMessage - Ticket index embed page (
/embed/tickets/room/:room_id) — shows the most recent ticket for a room, or empty state with create button - Ticket create embed page (
/embed/tickets/create) — already exists, security hardening only (targetOrigin restriction) - Infrastructure: Verify
frame-ancestorsin SPA Helm config (values-production.yaml) covers parent origin; removeX-Frame-Options: SAMEORIGINfrom nginx if parent is cross-origin - Security: Typed postMessage contract, restricted targetOrigin (never
'*'), unsaved changes protection, error toast on create failure
Component Architecture
Component Architecture
flowchart LR
subgraph Browser
A[Parent Omnichannel App]
B[crm-fe-v3 iframe SPA]
end
C[CDN]
D[IAG Internal API Gateway]
subgraph qontak_com [qontak.com Rails 5.2]
E[V2dot8 DealsController]
F[V2dot8 TicketsController]
end
G[(Postgres)]
A -->|opens iframe with token and parent_origin| B
B --> C
B -->|API calls via IAG| D
D --> E
D --> F
E --> G
F --> G
B -->|postMessage| A
Repo Map (files touched)
qontak.com (Rails)
└── db/data/20260701000001_add_feature_embed_deal_sanitize.rb — NEW: seed migration
crm-fe-v3 (Nuxt 4) — Infrastructure:
├── deploy/nginx/default.conf:28 — MODIFIED: remove X-Frame-Options SAMEORIGIN
└── deploy-alicloud/chart/values-production.yaml — VERIFY: frame-ancestors allowlist
crm-fe-v3 (Nuxt 4) — Pages:
├── layers/embed-deal/
│ ├── pages/embed/deals/room/[roomId].vue — NEW: deal index page
│ ├── components/EmbedDealIndexPage.vue — NEW: deal index component
│ ├── pages/embed/deals/new/index.vue — NEW: deal create page
│ └── components/EmbedDealNewPage.vue — NEW: deal create wrapper
├── layers/embed-ticket/
│ ├── pages/embed/tickets/room/[roomId].vue — NEW: ticket index page
│ └── components/EmbedTicketIndexPage.vue — NEW: ticket index component
├── layers/embed-deal/composables/useEmbedDealCreate.ts — MODIFIED: 6 gap fixes
├── layers/embed-deal/composables/useEmbedDealAuth.ts — MODIFIED: authError on 401
└── layers/embed-ticket/composables/useEmbedTicketCreate.ts — MODIFIED: targetOrigin
Deal Form State Machine
Deal Form State Machine
stateDiagram-v2
[*] --> Loading: Page opens
Loading --> Form: Auth OK
Loading --> AuthError: No / invalid token
Loading --> LoadError: API failure
Form --> Dirty: User edits any field
Dirty --> Form: All fields reverted
Dirty --> Submitting: Save
Form --> Submitting: Save
Submitting --> Success: API 201
Submitting --> Dirty: API error (validation / network)
Success --> [*]: postMessage + close
AuthError --> [*]: Show error
LoadError --> [*]: Show error
Dirty --> [*]: Cancel with confirm dialog
Form --> [*]: Cancel closes immediately
Sequence Diagrams
Happy Path — Deal Index
Happy Path — Deal Index
sequenceDiagram
participant Parent as Parent App
participant SPA as SPA
participant IAG as IAG
participant API as Rails API
participant DB as Postgres
Parent->>SPA: Open iframe /embed/deals/room/123
Note over SPA: token + parent_origin + can_create from query params
SPA->>SPA: useEmbedDealAuth reads token
SPA->>IAG: GET /api/internal/v1/deals?room_id=123&per_page=1
IAG->>API: Forward to v2.7 crm/deals#index
Note over API: AdvancedSearch merges room_id filter
API->>DB: Query crm_deals by team_id and room_id LIMIT 1
DB-->>API: latest deal or empty
API-->>IAG: 200 with deals array and pagination
IAG-->>SPA: Response
SPA->>SPA: Render deal card or empty state
Failure Path — Auth Error
Failure Path — Auth Error
sequenceDiagram
participant Parent as Parent App
participant SPA as SPA
Parent->>SPA: Open iframe without token param
SPA->>SPA: useEmbedDealAuth finds no token, isAuthenticated = false
Note over SPA: authError also set on 401 from API (hardened)
SPA->>SPA: Render EmbedDealAuthError component
Key Technical Decisions
| # | Decision | Chosen | Rationale |
|---|---|---|---|
| 1 | Ticket create page | Harden only, don't rebuild | Already exists and fully functional. Only targetOrigin needs fixing. |
| 2 | Deal index data source | Existing v2.7 index + ?room_id= | Filter already in Crm::AdvancedSearch (advanced_search.rb:336). No backend change. |
| 3 | Ticket index data source | Existing v2.8 index + ?channel_integration_room_id= | Same pattern as deal index. Filter in advanced_search.rb:1457. |
| 4 | Deal create page | New wrapper at /embed/deals/new, old as fallback | Old 480-line component has gap fixes needed. New wrapper uses stable DealsCreate* components. |
| 5 | postMessage contract | Typed messages with type field | Parent app needs to discriminate message types reliably. Ticket already typed. |
| 6 | Clickjacking defense | Update SPA nginx X-Frame-Options + verify Helm frame-ancestors | SAMEORIGIN blocks cross-origin iframe. frame-ancestors already set at K8s ingress level. |
APIs at a Glance
| Method | Endpoint | Status | Owner |
|---|---|---|---|
GET | /api/internal/v1/deals | Extended (optional ?room_id= filter) | BE — no code change |
GET | /api/internal/v1/tickets | Extended (optional ?channel_integration_room_id= filter) | BE — no code change |
POST | /api/internal/v1/deals | Exists with embed support | BE — reused |
POST | /api/internal/v1/tickets | Exists with embed support | BE — reused |
No new endpoints. Both index filters already exist in Crm::AdvancedSearch.
Open Blockers
| # | Blocker | Owner | Blocks |
|---|---|---|---|
| 1 | None — all 4 OQs resolved | — | — |
RFC is ready for execution. Remaining items: verify SPA
frame-ancestorsallowlist, remove nginxX-Frame-Options SAMEORIGINif needed, coordinate parent app postMessage.
Rollout
| Stage | Audience | Go/No-go evidence |
|---|---|---|
Verify SPA frame-ancestors allowlist + nginx XFO | Production | SPA page headers include frame-ancestors; nginx X-Frame-Options removed if cross-origin |
| Seed migration | Production | Feature flag exists in DB |
| Index pages (deal + ticket) | Production | Deal/ticket cards load by room via /api/internal/v1/ endpoints |
| Deal create new page | Production | Typed deal-created postMessage received by parent app |
| Security hardening | Production | No '*' targetOrigin in any postMessage call; error toast on create failure |
Stop conditions: frame-ancestors blocks parent app → update SPA Helm allowlist. X-Frame-Options SAMEORIGIN blocks cross-origin → remove from nginx. PostMessage contract breaks parent → revert to old format.
Rollback: Deploy: SPA infra → seed → FE. Rollback: FE → seed → SPA infra. Index filters are optional params — backward compatible.