Skip to main content

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 — reason when 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

FieldValueNotes
StatusIDEAIDEA / RFC / ABANDON / AGREED
Typefull-stack
Sub-typeenhancement
OwnerCRM squadTeam owning the RFC
Author(s)Engineering (RFC author)Primary author(s)
ReviewersTBDTech reviewers across affected squads
Approver(s)TBD (tech lead + infosec)Tech leaders + infosec approver
Submitted Date2026-07-01ISO-8601
Last Updated2026-07-01Bump on every material edit
Target Release2026-Q3
Related DocumentsPRD at ../prds/create-deals-tickets-while-viewing-chat.md; Anchor PRD: Embeddable Deal & Ticket Forms (Confluence)
DiscussionTBDSlack channel / thread URL

Type: full-stack Sub-type: enhancement

Sections at a Glance

  1. Overview (PRD Traceability + Design References + PRD-to-Schema Derivation + Per-Story Change Map)
  2. Technical Design (Infrastructure Topology → ADR Technical Decisions → Repo Reading Guide → Architecture → Sequence Diagrams → DB Model → APIs → UI Contract → Data-Fetching → Concurrency)
  3. High-Availability & Security (CSP, postMessage, sanitization, observability, failure catalog, accessibility)
  4. Backwards Compatibility and Rollout Plan (cross-layer matrix + deploy order + feature flags + agent execution plan + verification recipe)
  5. Concerns, Questions, or Known Limitations
  6. Comment Logs
  7. Ready for Agent Execution

Document Conventions

  • crm-fe-v3 — Nuxt 4 SPA repo
  • qontak.com — Rails API repo
  • postMessagewindow.parent.postMessage() calls from iframe to parent
  • CSP — Content Security Policy
  • IAG — Internal API Gateway. Embed pages call GET/POST /api/internal/v1/{object} — the Rails route aliases defined at routes.rb:111-129 that forward to the appropriate v2.7/v2.8 controllers. Deal embed uses buildIagUrl() (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 supports channel_integration_room_id filter via crm_channel_room_id_filter in Crm::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 supports room_id filter via crm_channel_room_id_filter in Crm::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):

PageURLStatus
Deal index embed/embed/deals/room/:room_idNew
Deal create embed/embed/deals/newNew — wrapper reusing DealsCreate* components; /embed/deals/create (old, unchanged) kept as fallback
Ticket index embed/embed/tickets/room/:room_idNew
Ticket create embed/embed/tickets/createAlready exists — harden only (typed postMessage, restricted targetOrigin)

Infrastructure in scope:

  • Update crm-fe-v3 nginx deploy/nginx/default.conf:28 — remove X-Frame-Options: SAMEORIGIN (if parent app is cross-origin)
  • Update crm-fe-v3 deploy-alicloud/chart/values-production.yaml — verify frame-ancestors allowlist includes the parent Omnichannel app origin

Backend in scope:

  • Seed migration for embed_deal_sanitize feature flag

Backend NOT needed (already exists):

  • Deal index room_id filter — already in Crm::AdvancedSearch#crm_channel_room_id_filter (advanced_search.rb:336), accessible via GET /api/internal/v1/deals?room_id=xxx
  • Ticket index channel_integration_room_id filter — already in Crm::AdvancedSearch tickets path, accessible via GET /api/internal/v1/tickets?channel_integration_room_id=xxx
  • Rails CSP frame-ancestors config — CSP for iframe protection is set at the SPA infrastructure layer (K8s ingress in values-production.yaml), not in Rails. Rails content_security_policy.rb is 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

CriterionMeasurable Outcome
Deal index loads latest deal for a roomGET /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 dealPOST returns 201, parent receives { type: 'deal-created', dealId, room_id, data } postMessage
Ticket index loads latest ticket for a roomGET /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 ticketPOST returns 201, parent receives { type: 'ticket-created', ticketId, data } postMessage
Auth failure shows error pageNo token or invalid token → EmbedDealAuthError / EmbedTicketAuthError component renders
Create failure shows toastuseEmbedDealCreate.ts:156-158 gap fixed — error toast shown on catch
Cancel confirm on dirty formuseEmbedDealCreate.ts:123-128 gap fixed — confirm dialog shown when isDirty
postMessage never uses '*'All postMessage calls use restricted targetOrigin
CSP frame-ancestors sentSPA response headers include Content-Security-Policy: frame-ancestors 'self' https://*.qontak.com set at K8s ingress level

Dependencies

DependencyDescriptionStatus
embed_deal_sanitize feature flagMust exist before sanitization runsNot created — seed migration needed
GET /api/internal/v1/deals?room_id=Deal index with room filter via AdvancedSearchAlready exists — no backend change
GET /api/mobile/v2.8/tickets?channel_integration_room_id=Ticket index with room filter via AdvancedSearchAlready exists — no backend change
crm-fe-v3 nginx XFO configdeploy/nginx/default.conf:28SAMEORIGIN may block cross-origin parentMust update if parent is cross-origin
crm-fe-v3 Helm frame-ancestorsvalues-production.yaml — current allowlist *.qontak.comVerify parent origin is covered

Assumptions

  1. FE embed pages call via IAG at /api/internal/v1/{object} paths (routes.rb:111-129). Deal embed uses buildIagUrl() (useEmbedDealStore.ts:39-43). Ticket embed uses useEmbedTicketApiInterceptor.ts to rewrite calls through IAG. IAG routes to the appropriate v2.7/v2.8 Rails controllers.
  2. Parent Omnichannel app passes parent_origin query param for postMessage targetOrigin.
  3. Parent Omnichannel app passes can_create=true|false based on agent's CRM create permission in user management. The embed page also checks user permission locally from GET /v2.8/users/me response — dual gate (both must pass).
  4. Existing JWT auth flow (token → qcrm_access_token cookie → Bearer header) works for all 4 pages.
  5. embed_deal_sanitize is checked in code (deals_controller.rb:422-462) but the feature record does not exist as a seed — it must be created.
  6. CSP for iframe protection is set at the SPA infrastructure layer (crm-fe-v3 K8s ingress), not in Rails. The existing values-production.yaml sets frame-ancestors 'self' https://*.qontak.com http://localhost:*. Verify the parent Omnichannel app origin is covered by this allowlist. The X-Frame-Options: SAMEORIGIN in deploy/nginx/default.conf:28 may need to be removed if the parent app is on a different origin.

Design References (frontend-specific)

PRD-named surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
Deal index embed pagen/a — design pendingn/a@mekari/pixel3 1.0.12-dev.0TBDNo dedicated Figma frame; embed widget uses pixel3 components
Deal create embed pageInbox Revamp — InfobarInbox Revamp Infobar@mekari/pixel3 1.0.12-dev.0Alma SyafiraForm components reused from full-app create page
Ticket index embed pagen/a — design pendingn/a@mekari/pixel3 1.0.12-dev.0TBDFollows same pattern as deal index
Ticket create embed pageInbox Revamp — InfobarInbox Revamp Infobar@mekari/pixel3 1.0.12-dev.0Alma SyafiraAlready 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 / rulePersisted asExposed viaEnforced wherePRD section
Deal associated with conversation roomcrm_deals.channel_integration_room_idGET /api/internal/v1/deals?room_id=Crm::AdvancedSearch#crm_channel_room_id_filter§6 S01
Ticket associated with conversation roomtickets.channel_integration_room_idGET /api/mobile/v2.8/tickets?channel_integration_room_id=Crm::AdvancedSearch#determine_ticket_params§6 S03
Deal creation from embed contextcrm_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 contexttickets.data_source = 'embed-web-chat'POST /api/mobile/v2.8/tickets (embed: true)tickets_controller.rb#create§6 S04
Embed param sanitizationN/A — controller-layer onlyN/Adeals_controller.rb:422-462 (feature-gated)§4 constraints
CSP frame-ancestors policyN/A — HTTP header onlycrm-fe-v3 K8s ingress (values-production.yaml)§4 security
Embed sanitize feature flagfeatures.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 RequirementRFC SectionComponent / file
Agents see deals for a conversation§2.8 Chunk 3, §2.6 APIEmbedDealIndexPage.vue, GET /api/internal/v1/deals?room_id=
Agents create deals from conversation§2.8 Chunk 5, §2.6 POST /dealsEmbedDealNewPage.vue, POST /api/mobile/v2.8/crm/deals
Agents see ticket for a conversation§2.8 Chunk 4, §2.6 APIEmbedTicketIndexPage.vue, GET /api/mobile/v2.8/tickets?channel_integration_room_id=
Agents create tickets from conversation§2.8 ticket create (existing), §2.6 POST /ticketsEmbedTicketCreatePage.vue (existing), POST /api/mobile/v2.8/tickets
Secure iframe embedding§2.1 ADR-6, §3 CSPcontent_security_policy.rb, ALLOWED_POSTMESSAGE_ORIGINS constant
Create failure feedback (CHG-003)§2.8 Chunk 6 gap fix #1useEmbedDealCreate.ts:156-158
Unsaved data protection (CHG-003)§2.8 Chunk 6 gap fix #2useEmbedDealCreate.ts:123-128

Reverse (RFC → PRD):

RFC decisionPRD requirement driving it
ADR-1: Ticket create harden onlyPRD ticket create flow (already shipped)
ADR-2: Deal index via existing index + room_idPRD S01 — agents see deals
ADR-3: Ticket index via existing index + channel_integration_room_idPRD S03 — agents see tickets
ADR-4: Deal create new wrapper at /embed/deals/newPRD S02 — agents create deals
ADR-5: Typed postMessagePRD CHG-003 — parent app receives structured events
ADR-6: CSP frame-ancestorsSecurity constraint (§4 PRD constraints)
Seed migration embed_deal_sanitizeSecurity hardening for embed params

PRD Section Coverage:

PRD sectionTitleRFC coverage
§1One-liner + Problem§1 Overview → Problem
§3Non-Goals§1 Scope → Out of scope
§4Constraints§1 Assumptions + §3 Security
§5 CHG-001Infobar tab structuren/a — parent app scope
§5 CHG-002Embedded form layout§2.3 Component Diagrams
§5 CHG-003Unsaved changes protection§2.8 Chunk 6 gap fixes #1 #2
§7Rollout§4 Rollout Plan
§8Observability§3 Observability
§9Success Metrics§1 Success Criteria

Detail 1.B — Decisions Closed

#DecisionChosen option§2 ADR blockAlternatives rejected
1Ticket create pageHarden only, don't rebuildADR-1Rebuild from scratch
2Deal index data sourceExisting v2.7 index + ?room_id=ADR-2New by_room endpoint
3Ticket index data sourceExisting v2.8 index + ?channel_integration_room_id=ADR-3Reuse show_by_room (1:1 only)
4Deal create pageNew wrapper at /embed/deals/new, old fallback at /embed/deals/createADR-4Patch 480-line EmbedDealCreatePage.vue
5postMessage contractTyped messages with type discriminantADR-5Keep existing ad-hoc format
6Clickjacking defenseCSP frame-ancestors + X-Frame-Options: DENY fallbackADR-6Keep deprecated ALLOW-FROM

Detail 1.C — Per-Story Change Map

StoryTitleLayer scopeChangesAcceptance criteria (verifiable)RFC anchors
S01Agents see deals for conversationFE + BE existingNew: EmbedDealIndexPage.vue, [roomId].vue (embed-deal layer). BE: no change — ?room_id= filter already in AdvancedSearchGET returns deal card or empty state; per_page=1 confirmed in response§2.8 Chunk 3, §2.6 API
S02Agents create deal from conversationFE + BE existingNew: EmbedDealNewPage.vue, embed/deals/new/index.vue. BE: no change — existing POST endpoint. Chunk 6: error toast, cancel confirm, typed postMessagePOST 201, parent receives { type: 'deal-created', dealId, room_id }§2.8 Chunk 5+6, §2.7
S03Agents see ticket for conversationFE + BE existingNew: EmbedTicketIndexPage.vue, [roomId].vue (embed-ticket layer). BE: no changeGET returns ticket card or empty state; per_page=1 confirmed§2.8 Chunk 4, §2.6 API
S04Agents create ticket from conversationFE existing (harden only)Existing create.vue + useEmbedTicketCreate.ts — targetOrigin restriction onlyPOST 201, parent receives { type: 'ticket-created', ticketId }§2.8 Chunk 6, §2.7
S05CSP / security hardeningConfig + FE4 config file edits, 1 seed migration, postMessage hardeningframe-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
ServiceResponsibility
Parent Omnichannel AppOpens iframe, passes token + room_id + parent_origin, listens for postMessage events
crm-fe-v3 SPARenders all 4 embed pages, handles auth, form state, postMessage
IAGProxies /api/mobile/v2.8/* to qontak.com, handles CORS
Rails APIServes deal/ticket data, enforces auth, sanitizes embed params. CSP headers set at SPA infra layer (K8s ingress), not in Rails.
PostgresStores crm_deals with channel_integration_room_id, tickets with room association
CDNServes 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

FieldValue
Contextlayers/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.
Options1) Rebuild from scratch matching deal create pattern. 2) Verify, document contract, apply security hardening only.
DecisionHarden only.
RationaleWorking code is working code. Rebuilding introduces regression risk. Security hardening (typed postMessage, restricted targetOrigin) is additive.
ConsequencespostMessage 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).
ReversibilityN/A — hardening is backward compatible.

ADR-2: Deal index — extend existing index with room_id filter

FieldValue
ContextDeal index page needs to show deals for a room. A room can have multiple deals.
Options1) New by_room/:room_id endpoint. 2) Extend existing GET /v2.8/crm/deals index with optional ?room_id= param.
DecisionExtend existing index with ?room_id= filter param.
RationaleReusing 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.
ConsequencesExisting 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/dealsapi/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.
ReversibilityTrivial — 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)

FieldValue
ContextGET /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.
Options1) 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.
DecisionUse v2.8 index with ?channel_integration_room_id= filter param — same pattern as deal index.
RationaleConsistent 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.
ConsequencesTicket 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.
ReversibilityTrivial — remove query param.

ADR-4: Deal create — new wrapper component at new URL, old component kept as fallback

FieldValue
Contextlayers/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.
Options1) 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.
DecisionNew wrapper EmbedDealNewPage.vue at /embed/deals/new. Old EmbedDealCreatePage.vue at /embed/deals/create kept as fallback.
RationaleThe 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.
ConsequencesTwo 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 referencesNew 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.
ReversibilityHigh — old component unchanged, just add new wrapper. Remove wrapper anytime.

ADR-5: postMessage contract — typed messages

FieldValue
ContextExisting deal create page sends { embed: false, msg: 'Deal closed', response_data: {} } with targetOrigin: '*'. Ticket create already sends typed { type: 'ticket-created', ... }.
Options1) Upgrade deal postMessage to typed format. 2) Keep existing ad-hoc format.
DecisionTyped messages with type discriminant for deal embed. Ticket embed already typed — verify unchanged.
RationaleParent app needs to discriminate message types reliably. Ticket format is already correct. Deal format needs upgrade for consistency.
ConsequencesExisting 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' }.
ReversibilityLow — once parent updates handlers, old format is dead.

ADR-6: CSP frame-ancestors — update SPA infrastructure layer

FieldValue
ContextCSP 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.
Options1) 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).
DecisionUpdate SPA infrastructure only. Rails CSP is irrelevant for iframe protection.
RationaleX-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.
ConsequencesIf 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.
ReversibilityTrivial — 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):

AnchorPathWhat It Teaches
DealsController v2.8app/controllers/api/mobile/v2dot8/crm/deals_controller.rbALLOWED_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-82Uses 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_roomconfig/routes.rb:966get 'by_room/:room_id', to: 'tickets#show_by_room'
Routes — deals collectionconfig/routes.rb:1102resources :deals with collection actions in v2.8 namespace
X-Frame-Options prodconfig/environments/production.rb:114ALLOW-FROM: http://dev.indigo.id — to replace
CSP initializerconfig/initializers/content_security_policy.rbEntirely commented out — to enable
Seed migration (ticket)db/data/20260505000001_add_feature_embed_ticket_omnichannel.rbFeature.find_or_create_by(name: ..., code: ..., enabled_by_default: false) — pattern for new seed

Frontend (crm-fe-v3):

AnchorPathWhat It Teaches
useEmbedDealCreate.tslayers/embed-deal/composables/useEmbedDealCreate.tsGaps at lines 156-158 (no error toast), 123-128 (no cancel confirm), untyped postMessage
useEmbedDealAuth.tslayers/embed-deal/composables/useEmbedDealAuth.tsToken → cookie → axios header pattern; authError flag gap
useEmbedDealStore.tslayers/embed-deal/stores/useEmbedDealStore.tsIAG URL pattern, embed: true, offline: false, room_id, hub params
useEmbedTicketCreate.tslayers/embed-ticket/composables/useEmbedTicketCreate.tsAlready typed: { type: 'ticket-created', ticketId, data }, { type: 'ticket-cancel' }, { type: 'close-drawer' }
useEmbedTicketAuth.tslayers/embed-ticket/composables/useEmbedTicketAuth.tsCookie chain: global_sso_token → qcrm_access_token → crm_sso_token → query param

Reading Order for Agent

  1. tickets_controller.rb:25-82 — understand the ticket index action: uses Crm::AdvancedSearch, accepts filter params via filter_query_params, returns paginated response. The crm_channel_room_id_filter at search_parameter.rb:2474-2476 maps params[:room_id] to Elasticsearch conditions.
  2. deals_controller.rb (v2.8) — understand existing embed + sanitize
  3. 20260505000001_*.rb — seed migration pattern
  4. production.rb:114 + content_security_policy.rb — CSP changes
  5. useEmbedDealAuth.ts + useEmbedDealCreate.ts — deal frontend patterns
  6. useEmbedTicketCreate.ts + useEmbedTicketAuth.ts — ticket frontend patterns (reference for hardening)
  7. components/Deals/Form/Create/DealsCreate*.vue — form components to reuse

Source Verification

ClaimEvidence
ALLOWED_EMBED_SOURCES at line 8deals_controller.rb:8%w[omnichannel cdp true]
sanitize_embed_params before_action at line 15deals_controller.rb:15
hub_params at line 408deals_controller.rb:408-420
embed_request? at line 460deals_controller.rb:460-462
embed_sanitize_enabled? at line 422deals_controller.rb:422-424
Tickets index actiontickets_controller.rb:25-82 — uses Crm::AdvancedSearch, accepts channel_integration_room_id via crm_channel_room_id_filter
Tickets crm_channel_room_id_filtersearch_parameter.rb:2474-2476 — merges room_id param into ES conditions
Deals AdvancedSearch calls crm_channel_room_id_filteradvanced_search.rb:336default_deal_params calls crm_channel_room_id_filter(params)
Tickets AdvancedSearch calls crm_channel_room_id_filteradvanced_search.rb:1457determine_ticket_params calls crm_channel_room_id_filter(params)
Deal index route (v2.7 internal)routes.rb:124GET /api/internal/v1/dealsv2dot7/crm/deals#index
XFO config line 114production.rb:114
CSP initializer commented outcontent_security_policy.rb:1-25
Seed pattern20260505000001_add_feature_embed_ticket_omnichannel.rb:3
Routes deals block line 1102routes.rb:1102-1119
Routes tickets by_room line 966routes.rb:966
useEmbedDealCreate.ts create failure gapuseEmbedDealCreate.ts:156-158console.error only
useEmbedDealCreate.ts cancel gapuseEmbedDealCreate.ts:123-128 — no confirm dialog
useEmbedTicketCreate.ts postMessage formatuseEmbedTicketCreate.ts — already typed with type field
Ticket create page existslayers/embed-ticket/pages/embed/tickets/create.vue — 30 lines
Deal create page existslayers/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 parentOrigin query 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 browser
  • IAG — Internal API Gateway that proxies /api/mobile/ requests to the Rails backend
  • API — the qontak.com Rails API running on the server
  • DB — 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

MethodPathStatusNotes
GET/api/internal/v1/dealsExtendedOptional ?room_id= filter. Routes to v2.7 deals#index (routes.rb:124).
GET/api/internal/v1/ticketsExtendedOptional ?channel_integration_room_id= filter. Routes to v2.8 tickets#index (routes.rb:111).
POST/api/internal/v1/dealsReusedExisting create with embed support. Routes to v2.8 crm/deals#create.
POST/api/internal/v1/ticketsReusedExisting create with embed support. Routes to v2.8 tickets#create.
GET/api/internal/v1/deals/pipelinesReusedPipeline 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:124v2dot7/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:111v2dot8/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):

MessagePayloadTrigger
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):

MessagePayloadTrigger
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.rb and config/environments/{production,staging,development}.rb X-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 :roomId from route params
  • Auth via useEmbedDealAuth()
  • Reads can_create from query param (getStringQuery(route, 'can_create'))
  • Fetches GET /api/internal/v1/deals?room_id=:roomId&per_page=1&order_by=created_at&order_dir=desc via 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:

  1. Parent gate: ?can_create=true/false query param. Parent app sets this based on agent's CRM create permission in user management. This is the primary gate.
  2. Local gate: On mount, check the user's permission data from the existing GET /api/mobile/v2.8/users/me response. The back-end returns user.permission.deal (and ticket) objects. The FE checks a create field 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 :roomId from route params
  • Auth via useEmbedTicketAuth() + optional useEmbedTicketApiInterceptor (when ?use_iag=true)
  • Reads can_create from query param
  • Fetches GET /api/internal/v1/tickets?channel_integration_room_id=:roomId&per_page=1&order_by=created_at&order_dir=desc via 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/new
  • layers/embed-deal/components/EmbedDealNewPage.vue — new thin wrapper component

Old page stays unchanged at /embed/deals/createEmbedDealCreatePage.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:

#GapLocationFix
1Create failure no toastuseEmbedDealCreate.ts:156-158Add toast.error('Failed to create deal. Please try again.') in catch block
2No cancel confirmuseEmbedDealCreate.ts:123-128Show confirm dialog when isDirty before calling cancel postMessage
3Race on submit while propertiesLoadinghandleSubmitGuard: if (propertiesLoading.value) return
4authError never triggered from real 401useEmbedDealAuth.ts:38-40Wire 401 axios interceptor to set authError.value = true
5Deal postMessage uses '*' targetOriginAll window.parent.postMessage callsReplace with targetOrigin = parentOrigin ?? ALLOWED_ORIGINS[0]
6Deal postMessage untyped formatuseEmbedDealCreate.tsUpgrade to typed { type: 'deal-created', dealId, room_id, data }
7Ticket postMessage uses '*'useEmbedTicketCreate.tsSame 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 is embed-ticket, auth gate is useEmbedTicketAuth
  • useEmbedTicketCreate.ts — confirm postMessage payloads match documented contract: ticket-created, ticket-cancel, close-drawer
  • useEmbedTicketCreate.ts — confirm window.parent.postMessage calls updated to restricted targetOrigin (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, includes channel_integration_room_id, data_source = 'omnichannel')
  • INSERT INTO crm_properties (deal custom fields, via deal.save! on has_many :crm_properties)
  • INSERT INTO crm_products_deals (product associations, via crm_products_deals_attributes=)
  • INSERT INTO crm_company_deals (company associations, via crm_company_ids=)
  • INSERT INTO crm_contact_deals (contact associations, via crm_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).

ConcernMitigation
Double-submit (deal create)Disable Save button on first click via isSaving flag. Server-side: wrapped in ActiveRecord transaction — no partial writes
Room-based concurrencyMultiple agents on same room see same data via GET; each has own iframe session
Deal index freshnessFetched on iframe load. Stale if another agent creates a deal. Acceptable: close and re-open to refresh
Ticket 1:1Per-page=1 on index returns latest ticket; no concurrency concern on index
Submit while propertiesLoadingBlocked via if (propertiesLoading.value) return in handleSubmit (Chunk 6 gap fix #3)
POST /deals duplicate on network timeoutClient 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

ContractFrontend ExpectsBackend DeliversVerification
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 AdvancedSearchExisting tests pass; uses per_page param
POST /deals embed201 { deal: {...} }sanitize_embed_params + hub_params + CreateServiceExisting (reused)
POST /tickets embed201 { ticket: {...} }Existing embed supportExisting (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 codeembed_deal_sanitizeFeature.find_by(code: 'embed_deal_sanitize')Seed migration creates it
CSP headerN/AContent-Security-Policy: frame-ancestors ...Integration test
parentOrigin restrictionAll postMessage calls use non-'*' targetOriginN/ACode 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 h2 for "Deals", card links have aria-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: EmbedDealAuthError if not authenticated; form blocked if propertiesLoading
  • 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

SurfaceLoadingEmptyErrorPartialSuccess
Deal indexSkeleton card"No deals yet." + Create button (if permitted)Auth error component / retry buttonN/ASingle deal card (name, stage, amount)
Ticket indexSkeleton card"No ticket for this conversation." + Create button (if permitted)Auth error component / retry buttonN/ASingle ticket card (name, stage, status)
Deal create formPipeline/properties loading spinnerN/A (form always shown)Auth error; API error → toastDirty (unsaved changes)postMessage + close
Ticket create formForm skeleton (existing)N/AAuth error (existing)N/ApostMessage + close (existing)

Detail 2.D — Scope Boundaries

Files to create:

  • layers/embed-deal/pages/embed/deals/room/[roomId].vue
  • layers/embed-deal/components/EmbedDealIndexPage.vue
  • layers/embed-deal/pages/embed/deals/new/index.vue
  • layers/embed-deal/components/EmbedDealNewPage.vue
  • layers/embed-ticket/pages/embed/tickets/room/[roomId].vue
  • layers/embed-ticket/components/EmbedTicketIndexPage.vue
  • db/data/20260701000001_add_feature_embed_deal_sanitize.rb

Files to modify:

  • config/initializers/content_security_policy.rb
  • config/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

EntityState field consumedDefaultSource endpointStale 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 permissionuser.permission.deal.create, user.permission.ticket?.createtrue (if absent)GET /api/mobile/v2.8/users/me (already called by useEmbedDealStore.ts:66)Session lifetime
Form dirty stateisDirty: booleanfalseComputed from form values vs initial snapshotN/A — reactive

3. High-Availability & Security

Performance Requirement

MetricTargetRequests (7d)AvgP95
Deal index API time< 5s194k2.79s3.79s
Deal create (v2.8 embed) API time< 8s3.97k2.60s6.73s
Ticket index API time< 3s26.1k0.91s2.04s
Ticket create API time< 8s2.29k2.27s6.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_mounted count per page type (daily bar chart)
  • Panel 2: embed_deal_created vs embed_deal_create_failed ratio (timeseries)
  • Panel 3: embed_ticket_created vs embed_ticket_create_failed ratio (timeseries)
  • Panel 4: P95 duration of GET /api/internal/v1/deals?room_id= and POST /api/mobile/v2.8/crm/deals filtered by @embed:true (timeseries)

Alerts:

  • embed_deal_create_failed rate > 5% in any 1-hour window → notify #crm-alerts (P2)
  • embed_ticket_create_failed rate > 5% in any 1-hour window → notify #crm-alerts (P2)
  • Zero embed_page_mounted events 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:

FieldValueExample
@embedtrueLogged by deals_controller.rb when embed_request? is true
@embed_page_typedeal_index / deal_create / ticket_index / ticket_createIdentifies which embed page made the call
@embed_duration_msIntegerTime from page mount to success/failure

Queriable in Datadog Logs: service:qontak.com @embed:true @embed_page_type:deal_index @status:error

Clickjacking Defense

LayerMechanismLocationPriority
CSPframe-ancestors 'self' https://*.qontak.com http://localhost:*crm-fe-v3 values-production.yaml (K8s ingress)Primary
X-Frame-OptionsSAMEORIGINcrm-fe-v3 deploy/nginx/default.conf:28Must be removed if parent is cross-origin
VerificationCurl SPA page headers for CSP frame-ancestorsCI 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

  • parentOrigin query param = primary target origin.
  • If missing, fall back to ALLOWED_POSTMESSAGE_ORIGINS constant.
  • Never '*' as targetOrigin.
  • Parent app should validate event.origin on 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 embed param against ALLOWED_EMBED_SOURCES
  • Gated by embed_deal_sanitize feature 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_token cookie
  • Sent as Authorization: Bearer header
  • 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).

EventTriggerPropertiesPurpose
embed_page_mountedAny embed page mountspage_type (deal_index/deal_create/ticket_index/ticket_create), room_idTracks adoption per page type
embed_auth_failedAuth token missing or invalidpage_type, room_idTracks auth failure rate — high rate = parent app token issue
embed_deal_createdPOST /deals returns 201deal_id, room_id, duration_ms (time from mount to success)Funnel completion — deal create
embed_deal_create_failedPOST /deals returns errorroom_id, error_type (validation/network/timeout), error_messageTracks create failure rate — alert if >5%
embed_ticket_createdPOST /tickets returns 201ticket_id, room_id, duration_msFunnel completion — ticket create
embed_ticket_create_failedPOST /tickets returns errorroom_id, error_typeTracks create failure rate
embed_unsaved_changes_warningUnsaved changes dialog shownpage_type, room_id, exit_path (close/tab_switch/beforeunload)Tracks protection usage — decreasing rate = user adaptation
embed_create_cancelledCancel confirmed (dirty or clean)page_type, room_id, had_unsaved_changesTracks form abandonment
embed_permission_deniedcan_create=false or local permission blockspage_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_created event count per day in the existing CRM product dashboard. Filter by page_type to distinguish embed from normal creation.
  • Alert: If embed_deal_create_failed rate 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: true in the request — queryable in Datadog Logs: service:qontak.com @embed:true.

Failure Mode Catalog

FailureTriggerFrontend BehaviorBackend Response
Auth failureNo token / invalid / expiredEmbedDealAuthError or EmbedTicketAuthError401/403
Token expires during form fill60+ min idleSilent 401 on submit — no refresh401
Deal index load errorAPI downError state with retry500 or timeout
Deal create validationMissing required fieldsClient-side errors (existing)422
Deal create API error (gap fix #1)Backend 422/500Toast: "Failed to create deal. Please try again."422/500
Submit while propertiesLoading (gap fix #3)Rapid pipeline switch + saveButton disabled, submit blockedN/A
Ticket index 404No ticket for roomEmpty state + "Create Ticket" link404
Ticket create validationMissing fieldsForm errors (existing)422
authError never triggered (gap fix #4)Real 401 from APINow sets authError = true via interceptor401
CSP blockBrowser blocks iframeBlank iframeNo response
postMessage blockedParent removedCaught in try/catchN/A

Error Message Catalog

ScenarioUser MessageLog Level
Auth failed"Authentication Failed. Invalid or expired token."warn
Token expired during fillNo 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 successpostMessage to parentinfo
Deal create failure (gap fix)"Failed to create deal. Please try again."error
Ticket create successpostMessage to parentinfo
Feature not enabled"Feature not enabled"info
Network error"Failed to save. Please try again."error

Detail 3.B — Rate Limiting & Throttling

Endpoint / consumerRate limitBurstThrottle responseMonitoring
GET /api/internal/v1/dealsExisting global Rails rate limitExisting429 Too Many RequestsDatadog existing monitor
GET /api/mobile/v2.8/ticketsExisting global Rails rate limitExisting429Datadog existing monitor
POST /api/mobile/v2.8/crm/dealsExisting global Rails rate limitExisting422 + isSaving client guardDatadog existing monitor
POST /api/mobile/v2.8/ticketsExisting global Rails rate limitExisting422Datadog 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 descriptive aria-label including record name.
  • Color contrast: Inherits from @mekari/pixel3 design system (AA compliant by default).
  • prefers-reduced-motion: @mekari/pixel3 components respect this setting. No custom animations added in this RFC.

4. Backwards Compatibility & Rollout Plan

Compatibility

ChangeImpactMitigation
Deal index via /api/internal/v1/deals?room_id=None — optional param; existing callers omit itN/A
Ticket index via GET /v2.8/tickets?channel_integration_room_id=None — optional param; existing callers omit itN/A
CSP frame-ancestorsMay block unlisted framersVerify Helm frame-ancestors allowlist covers parent origin before deploy
X-Frame-Options SAMEORIGINBlocks cross-origin parentRemove from nginx default.conf:28 if parent is cross-origin
Deal postMessage contract changeExisting parent that parses { embed, msg, response_data } breaksCoordinate parent app update alongside deploy
embed_deal_sanitize seedNo impact — feature flag OFF by defaultOpt-in per team
Ticket create page targetOrigin hardeningExisting 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.

ScenarioFE versionBE versionWorks?Notes
Pre-deploy (baseline)OldOldExisting behavior, no embed pages
BE deploys firstOldNewCSP + seed migration are transparent to old FE. Index filters are optional params — old FE doesn't send them, works identically.
FE deploys firstNewOldIndex 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 deployNewNewTarget state — CSP protects framing, filters work
Rollback BE firstNewOldCSP 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 firstOldNewOld 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

  1. CSP config — can deploy independently; verify no framers blocked
  2. Seed migration — deploy before backend code changes
  3. Frontend: deal index page — calls existing /api/internal/v1/deals?room_id= (no backend change needed)
  4. Frontend: ticket index page — calls existing GET /v2.8/tickets?channel_integration_room_id= (no backend change needed)
  5. Frontend: deal create new URL — reuses DealsCreate* components
  6. Frontend: security hardening — postMessage typed + restricted targetOrigin, error toast, cancel confirm, authError wiring
  7. api_spec.yaml update + contract test run

Feature Flags

FlagCodeDefaultPurpose
embed_deal_sanitizeembed_deal_sanitizeOFFControls sanitization of embed params on deal create

Rollback Strategy

ConditionTriggerAction
CSP blocks legitimate framerAgent reports blank iframeRevert CSP initializer; add missing origin and redeploy
Deal postMessage breaks parentParent handler failuresRoll back typed format in useEmbedDealCreate.ts
Ticket hardening breaks createTicket create failsRevert targetOrigin change in useEmbedTicketCreate.ts

Config Contract Table

ConfigFileBeforeAfter
CSP frame-ancestorscrm-fe-v3 values-production.yaml'self' https://*.qontak.com http://localhost:*Verify/update for parent origin
X-Frame-Optionscrm-fe-v3 deploy/nginx/default.conf:28SAMEORIGINRemove if parent is cross-origin
Feature seedqontak.com db/data/ (new)Does not existFeature.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

OrderChunkRepoFilesCommandsAcceptance Criteria
1CSP / XFO configcrm-fe-v3deploy/nginx/default.conf, deploy-alicloud/chart/values-*.yamlRemove X-Frame-Options: SAMEORIGIN from nginx; verify frame-ancestors in Helm values covers parent origin
2Seed migrationqontak.comdb/data/20260701000001_add_feature_embed_deal_sanitize.rbrake db:migrate:dataFeature.find_by(code: 'embed_deal_sanitize') is not nil
3Deal index embed pagecrm-fe-v3layers/embed-deal/pages/embed/deals/room/[roomId].vue, layers/embed-deal/components/EmbedDealIndexPage.vuenpx vue-tsc --noEmitCalls GET /api/internal/v1/deals?room_id=:roomId; shows paginated deal list or empty state + "Create Deal" button
4Ticket index embed pagecrm-fe-v3layers/embed-ticket/pages/embed/tickets/room/[roomId].vue, layers/embed-ticket/components/EmbedTicketIndexPage.vuenpx vue-tsc --noEmitCalls GET /api/mobile/v2.8/tickets?channel_integration_room_id=:roomId; shows ticket list or empty state + "Create Ticket" button
5Deal create embed page (new URL)crm-fe-v3layers/embed-deal/pages/embed/deals/new/index.vue, layers/embed-deal/components/EmbedDealNewPage.vuenpx vue-tsc --noEmitForm renders with DealsCreate* components; creates deal; sends typed deal-created postMessage
6Security hardening (both layers)crm-fe-v3useEmbedDealCreate.ts, useEmbedDealAuth.ts, useEmbedTicketCreate.tsnpx vue-tsc --noEmit + npx vitest runError 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:

  1. npx vue-tsc --noEmit (crm-fe-v3)
  2. bundle exec rspec spec/controllers/api/mobile/v2dot8/crm/deals_controller_spec.rb spec/controllers/api/mobile/v2dot8/tickets_controller_spec.rb
  3. npx vitest run layers/embed-deal/ layers/embed-ticket/
  4. bundle exec brakeman --no-pager
  5. bundle 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 include frame-ancestors
  • Datadog: embed_deal_created and embed_ticket_created events appear within 24h of first agent test
  • Datadog Logs: service:qontak.com @embed:true returns results for embed-sourced creates
  • Feature.find_by(code: 'embed_deal_sanitize').present? → true in Rails console

Rollback recipe:

  1. If CSP breaks framers: revert config/initializers/content_security_policy.rb → redeploy → verify blank iframe gone
  2. If deal postMessage breaks parent: revert useEmbedDealCreate.ts to old format → coordinate parent app update
  3. If ticket hardening breaks create: revert useEmbedTicketCreate.ts targetOrigin change
  4. Confirm error rate returns to baseline via embed_deal_create_failed Datadog event

Verification After Deployment

  1. Open Omnichannel conversation → "Deals" tab → confirm deal index iframe loads
  2. Deal index empty state → click "Create Deal" → confirm navigation to new create page
  3. Fill deal form → save → confirm deal-created postMessage received by parent
  4. Cancel dirty form → confirm confirm dialog appears
  5. Verify no deal create call fired while propertiesLoading
  6. Open Omnichannel conversation → "Tickets" tab → confirm ticket index iframe loads
  7. Ticket index with no ticket → click "Create Ticket" → confirm ticket create page loads
  8. Fill ticket form → save → confirm ticket-created + close-drawer postMessage received
  9. Inspect network: verify Content-Security-Policy: frame-ancestors header on all embed responses
  10. Open any embed page without token → confirm auth error component renders

5. Concern, Questions, or Known Limitations

Risks & Mitigations

RiskLikelihoodImpactMitigation
CSP blocks legitimate iframe consumersMediumMediumVerify 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 parentLowHighRemove from deploy/nginx/default.conf:28 if parent app is cross-origin
Deal postMessage contract change breaks parentMediumHighCoordinate with parent app team; support both old and new format during transition window
embed_deal_sanitize seed not run before code deployLowMediumInclude in deploy checklist; add to CI rake db:migrate:data step
Ticket EmbedTicketCreateSuccess.vue not wiredLowLowVerify during Chunk 6; wire if needed
Deal index stale after another agent createsHigh (UX)Low (data)Document; add refresh button in follow-up

Open Questions (Resolved)

OQ-1: Parent origin allowlist

Proposal (both — this is the approach):

  1. ?parent_origin= query param is the primary targetOrigin. Parent app passes its own origin.
  2. Hardcoded ALLOWED_POSTMESSAGE_ORIGINS fallback when parent_origin is absent:
    • Production: ['self', 'https://omnichannel.qontak.com']
    • Staging: ['self', 'https://staging-omnichannel.qontak.com', 'https://omnichannel-staging.qontak.net']
    • Development: ['self', 'http://localhost:*']
  3. Parent app must validate event.origin on incoming messages (security best practice).
  4. If parent_origin param 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:28add_header X-Frame-Options "SAMEORIGIN" always; — remove if parent app is cross-origin
  • crm-fe-v3:deploy-alicloud/chart/values-production.yaml:25frame-ancestors 'self' https://*.qontak.com http://localhost:* — confirm parent origin is covered
  • crm-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_token cookie (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 true in a follow-up change.

Decision: enabled_by_default: false. Resolved.

Known Limitations

  1. 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.
  2. Ticket index shows at most 1 ticket. Using per_page=1 limits results. If a room accumulates multiple tickets, increase per_page — no backend change needed. The channel_integration_room_id filter in AdvancedSearch supports arbitrary result counts.
  3. No search/filter on deal index. Shows all deals for the room ordered by creation date only.
  4. No edit pages (out of scope). Deal and ticket edit pages removed from this RFC by product team decision.
  5. CSP allowlist is per-environment. Must be maintained in each environment config. Development may need broader allowlist.
  6. 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.
  7. No user-facing error on deal create failure (gap fix in Chunk 6). Currently useEmbedDealCreate.ts:156-158 only logs to console. Fixed in Chunk 6.
  8. No cancel confirm on dirty deal form (gap fix in Chunk 6). useEmbedDealCreate.ts:123-128 closes immediately. Fixed in Chunk 6.
  9. No API timeouts on any store call. If IAG proxy hangs, form freezes indefinitely. Out of scope for this RFC — acceptable for MVP.
  10. Sanitization skips non-string crm_property values. Array and object values pass through unsanitized (deals_controller.rb:448). Acceptable for MVP since these are dropdown/structured data.
  11. Sanitization only in controller. Callers bypassing controller get no sanitization. All embed calls route through controller — acceptable.
  12. 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.
  13. 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.
  14. EmbedTicketCreateSuccess.vue exists but not verified wired. Audit gap. Must verify during Chunk 6 ticket hardening.
  15. Two deal create URLs coexist. /embed/deals/create serves the old EmbedDealCreatePage.vue (with gaps). /embed/deals/new serves the new EmbedDealNewPage.vue (with fixes). Old is fallback — no deprecation in this RFC. Parent app decides which URL to load.

6. Comment Logs

DateComment(s) FromAction Item(s)
2026-07-01RFC AuthorInitial draft — deal + ticket edit scope. Scope reduced to index + create only after product team review.
2026-07-01RFC AuthorTicket 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-01RFC AuthorDeal 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

  1. ✅ OQ-1 resolved — ?parent_origin= param + env-specific allowlist, never '*'
  2. ✅ OQ-2 resolved — SPA infra layer (nginx/Helm) handles CSP; verify frame-ancestors allowlist covers parent origin and remove X-Frame-Options: SAMEORIGIN if parent is cross-origin
  3. ✅ OQ-3 resolved — keep 1-week default; cookie expiry (1 day) is effective limit
  4. ✅ OQ-4 resolved — enabled_by_default: false, match ticket pattern
  5. Verify SPA values-production.yaml frame-ancestors covers parent Omnichannel app origin
  6. Remove/update X-Frame-Options: SAMEORIGIN in nginx default.conf:28 if parent is cross-origin
  7. Parent Omnichannel app team confirms postMessage contract upgrade for deals (typed messages)
  8. api_spec.yaml updated (no new endpoints — only room_id filter on existing index; contract tests must pass)
  9. EmbedTicketCreateSuccess.vue wiring status verified before Chunk 6

Execution Summary

ChunkWhatOwnerEffort
1nginx XFO + Helm frame-ancestors configInfra (crm-fe-v3)0.5 day
2Seed migration (1 file)BE (qontak.com)0.5 day
3Deal index embed page (2 new files)FE (crm-fe-v3)2 days
4Ticket index embed page (2 new files)FE (crm-fe-v3)2 days
5Deal create embed page, new URL (2 new files)FE (crm-fe-v3)3 days
6Security 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.