RFC: Embeddable Deal & Ticket Widgets — Index + Deal Create + Ticket Create
Document Conventions
This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. Replace placeholder values; mark sections
N/A — reasonwhen truly inapplicable.It is also agent-execution-ready: the §1 PRD-to-Schema Derivation + Design References, §2 Repo Reading Guide (Detail 2.0), Infrastructure Topology, ADR-format Technical Decisions, mermaid diagrams, UI Contract (Detail 2.A), Data-Fetching Strategy (Detail 2.B), and §4 Agent Execution Plan + Verification & Rollback Recipe must be complete before §7 Ready for agent execution: yes.
Metadata
| Field | Value | Notes |
|---|---|---|
| Status | IDEA | IDEA / RFC / ABANDON / AGREED |
| Type | full-stack | |
| Sub-type | enhancement | |
| Owner | CRM squad | Team owning the RFC |
| Author(s) | Engineering (RFC author) | Primary author(s) |
| Reviewers | TBD | Tech reviewers across affected squads |
| Approver(s) | TBD (tech lead + infosec) | Tech leaders + infosec approver |
| Submitted Date | 2026-07-01 | ISO-8601 |
| Last Updated | 2026-07-01 | Bump on every material edit |
| Target Release | 2026-Q3 | |
| Related Documents | PRD at ../prds/create-deals-tickets-while-viewing-chat.md; Anchor PRD: Embeddable Deal & Ticket Forms (Confluence) | |
| Discussion | TBD | Slack channel / thread URL |
Type: full-stack Sub-type: enhancement
Sections at a Glance
- Overview (PRD Traceability + Design References + PRD-to-Schema Derivation + Per-Story Change Map)
- Technical Design (Infrastructure Topology → ADR Technical Decisions → Repo Reading Guide → Architecture → Sequence Diagrams → DB Model → APIs → UI Contract → Data-Fetching → Concurrency)
- High-Availability & Security (CSP, postMessage, sanitization, observability, failure catalog, accessibility)
- Backwards Compatibility and Rollout Plan (cross-layer matrix + deploy order + feature flags + agent execution plan + verification recipe)
- Concerns, Questions, or Known Limitations
- Comment Logs
- Ready for Agent Execution
Document Conventions
crm-fe-v3— Nuxt 4 SPA repoqontak.com— Rails API repopostMessage—window.parent.postMessage()calls from iframe to parentCSP— Content Security PolicyIAG— Internal API Gateway. Embed pages callGET/POST /api/internal/v1/{object}— the Rails route aliases defined atroutes.rb:111-129that forward to the appropriate v2.7/v2.8 controllers. Deal embed usesbuildIagUrl()(useEmbedDealStore.ts:39-43). Ticket embed uses an axios interceptor (useEmbedTicketApiInterceptor.ts) that rewrites calls to go through IAG.embed— query param?embed=passed to API to distinguish embed context- All file paths are relative to their repo root unless absolute
[TOC]
1. Overview
Problem
Omnichannel agents viewing a conversation (room) have no way to see existing deals or tickets associated with that room, or create new ones, without leaving the conversation context. The existing embed-ticket layer has a working create page but no index page. The embed-deal layer has a working create page but no index page, and its create flow has several known gaps (no error toast on failure, no cancel confirm, untyped postMessage).
Existing Patterns
- Ticket create:
layers/embed-ticket/pages/embed/tickets/create.vue— 30 lines, auth gate →EmbedTicketCreatePage. Fully functional. This RFC verifies it, documents its contract, and hardens security only. - Ticket index:
GET /api/internal/v1/tickets(routes.rb:111) — v2.8 index already exists and already supportschannel_integration_room_idfilter viacrm_channel_room_id_filterinCrm::AdvancedSearch. FE passes?channel_integration_room_id=xxx. No backend change needed. Multiple tickets per room supported by design. - Deal create:
layers/embed-deal/pages/embed/deals/create.vue+EmbedDealCreatePage.vue(480 lines) +useEmbedDealCreate.ts(187 lines). Functional but has gaps enumerated in Known Limitations. - Deal index:
GET /api/internal/v1/deals(routes.rb:124) — v2.7 index already supportsroom_idfilter viacrm_channel_room_id_filterinCrm::AdvancedSearch(advanced_search.rb:336). FE passes?room_id=xxx. No backend change needed. Multiple deals per room supported by design.
Out of Scope
In scope (4 pages):
| Page | URL | Status |
|---|---|---|
| Deal index embed | /embed/deals/room/:room_id | New |
| Deal create embed | /embed/deals/new | New — wrapper reusing DealsCreate* components; /embed/deals/create (old, unchanged) kept as fallback |
| Ticket index embed | /embed/tickets/room/:room_id | New |
| Ticket create embed | /embed/tickets/create | Already exists — harden only (typed postMessage, restricted targetOrigin) |
Infrastructure in scope:
- Update crm-fe-v3 nginx
deploy/nginx/default.conf:28— removeX-Frame-Options: SAMEORIGIN(if parent app is cross-origin) - Update crm-fe-v3
deploy-alicloud/chart/values-production.yaml— verifyframe-ancestorsallowlist includes the parent Omnichannel app origin
Backend in scope:
- Seed migration for
embed_deal_sanitizefeature flag
Backend NOT needed (already exists):
- Deal index
room_idfilter — already inCrm::AdvancedSearch#crm_channel_room_id_filter(advanced_search.rb:336), accessible viaGET /api/internal/v1/deals?room_id=xxx - Ticket index
channel_integration_room_idfilter — already inCrm::AdvancedSearchtickets path, accessible viaGET /api/internal/v1/tickets?channel_integration_room_id=xxx - Rails CSP
frame-ancestorsconfig — CSP for iframe protection is set at the SPA infrastructure layer (K8s ingress invalues-production.yaml), not in Rails. Railscontent_security_policy.rbis irrelevant for iframe framing protection.
Out of scope (removed from previous RFC):
- Deal edit page
- Ticket edit page
PUT /api/mobile/v2.8/crm/deals/:id
Success Criteria
| Criterion | Measurable Outcome |
|---|---|
| Deal index loads latest deal for a room | GET /api/internal/v1/deals?room_id=xxx&per_page=1&order_by=created_at&order_dir=desc returns 200 with latest deal or empty array |
| Deal create creates a deal | POST returns 201, parent receives { type: 'deal-created', dealId, room_id, data } postMessage |
| Ticket index loads latest ticket for a room | GET /api/internal/v1/tickets?channel_integration_room_id=xxx&per_page=1 returns 200 with latest ticket or empty array |
| Create button hidden when no permission | ?can_create=false → create/+ button not rendered on index |
| Ticket create creates a ticket | POST returns 201, parent receives { type: 'ticket-created', ticketId, data } postMessage |
| Auth failure shows error page | No token or invalid token → EmbedDealAuthError / EmbedTicketAuthError component renders |
| Create failure shows toast | useEmbedDealCreate.ts:156-158 gap fixed — error toast shown on catch |
| Cancel confirm on dirty form | useEmbedDealCreate.ts:123-128 gap fixed — confirm dialog shown when isDirty |
postMessage never uses '*' | All postMessage calls use restricted targetOrigin |
| CSP frame-ancestors sent | SPA response headers include Content-Security-Policy: frame-ancestors 'self' https://*.qontak.com set at K8s ingress level |
Dependencies
| Dependency | Description | Status |
|---|---|---|
embed_deal_sanitize feature flag | Must exist before sanitization runs | Not created — seed migration needed |
GET /api/internal/v1/deals?room_id= | Deal index with room filter via AdvancedSearch | Already exists — no backend change |
GET /api/mobile/v2.8/tickets?channel_integration_room_id= | Ticket index with room filter via AdvancedSearch | Already exists — no backend change |
| crm-fe-v3 nginx XFO config | deploy/nginx/default.conf:28 — SAMEORIGIN may block cross-origin parent | Must update if parent is cross-origin |
| crm-fe-v3 Helm frame-ancestors | values-production.yaml — current allowlist *.qontak.com | Verify parent origin is covered |
Assumptions
- FE embed pages call via IAG at
/api/internal/v1/{object}paths (routes.rb:111-129). Deal embed usesbuildIagUrl()(useEmbedDealStore.ts:39-43). Ticket embed usesuseEmbedTicketApiInterceptor.tsto rewrite calls through IAG. IAG routes to the appropriate v2.7/v2.8 Rails controllers. - Parent Omnichannel app passes
parent_originquery param for postMessage targetOrigin. - Parent Omnichannel app passes
can_create=true|falsebased on agent's CRM create permission in user management. The embed page also checks user permission locally fromGET /v2.8/users/meresponse — dual gate (both must pass). - Existing JWT auth flow (token →
qcrm_access_tokencookie → Bearer header) works for all 4 pages. embed_deal_sanitizeis checked in code (deals_controller.rb:422-462) but the feature record does not exist as a seed — it must be created.- CSP for iframe protection is set at the SPA infrastructure layer (crm-fe-v3 K8s ingress), not in Rails. The existing
values-production.yamlsetsframe-ancestors 'self' https://*.qontak.com http://localhost:*. Verify the parent Omnichannel app origin is covered by this allowlist. TheX-Frame-Options: SAMEORIGINindeploy/nginx/default.conf:28may need to be removed if the parent app is on a different origin.
Design References (frontend-specific)
| PRD-named surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|---|---|---|---|---|
| Deal index embed page | n/a — design pending | n/a | @mekari/pixel3 1.0.12-dev.0 | TBD | No dedicated Figma frame; embed widget uses pixel3 components |
| Deal create embed page | Inbox Revamp — Infobar | Inbox Revamp Infobar | @mekari/pixel3 1.0.12-dev.0 | Alma Syafira | Form components reused from full-app create page |
| Ticket index embed page | n/a — design pending | n/a | @mekari/pixel3 1.0.12-dev.0 | TBD | Follows same pattern as deal index |
| Ticket create embed page | Inbox Revamp — Infobar | Inbox Revamp Infobar | @mekari/pixel3 1.0.12-dev.0 | Alma Syafira | Already exists — harden only |
PRD-to-Schema Derivation (backend half — required)
For every PRD entity/rule, what the backend must persist, expose, or enforce.
| PRD entity / rule | Persisted as | Exposed via | Enforced where | PRD section |
|---|---|---|---|---|
| Deal associated with conversation room | crm_deals.channel_integration_room_id | GET /api/internal/v1/deals?room_id= | Crm::AdvancedSearch#crm_channel_room_id_filter | §6 S01 |
| Ticket associated with conversation room | tickets.channel_integration_room_id | GET /api/mobile/v2.8/tickets?channel_integration_room_id= | Crm::AdvancedSearch#determine_ticket_params | §6 S03 |
| Deal creation from embed context | crm_deals.data_source = 'omnichannel' | POST /api/mobile/v2.8/crm/deals (embed: true) | deals_controller.rb#embed_data_source + sanitize_embed_params | §6 S02 |
| Ticket creation from embed context | tickets.data_source = 'embed-web-chat' | POST /api/mobile/v2.8/tickets (embed: true) | tickets_controller.rb#create | §6 S04 |
| Embed param sanitization | N/A — controller-layer only | N/A | deals_controller.rb:422-462 (feature-gated) | §4 constraints |
| CSP frame-ancestors policy | N/A — HTTP header only | crm-fe-v3 K8s ingress (values-production.yaml) | §4 security | |
| Embed sanitize feature flag | features.code = 'embed_deal_sanitize' | current_user.feature_enabled('embed_deal_sanitize') | deals_controller.rb:422 | §7 rollout |
Detail 1.A — PRD Traceability Matrix
Forward (PRD → RFC):
| PRD Requirement | RFC Section | Component / file |
|---|---|---|
| Agents see deals for a conversation | §2.8 Chunk 3, §2.6 API | EmbedDealIndexPage.vue, GET /api/internal/v1/deals?room_id= |
| Agents create deals from conversation | §2.8 Chunk 5, §2.6 POST /deals | EmbedDealNewPage.vue, POST /api/mobile/v2.8/crm/deals |
| Agents see ticket for a conversation | §2.8 Chunk 4, §2.6 API | EmbedTicketIndexPage.vue, GET /api/mobile/v2.8/tickets?channel_integration_room_id= |
| Agents create tickets from conversation | §2.8 ticket create (existing), §2.6 POST /tickets | EmbedTicketCreatePage.vue (existing), POST /api/mobile/v2.8/tickets |
| Secure iframe embedding | §2.1 ADR-6, §3 CSP | content_security_policy.rb, ALLOWED_POSTMESSAGE_ORIGINS constant |
| Create failure feedback (CHG-003) | §2.8 Chunk 6 gap fix #1 | useEmbedDealCreate.ts:156-158 |
| Unsaved data protection (CHG-003) | §2.8 Chunk 6 gap fix #2 | useEmbedDealCreate.ts:123-128 |
Reverse (RFC → PRD):
| RFC decision | PRD requirement driving it |
|---|---|
| ADR-1: Ticket create harden only | PRD ticket create flow (already shipped) |
| ADR-2: Deal index via existing index + room_id | PRD S01 — agents see deals |
| ADR-3: Ticket index via existing index + channel_integration_room_id | PRD S03 — agents see tickets |
ADR-4: Deal create new wrapper at /embed/deals/new | PRD S02 — agents create deals |
| ADR-5: Typed postMessage | PRD CHG-003 — parent app receives structured events |
| ADR-6: CSP frame-ancestors | Security constraint (§4 PRD constraints) |
Seed migration embed_deal_sanitize | Security hardening for embed params |
PRD Section Coverage:
| PRD section | Title | RFC coverage |
|---|---|---|
| §1 | One-liner + Problem | §1 Overview → Problem |
| §3 | Non-Goals | §1 Scope → Out of scope |
| §4 | Constraints | §1 Assumptions + §3 Security |
| §5 CHG-001 | Infobar tab structure | n/a — parent app scope |
| §5 CHG-002 | Embedded form layout | §2.3 Component Diagrams |
| §5 CHG-003 | Unsaved changes protection | §2.8 Chunk 6 gap fixes #1 #2 |
| §7 | Rollout | §4 Rollout Plan |
| §8 | Observability | §3 Observability |
| §9 | Success Metrics | §1 Success Criteria |
Detail 1.B — Decisions Closed
| # | Decision | Chosen option | §2 ADR block | Alternatives rejected |
|---|---|---|---|---|
| 1 | Ticket create page | Harden only, don't rebuild | ADR-1 | Rebuild from scratch |
| 2 | Deal index data source | Existing v2.7 index + ?room_id= | ADR-2 | New by_room endpoint |
| 3 | Ticket index data source | Existing v2.8 index + ?channel_integration_room_id= | ADR-3 | Reuse show_by_room (1:1 only) |
| 4 | Deal create page | New wrapper at /embed/deals/new, old fallback at /embed/deals/create | ADR-4 | Patch 480-line EmbedDealCreatePage.vue |
| 5 | postMessage contract | Typed messages with type discriminant | ADR-5 | Keep existing ad-hoc format |
| 6 | Clickjacking defense | CSP frame-ancestors + X-Frame-Options: DENY fallback | ADR-6 | Keep deprecated ALLOW-FROM |
Detail 1.C — Per-Story Change Map
| Story | Title | Layer scope | Changes | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|
| S01 | Agents see deals for conversation | FE + BE existing | New: EmbedDealIndexPage.vue, [roomId].vue (embed-deal layer). BE: no change — ?room_id= filter already in AdvancedSearch | GET returns deal card or empty state; per_page=1 confirmed in response | §2.8 Chunk 3, §2.6 API |
| S02 | Agents create deal from conversation | FE + BE existing | New: EmbedDealNewPage.vue, embed/deals/new/index.vue. BE: no change — existing POST endpoint. Chunk 6: error toast, cancel confirm, typed postMessage | POST 201, parent receives { type: 'deal-created', dealId, room_id } | §2.8 Chunk 5+6, §2.7 |
| S03 | Agents see ticket for conversation | FE + BE existing | New: EmbedTicketIndexPage.vue, [roomId].vue (embed-ticket layer). BE: no change | GET returns ticket card or empty state; per_page=1 confirmed | §2.8 Chunk 4, §2.6 API |
| S04 | Agents create ticket from conversation | FE existing (harden only) | Existing create.vue + useEmbedTicketCreate.ts — targetOrigin restriction only | POST 201, parent receives { type: 'ticket-created', ticketId } | §2.8 Chunk 6, §2.7 |
| S05 | CSP / security hardening | Config + FE | 4 config file edits, 1 seed migration, postMessage hardening | frame-ancestors header present; no '*' targetOrigin | §2.8 Chunk 1+2+6 |
2. Technical Design
2.0 Infrastructure Topology
2.0 Infrastructure Topology
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 fetch| D
D --> E
D --> F
E --> G
F --> G
B -->|postMessage| A
| Service | Responsibility |
|---|---|
| Parent Omnichannel App | Opens iframe, passes token + room_id + parent_origin, listens for postMessage events |
| crm-fe-v3 SPA | Renders all 4 embed pages, handles auth, form state, postMessage |
| IAG | Proxies /api/mobile/v2.8/* to qontak.com, handles CORS |
| Rails API | Serves deal/ticket data, enforces auth, sanitizes embed params. CSP headers set at SPA infra layer (K8s ingress), not in Rails. |
| Postgres | Stores crm_deals with channel_integration_room_id, tickets with room association |
| CDN | Serves crm-fe-v3 static assets, may add/override CSP headers |
2.1 Technical Decisions (ADR)
ADR-1: Ticket create page — harden only, don't rebuild
| Field | Value |
|---|---|
| Context | layers/embed-ticket/pages/embed/tickets/create.vue exists (30 lines). Delegates to EmbedTicketCreatePage.vue which delegates to TicketsFormCreate component via formRef.value?.createTicket(embedContext). Fully functional. |
| Options | 1) Rebuild from scratch matching deal create pattern. 2) Verify, document contract, apply security hardening only. |
| Decision | Harden only. |
| Rationale | Working code is working code. Rebuilding introduces regression risk. Security hardening (typed postMessage, restricted targetOrigin) is additive. |
| Consequences | postMessage format on ticket create stays { type: 'ticket-created', ticketId, data } — already typed. Only targetOrigin restriction needed. |
| Code references | [layers/embed-ticket/pages/embed/tickets/create.vue] — 30-line page entry, auth gate. [layers/embed-ticket/composables/useEmbedTicketCreate.ts] — already sends typed { type: 'ticket-created', ticketId, data } (lines ~137-139) and { type: 'ticket-cancel' } (line ~113). targetOrigin: '*' used throughout (line ~106). |
| Reversibility | N/A — hardening is backward compatible. |
ADR-2: Deal index — extend existing index with room_id filter
| Field | Value |
|---|---|
| Context | Deal index page needs to show deals for a room. A room can have multiple deals. |
| Options | 1) New by_room/:room_id endpoint. 2) Extend existing GET /v2.8/crm/deals index with optional ?room_id= param. |
| Decision | Extend existing index with ?room_id= filter param. |
| Rationale | Reusing index gives pagination, sorting, and existing response format for free. 4-line change. No new route. Ticket by_room pattern assumes 1:1 — does not fit deals. |
| Consequences | Existing index callers unaffected (omitting room_id returns all deals). |
| Code references | [app/controllers/api/mobile/v2dot8/crm/deals_controller.rb] — no index action exists in v2.8; the real deal index lives in v2.7 and is routed via GET /api/internal/v1/deals → api/mobile/v2dot7/crm/deals#index (routes.rb:124). [app/models/concerns/search_parameter.rb:2474-2476] — crm_channel_room_id_filter already exists and merges room_id param into Elasticsearch conditions. [app/models/crm/advanced_search.rb:336] — default_deal_params already calls crm_channel_room_id_filter(params). No controller code changes needed. |
| Reversibility | Trivial — remove the where clause. No route or endpoint to deprecate. |
ADR-3: Ticket index — use same pattern as deal index (existing index + channel_integration_room_id filter)
| Field | Value |
|---|---|
| Context | GET /api/mobile/v2.8/tickets/by_room/:room_id exists at routes.rb:966, tickets_controller.rb:171-197. Returns one ticket per room. But the v2.8 ticket index action (lines 25-82) also exists and already supports channel_integration_room_id filter via Crm::AdvancedSearch. |
| Options | 1) Reuse existing show_by_room (returns 1 ticket). 2) Use v2.8 index with ?channel_integration_room_id= filter param — same pattern as deal index. |
| Decision | Use v2.8 index with ?channel_integration_room_id= filter param — same pattern as deal index. |
| Rationale | Consistent with deal index approach. show_by_room returns 1 ticket without pagination. index returns paginated results via Crm::AdvancedSearch. Future-proof: if a room accumulates multiple tickets, no backend change needed — just increase per_page. The filter already exists in search_parameter.rb:2474-2476 and is already called by determine_ticket_params at advanced_search.rb:1457. |
| Consequences | Ticket index embed page now follows identical pattern to deal index. Both use ?channel_integration_room_id=xxx (ticket) or ?room_id=xxx (deal) with per_page=1&order_by=created_at&order_dir=desc. Consistent for future maintainers. |
| Code references | [app/controllers/api/mobile/v2dot8/tickets_controller.rb:25-82] — index action. Uses Crm::AdvancedSearch for filtering. [app/models/concerns/search_parameter.rb:2474-2476] — crm_channel_room_id_filter maps params[:room_id] (ticket) or params[:channel_integration_room_id] to ES condition. [app/models/crm/advanced_search.rb:1457] — determine_ticket_params calls crm_channel_room_id_filter. |
| Reversibility | Trivial — remove query param. |
ADR-4: Deal create — new wrapper component at new URL, old component kept as fallback
| Field | Value |
|---|---|
| Context | layers/embed-deal/pages/embed/deals/create.vue exists (20 lines). Renders EmbedDealCreatePage.vue (480-line custom form). Separate components/Deals/Form/Create/DealsCreate*.vue components exist for the full-app create page. The existing EmbedDealCreatePage.vue has known gaps: no error toast on create failure (useEmbedDealCreate.ts:156-158), no cancel confirm when dirty (useEmbedDealCreate.ts:123-128), untyped postMessage, race condition on submit while properties still loading. |
| Options | 1) Patch the 480-line EmbedDealCreatePage.vue to fix gaps. 2) Build new wrapper component at new URL that wraps the stable DealsCreate* components, keeping old component as fallback. |
| Decision | New wrapper EmbedDealNewPage.vue at /embed/deals/new. Old EmbedDealCreatePage.vue at /embed/deals/create kept as fallback. |
| Rationale | The old 480-line component interleaves layout, logic, and styling — patching it for 6 gaps risks breaking existing behavior. The new wrapper is a thin shell: it renders DealsCreateHeader + DealsPipelineStages + DealsCreateAboutDeal + DealsCreateDynamicProperties + DealsCreateCompanySection + DealsCreateContactsSection + DealsCreateProductsSection + DealsCreateActionBar inside the embed-deal layout, and uses useEmbedDealCreate.ts for submit/cancel/postMessage. The existing layers/deals/composables/useDealCreate.ts navigates to the full deal list — NOT suitable for embed; use useEmbedDealCreate.ts instead. |
| Consequences | Two URLs for deal create until parent app migrates. Old URL (/embed/deals/create) serves EmbedDealCreatePage.vue (existing gaps, untyped postMessage). New URL serves EmbedDealNewPage.vue (typed postMessage, error toast, cancel confirm). Old component is NOT deprecated in this RFC — it stays as fallback. Migration plan and deprecation to be decided separately. |
| Code references | New wrapper: [components/Deals/Form/Create/DealsCreateAboutDeal.vue], [DealsPipelineStages.vue], [DealsCreateDynamicProperties.vue], [DealsCreateCompanySection.vue], [DealsCreateContactsSection.vue], [DealsCreateProductsSection.vue], [DealsCreateActionBar.vue], [DealsCreateHeader.vue] — all existing stable form components to wrap. Old fallback: [layers/embed-deal/pages/embed/deals/create.vue] — existing page, stays unchanged. [layers/embed-deal/components/EmbedDealCreatePage.vue] — 480-line old component, kept as fallback. NOT reusable: [layers/deals/composables/useDealCreate.ts] — navigates to full deal list on success. Pattern to follow: [layers/embed-deal/composables/useEmbedDealCreate.ts] — postMessage on success/cancel. |
| Reversibility | High — old component unchanged, just add new wrapper. Remove wrapper anytime. |
ADR-5: postMessage contract — typed messages
| Field | Value |
|---|---|
| Context | Existing deal create page sends { embed: false, msg: 'Deal closed', response_data: {} } with targetOrigin: '*'. Ticket create already sends typed { type: 'ticket-created', ... }. |
| Options | 1) Upgrade deal postMessage to typed format. 2) Keep existing ad-hoc format. |
| Decision | Typed messages with type discriminant for deal embed. Ticket embed already typed — verify unchanged. |
| Rationale | Parent app needs to discriminate message types reliably. Ticket format is already correct. Deal format needs upgrade for consistency. |
| Consequences | Existing parent app code that parses { embed: false, msg } must update handlers alongside this deploy. |
| Code references | [layers/embed-deal/composables/useEmbedDealCreate.ts:123-128] — cancel sends { embed: false, msg: 'Deal closed', response_data: {} } with targetOrigin: '*'. [layers/embed-deal/composables/useEmbedDealCreate.ts:149-153] — success sends { embed: false, msg: 'Deal successfully created', response_data: deal } with targetOrigin: '*'. [layers/embed-ticket/composables/useEmbedTicketCreate.ts:104-109] — postToParent() helper uses targetOrigin: '*'. [useEmbedTicketCreate.ts:113] — cancel sends { type: 'ticket-cancel' }. [useEmbedTicketCreate.ts:137-139] — success sends { type: 'ticket-created', ticketId, data } then { type: 'close-drawer' }. |
| Reversibility | Low — once parent updates handlers, old format is dead. |
ADR-6: CSP frame-ancestors — update SPA infrastructure layer
| Field | Value |
|---|---|
| Context | CSP for iframe protection must be set by the server that serves the framed page. The embed pages are SPA routes served by crm-fe-v3's CDN/nginx/K8s ingress — not by the Rails API. The Rails config/initializers/content_security_policy.rb only affects API responses and is irrelevant for iframe protection. The SPA already has frame-ancestors configured at the K8s ingress level (values-production.yaml) and X-Frame-Options: SAMEORIGIN in deploy/nginx/default.conf:28. |
| Options | 1) Update SPA infrastructure: remove X-Frame-Options: SAMEORIGIN from nginx if cross-origin, verify frame-ancestors in Helm values covers the parent origin. 2) Leave as-is (may block cross-origin embedding). 3) Add CSP in Rails (has no effect on iframe). |
| Decision | Update SPA infrastructure only. Rails CSP is irrelevant for iframe protection. |
| Rationale | X-Frame-Options: SAMEORIGIN in deploy/nginx/default.conf:28 blocks cross-origin embedding — must be removed or relaxed. The existing values-production.yaml already sets frame-ancestors 'self' https://*.qontak.com http://localhost:* — verify this covers the parent Omnichannel app origin. Rails content_security_policy.rb only controls API response headers, not SPA page headers. |
| Consequences | If the parent app is on the same wildcard domain (e.g., *.qontak.com), only the nginx X-Frame-Options: SAMEORIGIN needs to be removed. If on a different domain, both nginx and the Helm frame-ancestors allowlist must be updated. |
| Code references | [crm-fe-v3:deploy/nginx/default.conf:28] — add_header X-Frame-Options "SAMEORIGIN" always;. [crm-fe-v3:deploy-alicloud/chart/values-production.yaml:25,105,123,193,200] — frame-ancestors 'self' https://*.qontak.com http://localhost:*. [crm-fe-v3:deploy-alicloud/chart/values-staging.yaml:25,105,123,193,205,217-218] — staging allowlist. [qontak.com:config/initializers/content_security_policy.rb] — NOT used for iframe protection; only affects API responses. |
| Reversibility | Trivial — revert the nginx config line and Helm values. |
Detail 2.0 — Repo Reading Guide
qontak.com (Rails)
├── app/controllers/api/mobile/v2dot8/crm/deals_controller.rb — embed create, sanitize (lines 422–462), embed_request? (460)
├── app/controllers/api/mobile/v2dot8/tickets_controller.rb — index (25–82), show_by_room (171–197)
├── config/routes.rb:966 — tickets by_room route
├── config/routes.rb:956 — tickets REST (index at line 956)
├── config/routes.rb:1102 — v2.8 deals resource block
├── config/initializers/content_security_policy.rb — commented out, to enable
├── config/environments/production.rb:114 — ALLOW-FROM to replace
├── config/environments/staging.rb:110 — ALLOW-FROM to replace
├── config/environments/development.rb:94 — ALLOW-FROM ALLOWALL to replace
└── db/data/20260505000001_add_feature_embed_ticket_omnichannel.rb — seed pattern
crm-fe-v3 (Nuxt 4)
├── layers/embed-deal/
│ ├── pages/embed/deals/create.vue — existing (20 lines) fallback, unchanged
│ ├── components/EmbedDealCreatePage.vue — existing (480 lines), NOT reused in new page
│ ├── composables/useEmbedDealCreate.ts — existing (187 lines), to extend/fix
│ ├── composables/useEmbedDealAuth.ts — auth pattern
│ ├── stores/useEmbedDealStore.ts — API calls (253 lines)
│ ├── layouts/embed-deal.vue — minimal layout
│ └── components/EmbedDealAuthError.vue — auth error UI
├── layers/embed-ticket/
│ ├── pages/embed/tickets/create.vue — EXISTS (30 lines), harden only
│ ├── components/EmbedTicketCreatePage.vue — EXISTS, delegates to TicketsFormCreate
│ ├── composables/useEmbedTicketCreate.ts — EXISTS (163 lines), already typed postMessage
│ ├── composables/useEmbedTicketAuth.ts — EXISTS, cookie chain auth
│ ├── composables/useEmbedTicketApiInterceptor.ts — EXISTS, IAG URL rewriting
│ ├── components/EmbedTicketAuthError.vue — EXISTS
│ └── layouts/embed-ticket.vue — EXISTS, minimal layout
└── components/Deals/Form/Create/
├── DealsCreateAboutDeal.vue — REUSE in new deal create page
├── DealsCreateActionBar.vue — REUSE
├── DealsCreateCompanySection.vue — REUSE
├── DealsCreateContactsSection.vue — REUSE
├── DealsCreateDynamicProperties.vue — REUSE
├── DealsCreateHeader.vue — REUSE
├── DealsCreateProductsSection.vue — REUSE
└── DealsPipelineStages.vue — REUSE
Existing Code Anchors
Backend (qontak.com):
| Anchor | Path | What It Teaches |
|---|---|---|
| DealsController v2.8 | app/controllers/api/mobile/v2dot8/crm/deals_controller.rb | ALLOWED_EMBED_SOURCES (line 8), sanitize_embed_params (line 15), hub_params (line 408), embed_request? (line 460), embed_sanitize_enabled? (line 422) |
| TicketsController index (v2.8) | app/controllers/api/mobile/v2dot8/tickets_controller.rb:25-82 | Uses Crm::AdvancedSearch with pagination. Any filter_query_params are passed to search. Accepts channel_integration_room_id filter via crm_channel_room_id_filter. Returns 200 with { tickets, pagination } — even if empty (no 404 for empty). |
| Routes — tickets by_room | config/routes.rb:966 | get 'by_room/:room_id', to: 'tickets#show_by_room' |
| Routes — deals collection | config/routes.rb:1102 | resources :deals with collection actions in v2.8 namespace |
| X-Frame-Options prod | config/environments/production.rb:114 | ALLOW-FROM: http://dev.indigo.id — to replace |
| CSP initializer | config/initializers/content_security_policy.rb | Entirely commented out — to enable |
| Seed migration (ticket) | db/data/20260505000001_add_feature_embed_ticket_omnichannel.rb | Feature.find_or_create_by(name: ..., code: ..., enabled_by_default: false) — pattern for new seed |
Frontend (crm-fe-v3):
| Anchor | Path | What It Teaches |
|---|---|---|
| useEmbedDealCreate.ts | layers/embed-deal/composables/useEmbedDealCreate.ts | Gaps at lines 156-158 (no error toast), 123-128 (no cancel confirm), untyped postMessage |
| useEmbedDealAuth.ts | layers/embed-deal/composables/useEmbedDealAuth.ts | Token → cookie → axios header pattern; authError flag gap |
| useEmbedDealStore.ts | layers/embed-deal/stores/useEmbedDealStore.ts | IAG URL pattern, embed: true, offline: false, room_id, hub params |
| useEmbedTicketCreate.ts | layers/embed-ticket/composables/useEmbedTicketCreate.ts | Already typed: { type: 'ticket-created', ticketId, data }, { type: 'ticket-cancel' }, { type: 'close-drawer' } |
| useEmbedTicketAuth.ts | layers/embed-ticket/composables/useEmbedTicketAuth.ts | Cookie chain: global_sso_token → qcrm_access_token → crm_sso_token → query param |
Reading Order for Agent
tickets_controller.rb:25-82— understand the ticketindexaction: usesCrm::AdvancedSearch, accepts filter params viafilter_query_params, returns paginated response. Thecrm_channel_room_id_filteratsearch_parameter.rb:2474-2476mapsparams[:room_id]to Elasticsearch conditions.deals_controller.rb(v2.8) — understand existing embed + sanitize20260505000001_*.rb— seed migration patternproduction.rb:114+content_security_policy.rb— CSP changesuseEmbedDealAuth.ts+useEmbedDealCreate.ts— deal frontend patternsuseEmbedTicketCreate.ts+useEmbedTicketAuth.ts— ticket frontend patterns (reference for hardening)components/Deals/Form/Create/DealsCreate*.vue— form components to reuse
Source Verification
| Claim | Evidence |
|---|---|
| ALLOWED_EMBED_SOURCES at line 8 | deals_controller.rb:8 — %w[omnichannel cdp true] |
| sanitize_embed_params before_action at line 15 | deals_controller.rb:15 |
| hub_params at line 408 | deals_controller.rb:408-420 |
| embed_request? at line 460 | deals_controller.rb:460-462 |
| embed_sanitize_enabled? at line 422 | deals_controller.rb:422-424 |
| Tickets index action | tickets_controller.rb:25-82 — uses Crm::AdvancedSearch, accepts channel_integration_room_id via crm_channel_room_id_filter |
| Tickets crm_channel_room_id_filter | search_parameter.rb:2474-2476 — merges room_id param into ES conditions |
| Deals AdvancedSearch calls crm_channel_room_id_filter | advanced_search.rb:336 — default_deal_params calls crm_channel_room_id_filter(params) |
| Tickets AdvancedSearch calls crm_channel_room_id_filter | advanced_search.rb:1457 — determine_ticket_params calls crm_channel_room_id_filter(params) |
| Deal index route (v2.7 internal) | routes.rb:124 — GET /api/internal/v1/deals → v2dot7/crm/deals#index |
| XFO config line 114 | production.rb:114 |
| CSP initializer commented out | content_security_policy.rb:1-25 |
| Seed pattern | 20260505000001_add_feature_embed_ticket_omnichannel.rb:3 |
| Routes deals block line 1102 | routes.rb:1102-1119 |
| Routes tickets by_room line 966 | routes.rb:966 |
| useEmbedDealCreate.ts create failure gap | useEmbedDealCreate.ts:156-158 — console.error only |
| useEmbedDealCreate.ts cancel gap | useEmbedDealCreate.ts:123-128 — no confirm dialog |
| useEmbedTicketCreate.ts postMessage format | useEmbedTicketCreate.ts — already typed with type field |
| Ticket create page exists | layers/embed-ticket/pages/embed/tickets/create.vue — 30 lines |
| Deal create page exists | layers/embed-deal/pages/embed/deals/create.vue — 20 lines |
Detail 2.1 — Architecture (mermaid)
Component Diagram — Deal Index Page (new)
Parent App
└── <iframe src="/embed/deals/room/:room_id?token=...&parent_origin=...">
└── embed-deal layout (no nav/sidebar)
├── EmbedDealAuthError (if !isAuthenticated)
└── EmbedDealIndexPage
├── Loading state
├── Empty state ("No deals yet.")
│ └── "Create Deal" button → navigate to create page
├── Single deal card (name, stage, amount, created_at)
│ ↑ only 1 deal shown (per_page=1, newest first)
└── "New Deal" button → navigate to /embed/deals/create?room_id=...
Component Diagram — Deal Create Page (new wrapper at new URL, old component kept as fallback)
Parent App → new URL /embed/deals/new (new wrapper)
└── embed-deal layout
├── EmbedDealAuthError
└── EmbedDealNewPage ← NEW thin wrapper component
├── DealsCreateHeader (sticky)
├── (scrollable body)
│ ├── DealsPipelineStages — pipeline/stage dropdowns
│ ├── DealsCreateAboutDeal — deal name, size, currency
│ ├── DealsCreateDynamicProperties — custom field values
│ ├── DealsCreateCompanySection — company assoc
│ ├── DealsCreateContactsSection — contact assoc
│ └── DealsCreateProductsSection — product assoc
└── DealsCreateActionBar (sticky) — Cancel + Save
├── onCancel → confirm if dirty → postMessage: { type: 'deal-cancel', room_id }
└── onSave → POST /deals → postMessage: { type: 'deal-created', dealId, room_id, data }
(uses useEmbedDealCreate.ts pattern, NOT useDealCreate.ts)
Parent App → old URL /embed/deals/create (unchanged fallback)
└── embed-deal layout
└── EmbedDealCreatePage ← EXISTING 480-line component, kept as-is
└── (same form + gaps: no error toast, no cancel confirm, untyped postMessage)
Component Diagram — Ticket Index Page (new)
Parent App
└── <iframe src="/embed/tickets/room/:room_id?token=...&parent_origin=...">
└── embed-ticket layout (no nav/sidebar)
├── EmbedTicketAuthError (if !isAuthenticated)
└── EmbedTicketIndexPage
├── Loading state
├── Empty state ("No ticket for this conversation.")
│ └── "Create Ticket" button → navigate to /embed/tickets/create
└── Single ticket card (if exists)
├── Ticket name, stage, status, created_at
└── (no edit link — edit is out of scope)
Component Diagram — Ticket Create Page (already exists — harden only)
Parent App
└── <iframe src="/embed/tickets/create?room_id=...&token=...&parent_origin=...">
└── embed-ticket layout
├── EmbedTicketAuthError
└── EmbedTicketCreatePage
└── TicketsFormCreate (via formRef.value?.createTicket(embedContext))
├── onCancel → postMessage: { type: 'ticket-cancel' } ← verify targetOrigin restricted
└── onSave → POST /tickets → postMessage: { type: 'ticket-created', ticketId, data }
→ postMessage: { type: 'close-drawer' }
NOTE: Ticket create page is fully functional. This RFC only adds
parentOriginquery param read + restricted targetOrigin. No form logic changes.
State Machine — Deal Form Lifecycle
State Machine — Deal Form Lifecycle
stateDiagram-v2
[*] --> Loading: Page opens
Loading --> Form: Auth OK
Loading --> AuthError: No token or 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
How to read: Boxes = UI states. Arrows = actions or events. [*] = page mounted or closed. Dirty = form has unsubmitted changes. The cancel flow branches: clean form closes immediately; dirty form shows a confirm dialog first.
State Machine — Index Page Lifecycle (both deal & ticket)
Both index pages follow the same pattern: call existing index endpoint with per_page=1 and room filter, show the latest record or empty state.
State Machine — Index Page Lifecycle (both deal & ticket)
stateDiagram-v2
[*] --> Loading: Page opens
Loading --> HasRecord: API returns 1 record
Loading --> Empty: API returns 0 records
Loading --> AuthError: 401 (invalid token)
HasRecord --> [*]: Show record card
Empty --> [*]: Show empty state + create button
AuthError --> [*]: Show error
Branch/Skip Flow — Deal Sanitization Gate
Branch/Skip Flow — Deal Sanitization Gate
flowchart LR
A["API Request with embed param"] --> B{embed_sanitize_enabled?}
B -->|"Yes (feature flag ON)"| C["sanitize_embed_params"]
B -->|"No (feature flag OFF)"| D["Skip sanitization"]
C --> E["Validate ALLOWED_EMBED_SOURCES"]
E --> F["strip_unsafe_content on string params"]
F --> G["strip_unsafe_content on crm_properties values"]
G --> H["Proceed to controller action"]
D --> H
Detail 2.2 — Sequence (mermaid)
The diagrams below show the step-by-step flow of data between components for each major user action. Each column represents a system component. Arrows show requests and responses, ordered by time from top to bottom.
Participants used across diagrams:
Parent App— the Omnichannel application hosting the iframe (external system, out of scope for this RFC)SPA— the crm-fe-v3 Nuxt 4 application running inside the iframe in the agent's browserIAG— Internal API Gateway that proxies/api/mobile/requests to the Rails backendAPI— the qontak.com Rails API running on the serverDB— PostgreSQL database
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, sets cookie and axios header
SPA->>SPA: Read can_create param, check user permission
SPA->>IAG: GET /api/internal/v1/deals with room_id=123 per_page=1
IAG->>API: Forward to v2.7 crm/deals#index
API->>API: authenticate_request
Note over API: AdvancedSearch merges room_id filter into Elasticsearch query
API->>DB: Query crm_deals by team_id and room_id ordered by created_at LIMIT 1
DB-->>API: latest deal or empty
API-->>IAG: 200 OK with deals array and pagination
IAG-->>SPA: Response
SPA->>SPA: Render single deal card or empty state
Happy Path — Deal Create
Happy Path — Deal Create
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/new with room_id and token
SPA->>SPA: Auth setup, initialize form
SPA->>IAG: GET /api/internal/v1/deals/pipelines
Note over IAG: deal_properties has no internal alias; FE uses buildIagUrl for this call
IAG->>API: Proxy requests
API-->>IAG: Pipelines and field definitions
IAG-->>SPA: Data
SPA->>SPA: Render form with pipeline and field components
User->>SPA: Select pipeline and stage, fill fields
SPA->>SPA: isDirty becomes true, propertiesLoading is false
User->>SPA: Click Save
SPA->>IAG: POST /api/internal/v1/deals with embed true, offline false, room_id, crm_properties
IAG->>API: Forward
API->>API: sanitize_embed_params if feature enabled
API->>API: CreateService inserts deal
API->>DB: INSERT INTO crm_deals
DB-->>API: New deal
API-->>IAG: 201 Created with deal data
IAG-->>SPA: Response
SPA->>Parent: postMessage type deal-created with dealId and room_id
Happy Path — Ticket Index
Happy Path — Ticket 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/tickets/room/123
Note over SPA: token + parent_origin + can_create from query params
SPA->>SPA: useEmbedTicketAuth reads token, sets cookie and axios header
SPA->>SPA: Read can_create param, check user permission
SPA->>IAG: GET /api/internal/v1/tickets with channel_integration_room_id=123 per_page=1
IAG->>API: Forward request
API->>API: authenticate_request
Note over API: AdvancedSearch applies channel_integration_room_id filter via crm_channel_room_id_filter
API->>DB: Query tickets by team_id and room_id ordered by created_at LIMIT 1
DB-->>API: latest ticket or empty
API-->>IAG: 200 OK with tickets array and pagination
IAG-->>SPA: Response
alt ticket found
SPA->>SPA: Render single ticket card
else no ticket
SPA->>SPA: Render empty state with Create Ticket button
end
Happy Path — Ticket Create (existing, documented)
Happy Path — Ticket Create (existing, documented)
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/tickets/create with room_id and token
SPA->>SPA: useEmbedTicketAuth sets up auth
SPA->>SPA: EmbedTicketCreatePage renders TicketsFormCreate via formRef
User->>SPA: Fill form and click Save
SPA->>SPA: formRef createTicket with embedContext
SPA->>IAG: POST /api/internal/v1/tickets with embed true, room_id, crm_properties
IAG->>API: Forward
API->>DB: INSERT INTO tickets
DB-->>API: New ticket
API-->>IAG: 201 Created with ticket data
IAG-->>SPA: Response
Note over SPA: targetOrigin now restricted to parentOrigin (hardened)
SPA->>Parent: postMessage type ticket-created with ticketId and data
SPA->>Parent: postMessage type close-drawer
Failure — Auth Error (deal)
Failure — Auth Error (deal)
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 to true on 401 from API (hardened)
SPA->>SPA: Render EmbedDealAuthError component
Failure — Deal Create API Error (gap fix)
Failure — Deal Create API Error (gap fix)
sequenceDiagram
participant User as User
participant SPA as SPA
participant API as Rails API
User->>SPA: Click Save
SPA->>API: POST /crm/deals
API-->>SPA: 422 or 500 error response
Note over SPA: Before fix - only console.error at line 156-158
Note over SPA: After fix - shows error toast to user
SPA->>SPA: Display toast - Failed to create deal. Please try again.
Detail 2.3 — Database Model
No new tables. All data reuses existing crm_deals and tickets models.
crm_deals.channel_integration_room_id — existing column. The room_id filter on index uses:
SELECT * FROM crm_deals
WHERE team_id = ?
AND channel_integration_room_id = ?
ORDER BY created_at DESC
LIMIT 1 OFFSET 0
tickets.channel_integration_room_id — existing column. Both deal and ticket index pages use the same pattern: per_page=1 with channel_integration_room_id filter.
If crm_deals exceeds 100K rows per team, add composite index:
CREATE INDEX CONCURRENTLY idx_crm_deals_team_room_created
ON crm_deals (team_id, channel_integration_room_id, created_at DESC);
Detail 2.4 — APIs
| Method | Path | Status | Notes |
|---|---|---|---|
| GET | /api/internal/v1/deals | Extended | Optional ?room_id= filter. Routes to v2.7 deals#index (routes.rb:124). |
| GET | /api/internal/v1/tickets | Extended | Optional ?channel_integration_room_id= filter. Routes to v2.8 tickets#index (routes.rb:111). |
| POST | /api/internal/v1/deals | Reused | Existing create with embed support. Routes to v2.8 crm/deals#create. |
| POST | /api/internal/v1/tickets | Reused | Existing create with embed support. Routes to v2.8 tickets#create. |
| GET | /api/internal/v1/deals/pipelines | Reused | Pipeline list. Routes to v2.7 pipelines#index (routes.rb:128). |
GET /api/internal/v1/deals (extended with room_id filter)
FE calls GET /api/internal/v1/deals?room_id=xxx via IAG (routes.rb:124 → v2dot7/crm/deals#index). Uses Crm::AdvancedSearch#deals which calls crm_channel_room_id_filter(params) at advanced_search.rb:336. No controller code change needed.
Request:
GET /api/internal/v1/deals?room_id=room_abc123&per_page=1&order_by=created_at&order_dir=desc
Authorization: Bearer <token>
Response (200) — deal found:
{
"deals": [{ "id": 456, "name": "Enterprise Deal", "crm_stage_id": 12, "stage_name": "Proposal", "size": 5000000.0, "currency": "IDR", "created_at": "2026-06-30T10:00:00Z" }],
"pagination": { "page": 1, "per_page": 1, "total": 1 }
}
Response (200) — no deal for room:
{
"deals": [],
"pagination": { "page": 1, "per_page": 1, "total": 0 }
}
GET /api/internal/v1/tickets (extended with channel_integration_room_id filter)
FE calls GET /api/internal/v1/tickets?channel_integration_room_id=xxx via IAG (routes.rb:111 → v2dot8/tickets#index). Uses Crm::AdvancedSearch#tickets, determine_ticket_params at advanced_search.rb:1457 calls crm_channel_room_id_filter(params). No controller code change needed.
Request:
GET /api/internal/v1/tickets?channel_integration_room_id=room_abc123&per_page=1&order_by=created_at&order_dir=desc
Authorization: Bearer <token>
Response (200) — ticket found:
{
"tickets": [{ "id": 789, "name": "Support Ticket", "ticket_stage_id": 123, "created_at": "2026-06-30T10:00:00Z" }],
"pagination": { "page": 1, "per_page": 1, "total": 1 }
}
Response (200) — no ticket for room:
{
"tickets": [],
"pagination": { "page": 1, "per_page": 1, "total": 0 }
}
POST /api/internal/v1/deals (existing — reused)
Request:
{
"embed": true,
"offline": false,
"room_id": "room_abc123",
"channel": "whatsapp",
"organization_id": "org_1",
"account_uniq_id": "acc_1",
"channel_integration_id": "ci_1",
"ext_user_id": "ext_1",
"ext_username": "Agent Name",
"crm_pipeline_id": "15",
"crm_stage_id": "42",
"layout_id": 9,
"crm_properties": [
{ "id": 101, "name": "name", "type": "Text", "value": "Enterprise Deal" },
{ "id": 102, "name": "size", "type": "Number", "value": "5000000" }
],
"crm_products_deals": [{ "crm_product_id": 300, "sequence": 1 }],
"crm_company_ids": [7, 8]
}
Response (201):
{ "deal": { "id": 456, "name": "Enterprise Deal", "crm_stage_id": 42, "size": 5000000.0, "currency": "IDR", "data_source": "omnichannel" } }
Response (422) — validation error:
{ "error": "Validation failed", "errors": { "name": ["can't be blank"], "crm_stage_id": ["is not a number"] } }
Response (422) — sanitization rejected:
{ "error": "Invalid embed source" }
POST /api/internal/v1/tickets (existing — reused)
Request:
{
"embed": true,
"room_id": "room_abc123",
"channel": "whatsapp",
"organization_id": "org_1",
"account_uniq_id": "acc_1",
"channel_integration_id": "ci_1",
"ext_user_id": "ext_1",
"ext_username": "Agent Name",
"layout_id": null,
"source": "web",
"crm_properties": [
{ "id": 228516, "property_id": null, "additional_fields": false, "name": "name", "value": "Ticket title" },
{ "id": 228517, "property_id": null, "additional_fields": false, "name": "ticket_stage_id", "value": "123" }
]
}
Response (201):
{ "ticket": { "id": 789, "name": "Support Ticket", "ticket_stage_id": 123, "created_at": "2026-06-30T10:00:00Z" } }
Response (422) — validation error:
{ "error": "Validation failed", "errors": { "name": ["can't be blank"], "ticket_stage_id": ["is not a number"] } }
2.7 postMessage Contracts
Deal embed (upgrade from current untyped format):
| Message | Payload | Trigger |
|---|---|---|
deal-created | { type: 'deal-created', dealId, room_id, data } | POST 201 success |
deal-cancel | { type: 'deal-cancel', room_id } | Cancel confirmed (dirty or clean) |
form-dirty | { type: 'form-dirty', dirty: boolean, room_id } | On any field change |
All deal postMessage calls: window.parent.postMessage(payload, targetOrigin) where targetOrigin = parentOrigin ?? ALLOWED_ORIGINS[0]. Never '*'.
Ticket embed (already typed — verify targetOrigin only):
| Message | Payload | Trigger |
|---|---|---|
ticket-created | { type: 'ticket-created', ticketId, data } | POST 201 success |
ticket-cancel | { type: 'ticket-cancel' } | Cancel |
close-drawer | { type: 'close-drawer' } | After success |
Change needed: verify all window.parent.postMessage calls in useEmbedTicketCreate.ts use restricted targetOrigin (not '*').
2.8 What Changes
Infrastructure Changes (crm-fe-v3)
Chunk 1 — SPA CSP / X-Frame-Options Config (infra layer):
CSP for iframe protection is set at the SPA infrastructure layer — not in Rails. The embed pages are SPA routes served by crm-fe-v3's CDN/nginx/K8s ingress.
crm-fe-v3:deploy/nginx/default.conf:28 — remove or update X-Frame-Options (blocks cross-origin iframes):
# Remove this line if parent app is cross-origin:
# add_header X-Frame-Options "SAMEORIGIN" always;
# CSP frame-ancestors handles the protection instead (set in Helm values)
crm-fe-v3:deploy-alicloud/chart/values-production.yaml — verify frame-ancestors allowlist includes the parent Omnichannel app origin. Current value (line 25):
more_set_headers "Content-Security-Policy: frame-ancestors 'self' https://*.qontak.com http://localhost:*";
If the parent app is on a different origin (e.g., https://omnichannel.qontak.com), the existing *.qontak.com wildcard already covers it. If the parent is on a completely different domain, add it to the allowlist.
crm-fe-v3:deploy-alicloud/chart/values-staging.yaml — same verification for staging origins.
Note: The Rails
config/initializers/content_security_policy.rbandconfig/environments/{production,staging,development}.rbX-Frame-Options settings are NOT relevant for iframe protection. They only affect API response headers. The Rails CSP initializer is commented out by default and can remain commented out. No changes needed in the Rails repo for CSP.
Chunk 2 — Seed Migration:
New file db/data/20260701000001_add_feature_embed_deal_sanitize.rb:
class AddFeatureEmbedDealSanitize < SeedMigration::Migration
def up
Feature.find_or_create_by(
name: 'Embed Deal Sanitize',
code: 'embed_deal_sanitize',
enabled_by_default: false
)
end
def down
Feature.find_by(code: 'embed_deal_sanitize').try(:destroy)
end
end
Frontend Changes (crm-fe-v3)
Chunk 3 — Deal Index Embed Page (new):
No backend change needed — deal index already supports room_id filter via Crm::AdvancedSearch#crm_channel_room_id_filter (advanced_search.rb:336). FE calls GET /api/internal/v1/deals?room_id=:roomId via IAG.
New file: layers/embed-deal/pages/embed/deals/room/[roomId].vue
definePageMeta({ layout: 'embed-deal' })- Reads
:roomIdfrom route params - Auth via
useEmbedDealAuth() - Reads
can_createfrom query param (getStringQuery(route, 'can_create')) - Fetches
GET /api/internal/v1/deals?room_id=:roomId&per_page=1&order_by=created_at&order_dir=descvia IAG (limit 1, newest first) - Shows latest deal card (name, stage, amount) if exists, or empty state
- Create button shown only if
can_create === 'true' - "New Deal" button navigates to
/embed/deals/create?room_id=:roomId&token=:token&parent_origin=:parentOrigin - No edit link (out of scope)
New component: layers/embed-deal/components/EmbedDealIndexPage.vue
Index behavior: Shows at most 1 most recently created deal. The per_page=1 param is respected by Crm::AdvancedSearch#build_deal_searchkick_options which passes per_page to Elasticsearch. If future iterations need full list, increase per_page.
Create button visibility: Dual gate — both must pass:
- Parent gate:
?can_create=true/falsequery param. Parent app sets this based on agent's CRM create permission in user management. This is the primary gate. - Local gate: On mount, check the user's permission data from the existing
GET /api/mobile/v2.8/users/meresponse. The back-end returnsuser.permission.deal(andticket) objects. The FE checks acreatefield if present; if absent/undefined, defaults to allowed. This ensures the embed page enforces CRM permission even if the parent passes the wrong value.
Both must be true for the create button to render. Logic: showCreateButton = can_create_param === 'true' && userPermissionCreate !== false
The existing useEmbedDealStore already calls GET /v2.8/users/me during initialization (useEmbedDealStore.ts:66) — the permission data is already available in the store. For the ticket embed, the user data is available via the existing auth composable or the ticket store.
Note on current API: The back-end GET /api/mobile/v2.8/users/me response currently does not include a create field in the permission.deal object (main app's useDealPermissions.ts hardcodes canCreate: true with comment "API does not expose a deal.create field"). If the API later adds this field, the local gate works without FE changes. If it never does, the local gate always passes and the parent's can_create is the sole effective gate.
Chunk 4 — Ticket Index Embed Page (new):
No backend change needed — ticket index already supports channel_integration_room_id filter via Crm::AdvancedSearch tickets path.
New file: layers/embed-ticket/pages/embed/tickets/room/[roomId].vue
definePageMeta({ layout: 'embed-ticket' })- Reads
:roomIdfrom route params - Auth via
useEmbedTicketAuth()+ optionaluseEmbedTicketApiInterceptor(when?use_iag=true) - Reads
can_createfrom query param - Fetches
GET /api/internal/v1/tickets?channel_integration_room_id=:roomId&per_page=1&order_by=created_at&order_dir=descvia IAG (limit 1, newest first) - Shows ticket card (name, stage, status, created_at) if exists, or empty state
- Create button shown only if
can_create === 'true' - "New Ticket" button navigates to
/embed/tickets/create?room_id=:roomId&token=:token&parent_origin=:parentOrigin - No edit link (out of scope)
New component: layers/embed-ticket/components/EmbedTicketIndexPage.vue
Index behavior: Shows at most 1 most recently created ticket. per_page=1 is respected by Crm::AdvancedSearch tickets path via Searchkick options. The ticket index controller re-fetches by id for the full includes — this still works with a single result.
Create button visibility: Same dual gate as deal index — parent can_create param AND local user.permission.ticket?.create check. Both must be true. Ticket currently has no create field in the API response either — defaults to allowed if absent.
Chunk 5 — Deal Create Embed Page (new wrapper component, old page kept as fallback):
New files:
layers/embed-deal/pages/embed/deals/new/index.vue— new page at URL/embed/deals/newlayers/embed-deal/components/EmbedDealNewPage.vue— new thin wrapper component
Old page stays unchanged at /embed/deals/create → EmbedDealCreatePage.vue. Two URLs, two components. Parent app switches to /embed/deals/new when ready. The old URL remains available as fallback for any consumer that hasn't migrated. No deprecation in this RFC.
Chunk 6 — Security Hardening (both layers):
All items below apply to existing + new files:
| # | Gap | Location | Fix |
|---|---|---|---|
| 1 | Create failure no toast | useEmbedDealCreate.ts:156-158 | Add toast.error('Failed to create deal. Please try again.') in catch block |
| 2 | No cancel confirm | useEmbedDealCreate.ts:123-128 | Show confirm dialog when isDirty before calling cancel postMessage |
| 3 | Race on submit while propertiesLoading | handleSubmit | Guard: if (propertiesLoading.value) return |
| 4 | authError never triggered from real 401 | useEmbedDealAuth.ts:38-40 | Wire 401 axios interceptor to set authError.value = true |
| 5 | Deal postMessage uses '*' targetOrigin | All window.parent.postMessage calls | Replace with targetOrigin = parentOrigin ?? ALLOWED_ORIGINS[0] |
| 6 | Deal postMessage untyped format | useEmbedDealCreate.ts | Upgrade to typed { type: 'deal-created', dealId, room_id, data } |
| 7 | Ticket postMessage uses '*' | useEmbedTicketCreate.ts | Same parentOrigin restriction |
New constant (shared by both layers):
const ALLOWED_POSTMESSAGE_ORIGINS = [
'https://omnichannel.qontak.com',
'https://staging-omnichannel.qontak.com',
]
No API timeouts added in this chunk (out of scope for MVP — document as known limitation).
Ticket Create Page — Verification Checklist (no code changes beyond targetOrigin)
-
layers/embed-ticket/pages/embed/tickets/create.vue— confirm layout isembed-ticket, auth gate isuseEmbedTicketAuth -
useEmbedTicketCreate.ts— confirm postMessage payloads match documented contract:ticket-created,ticket-cancel,close-drawer -
useEmbedTicketCreate.ts— confirmwindow.parent.postMessagecalls updated to restrictedtargetOrigin(Chunk 6) -
useEmbedTicketApiInterceptor.ts— confirm IAG URL rewriting works when?use_iag=true -
EmbedTicketCreateSuccess.vue— verify wired into flow (existing gap noted in audit)
2.9 Concurrency / Integrity
Transaction scope for POST /api/internal/v1/deals:
The existing Crm::Deals::CreateService wraps the deal creation in a single ActiveRecord transaction. The write path includes:
INSERT INTO crm_deals(main record, includeschannel_integration_room_id,data_source = 'omnichannel')INSERT INTO crm_properties(deal custom fields, viadeal.save!onhas_many :crm_properties)INSERT INTO crm_products_deals(product associations, viacrm_products_deals_attributes=)INSERT INTO crm_company_deals(company associations, viacrm_company_ids=)INSERT INTO crm_contact_deals(contact associations, viacrm_contact_ids=)- 84 model callbacks (Salesforce sync, WhatsApp notification, automation checks — existing behavior)
If any insert fails, the transaction rolls back all writes. No partial writes occur.
Idempotency: No idempotency key on POST /deals or POST /tickets in this RFC. The client-side isSaving flag prevents double-submit within the same session. If the network drops the response after the server commits but before the client receives 201, the user sees an error toast but the deal already exists. Acceptable for MVP — the agent sees the duplicate deal on index refresh and can manually delete. If duplicate detection is needed in future, add an idempotency_key column to crm_deals and check via unique index.
POST /tickets: Same approach — TicketsController#create uses ticket.save! within a transaction. No idempotency key. Same client-side guard (isSaving).
| Concern | Mitigation |
|---|---|
| Double-submit (deal create) | Disable Save button on first click via isSaving flag. Server-side: wrapped in ActiveRecord transaction — no partial writes |
| Room-based concurrency | Multiple agents on same room see same data via GET; each has own iframe session |
| Deal index freshness | Fetched on iframe load. Stale if another agent creates a deal. Acceptable: close and re-open to refresh |
| Ticket 1:1 | Per-page=1 on index returns latest ticket; no concurrency concern on index |
| Submit while propertiesLoading | Blocked via if (propertiesLoading.value) return in handleSubmit (Chunk 6 gap fix #3) |
| POST /deals duplicate on network timeout | Client shows error toast but deal may exist. User can verify on index. Acceptable for MVP — no server-side idempotency key |
Detail 2.G — Cross-Layer Contract Verification
| Contract | Frontend Expects | Backend Delivers | Verification |
|---|---|---|---|
GET /deals?room_id= | { deals: [...], pagination: {...} } | current_user.available_deals.where(channel_integration_room_id: ...) | Integration test |
GET /tickets?channel_integration_room_id= | { tickets: [...], pagination } or { tickets: [], pagination } | Existing index action with AdvancedSearch | Existing tests pass; uses per_page param |
POST /deals embed | 201 { deal: {...} } | sanitize_embed_params + hub_params + CreateService | Existing (reused) |
POST /tickets embed | 201 { ticket: {...} } | Existing embed support | Existing (reused) |
| Deal postMessage typed | { type, dealId, room_id, data } | N/A (FE only) | Composable unit test |
| Ticket postMessage typed | { type, ticketId, data } | N/A (FE only) | Verify unchanged |
| Feature flag code | embed_deal_sanitize | Feature.find_by(code: 'embed_deal_sanitize') | Seed migration creates it |
| CSP header | N/A | Content-Security-Policy: frame-ancestors ... | Integration test |
| parentOrigin restriction | All postMessage calls use non-'*' targetOrigin | N/A | Code review + unit test |
Detail 2.A — UI Contract
For every new component introduced:
EmbedDealIndexPage.vue (layers/embed-deal/components/EmbedDealIndexPage.vue)
- Figma: n/a — design pending
- Props:
roomId: string,canCreate: boolean - State:
deals: Deal[],loading: boolean,error: boolean - Events: none (navigation via router)
- Conditional rendering: Loading skeleton → empty state (canCreate ? show create btn : hide) → single deal card
- A11y: heading
h2for "Deals", card links havearia-label, create button has descriptive label
EmbedDealNewPage.vue (layers/embed-deal/components/EmbedDealNewPage.vue)
- Figma: Inbox Revamp — Infobar
- Props: none (reads all from route query via
useEmbedDealAuth) - State:
isDirty: boolean,isSaving: boolean,propertiesLoading: boolean - Events (postMessage):
deal-created,deal-cancel,form-dirty - Conditional rendering:
EmbedDealAuthErrorif not authenticated; form blocked ifpropertiesLoading - A11y: Focus trapped in form; cancel button returns focus to trigger
EmbedTicketIndexPage.vue (layers/embed-ticket/components/EmbedTicketIndexPage.vue)
- Figma: n/a — design pending
- Props:
roomId: string,canCreate: boolean - State:
ticket: Ticket | null,loading: boolean,error: boolean - Events: none (navigation via router)
- Conditional rendering: Loading → empty state (canCreate ? show create btn : hide) → single ticket card
- A11y: same as deal index
Detail 2.B — Data-Fetching Strategy
- Library: Axios (configured in
plugins/axios.js). No SWR, no React Query, no Pinia$fetch. - Cache key structure: None — no client-side caching. Every page mount triggers a fresh fetch.
- TTL & refetch triggers: Data fetched once on mount. No interval refetch, no focus-refetch. User closes and reopens to refresh (Known Limitation #1).
- Optimistic updates: None. All state updates are server-confirmed.
- Stale handling: Index pages show stale data until iframe is closed/reopened. Acceptable for MVP.
Detail 2.C — UI State Matrix
| Surface | Loading | Empty | Error | Partial | Success |
|---|---|---|---|---|---|
| Deal index | Skeleton card | "No deals yet." + Create button (if permitted) | Auth error component / retry button | N/A | Single deal card (name, stage, amount) |
| Ticket index | Skeleton card | "No ticket for this conversation." + Create button (if permitted) | Auth error component / retry button | N/A | Single ticket card (name, stage, status) |
| Deal create form | Pipeline/properties loading spinner | N/A (form always shown) | Auth error; API error → toast | Dirty (unsaved changes) | postMessage + close |
| Ticket create form | Form skeleton (existing) | N/A | Auth error (existing) | N/A | postMessage + close (existing) |
Detail 2.D — Scope Boundaries
Files to create:
layers/embed-deal/pages/embed/deals/room/[roomId].vuelayers/embed-deal/components/EmbedDealIndexPage.vuelayers/embed-deal/pages/embed/deals/new/index.vuelayers/embed-deal/components/EmbedDealNewPage.vuelayers/embed-ticket/pages/embed/tickets/room/[roomId].vuelayers/embed-ticket/components/EmbedTicketIndexPage.vuedb/data/20260701000001_add_feature_embed_deal_sanitize.rb
Files to modify:
config/initializers/content_security_policy.rbconfig/environments/production.rb(line 113-115)config/environments/staging.rb(line 109-111)config/environments/development.rb(line 93-95)layers/embed-deal/composables/useEmbedDealCreate.ts(gap fixes #1-6)layers/embed-deal/composables/useEmbedDealAuth.ts(gap fix #4)layers/embed-ticket/composables/useEmbedTicketCreate.ts(gap fix #7)
Files not touched:
layers/embed-deal/pages/embed/deals/create.vue(old fallback — unchanged)layers/embed-deal/components/EmbedDealCreatePage.vue(old fallback — unchanged)layers/embed-ticket/pages/embed/tickets/create.vue(harden only via composable)- All existing ticket form components (
EmbedTicketCreatePage.vue,TicketsFormCreate) - All existing deal form components (
components/Deals/Form/Create/*) — reused not modified - Any BE controller files — no code changes
Detail 2.E — State Surface Contract
| Entity | State field consumed | Default | Source endpoint | Stale tolerance |
|---|---|---|---|---|
| Deal list (index) | deals: Deal[], pagination | [] | GET /api/internal/v1/deals?room_id= | Until iframe closed/reopened |
| Ticket list (index) | tickets: Ticket[], pagination | [] | GET /api/mobile/v2.8/tickets?channel_integration_room_id= | Until iframe closed/reopened |
| User permission | user.permission.deal.create, user.permission.ticket?.create | true (if absent) | GET /api/mobile/v2.8/users/me (already called by useEmbedDealStore.ts:66) | Session lifetime |
| Form dirty state | isDirty: boolean | false | Computed from form values vs initial snapshot | N/A — reactive |
3. High-Availability & Security
Performance Requirement
| Metric | Target | Requests (7d) | Avg | P95 |
|---|---|---|---|---|
| Deal index API time | < 5s | 194k | 2.79s | 3.79s |
| Deal create (v2.8 embed) API time | < 8s | 3.97k | 2.60s | 6.73s |
| Ticket index API time | < 3s | 26.1k | 0.91s | 2.04s |
| Ticket create API time | < 8s | 2.29k | 2.27s | 6.94s |
| Embed page JS bundle size | < 500KB | — | — | — |
Source: Request count and Avg from APM metrics (unsampled). P95 from Datadog resource pages (unsampled).
No additional performance optimization in this RFC. If deal index exceeds P95 5s or ticket index exceeds P95 3s, add server-side caching on those endpoints before general availability.
Monitoring & Alerting
Dashboard: Add a new "Embed Widgets" panel to the existing CRM product dashboard in Datadog:
- Panel 1:
embed_page_mountedcount per page type (daily bar chart) - Panel 2:
embed_deal_createdvsembed_deal_create_failedratio (timeseries) - Panel 3:
embed_ticket_createdvsembed_ticket_create_failedratio (timeseries) - Panel 4: P95 duration of
GET /api/internal/v1/deals?room_id=andPOST /api/mobile/v2.8/crm/dealsfiltered by@embed:true(timeseries)
Alerts:
embed_deal_create_failedrate > 5% in any 1-hour window → notify #crm-alerts (P2)embed_ticket_create_failedrate > 5% in any 1-hour window → notify #crm-alerts (P2)- Zero
embed_page_mountedevents for 24h → notify #crm-alerts (P3 — possible deployment issue)
Logging
All embed API calls already logged server-side by Rails. Add structured fields for embed-specific queries:
| Field | Value | Example |
|---|---|---|
@embed | true | Logged by deals_controller.rb when embed_request? is true |
@embed_page_type | deal_index / deal_create / ticket_index / ticket_create | Identifies which embed page made the call |
@embed_duration_ms | Integer | Time from page mount to success/failure |
Queriable in Datadog Logs: service:qontak.com @embed:true @embed_page_type:deal_index @status:error
Clickjacking Defense
| Layer | Mechanism | Location | Priority |
|---|---|---|---|
| CSP | frame-ancestors 'self' https://*.qontak.com http://localhost:* | crm-fe-v3 values-production.yaml (K8s ingress) | Primary |
| X-Frame-Options | SAMEORIGIN | crm-fe-v3 deploy/nginx/default.conf:28 | Must be removed if parent is cross-origin |
| Verification | Curl SPA page headers for CSP frame-ancestors | CI gate |
Important: CSP must be set at the SPA serving layer (CDN/nginx/K8s ingress), not in Rails. The Rails config/initializers/content_security_policy.rb only controls API response headers and has no effect on iframe framing protection. The embed pages are SPA routes — the browser checks frame-ancestors on the SPA origin, not on API calls.
postMessage Origin Validation
parentOriginquery param = primary target origin.- If missing, fall back to
ALLOWED_POSTMESSAGE_ORIGINSconstant. - Never
'*'as targetOrigin. - Parent app should validate
event.originon incoming messages.
Sanitization
Existing sanitize_embed_params at deals_controller.rb:422-462:
- Strips HTML via
ActionView::Base.full_sanitizer.sanitize - Removes
javascript:protocol - Validates
embedparam againstALLOWED_EMBED_SOURCES - Gated by
embed_deal_sanitizefeature flag (enabled_by_default: false)
Known gap: sanitization skips non-string crm_property values (arrays/objects at deals_controller.rb:448). Acceptable for MVP — dropdown/structured data, not free text. Noted in Known Limitations.
Auth Token Handling
- Token passed as
?token=query param - Stored in
qcrm_access_tokencookie - Sent as
Authorization: Bearerheader - JWT encoded with
secret_key_base, 1-week default expiry - Recommendation: reduce embed token TTL to 1 day (open question OQ-3)
Observability & Analytics
New events to track: Each event is emitted by the crm-fe-v3 SPA via the existing analytics platform (Datadog RUM / Mixpanel — already configured in the app).
| Event | Trigger | Properties | Purpose |
|---|---|---|---|
embed_page_mounted | Any embed page mounts | page_type (deal_index/deal_create/ticket_index/ticket_create), room_id | Tracks adoption per page type |
embed_auth_failed | Auth token missing or invalid | page_type, room_id | Tracks auth failure rate — high rate = parent app token issue |
embed_deal_created | POST /deals returns 201 | deal_id, room_id, duration_ms (time from mount to success) | Funnel completion — deal create |
embed_deal_create_failed | POST /deals returns error | room_id, error_type (validation/network/timeout), error_message | Tracks create failure rate — alert if >5% |
embed_ticket_created | POST /tickets returns 201 | ticket_id, room_id, duration_ms | Funnel completion — ticket create |
embed_ticket_create_failed | POST /tickets returns error | room_id, error_type | Tracks create failure rate |
embed_unsaved_changes_warning | Unsaved changes dialog shown | page_type, room_id, exit_path (close/tab_switch/beforeunload) | Tracks protection usage — decreasing rate = user adaptation |
embed_create_cancelled | Cancel confirmed (dirty or clean) | page_type, room_id, had_unsaved_changes | Tracks form abandonment |
embed_permission_denied | can_create=false or local permission blocks | page_type, room_id, gate (parent/local) | Tracks how often create button is hidden |
Monitoring:
- Error monitoring: Existing Datadog RUM captures unhandled exceptions, API errors (4xx/5xx), and request durations. No additional setup needed — the SPA already has Datadog RUM configured.
- Dashboard: Add panel tracking
embed_deal_created/embed_ticket_createdevent count per day in the existing CRM product dashboard. Filter bypage_typeto distinguish embed from normal creation. - Alert: If
embed_deal_create_failedrate exceeds 5% in any 1-hour window, notify #crm-alerts. - Logging: All API errors already logged server-side by Rails. Embed-specific errors tagged with
embed: truein the request — queryable in Datadog Logs:service:qontak.com @embed:true.
Failure Mode Catalog
| Failure | Trigger | Frontend Behavior | Backend Response |
|---|---|---|---|
| Auth failure | No token / invalid / expired | EmbedDealAuthError or EmbedTicketAuthError | 401/403 |
| Token expires during form fill | 60+ min idle | Silent 401 on submit — no refresh | 401 |
| Deal index load error | API down | Error state with retry | 500 or timeout |
| Deal create validation | Missing required fields | Client-side errors (existing) | 422 |
| Deal create API error (gap fix #1) | Backend 422/500 | Toast: "Failed to create deal. Please try again." | 422/500 |
| Submit while propertiesLoading (gap fix #3) | Rapid pipeline switch + save | Button disabled, submit blocked | N/A |
| Ticket index 404 | No ticket for room | Empty state + "Create Ticket" link | 404 |
| Ticket create validation | Missing fields | Form errors (existing) | 422 |
| authError never triggered (gap fix #4) | Real 401 from API | Now sets authError = true via interceptor | 401 |
| CSP block | Browser blocks iframe | Blank iframe | No response |
| postMessage blocked | Parent removed | Caught in try/catch | N/A |
Error Message Catalog
| Scenario | User Message | Log Level |
|---|---|---|
| Auth failed | "Authentication Failed. Invalid or expired token." | warn |
| Token expired during fill | No user-facing message (silent 401) | warn |
| No deals for room | "No deals found for this conversation" | info |
| No ticket for room | "No ticket for this conversation" | info |
| Deal create success | postMessage to parent | info |
| Deal create failure (gap fix) | "Failed to create deal. Please try again." | error |
| Ticket create success | postMessage to parent | info |
| Feature not enabled | "Feature not enabled" | info |
| Network error | "Failed to save. Please try again." | error |
Detail 3.B — Rate Limiting & Throttling
| Endpoint / consumer | Rate limit | Burst | Throttle response | Monitoring |
|---|---|---|---|---|
GET /api/internal/v1/deals | Existing global Rails rate limit | Existing | 429 Too Many Requests | Datadog existing monitor |
GET /api/mobile/v2.8/tickets | Existing global Rails rate limit | Existing | 429 | Datadog existing monitor |
POST /api/mobile/v2.8/crm/deals | Existing global Rails rate limit | Existing | 422 + isSaving client guard | Datadog existing monitor |
POST /api/mobile/v2.8/tickets | Existing global Rails rate limit | Existing | 422 | Datadog existing monitor |
No new rate limits introduced. Embed-specific abuse prevention via embed_request? guard on room_id filter param.
Detail 3.C — Accessibility
- WCAG level: AA minimum
- Keyboard navigation: All interactive elements (deal card, create button, form fields, cancel/save buttons) reachable via Tab. No keyboard traps except in cancel confirm dialog (modal traps focus until resolved — correct behavior).
- Focus management: On confirm dialog open, focus moves to the dialog's primary action button. On close, focus returns to the cancel button that triggered it.
- ARIA labels: Create button:
aria-label="Create deal for this conversation"/"Create ticket for this conversation". Deal/ticket cards have descriptivearia-labelincluding record name. - Color contrast: Inherits from
@mekari/pixel3design system (AA compliant by default). - prefers-reduced-motion:
@mekari/pixel3components respect this setting. No custom animations added in this RFC.
4. Backwards Compatibility & Rollout Plan
Compatibility
| Change | Impact | Mitigation |
|---|---|---|
Deal index via /api/internal/v1/deals?room_id= | None — optional param; existing callers omit it | N/A |
Ticket index via GET /v2.8/tickets?channel_integration_room_id= | None — optional param; existing callers omit it | N/A |
CSP frame-ancestors | May block unlisted framers | Verify Helm frame-ancestors allowlist covers parent origin before deploy |
| X-Frame-Options SAMEORIGIN | Blocks cross-origin parent | Remove from nginx default.conf:28 if parent is cross-origin |
| Deal postMessage contract change | Existing parent that parses { embed, msg, response_data } breaks | Coordinate parent app update alongside deploy |
embed_deal_sanitize seed | No impact — feature flag OFF by default | Opt-in per team |
| Ticket create page targetOrigin hardening | Existing parent must accept messages from restricted origin (should already do so) | Verify parent app origin validation |
Cross-Layer Rollout Compatibility Matrix
Both index endpoints already exist and accept the filter params optionally. This means the rollout is safe in any order — no scenario breaks.
| Scenario | FE version | BE version | Works? | Notes |
|---|---|---|---|---|
| Pre-deploy (baseline) | Old | Old | ✅ | Existing behavior, no embed pages |
| BE deploys first | Old | New | ✅ | CSP + seed migration are transparent to old FE. Index filters are optional params — old FE doesn't send them, works identically. |
| FE deploys first | New | Old | ✅ | Index pages call existing endpoints with optional params. Without CSP header, iframe loading depends on infra config (will work in staging; prod CSP must be deployed first). |
| Full deploy | New | New | ✅ | Target state — CSP protects framing, filters work |
| Rollback BE first | New | Old | ✅ | CSP header reverts → iframe may not load on strict orgins (temporary, fix by rolling back FE or re-deploying CSP). Index filters still work (optional params). |
| Rollback FE first | Old | New | ✅ | Old FE doesn't call new pages. Backend changes (CSP, seed) are backward compatible. |
Key takeaway: Only CSP deploy needs coordination — if CSP is rolled back before the FE rollback, the iframe may not load. Deploy order: CSP → seed → FE. Rollback order: FE → CSP.
Deploy Order
- CSP config — can deploy independently; verify no framers blocked
- Seed migration — deploy before backend code changes
- Frontend: deal index page — calls existing
/api/internal/v1/deals?room_id=(no backend change needed) - Frontend: ticket index page — calls existing
GET /v2.8/tickets?channel_integration_room_id=(no backend change needed) - Frontend: deal create new URL — reuses
DealsCreate*components - Frontend: security hardening — postMessage typed + restricted targetOrigin, error toast, cancel confirm, authError wiring
api_spec.yamlupdate + contract test run
Feature Flags
| Flag | Code | Default | Purpose |
|---|---|---|---|
embed_deal_sanitize | embed_deal_sanitize | OFF | Controls sanitization of embed params on deal create |
Rollback Strategy
| Condition | Trigger | Action |
|---|---|---|
| CSP blocks legitimate framer | Agent reports blank iframe | Revert CSP initializer; add missing origin and redeploy |
| Deal postMessage breaks parent | Parent handler failures | Roll back typed format in useEmbedDealCreate.ts |
| Ticket hardening breaks create | Ticket create fails | Revert targetOrigin change in useEmbedTicketCreate.ts |
Config Contract Table
| Config | File | Before | After |
|---|---|---|---|
| CSP frame-ancestors | crm-fe-v3 values-production.yaml | 'self' https://*.qontak.com http://localhost:* | Verify/update for parent origin |
| X-Frame-Options | crm-fe-v3 deploy/nginx/default.conf:28 | SAMEORIGIN | Remove if parent is cross-origin |
| Feature seed | qontak.com db/data/ (new) | Does not exist | Feature.find_or_create_by(code: 'embed_deal_sanitize') |
Test Plan
Backend:
bundle exec rspec spec/controllers/api/mobile/v2dot8/crm/deals_controller_spec.rb
bundle exec rspec spec/controllers/api/mobile/v2dot8/tickets_controller_spec.rb
bundle exec rspec spec/contract/ --format documentation
bundle exec rubocop app/controllers/api/mobile/v2dot8/crm/deals_controller.rb
bundle exec brakeman --no-pager
Frontend:
npx vue-tsc --noEmit
npx vitest run layers/embed-deal/
npx vitest run layers/embed-ticket/
Agent Execution Plan
| Order | Chunk | Repo | Files | Commands | Acceptance Criteria |
|---|---|---|---|---|---|
| 1 | CSP / XFO config | crm-fe-v3 | deploy/nginx/default.conf, deploy-alicloud/chart/values-*.yaml | Remove X-Frame-Options: SAMEORIGIN from nginx; verify frame-ancestors in Helm values covers parent origin | |
| 2 | Seed migration | qontak.com | db/data/20260701000001_add_feature_embed_deal_sanitize.rb | rake db:migrate:data | Feature.find_by(code: 'embed_deal_sanitize') is not nil |
| 3 | Deal index embed page | crm-fe-v3 | layers/embed-deal/pages/embed/deals/room/[roomId].vue, layers/embed-deal/components/EmbedDealIndexPage.vue | npx vue-tsc --noEmit | Calls GET /api/internal/v1/deals?room_id=:roomId; shows paginated deal list or empty state + "Create Deal" button |
| 4 | Ticket index embed page | crm-fe-v3 | layers/embed-ticket/pages/embed/tickets/room/[roomId].vue, layers/embed-ticket/components/EmbedTicketIndexPage.vue | npx vue-tsc --noEmit | Calls GET /api/mobile/v2.8/tickets?channel_integration_room_id=:roomId; shows ticket list or empty state + "Create Ticket" button |
| 5 | Deal create embed page (new URL) | crm-fe-v3 | layers/embed-deal/pages/embed/deals/new/index.vue, layers/embed-deal/components/EmbedDealNewPage.vue | npx vue-tsc --noEmit | Form renders with DealsCreate* components; creates deal; sends typed deal-created postMessage |
| 6 | Security hardening (both layers) | crm-fe-v3 | useEmbedDealCreate.ts, useEmbedDealAuth.ts, useEmbedTicketCreate.ts | npx vue-tsc --noEmit + npx vitest run | Error toast on deal create failure; cancel confirm when dirty; no '*' targetOrigin anywhere; typed deal postMessage; authError set on 401; submit blocked while propertiesLoading |
Ticket create page: No chunk needed. Already exists and is functional. Hardening applied as part of Chunk 6 (targetOrigin restriction only). Verify checklist in §2.8.
Detail 4.D — Verification & Rollback Recipe
Pre-merge verification commands:
npx vue-tsc --noEmit(crm-fe-v3)bundle exec rspec spec/controllers/api/mobile/v2dot8/crm/deals_controller_spec.rb spec/controllers/api/mobile/v2dot8/tickets_controller_spec.rbnpx vitest run layers/embed-deal/ layers/embed-ticket/bundle exec brakeman --no-pagerbundle exec rspec spec/contract/ --format documentation
Post-deploy verification signals:
curl -sI https://crm.qontak.com/embed/deals/room/abc123 | grep -i content-security-policy→ must includeframe-ancestors- Datadog:
embed_deal_createdandembed_ticket_createdevents appear within 24h of first agent test - Datadog Logs:
service:qontak.com @embed:truereturns results for embed-sourced creates Feature.find_by(code: 'embed_deal_sanitize').present?→ true in Rails console
Rollback recipe:
- If CSP breaks framers: revert
config/initializers/content_security_policy.rb→ redeploy → verify blank iframe gone - If deal postMessage breaks parent: revert
useEmbedDealCreate.tsto old format → coordinate parent app update - If ticket hardening breaks create: revert
useEmbedTicketCreate.tstargetOrigin change - Confirm error rate returns to baseline via
embed_deal_create_failedDatadog event
Verification After Deployment
- Open Omnichannel conversation → "Deals" tab → confirm deal index iframe loads
- Deal index empty state → click "Create Deal" → confirm navigation to new create page
- Fill deal form → save → confirm
deal-createdpostMessage received by parent - Cancel dirty form → confirm confirm dialog appears
- Verify no deal create call fired while propertiesLoading
- Open Omnichannel conversation → "Tickets" tab → confirm ticket index iframe loads
- Ticket index with no ticket → click "Create Ticket" → confirm ticket create page loads
- Fill ticket form → save → confirm
ticket-created+close-drawerpostMessage received - Inspect network: verify
Content-Security-Policy: frame-ancestorsheader on all embed responses - Open any embed page without token → confirm auth error component renders
5. Concern, Questions, or Known Limitations
Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| CSP blocks legitimate iframe consumers | Medium | Medium | Verify SPA values-production.yaml frame-ancestors allowlist covers parent origin before deploy; test with all embed consumers in staging |
| X-Frame-Options SAMEORIGIN blocks cross-origin parent | Low | High | Remove from deploy/nginx/default.conf:28 if parent app is cross-origin |
| Deal postMessage contract change breaks parent | Medium | High | Coordinate with parent app team; support both old and new format during transition window |
embed_deal_sanitize seed not run before code deploy | Low | Medium | Include in deploy checklist; add to CI rake db:migrate:data step |
Ticket EmbedTicketCreateSuccess.vue not wired | Low | Low | Verify during Chunk 6; wire if needed |
| Deal index stale after another agent creates | High (UX) | Low (data) | Document; add refresh button in follow-up |
Open Questions (Resolved)
OQ-1: Parent origin allowlist
Proposal (both — this is the approach):
?parent_origin=query param is the primary targetOrigin. Parent app passes its own origin.- Hardcoded
ALLOWED_POSTMESSAGE_ORIGINSfallback whenparent_originis absent:- Production:
['self', 'https://omnichannel.qontak.com'] - Staging:
['self', 'https://staging-omnichannel.qontak.com', 'https://omnichannel-staging.qontak.net'] - Development:
['self', 'http://localhost:*']
- Production:
- Parent app must validate
event.originon incoming messages (security best practice). - If
parent_originparam is absent AND origin not in allowlist → log warning but send to allowlist[0] (degrade gracefully, never'*').
Decision: ?parent_origin= param primary + env-specific fallback allowlist. Never '*'. Resolved — implement as described.
OQ-2: SPA layer CSP configuration
Action: Verify crm-fe-v3 infrastructure configs:
crm-fe-v3:deploy/nginx/default.conf:28—add_header X-Frame-Options "SAMEORIGIN" always;— remove if parent app is cross-origincrm-fe-v3:deploy-alicloud/chart/values-production.yaml:25—frame-ancestors 'self' https://*.qontak.com http://localhost:*— confirm parent origin is coveredcrm-fe-v3:deploy-alicloud/chart/values-staging.yaml— same check for staging
The embed pages are SPA routes. CSP for iframe protection must be served by the SPA infrastructure (CDN/nginx/K8s ingress), not by the Rails API. Rails content_security_policy.rb only controls API response headers and is irrelevant for iframe framing. No Rails config changes needed for CSP.
Decision: Resolved — update nginx X-Frame-Options if needed, verify Helm frame-ancestors allowlist covers parent origin. No Rails changes needed.
OQ-3: Embed token TTL
Proposal: Keep default 1-week TTL for now. Reasons:
- Embed token is stored in
qcrm_access_tokencookie (1-day expiry by the FE) — the cookie expires before the JWT does, effectively limiting the window. - Token is passed as
?token=query param over HTTPS — leakage risk is low. - Adding a shorter TTL requires either a new embed token endpoint or modifying the existing JWT encoder — over-engineering for MVP.
- If security audit flags this, add a dedicated embed token endpoint with 1-hour TTL.
Decision: Keep 1-week default. Cookie expiry (1 day) is the effective limit. Resolved.
OQ-4: embed_deal_sanitize default
Proposal: enabled_by_default: false — match embed_ticket_omnichannel pattern. Rationale:
- Sanitization is a safety net, but enabling it by default for all existing tenants may break workflows that rely on HTML-rich values in custom fields.
- Teams opt in by enabling the flag, which gives them time to test.
- If no issues surface after 2 quarters, the default can be flipped to
truein a follow-up change.
Decision: enabled_by_default: false. Resolved.
Known Limitations
- Deal index not real-time. Fetched on iframe load via
GET /deals?room_id=. Stale if another agent creates a deal. Close and re-open to refresh. No polling/websocket in this version. - Ticket index shows at most 1 ticket. Using
per_page=1limits results. If a room accumulates multiple tickets, increaseper_page— no backend change needed. Thechannel_integration_room_idfilter in AdvancedSearch supports arbitrary result counts. - No search/filter on deal index. Shows all deals for the room ordered by creation date only.
- No edit pages (out of scope). Deal and ticket edit pages removed from this RFC by product team decision.
- CSP allowlist is per-environment. Must be maintained in each environment config. Development may need broader allowlist.
- No automatic deal index refresh after create. After deal created in child iframe, parent must close and reopen deal index to see new deal. Out of scope for this RFC.
- No user-facing error on deal create failure (gap fix in Chunk 6). Currently
useEmbedDealCreate.ts:156-158only logs to console. Fixed in Chunk 6. - No cancel confirm on dirty deal form (gap fix in Chunk 6).
useEmbedDealCreate.ts:123-128closes immediately. Fixed in Chunk 6. - No API timeouts on any store call. If IAG proxy hangs, form freezes indefinitely. Out of scope for this RFC — acceptable for MVP.
- Sanitization skips non-string
crm_propertyvalues. Array and object values pass through unsanitized (deals_controller.rb:448). Acceptable for MVP since these are dropdown/structured data. - Sanitization only in controller. Callers bypassing controller get no sanitization. All embed calls route through controller — acceptable.
- 84 model callbacks fire on every embed deal create. Salesforce sync, WhatsApp notifications, automation checks all run. Existing behavior. No change, but document as performance baseline for high-volume embed.
- Embed token has no refresh mechanism. Token set once on page load from query param. If it expires during form fill, user must refresh the page.
EmbedTicketCreateSuccess.vueexists but not verified wired. Audit gap. Must verify during Chunk 6 ticket hardening.- Two deal create URLs coexist.
/embed/deals/createserves the oldEmbedDealCreatePage.vue(with gaps)./embed/deals/newserves the newEmbedDealNewPage.vue(with fixes). Old is fallback — no deprecation in this RFC. Parent app decides which URL to load.
6. Comment Logs
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-07-01 | RFC Author | Initial draft — deal + ticket edit scope. Scope reduced to index + create only after product team review. |
| 2026-07-01 | RFC Author | Ticket index switched from show_by_room to v2.8 index + channel_integration_room_id filter to match deal index pattern and support future multi-ticket rooms. |
| 2026-07-01 | RFC Author | Deal create: new wrapper at /embed/deals/new keeps old /embed/deals/create as fallback. Two URLs, one component each. |
7. Ready for Agent Execution
Status: YES — all open questions resolved, observability defined, rollout matrix documented.
Prerequisites
- ✅ OQ-1 resolved —
?parent_origin=param + env-specific allowlist, never'*' - ✅ OQ-2 resolved — SPA infra layer (nginx/Helm) handles CSP; verify
frame-ancestorsallowlist covers parent origin and removeX-Frame-Options: SAMEORIGINif parent is cross-origin - ✅ OQ-3 resolved — keep 1-week default; cookie expiry (1 day) is effective limit
- ✅ OQ-4 resolved —
enabled_by_default: false, match ticket pattern - Verify SPA
values-production.yamlframe-ancestorscovers parent Omnichannel app origin - Remove/update
X-Frame-Options: SAMEORIGINin nginxdefault.conf:28if parent is cross-origin - Parent Omnichannel app team confirms postMessage contract upgrade for deals (typed messages)
-
api_spec.yamlupdated (no new endpoints — only room_id filter on existing index; contract tests must pass) -
EmbedTicketCreateSuccess.vuewiring status verified before Chunk 6
Execution Summary
| Chunk | What | Owner | Effort |
|---|---|---|---|
| 1 | nginx XFO + Helm frame-ancestors config | Infra (crm-fe-v3) | 0.5 day |
| 2 | Seed migration (1 file) | BE (qontak.com) | 0.5 day |
| 3 | Deal index embed page (2 new files) | FE (crm-fe-v3) | 2 days |
| 4 | Ticket index embed page (2 new files) | FE (crm-fe-v3) | 2 days |
| 5 | Deal create embed page, new URL (2 new files) | FE (crm-fe-v3) | 3 days |
| 6 | Security hardening (3+ files, 7 gap fixes) | FE (crm-fe-v3) | 2 days |
| Total | ~10 days |
Zero new backend endpoints. All index filters already exist in Crm::AdvancedSearch. No new tables, no migrations.