Skip to main content

Task Breakdown: Embeddable Deal & Ticket Widgets — Index + Deal Create + Ticket Create

Generated from RFC: embed-deal-ticket-index-deal-create-ticket-create-rfc.md Slicing: Vertical — one task per page/surface + infra + security hardening


Effort Summary

TaskEffort
Task 1 — SPA CSP / X-Frame-Options config0.5 day
Task 2 — Seed migration embed_deal_sanitize0.5 day
Task 3 — Deal index embed page2 days
Task 4 — Ticket index embed page1.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)

#TaskOwnerDetail
P1Confirm parent app origin for CSP frame-ancestorsInfosec + Omnichannel*.qontak.com may cover — verify before Task 1
P2Confirm postMessage contract with parent appFE + OmnichannelParent must handle typed deal-created / deal-cancel / form-dirty
P3Verify EmbedTicketCreateSuccess.vue is wired into ticket create flowFEExisting 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

FileChange
crm-fe-v3:deploy/nginx/default.conf:28Remove add_header X-Frame-Options "SAMEORIGIN" always;
crm-fe-v3:deploy-alicloud/nginx/default.conf:9,39Same removal for alicloud
crm-fe-v3:deploy-alicloud/chart/values-production.yaml:25,105,123,193,200Verify frame-ancestors allowlist covers parent origin
crm-fe-v3:deploy-alicloud/chart/values-staging.yamlSame for staging

Note: Rails content_security_policy.rb is NOT relevant — it only affects API response headers, not SPA iframe headers.

Implementation steps:

  1. Confirm parent app origin with Omnichannel team (P1)
  2. Remove X-Frame-Options: SAMEORIGIN from nginx configs
  3. Verify frame-ancestors allowlist in production + staging Helm values
  4. If parent origin not covered — add it to the allowlist

Files modified:

  • crm-fe-v3:deploy/nginx/default.conf
  • crm-fe-v3:deploy-alicloud/nginx/default.conf
  • crm-fe-v3:deploy-alicloud/chart/values-production.yaml
  • crm-fe-v3:deploy-alicloud/chart/values-staging.yaml

Expected Outcome

  • X-Frame-Options: SAMEORIGIN no longer in SPA response headers
  • Content-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-Options header no longer present in SPA responses
  • Content-Security-Policy: frame-ancestors header 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:

  1. Create db/data/20260701000001_add_feature_embed_deal_sanitize.rb
  2. Run rake db:migrate:data
  3. 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=20260701000001 removes 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

ActionFileWhat changes
createlayers/embed-deal/pages/embed/deals/room/[roomId].vuePage entry with auth gate, route param, layout embed-deal
createlayers/embed-deal/components/EmbedDealIndexPage.vueLoading → empty state → deal card; dual-gate create button
createlayers/embed-deal/components/EmbedDealIndexPage.spec.tsVitest unit tests

Implementation steps:

  1. Read layers/embed-deal/pages/embed/deals/create.vue — understand page pattern, definePageMeta, auth gate
  2. Read layers/embed-deal/composables/useEmbedDealAuth.ts — auth pattern
  3. Read layers/embed-deal/stores/useEmbedDealStore.ts:39-43buildIagUrl() for API call
  4. Write failing specs
  5. Create page file ([roomId].vue)
  6. Create index component (EmbedDealIndexPage.vue) with loading/empty/card states + dual-gate create button
  7. 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=xxx returns 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
  • EmbedDealAuthError rendered on auth failure
  • per_page=1 confirmed 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

ActionFileWhat changes
createlayers/embed-ticket/pages/embed/tickets/room/[roomId].vuePage entry; auth via useEmbedTicketAuth(); optional ?use_iag=true
createlayers/embed-ticket/components/EmbedTicketIndexPage.vueSame states as deal index; uses channel_integration_room_id param
createlayers/embed-ticket/components/EmbedTicketIndexPage.spec.tsVitest tests

Implementation steps:

  1. Read layers/embed-ticket/composables/useEmbedTicketAuth.ts — cookie chain auth pattern
  2. Read layers/embed-ticket/composables/useEmbedTicketApiInterceptor.ts — IAG URL rewriting (?use_iag=true)
  3. Write failing specs; create page + component same pattern as Task 3
  4. Note: query param is channel_integration_room_id (not room_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 (not room_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

ActionFileWhat changes
createlayers/embed-deal/pages/embed/deals/new/index.vueNew page at /embed/deals/new; reads room_id, token, parent_origin from query
createlayers/embed-deal/components/EmbedDealNewPage.vueThin wrapper; composes 8 DealsCreate* components; wires to useEmbedDealCreate.ts
createlayers/embed-deal/components/EmbedDealNewPage.spec.tsTests: submit calls postMessage deal-created; cancel calls deal-cancel
no changelayers/embed-deal/pages/embed/deals/create.vueOld fallback page — untouched

NOT used: layers/deals/composables/useDealCreate.ts — navigates to full deal list on success, wrong for embed context.

Implementation steps:

  1. Read layers/embed-deal/components/EmbedDealCreatePage.vue lines 1-50 — understand existing embed create pattern
  2. Read layers/embed-deal/composables/useEmbedDealCreate.tshandleSubmit and handleCancel signatures
  3. Read components/Deals/Form/Create/DealsCreate*.vue — identify which components to compose
  4. Write failing specs for submit → deal-created postMessage and cancel → deal-cancel
  5. Create new page + wrapper component
  6. Block submit while propertiesLoading.value (gap fix from Chunk 6 moved here)
  7. 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/dealspostMessage { 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/create page 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/new renders form with all 8 DealsCreate* 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/create still 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.ts is 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

#GapFileFix
1Create failure — no toastuseEmbedDealCreate.ts:156-158toast.error(...) in catch
2Cancel — no confirm when dirtyuseEmbedDealCreate.ts:123-128Confirm dialog when isDirty
3Submit while propertiesLoadinguseEmbedDealCreate.ts handleSubmitif (propertiesLoading.value) return
4authError never set from 401useEmbedDealAuth.ts:38-40Axios interceptor → authError.value = true
5Deal postMessage uses '*'useEmbedDealCreate.ts all callstargetOrigin = parentOrigin ?? ALLOWED_ORIGINS[0]
6Deal postMessage untypeduseEmbedDealCreate.tsUpgrade to { type: 'deal-created', dealId, room_id, data }
7Ticket postMessage uses '*'useEmbedTicketCreate.tsSame parentOrigin restriction

Implementation steps:

  1. Create shared ALLOWED_POSTMESSAGE_ORIGINS constant
  2. Fix all 7 gaps in order above
  3. Write specs for each gap fix

Files modified:

  • layers/embed-deal/composables/useEmbedDealCreate.ts
  • layers/embed-deal/composables/useEmbedDealAuth.ts
  • layers/embed-ticket/composables/useEmbedTicketCreate.ts
  • New: shared constant file (e.g. shared/constants/postMessageOrigins.ts)

Expected Outcome

  • All window.parent.postMessage calls use targetOrigin from parentOrigin ?? 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 = true triggered 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

#TaskDetail
V1Deal index loadsOpen Omnichannel → Deals tab → iframe renders deal card or empty state
V2Deal create + postMessageFill form → save → parent receives { type: 'deal-created', dealId, room_id }
V3Cancel confirm on dirty formEdit field → cancel → confirm dialog appears
V4Submit blocked while loadingSwitch pipeline mid-load → save button blocked
V5Auth error stateOpen embed page without token → EmbedDealAuthError renders
V6Ticket index loadsOpen Omnichannel → Tickets tab → iframe renders ticket card or empty state
V7Ticket create + postMessageFill form → save → parent receives { type: 'ticket-created' } + close-drawer
V8CSP header on SPA pagescurl -sI https://crm.qontak.com/embed/deals/room/abc123 | grep -i content-security-policy → contains frame-ancestors
V9Feature flag seededFeature.find_by(code: 'embed_deal_sanitize') → not nil
V10Analytics eventsembed_deal_created / embed_ticket_created appear in Datadog within 24h

Ordering rationale

  1. Tasks 1-2 first — infra (CSP) and seed migration are independent; unblock iframes and sanitization before FE work ships
  2. Task 3 before Task 4 — deal index establishes the pattern; ticket index follows the same structure
  3. Task 5 after Task 3 — deal create page builds on deal auth pattern learned in Task 3
  4. Task 6 last (or parallel with Task 5) — security hardening touches useEmbedDealCreate.ts which Task 5 also touches; coordinate on same PR or merge Task 5 first
  5. 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.