Task Breakdown: Embeddable Deal & Ticket Widgets — Index + Deal Create + Ticket Create
Generated from RFC:
embed-deal-ticket-index-deal-create-ticket-create-rfc.mdSlicing: Vertical — one task per page/surface + infra + security hardening
Effort Summary
| Task | Effort |
|---|---|
| Task 1 — SPA CSP / X-Frame-Options config | 0.5 day |
Task 2 — Seed migration embed_deal_sanitize | 0.5 day |
| Task 3 — Deal index embed page | 2 days |
| Task 4 — Ticket index embed page | 1.5 days |
| Task 5 — Deal create embed page (new wrapper) | 2.5 days |
| Task 6 — Security hardening (both layers) | 2 days |
| Total | ~9 days |
Confidence: medium. Key assumptions: FE pattern established from existing embed-deal/embed-ticket layers; BE no change needed (filters already exist). Unknown: parent app origin for CSP allowlist — must confirm with Omnichannel team before Task 1.
Pre-requisites (coordination)
| # | Task | Owner | Detail |
|---|---|---|---|
| P1 | Confirm parent app origin for CSP frame-ancestors | Infosec + Omnichannel | *.qontak.com may cover — verify before Task 1 |
| P2 | Confirm postMessage contract with parent app | FE + Omnichannel | Parent must handle typed deal-created / deal-cancel / form-dirty |
| P3 | Verify EmbedTicketCreateSuccess.vue is wired into ticket create flow | FE | Existing gap — confirm before Task 6 |
Task 1: [BE] SPA CSP / X-Frame-Options Config (S05)
Infra agents can load CRM embed pages inside an Omnichannel iframe without being blocked by
X-Frame-Options: SAMEORIGIN.
Status: ⚠️ Partially blocked — need to confirm parent app origin with Omnichannel before finalizing the frame-ancestors allowlist (P1).
Design reference: n/a — infra config only.
Purpose
deploy/nginx/default.conf:28 currently sends X-Frame-Options: SAMEORIGIN which blocks cross-origin embedding. CSP frame-ancestors in Helm values is the modern replacement. Remove the nginx header and verify the Helm allowlist covers the parent Omnichannel origin.
Scope
| File | Change |
|---|---|
crm-fe-v3:deploy/nginx/default.conf:28 | Remove add_header X-Frame-Options "SAMEORIGIN" always; |
crm-fe-v3:deploy-alicloud/nginx/default.conf:9,39 | Same removal for alicloud |
crm-fe-v3:deploy-alicloud/chart/values-production.yaml:25,105,123,193,200 | Verify frame-ancestors allowlist covers parent origin |
crm-fe-v3:deploy-alicloud/chart/values-staging.yaml | Same for staging |
Note: Rails content_security_policy.rb is NOT relevant — it only affects API response headers, not SPA iframe headers.
Implementation steps:
- Confirm parent app origin with Omnichannel team (P1)
- Remove
X-Frame-Options: SAMEORIGINfrom nginx configs - Verify
frame-ancestorsallowlist in production + staging Helm values - If parent origin not covered — add it to the allowlist
Files modified:
crm-fe-v3:deploy/nginx/default.confcrm-fe-v3:deploy-alicloud/nginx/default.confcrm-fe-v3:deploy-alicloud/chart/values-production.yamlcrm-fe-v3:deploy-alicloud/chart/values-staging.yaml
Expected Outcome
X-Frame-Options: SAMEORIGINno longer in SPA response headersContent-Security-Policy: frame-ancestors 'self' https://*.qontak.com ...present- Parent Omnichannel app can open CRM embed pages in an iframe
Test command:
curl -sI https://crm.qontak.com/embed/deals/room/abc123 | grep -i content-security-policy
curl -sI https://crm.qontak.com/embed/deals/room/abc123 | grep -i x-frame-options
Step-by-step Implementation Plan
Task 1 — Remove X-Frame-Options from nginx
# deploy/nginx/default.conf:28 — BEFORE:
add_header X-Frame-Options "SAMEORIGIN" always;
# AFTER: remove or comment out
# add_header X-Frame-Options "SAMEORIGIN" always;
Task 2 — Verify / update frame-ancestors in Helm values
# values-production.yaml (current, verify this covers parent):
more_set_headers "Content-Security-Policy: frame-ancestors 'self' https://*.qontak.com http://localhost:*";
# If parent is on a different domain, add it:
more_set_headers "Content-Security-Policy: frame-ancestors 'self' https://*.qontak.com https://omnichannel.qontak.com http://localhost:*";
Task 3 — Self-test
curl -sI https://crm.qontak.com/embed/deals/room/abc123 | grep -i content-security-policy
# → must contain frame-ancestors
curl -sI https://crm.qontak.com/embed/deals/room/abc123 | grep -i x-frame-options
# → must be empty (header removed)
Acceptance criteria
-
X-Frame-Optionsheader no longer present in SPA responses -
Content-Security-Policy: frame-ancestorsheader present and covers parent app origin - Parent Omnichannel app can render CRM embed page in iframe
Effort estimate
0.5 day — config-only change; blocked on P1 origin confirmation.
Run to verify
curl -sI https://crm.qontak.com/embed/deals/room/abc123 | grep -iE "x-frame-options|content-security-policy"
Task 2: [BE] Seed Migration — embed_deal_sanitize feature flag (S05)
Deal embed sanitization is active once the feature flag record exists in the database.
Status: ✅ Actionable
Design reference: n/a — backend seed migration.
Purpose
deals_controller.rb:422 calls current_user.feature_enabled('embed_deal_sanitize') but the Feature record does not exist as a seed. Without this seed, sanitization is always skipped. Create the seed migration following the existing db/data/20260505000001_add_feature_embed_ticket_omnichannel.rb pattern.
Scope
Implementation steps:
- Create
db/data/20260701000001_add_feature_embed_deal_sanitize.rb - Run
rake db:migrate:data - Verify in Rails console
Files modified:
db/data/20260701000001_add_feature_embed_deal_sanitize.rb(create)
Expected Outcome
Feature.find_by(code: 'embed_deal_sanitize')returns record- Sanitization activatable per account
Test command: Feature.find_by(code: 'embed_deal_sanitize') in Rails console
Step-by-step Implementation Plan
Critical: Follow exact pattern from db/data/20260505000001_add_feature_embed_ticket_omnichannel.rb — use SeedMigration::Migration and find_or_create_by.
Task 1 — Write failing spec
# spec/db/data/add_feature_embed_deal_sanitize_spec.rb
it 'creates embed_deal_sanitize feature' do
expect(Feature.find_by(code: 'embed_deal_sanitize')).to be_nil
AddFeatureEmbedDealSanitize.new.up
expect(Feature.find_by(code: 'embed_deal_sanitize')).not_to be_nil
expect(Feature.find_by(code: 'embed_deal_sanitize').enabled_by_default).to eq(false)
end
Run — expect FAIL:
RAILS_ENV=test bundle exec rspec spec/db/data/add_feature_embed_deal_sanitize_spec.rb
Task 2 — Create seed migration
# 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
Task 3 — Run + verify
rake db:migrate:data
# Then in Rails console:
Feature.find_by(code: 'embed_deal_sanitize')
# → returns record with enabled_by_default: false
Acceptance criteria
-
Feature.find_by(code: 'embed_deal_sanitize')returns record in all environments -
enabled_by_default: false -
rake db:migrate:data:down VERSION=20260701000001removes the record
Effort estimate
0.5 day — 1-file seed migration following established pattern.
Run to verify
rake db:migrate:data && rails console -e development <<< "puts Feature.find_by(code: 'embed_deal_sanitize').inspect"
Task 3: [FE] Deal Index Embed Page (S01)
A Sales Agent can see the latest deal associated with a conversation room inside an Omnichannel iframe, without leaving the conversation.
Status: ✅ Actionable
Design reference: n/a — design pending · DS version: @mekari/pixel3 1.0.12-dev.0 · Design QA: TBD
Purpose
New embed page at /embed/deals/room/:room_id. Calls existing GET /api/internal/v1/deals?room_id=:roomId&per_page=1 (no backend change — filter already in Crm::AdvancedSearch#crm_channel_room_id_filter at advanced_search.rb:336). Shows deal card or empty state with conditional create button.
Scope
| Action | File | What changes |
|---|---|---|
| create | layers/embed-deal/pages/embed/deals/room/[roomId].vue | Page entry with auth gate, route param, layout embed-deal |
| create | layers/embed-deal/components/EmbedDealIndexPage.vue | Loading → empty state → deal card; dual-gate create button |
| create | layers/embed-deal/components/EmbedDealIndexPage.spec.ts | Vitest unit tests |
Implementation steps:
- Read
layers/embed-deal/pages/embed/deals/create.vue— understand page pattern,definePageMeta, auth gate - Read
layers/embed-deal/composables/useEmbedDealAuth.ts— auth pattern - Read
layers/embed-deal/stores/useEmbedDealStore.ts:39-43—buildIagUrl()for API call - Write failing specs
- Create page file (
[roomId].vue) - Create index component (
EmbedDealIndexPage.vue) with loading/empty/card states + dual-gate create button - Go green + lint
Files modified:
layers/embed-deal/pages/embed/deals/room/[roomId].vue(create)layers/embed-deal/components/EmbedDealIndexPage.vue(create)layers/embed-deal/components/EmbedDealIndexPage.spec.ts(create)
Expected Outcome
- Page renders deal card when
GET /deals?room_id=xxxreturns a deal - Empty state with "Create Deal" button when no deal
- Create button hidden when
can_create=false - Auth error component when token missing/invalid
Test command: pnpm vitest run layers/embed-deal/components/EmbedDealIndexPage.spec.ts
Step-by-step Implementation Plan
Critical: Create button uses dual gate — can_create_param === 'true' && userPermissionCreate !== false. Permission data already available in useEmbedDealStore via GET /v2.8/users/me call at store init (useEmbedDealStore.ts:66).
Task 1 — Write failing specs
// EmbedDealIndexPage.spec.ts
describe('EmbedDealIndexPage', () => {
it('renders deal card when deal exists', async () => { ... })
it('renders empty state when no deal', async () => { ... })
it('shows create button when can_create=true', async () => { ... })
it('hides create button when can_create=false', async () => { ... })
it('renders EmbedDealAuthError when not authenticated', async () => { ... })
})
Run — expect FAIL:
pnpm vitest run layers/embed-deal/components/EmbedDealIndexPage.spec.ts
Task 2 — Create page entry file
<!-- layers/embed-deal/pages/embed/deals/room/[roomId].vue -->
<script setup lang="ts">
definePageMeta({ layout: 'embed-deal' })
const route = useRoute()
const roomId = route.params.roomId as string
const { isAuthenticated, authError } = useEmbedDealAuth()
const canCreate = getStringQuery(route, 'can_create')
</script>
<template>
<EmbedDealAuthError v-if="authError || !isAuthenticated" />
<EmbedDealIndexPage v-else :room-id="roomId" :can-create="canCreate === 'true'" />
</template>
Task 3 — Create index component
States: Loading → Empty (+ create btn if canCreate) → Deal card (name, stage, amount).
API call: GET /api/internal/v1/deals?room_id=:roomId&per_page=1&order_by=created_at&order_dir=desc
Dual-gate: showCreateButton = props.canCreate && userPermissionCreate !== false
Task 4 — Go green + lint
pnpm vitest run layers/embed-deal/components/EmbedDealIndexPage.spec.ts
pnpm lint
Acceptance criteria
- Loading state rendered while fetching
- Deal card rendered (name, stage, amount) when deal returned
- Empty state rendered when
deals: [] - Create button visible when
can_create=true - Create button hidden when
can_create=false -
EmbedDealAuthErrorrendered on auth failure -
per_page=1confirmed in GET request
Test strategy
Vitest unit tests with mocked useEmbedDealStore (mock the API call). Key assertions: deal card renders when store returns deal; create button gated by canCreate prop.
Effort estimate
2 days — new page + component + tests; FE pattern established by existing embed-deal layer.
Run to verify
pnpm vitest run layers/embed-deal/components/EmbedDealIndexPage.spec.ts && pnpm lint
Depends on
- Task 1 (CSP config) before production deploy
Task 4: [FE] Ticket Index Embed Page (S03)
A Sales Agent can see the latest ticket associated with a conversation room, or navigate to create one.
Status: ✅ Actionable
Design reference: n/a — design pending · DS version: @mekari/pixel3 1.0.12-dev.0 · Design QA: TBD
Purpose
New embed page at /embed/tickets/room/:room_id. Follows identical pattern to Task 3 but for tickets. Calls GET /api/internal/v1/tickets?channel_integration_room_id=:roomId&per_page=1 (filter exists in Crm::AdvancedSearch at advanced_search.rb:1457).
Scope
| Action | File | What changes |
|---|---|---|
| create | layers/embed-ticket/pages/embed/tickets/room/[roomId].vue | Page entry; auth via useEmbedTicketAuth(); optional ?use_iag=true |
| create | layers/embed-ticket/components/EmbedTicketIndexPage.vue | Same states as deal index; uses channel_integration_room_id param |
| create | layers/embed-ticket/components/EmbedTicketIndexPage.spec.ts | Vitest tests |
Implementation steps:
- Read
layers/embed-ticket/composables/useEmbedTicketAuth.ts— cookie chain auth pattern - Read
layers/embed-ticket/composables/useEmbedTicketApiInterceptor.ts— IAG URL rewriting (?use_iag=true) - Write failing specs; create page + component same pattern as Task 3
- Note: query param is
channel_integration_room_id(notroom_id)
Files modified:
layers/embed-ticket/pages/embed/tickets/room/[roomId].vue(create)layers/embed-ticket/components/EmbedTicketIndexPage.vue(create)layers/embed-ticket/components/EmbedTicketIndexPage.spec.ts(create)
Expected Outcome
Same as Task 3 for tickets. Auth, empty state, ticket card, create button gated.
Test command: pnpm vitest run layers/embed-ticket/components/EmbedTicketIndexPage.spec.ts
Step-by-step Implementation Plan
Same TDD flow as Task 3, adapted for ticket auth (useEmbedTicketAuth), ticket API (channel_integration_room_id param), and ticket card fields (name, stage, status).
Acceptance criteria
- Same acceptance criteria as Task 3 for tickets
- Uses
channel_integration_room_id(notroom_id) in API call - Auth via
useEmbedTicketAuth()cookie chain
Test strategy
Vitest unit tests with mocked ticket index API call. Same pattern as Task 3.
Effort estimate
1.5 days — identical pattern to Task 3; slightly faster since pattern established.
Run to verify
pnpm vitest run layers/embed-ticket/components/EmbedTicketIndexPage.spec.ts && pnpm lint
Task 5: [FE] Deal Create Embed Page — New Wrapper at /embed/deals/new (S02)
A Sales Agent can create a deal from within the Omnichannel conversation room via a new, hardened embed page at
/embed/deals/new.
Status: ✅ Actionable
Design reference: Inbox Revamp — Infobar · Frame: Inbox Revamp Infobar · DS version: @mekari/pixel3 1.0.12-dev.0 · Design QA: Alma Syafira
Purpose
New thin wrapper component EmbedDealNewPage.vue at URL /embed/deals/new. Reuses existing DealsCreate* form components. Uses useEmbedDealCreate.ts for submit/cancel/postMessage (NOT useDealCreate.ts which navigates to full deal list). Old page at /embed/deals/create is kept unchanged as fallback.
Scope
| Action | File | What changes |
|---|---|---|
| create | layers/embed-deal/pages/embed/deals/new/index.vue | New page at /embed/deals/new; reads room_id, token, parent_origin from query |
| create | layers/embed-deal/components/EmbedDealNewPage.vue | Thin wrapper; composes 8 DealsCreate* components; wires to useEmbedDealCreate.ts |
| create | layers/embed-deal/components/EmbedDealNewPage.spec.ts | Tests: submit calls postMessage deal-created; cancel calls deal-cancel |
| no change | layers/embed-deal/pages/embed/deals/create.vue | Old fallback page — untouched |
NOT used: layers/deals/composables/useDealCreate.ts — navigates to full deal list on success, wrong for embed context.
Implementation steps:
- Read
layers/embed-deal/components/EmbedDealCreatePage.vuelines 1-50 — understand existing embed create pattern - Read
layers/embed-deal/composables/useEmbedDealCreate.ts—handleSubmitandhandleCancelsignatures - Read
components/Deals/Form/Create/DealsCreate*.vue— identify which components to compose - Write failing specs for submit →
deal-createdpostMessage and cancel →deal-cancel - Create new page + wrapper component
- Block submit while
propertiesLoading.value(gap fix from Chunk 6 moved here) - Go green + lint
Files modified:
layers/embed-deal/pages/embed/deals/new/index.vue(create)layers/embed-deal/components/EmbedDealNewPage.vue(create)layers/embed-deal/components/EmbedDealNewPage.spec.ts(create)
Expected Outcome
- Form renders with pipeline/stage/fields via
DealsCreate*components - Submit → POST
/api/internal/v1/deals→postMessage { type: 'deal-created', dealId, room_id, data } - Cancel → confirm if dirty →
postMessage { type: 'deal-cancel', room_id } - Save blocked while properties loading
- Old
/embed/deals/createpage unchanged and still works
Test command: pnpm vitest run layers/embed-deal/components/EmbedDealNewPage.spec.ts
Step-by-step Implementation Plan
Critical: Use useEmbedDealCreate.ts for postMessage and submit. Do NOT import useDealCreate.ts — it navigates away from the embed on success. Also: propertiesLoading guard must be applied in handleSubmit.
Task 1 — Write failing specs
// EmbedDealNewPage.spec.ts
describe('EmbedDealNewPage', () => {
it('calls postMessage deal-created on successful submit', async () => {
// mock useEmbedDealCreate; trigger submit; assert postMessage called with { type: 'deal-created' }
})
it('calls postMessage deal-cancel on cancel (clean form)', async () => { ... })
it('shows confirm dialog on cancel when form is dirty', async () => { ... })
it('blocks submit when propertiesLoading is true', async () => { ... })
})
Run — expect FAIL:
pnpm vitest run layers/embed-deal/components/EmbedDealNewPage.spec.ts
Task 2 — Create page entry
<!-- layers/embed-deal/pages/embed/deals/new/index.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'embed-deal' })
const { isAuthenticated, authError } = useEmbedDealAuth()
</script>
<template>
<EmbedDealAuthError v-if="authError || !isAuthenticated" />
<EmbedDealNewPage v-else />
</template>
Task 3 — Create wrapper component
Compose DealsCreateHeader + DealsPipelineStages + DealsCreateAboutDeal + DealsCreateDynamicProperties + DealsCreateCompanySection + DealsCreateContactsSection + DealsCreateProductsSection + DealsCreateActionBar. Wire onCancel and onSubmit to useEmbedDealCreate.ts.
Task 4 — Go green + lint
pnpm vitest run layers/embed-deal/components/EmbedDealNewPage.spec.ts && pnpm lint
Acceptance criteria
-
/embed/deals/newrenders form with all 8DealsCreate*components - Submit →
{ type: 'deal-created', dealId, room_id, data }postMessage sent - Cancel (clean) →
{ type: 'deal-cancel', room_id }postMessage, no confirm - Cancel (dirty) → confirm dialog before postMessage
- Submit blocked when
propertiesLoading.value === true - Old
/embed/deals/createstill works (no regression)
Test strategy
Vitest unit tests. Mock useEmbedDealCreate.ts. Assert window.parent.postMessage calls with correct typed payload. Assert propertiesLoading guard prevents double-submit.
Effort estimate
2.5 days — thin wrapper but requires understanding 8 child components + composable wiring + gap fixes.
Run to verify
pnpm vitest run layers/embed-deal/components/EmbedDealNewPage.spec.ts && pnpm lint
Depends on
- Task 6 (security hardening) should be done on same PR or immediately after —
useEmbedDealCreate.tsis modified in both tasks.
Task 6: [FE] Security Hardening — postMessage + Auth (S04, S05)
All embed pages send typed postMessage events to a restricted origin; auth errors from 401 are surfaced to agents; deal embed handles create failures and dirty cancel correctly.
Status: ✅ Actionable — ⚠️ check P3 (EmbedTicketCreateSuccess.vue wiring) before closing this task.
Design reference: n/a — security/UX hardening only.
Purpose
Fix 7 known gaps across useEmbedDealCreate.ts, useEmbedDealAuth.ts, useEmbedTicketCreate.ts. Add shared ALLOWED_POSTMESSAGE_ORIGINS constant. All postMessage calls must use restricted targetOrigin, not '*'.
Scope
| # | Gap | File | Fix |
|---|---|---|---|
| 1 | Create failure — no toast | useEmbedDealCreate.ts:156-158 | toast.error(...) in catch |
| 2 | Cancel — no confirm when dirty | useEmbedDealCreate.ts:123-128 | Confirm dialog when isDirty |
| 3 | Submit while propertiesLoading | useEmbedDealCreate.ts handleSubmit | if (propertiesLoading.value) return |
| 4 | authError never set from 401 | useEmbedDealAuth.ts:38-40 | Axios interceptor → authError.value = true |
| 5 | Deal postMessage uses '*' | useEmbedDealCreate.ts all calls | targetOrigin = parentOrigin ?? ALLOWED_ORIGINS[0] |
| 6 | Deal postMessage untyped | useEmbedDealCreate.ts | Upgrade to { type: 'deal-created', dealId, room_id, data } |
| 7 | Ticket postMessage uses '*' | useEmbedTicketCreate.ts | Same parentOrigin restriction |
Implementation steps:
- Create shared
ALLOWED_POSTMESSAGE_ORIGINSconstant - Fix all 7 gaps in order above
- Write specs for each gap fix
Files modified:
layers/embed-deal/composables/useEmbedDealCreate.tslayers/embed-deal/composables/useEmbedDealAuth.tslayers/embed-ticket/composables/useEmbedTicketCreate.ts- New: shared constant file (e.g.
shared/constants/postMessageOrigins.ts)
Expected Outcome
- All
window.parent.postMessagecalls usetargetOriginfromparentOrigin ?? ALLOWED_ORIGINS[0] - Deal postMessage format: typed
{ type: 'deal-created' | 'deal-cancel' | 'form-dirty', ... } - Toast shown on deal create failure
- Confirm dialog on cancel when form dirty
authError.value = truetriggered on 401 axios response
Test command: pnpm vitest run layers/embed-deal/composables/useEmbedDealCreate.spec.ts
Step-by-step Implementation Plan
Critical: Upgrading deal postMessage format (gap #6) is a breaking change for any parent app still parsing { embed: false, msg }. Coordinate with P2 before deploy.
Task 1 — Write failing specs for each gap
// useEmbedDealCreate.spec.ts
it('shows toast on create failure') // gap 1
it('shows confirm dialog on dirty cancel') // gap 2
it('blocks submit while propertiesLoading') // gap 3
it('uses restricted targetOrigin') // gap 5
it('sends typed deal-created postMessage') // gap 6
Run — expect FAIL:
pnpm vitest run layers/embed-deal/composables/useEmbedDealCreate.spec.ts
Task 2 — Create shared origins constant
// shared/constants/postMessageOrigins.ts
export const ALLOWED_POSTMESSAGE_ORIGINS = [
'https://omnichannel.qontak.com',
'https://staging-omnichannel.qontak.com',
]
Task 3 — Fix gaps 1-6 in useEmbedDealCreate.ts
// Gap 1 (line 156-158): BEFORE: console.error(error)
// AFTER:
toast.error('Failed to create deal. Please try again.')
// Gap 2 (line 123-128): add confirm before cancel postMessage
const handleCancel = async () => {
if (isDirty.value) {
const confirmed = await showConfirmDialog('Discard unsaved changes?')
if (!confirmed) return
}
window.parent.postMessage({ type: 'deal-cancel', room_id: roomId }, targetOrigin)
}
// Gap 3: guard at top of handleSubmit
if (propertiesLoading.value) return
// Gap 5+6: replace postMessage calls
// BEFORE: window.parent.postMessage({ embed: false, msg: 'Deal successfully created', ... }, '*')
// AFTER:
const targetOrigin = parentOrigin.value ?? ALLOWED_POSTMESSAGE_ORIGINS[0]
window.parent.postMessage({ type: 'deal-created', dealId: deal.id, room_id: roomId, data: deal }, targetOrigin)
Task 4 — Fix gap 4 in useEmbedDealAuth.ts
// useEmbedDealAuth.ts:38-40 — add axios interceptor
axios.interceptors.response.use(null, (error) => {
if (error.response?.status === 401) authError.value = true
return Promise.reject(error)
})
Task 5 — Fix gap 7 in useEmbedTicketCreate.ts
Replace all postMessage(payload, '*') with postMessage(payload, targetOrigin).
Task 6 — Go green + lint
pnpm vitest run layers/embed-deal/composables/ && pnpm lint
Acceptance criteria
- Error toast shown on deal create failure
- Confirm dialog shown on cancel when form is dirty
- Submit blocked when
propertiesLoading - 401 response sets
authError = true - No
postMessage(payload, '*')in either embed layer - Deal postMessage uses typed format
{ type: 'deal-created' | ... }
Test strategy
Vitest unit tests for each composable. Key mocks: axios for gap 4, window.parent.postMessage spy for gaps 5-7, isDirty ref for gap 2.
Effort estimate
2 days — 7 focused gap fixes; most are 1-5 line changes; testing each gap adds time.
Run to verify
pnpm vitest run layers/embed-deal/ && pnpm vitest run layers/embed-ticket/ && pnpm lint
Post-Deployment Verification
| # | Task | Detail |
|---|---|---|
| V1 | Deal index loads | Open Omnichannel → Deals tab → iframe renders deal card or empty state |
| V2 | Deal create + postMessage | Fill form → save → parent receives { type: 'deal-created', dealId, room_id } |
| V3 | Cancel confirm on dirty form | Edit field → cancel → confirm dialog appears |
| V4 | Submit blocked while loading | Switch pipeline mid-load → save button blocked |
| V5 | Auth error state | Open embed page without token → EmbedDealAuthError renders |
| V6 | Ticket index loads | Open Omnichannel → Tickets tab → iframe renders ticket card or empty state |
| V7 | Ticket create + postMessage | Fill form → save → parent receives { type: 'ticket-created' } + close-drawer |
| V8 | CSP header on SPA pages | curl -sI https://crm.qontak.com/embed/deals/room/abc123 | grep -i content-security-policy → contains frame-ancestors |
| V9 | Feature flag seeded | Feature.find_by(code: 'embed_deal_sanitize') → not nil |
| V10 | Analytics events | embed_deal_created / embed_ticket_created appear in Datadog within 24h |
Ordering rationale
- Tasks 1-2 first — infra (CSP) and seed migration are independent; unblock iframes and sanitization before FE work ships
- Task 3 before Task 4 — deal index establishes the pattern; ticket index follows the same structure
- Task 5 after Task 3 — deal create page builds on deal auth pattern learned in Task 3
- Task 6 last (or parallel with Task 5) — security hardening touches
useEmbedDealCreate.tswhich Task 5 also touches; coordinate on same PR or merge Task 5 first - Critical external dependency: parent Omnichannel app must update postMessage handlers before Task 5/6 deploys to production (P2)
Skipped stories
No stories were blocked or excluded. All stories (S01-S05) are covered across Tasks 1-6.