Task Breakdown — Embeddable Create-Ticket Form in CDP Customer Detail (Web)
Derived from rfc-create-ticket-embeddable-web.md.
Repository: qontak-customer-fe (/Users/mekari/Documents/Works/task-force-workspace/qontak-customer-fe).
All file paths verified against the live repo.
Effort Summary
| Task | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Task 1 — Gate + UI Shell (TCKT-S07-NEG, TCKT-S01) | 1.5 | — | 0.5 | 2.0 |
| Task 2 — postMessage contract + parent refresh (TCKT-S02, TCKT-S03) | 1.0 | — | 0.5 | 1.5 |
| Task 3 — Fallback associate (TCKT-S04) | 0.5 | — | 0.5 | 1.0 |
| Grand total | 3.0 | — | 1.5 | 4.5 |
Confidence: low. The CRM
postMessagecontract (OQ-contract) is provisional, OQ-2 (Qontak One auth in the iframe) is unresolved, and the ticket-create permission key (OQ-4) is unknown. All three directly affect Task 2, and OQ-4 affects Task 1. Task 1 can start now with a// TODO: OQ-4placeholder for the key.
Task 1: [FE] Permission gate + TicketEmbedPanel scaffold + "+" menu (TCKT-S07-NEG, TCKT-S01)
A support agent with the ticket-create permission and CDP flag ON sees a "Create Ticket" option in the Tickets section header; clicking it opens a sandboxed iframe panel (loading state), while agents without permission or with the flag OFF see only the unchanged associate-existing button.
Status: ✅ Actionable — no CRM contract needed for the gate, shell, or menu.
Design reference: n/a — design pending (OQ-8). Shell mirrors AssociatedDeals.vue MpPopover + iframe panel pattern exactly.
What to build
Add canCreateTicket (permission + flag gate) to AssociatedTickets.vue, replace the single "add" MpButton with a popover "+" menu, create TicketEmbedPanel.vue as a sandboxed iframe scaffold with a state machine and stub message handler, and register the CDP ticket-embed flag in FeatureFlagStore.ts.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | features/customers/detail/components/AssociatedTickets.constants.ts | TICKET_CREATE_PERMISSION constant (placeholder for OQ-4) |
| extend | common/store/FeatureFlagStore.ts | Add cdp_create_ticket_embed: boolean to FeatureFlags interface + DEFAULT_FEATURE_FLAGS |
| extend | features/customers/detail/components/AssociatedTickets.vue | Import useFeatureFlagStore, storeToRefs, TICKET_CREATE_PERMISSION, MpPopover* components, TicketEmbedPanel; add canCreateTicket + isShowCreateTicket; replace MpButton add-button with MpPopover "+" menu; add panel block |
| create | features/customers/detail/components/TicketEmbedPanel.vue | Sandboxed iframe SFC — props (customerContext, embedReadyTimeoutMs), emits (created, close, error, resize), state machine (hidden/loading/ready/error/success), stub message handler |
| create | features/customers/detail/components/TicketEmbedPanel.spec.ts | Panel unit tests (iframe src, sandbox attr, loading state, timeout → error) |
| create | features/customers/detail/components/AssociatedTickets.spec.ts | Gate + menu + open/close tests |
Implementation steps
- Explore the reference files — Read
AssociatedDeals.vuelines 1–55 (the exact MpPopover menu template +isShowCreateDealtoggle) and lines 160–175 (createDealUrlcomputed +handleShowCreateDeal). Note the imports list:MpPopover,MpPopoverTrigger,MpPopoverContent,MpPopoverList,MpPopoverListItemmust be added to the@mekari/pixel3import inAssociatedTickets.vue. Readfeatures/customers/detail/components/Notes/Notes.vuelines 60–97 to internalize thecanAddNotespermission-gate pattern —canCreateTicketis a direct copy with a different key. - Write failing tests (red) — Create
features/customers/detail/components/AssociatedTickets.spec.tsusing theNotes.spec.tsharness as the template (vitest+@testing-library/vue render+createTestingPiniawithinitialState):vi.stubGlobal('useRoute', () => ({ path: '/customers/123' })).initialState: { user: { permissions: { customer_360: { permissions: [{ name: TICKET_CREATE_PERMISSION, is_enabled: true }] } } }, 'feature-flags': { featureFlags: { cdp_create_ticket_embed: true } } }.- Test: "Create Ticket" item in DOM when permission enabled + flag ON.
- Test: "Create Ticket" absent when permission
is_enabled: false(flag ON). - Test: "Create Ticket" absent when flag
false(permission enabled). - Test: clicking "Create Ticket" renders
TicketEmbedPanel. - Test: back-arrow in panel header sets
isShowCreateTicketfalse. - Test: "Associate existing" still visible and unchanged in all states.
Run:
pnpm vitest run features/customers/detail/components/AssociatedTickets.spec.ts
- Create
AssociatedTickets.constants.ts:// TODO OQ-4: confirm exact CanCan permission key with CRM teamexport const TICKET_CREATE_PERMISSION = 'TBD_ticket_create_key' - Extend
FeatureFlagStore.ts— add one line toFeatureFlagsinterface:cdp_create_ticket_embed: booleanand one line toDEFAULT_FEATURE_FLAGS:cdp_create_ticket_embed: false. - Scaffold
TicketEmbedPanel.vue— new SFC atfeatures/customers/detail/components/TicketEmbedPanel.vue:- Props type (mirrors RFC Detail 2.A):
interface TicketEmbedPanelProps {customerContext: {qontakCustomerId: stringcontactName?: string; contactPhone?: string; contactEmail?: stringcontactAccountUniqId?: string; channelType?: string; locale?: string}embedReadyTimeoutMs?: number // default 10000}
- Emits:
created,close,error,resize. panelState = ref<'loading' | 'ready' | 'error' | 'success'>('loading').const { config } = useCustomConfig()→embedUrl = computed(() => \${config.CRM_V3_EMBED_URL}/embed/ticket/create`)`.iframeEl = ref<HTMLIFrameElement | null>(null).- Template: spinner/skeleton when
loading;<iframe ref="iframeEl" :src="embedUrl" title="Create Ticket" width="100%" frameborder="0" sandbox="allow-forms allow-scripts allow-same-origin" referrerpolicy="strict-origin-when-cross-origin" .../>whenloading || ready; inline error with retry + "use Associate existing" hint whenerror. embedReadyTimeoutMstimer ononMounted→panelState.value = 'error'ifEMBED_READYnot received in time; cleared onEMBED_READY.- Stub handler:
function handleMessage(_event: MessageEvent) { /* TODO Task 2: origin + version guard + EMBED_INIT + handle TICKET_CREATED/ERROR/CLOSE/RESIZE */ }. onMounted(() => window.addEventListener('message', handleMessage)).onUnmounted(() => window.removeEventListener('message', handleMessage)).- Create
TicketEmbedPanel.spec.ts— tests:iframe[src]ends with/embed/ticket/create; iframe hassandbox="allow-forms allow-scripts allow-same-origin"; initial state is loading; timeout transitions to error state (vi.useFakeTimers+vi.runAllTimers); error state shows retry button.
- Props type (mirrors RFC Detail 2.A):
- Extend
AssociatedTickets.vue:- Add to
@mekari/pixel3import block:MpPopover,MpPopoverTrigger,MpPopoverContent,MpPopoverList,MpPopoverListItem. - Add to script imports:
import { storeToRefs } from 'pinia',import { useFeatureFlagStore } from '~/common/store/FeatureFlagStore',import { TICKET_CREATE_PERMISSION } from './AssociatedTickets.constants',import TicketEmbedPanel from './TicketEmbedPanel.vue'. - Add
isShowCreateTicket = ref(false). - Add
canCreateTicketcomputed (mirrorsNotes.vuecanAddNotesL83–97):const canCreateTicket = computed(() => {const { permissions } = storeToRefs(useUserStore())if (!permissions.value) return falseconst group = Object.values(permissions.value)[0]if (!group?.permissions) return falseconst perm = group.permissions.find((p: UserPermission) => p.name === TICKET_CREATE_PERMISSION)return perm?.is_enabled === true && useFeatureFlagStore().featureFlags['cdp_create_ticket_embed']}) - Replace single
<MpButton ... left-icon="add" @click="handleShowAssociateExisting" />(lines 15–21) with theMpPopoverblock mirroringAssociatedDeals.vuelines 35–53 (twoMpPopoverListItems: "Associate existing" + "Create Ticket"v-if="canCreateTicket"). - Add
TicketEmbedPanelblock (abovev-if="isShowAssociateExisting") with back-arrow header, shown whenisShowCreateTicket. - Add stub handler:
function handleCreateTicketCreated(_payload: unknown) { isShowCreateTicket.value = false; fetchWithReset() /* full wiring in Task 2 */ }.
- Add to
- Go green —
pnpm vitest run features/customers/detail/components/AssociatedTickets.spec.tsandpnpm vitest run features/customers/detail/components/TicketEmbedPanel.spec.ts. - Quality gate —
pnpm lint && pnpm build.
Acceptance criteria
- "Create Ticket" item visible when
TICKET_CREATE_PERMISSIONisis_enabled: trueandcdp_create_ticket_embedistrue—TCKT-S07-NEG/NEG-1, NEG-2. - "Create Ticket" absent from DOM when permission
is_enabled: false(flag ON). - "Create Ticket" absent from DOM when
cdp_create_ticket_embed: false(permission enabled). - "Associate existing" item visible and functional regardless of gate state — zero regression.
- Clicking "Create Ticket" renders
TicketEmbedPanelwith a sandboxed iframe whosesrcends in/embed/ticket/create—TCKT-S01/AC-2. - iframe has
sandbox="allow-forms allow-scripts allow-same-origin"andreferrerpolicy="strict-origin-when-cross-origin". - Loading state visible until
EMBED_READY; timeout transitions to error state with inline message —TCKT-S01/ERR-1. - Back-arrow closes the panel —
TCKT-S01/AC-1. -
pnpm lintclean,pnpm buildsucceeds.
Test strategy
AssociatedTickets.spec.ts uses createTestingPinia with initialState covering the four gate combinations (permission ✓/✗ × flag ON/OFF). Pixel3 components stubbed as pass-through divs. Key assertions: expect(screen.queryByText('Create Ticket')).toBeNull() vs screen.getByText('Create Ticket'). TicketEmbedPanel.spec.ts asserts on container.querySelector('iframe').src, .sandbox, and uses vi.useFakeTimers() to trigger the error state.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.5 |
| Backend | — |
| QA | 0.5 |
| Total | 2.0 |
Assumptions:
AssociatedDeals.vuepattern is a direct template (verified);useUserStorealready imported inAssociatedTickets.vue; no new pixel3 components to learn; permission key is a one-line constant swap once OQ-4 resolves.
Run to verify
pnpm vitest run features/customers/detail/components/AssociatedTickets.spec.ts && pnpm vitest run features/customers/detail/components/TicketEmbedPanel.spec.ts && pnpm lint
Depends on
Nothing — self-contained, uses only verified in-repo facts.
Task 2: [FE] postMessage contract — EMBED_INIT sender + TICKET_CREATED/ERROR/RESIZE handler + parent refresh (TCKT-S02, TCKT-S03)
When the embed confirms it's ready, the CDP host sends the customer context via typed
EMBED_INITto the exact CRM origin; when the agent submits the form,TICKET_CREATEDtriggers a list refresh, success toast, and Mixpanel event; spoofed origins and wrongversionvalues are silently dropped.
Status: 🚫 Blocked — requires CRM to (a) commit a CDP-reusable /embed/ticket/create (OQ-1), (b) confirm Qontak One auth in the iframe (OQ-2), and (c) freeze the postMessage payload contract (OQ-contract). The §2.A payload field names in the RFC are provisional — do not implement them until CRM signs off.
Design reference: n/a — design pending (OQ-8). Success state reuses the existing toastNotify success variant (pattern from AssociatedTickets.vue L141).
What to build
Replace the TicketEmbedPanel.vue stub handler with the real typed implementation, then wire AssociatedTickets.vue @created to trigger a list refresh, success toast, and Mixpanel event.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | features/customers/detail/components/TicketEmbedPanel.vue | Replace stub handleMessage with typed origin+version guard; add buildEmbedInit() sender on EMBED_READY; handle TICKET_CREATED/TICKET_CREATE_ERROR/EMBED_CLOSE/EMBED_RESIZE |
| extend | features/customers/detail/components/AssociatedTickets.vue | Replace stub handleCreateTicketCreated with full handler: fetchWithReset + markContactUpdated + toast + Mixpanel |
| extend | features/customers/detail/components/TicketEmbedPanel.spec.ts | Replace stub assertions with typed contract tests |
| extend | features/customers/detail/components/AssociatedTickets.spec.ts | Add @created handler integration tests |
Implementation steps
- Confirm the frozen CRM contract first — obtain the final
EMBED_INIT/TICKET_CREATED/TICKET_CREATE_ERROR/EMBED_CLOSE/EMBED_RESIZEfield names from the CRM FE team. Compare against RFC §2.A. Do not assume the PRD §8 field names are correct — they are sourced from an unfrozen RFC. - Write failing tests (red) — extend
features/customers/detail/components/TicketEmbedPanel.spec.ts:vi.spyOn(iframe.contentWindow, 'postMessage')to assert outboundEMBED_INIT.- Test:
EMBED_READYfrom CRM origin →postMessagecalled once with{ version: 1, roomId: null, qontakCustomerId: '123', ... }andtargetOrigin === CRM_ORIGIN. - Test:
EMBED_READYfrom spoofed origin →postMessageNOT called. - Test:
TICKET_CREATED(version 1, CRM origin) →createdemitted. - Test:
TICKET_CREATED(version 2) → no emit. - Test:
TICKET_CREATE_ERROR→panelState='error',erroremitted. - Test:
EMBED_RESIZE { height: 500 }→resizeemitted with500. - Test:
messagelistener removed on unmount. Run:pnpm vitest run features/customers/detail/components/TicketEmbedPanel.spec.ts
- Implement the message handler in
TicketEmbedPanel.vue— replace the// TODO Task 2stub:const allowedOrigin = computed(() => new URL(config.CRM_V3_EMBED_URL).origin)function buildEmbedInit() {return {version: 1 as const, roomId: null,qontakCustomerId: props.customerContext.qontakCustomerId,contactName: props.customerContext.contactName,contactPhone: props.customerContext.contactPhone,contactEmail: props.customerContext.contactEmail,contactAccountUniqId: props.customerContext.contactAccountUniqId,channelType: props.customerContext.channelType,locale: props.customerContext.locale,}}function handleMessage(event: MessageEvent) {if (event.origin !== allowedOrigin.value) returnconst payload = event.dataif (!payload || payload.version !== 1) returnif (payload.type === 'EMBED_READY') {clearTimeout(readyTimer)panelState.value = 'ready'iframeEl.value?.contentWindow?.postMessage(buildEmbedInit(), allowedOrigin.value)} else if (payload.type === 'TICKET_CREATED') {panelState.value = 'success'emit('created', payload)} else if (payload.type === 'TICKET_CREATE_ERROR') {panelState.value = 'error'emit('error', { errorCode: payload.errorCode, errorMessage: payload.errorMessage })} else if (payload.type === 'EMBED_CLOSE') {emit('close', payload.reason)} else if (payload.type === 'EMBED_RESIZE') {emit('resize', payload.height)}} - Extend
AssociatedTickets.vue— addimport { useMixpanel } from '~/common/composables/useMixpanel', replace stub with:async function handleCreateTicketCreated(payload: TicketCreatedPayload) {isShowCreateTicket.value = falseawait fetchWithReset()customerStore.markContactUpdated('tickets')toastNotify({ position: 'top-center', variant: 'success', title: 'Ticket created successfully' })useMixpanel().track('[Qontak One] [Customer] Create Ticket Embed Created', {contact_id: contactId,ticket_id: payload.ticketId,pipeline_id: payload.pipelineId ?? null,})} - Extend
AssociatedTickets.spec.ts— add tests: simulateTicketEmbedPanelemittingcreated(mock the component), assertfetchTicketsAssociatedcalled, success toast appeared, Mixpaneltrackcalled with correct event name. - Go green —
pnpm vitest run features/customers/detail/components/TicketEmbedPanel.spec.tsandpnpm vitest run features/customers/detail/components/AssociatedTickets.spec.ts. - Quality gate —
pnpm lint && pnpm build.
Acceptance criteria
- On
EMBED_READYfromnew URL(config.CRM_V3_EMBED_URL).origin,iframe.contentWindow.postMessagecalled once with{ version: 1, roomId: null, qontakCustomerId: <route contactId>, ... }—TCKT-S02/AC-1. -
EMBED_INITNOT posted whenEMBED_READYarrives from any other origin —TCKT-S02/ERR-1, ERR-2. -
TICKET_CREATED(correct origin, version 1) → panel closes, list refreshes, success toast fired,cdp_ticket_embed_createdtracked —TCKT-S03/AC-1, AC-2, AC-3. -
TICKET_CREATEDwith version 2 or wrong origin → no side effect —TCKT-S03/ERR-1. -
TICKET_CREATE_ERROR→ panel stays open in error state, retry available —TCKT-S03/ERR-2. -
EMBED_RESIZE→resizeemitted with correct height value. -
messagelistener removed on unmount.
Test strategy
TicketEmbedPanel.spec.ts dispatches MessageEvent on window with controlled origin and data. vi.spyOn(iframe.contentWindow, 'postMessage') confirms outbound calls. AssociatedTickets.spec.ts mounts the parent with TicketEmbedPanel stubbed; stub emits created; assertions cover fetchTicketsAssociated mock, toastNotify spy, and useMixpanel().track spy.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.0 |
| Backend | — |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: panel state machine already scaffolded in Task 1 (handler logic only);
useMixpanelverified in-repo; no new API calls from CDP side.
Run to verify
pnpm vitest run features/customers/detail/components/TicketEmbedPanel.spec.ts && pnpm vitest run features/customers/detail/components/AssociatedTickets.spec.ts && pnpm lint
Depends on
- Task 1 (panel scaffold must exist)
- External: CRM OQ-1 (CDP-reusable embed committed to a phase)
- External: CRM OQ-2 (Qontak One auth in the iframe confirmed)
- External: OQ-contract (postMessage payload field names frozen by CRM FE)
Task 3: [FE] Fallback associate (TCKT-S04)
When the CRM embed creates a ticket but does not auto-associate it to the customer (OQ-6 path), CDP calls the existing
POST /v1/leads/{contactId}/tickets/{ticketId}to link them; on failure a warning toast and Mixpanel event are logged without hiding the creation success.
Status: 🚫 Blocked — depends on (a) ticketId from TICKET_CREATED payload (Task 2), and (b) OQ-6 resolution. If CRM auto-associates on the CDP embed path, skip this task entirely.
Design reference: n/a — no new UI surface; failure uses the existing toastNotify warning variant.
What to build
A conditional fallback block inside handleCreateTicketCreated in AssociatedTickets.vue. The endpoint POST /v1/leads/{contactId}/tickets/{ticketId} is already in use at line 182 — this is a conditional reuse, not new code.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | features/customers/detail/components/AssociatedTickets.vue | Add fallback associate call inside handleCreateTicketCreated (conditional on OQ-6 decision) |
| extend | features/customers/detail/components/AssociatedTickets.spec.ts | Fallback path tests using msw mock of the associate endpoint |
Implementation steps
- Confirm OQ-6 — if CRM auto-associates on the CDP embed path, stop here. If not, proceed.
- Write failing tests (red) — extend
AssociatedTickets.spec.tswithmswhttp.post('/v1/leads/*/tickets/*', ...):- Test (auto-assoc = true in payload): no POST issued; success toast still shown.
- Test (auto-assoc = false): one
POST /v1/leads/123/tickets/456issued. - Test (fallback POST returns 500):
cdp_ticket_embed_associate_failedtracked, warning toast shown, list still refreshed.
- Implement in
AssociatedTickets.vue— insidehandleCreateTicketCreated, afterfetchWithReset():if (!payload.isAutoAssociated) { // field name confirmed from OQ-6const { $customFetch } = useCustomFetch()const { config } = useCustomConfig()try {await $customFetch(`/v1/leads/${contactId}/tickets/${payload.ticketId}`, {method: 'POST',baseURL: config.CUSTOMER_360_CRM_URL,})} catch (error) {useMixpanel().track('[Qontak One] [Customer] Create Ticket Embed Associate Failed', {contact_id: contactId, ticket_id: payload.ticketId, reason: (error as Error).message,})toastNotify({ position: 'top-center', variant: 'warning', title: "Ticket created but couldn't be linked. Retry?" })}} - Go green —
pnpm vitest run features/customers/detail/components/AssociatedTickets.spec.ts. - Quality gate —
pnpm lint && pnpm build.
Acceptance criteria
- If CRM auto-associates (OQ-6 = yes): no fallback POST issued —
TCKT-S04/AC-1. - If not: one
POST /v1/leads/{contactId}/tickets/{ticketId}withticketIdfromTICKET_CREATED—TCKT-S04/AC-2. - POST failure →
cdp_ticket_embed_associate_failedtracked + warning toast; list still refreshed —TCKT-S04/ERR-1.
Test strategy
AssociatedTickets.spec.ts uses msw http.post to return 200 or 500. vi.spyOn(useMixpanel(), 'track') asserts the Mixpanel call on failure. vi.spyOn(toastNotify) asserts variant: 'warning' on failure and variant: 'success' on success.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 0.5 |
| Backend | — |
| QA | 0.5 |
| Total | 1.0 |
Assumptions:
POST /v1/leads/{contactId}/tickets/{ticketId}is the same endpoint already in use atAssociatedTickets.vueL182 — conditional reuse, not new code;useCustomFetchandconfig.CUSTOMER_360_CRM_URLalready in scope.
Run to verify
pnpm vitest run features/customers/detail/components/AssociatedTickets.spec.ts && pnpm lint
Depends on
- Task 2 (
ticketIdcomes fromTICKET_CREATEDpayload wired in Task 2) - External: OQ-6 (CRM confirms whether auto-association happens on the CDP embed path)
Ordering rationale
- Task 1 first — immediately executable; ships a feature-flagged stub behind
cdp_create_ticket_embed: falsewith no visible change to users. Creates the spec harness that Tasks 2 and 3 extend. - Task 2 second — the critical-path contract work. Nothing closes until CRM resolves OQ-1/OQ-2/contract. Push on this externally now.
- Task 3 last and conditional — depends on
ticketIdfrom Task 2 and OQ-6 confirmation. If CRM auto-associates, Task 3 is a no-op; if not, it is small (0.5 FE day) and should bundle into the same PR as Task 2. - OQ-4 (permission key) is the only blocker preventing full end-to-end in Task 1 — a one-line constant swap. Start Task 1 now with the placeholder and unblock OQ-4 in parallel.
Skipped stories
| Story | Reason |
|---|---|
TCKT-S05 — data_source labels ticket as CDP-originated | Cross-squad: CRM Backend must add embed-web-cdp to ALLOWED_EMBED_SOURCES; no CDP-side code change (OQ-3) |
| TCKT-S06 — Pipeline + custom layout inherited from CRM | Cross-squad: CRM FE-owned (useTicketPipeline / useTicketLayout); CDP only hosts the iframe |