Skip to main content

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

TaskFE daysBE daysQA daysTotal
Task 1 — Gate + UI Shell (TCKT-S07-NEG, TCKT-S01)1.50.52.0
Task 2 — postMessage contract + parent refresh (TCKT-S02, TCKT-S03)1.00.51.5
Task 3 — Fallback associate (TCKT-S04)0.50.51.0
Grand total3.01.54.5

Confidence: low. The CRM postMessage contract (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-4 placeholder 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

ActionFileWhat changes
createfeatures/customers/detail/components/AssociatedTickets.constants.tsTICKET_CREATE_PERMISSION constant (placeholder for OQ-4)
extendcommon/store/FeatureFlagStore.tsAdd cdp_create_ticket_embed: boolean to FeatureFlags interface + DEFAULT_FEATURE_FLAGS
extendfeatures/customers/detail/components/AssociatedTickets.vueImport useFeatureFlagStore, storeToRefs, TICKET_CREATE_PERMISSION, MpPopover* components, TicketEmbedPanel; add canCreateTicket + isShowCreateTicket; replace MpButton add-button with MpPopover "+" menu; add panel block
createfeatures/customers/detail/components/TicketEmbedPanel.vueSandboxed iframe SFC — props (customerContext, embedReadyTimeoutMs), emits (created, close, error, resize), state machine (hidden/loading/ready/error/success), stub message handler
createfeatures/customers/detail/components/TicketEmbedPanel.spec.tsPanel unit tests (iframe src, sandbox attr, loading state, timeout → error)
createfeatures/customers/detail/components/AssociatedTickets.spec.tsGate + menu + open/close tests

Implementation steps

  1. Explore the reference files — Read AssociatedDeals.vue lines 1–55 (the exact MpPopover menu template + isShowCreateDeal toggle) and lines 160–175 (createDealUrl computed + handleShowCreateDeal). Note the imports list: MpPopover, MpPopoverTrigger, MpPopoverContent, MpPopoverList, MpPopoverListItem must be added to the @mekari/pixel3 import in AssociatedTickets.vue. Read features/customers/detail/components/Notes/Notes.vue lines 60–97 to internalize the canAddNotes permission-gate pattern — canCreateTicket is a direct copy with a different key.
  2. Write failing tests (red) — Create features/customers/detail/components/AssociatedTickets.spec.ts using the Notes.spec.ts harness as the template (vitest + @testing-library/vue render + createTestingPinia with initialState):
    • 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 isShowCreateTicket false.
    • Test: "Associate existing" still visible and unchanged in all states. Run: pnpm vitest run features/customers/detail/components/AssociatedTickets.spec.ts
  3. Create AssociatedTickets.constants.ts:
    // TODO OQ-4: confirm exact CanCan permission key with CRM team
    export const TICKET_CREATE_PERMISSION = 'TBD_ticket_create_key'
  4. Extend FeatureFlagStore.ts — add one line to FeatureFlags interface: cdp_create_ticket_embed: boolean and one line to DEFAULT_FEATURE_FLAGS: cdp_create_ticket_embed: false.
  5. Scaffold TicketEmbedPanel.vue — new SFC at features/customers/detail/components/TicketEmbedPanel.vue:
    • Props type (mirrors RFC Detail 2.A):
      interface TicketEmbedPanelProps {
      customerContext: {
      qontakCustomerId: string
      contactName?: string; contactPhone?: string; contactEmail?: string
      contactAccountUniqId?: 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" .../> when loading || ready; inline error with retry + "use Associate existing" hint when error.
    • embedReadyTimeoutMs timer on onMountedpanelState.value = 'error' if EMBED_READY not received in time; cleared on EMBED_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 has sandbox="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.
  6. Extend AssociatedTickets.vue:
    • Add to @mekari/pixel3 import 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 canCreateTicket computed (mirrors Notes.vue canAddNotes L83–97):
      const canCreateTicket = computed(() => {
      const { permissions } = storeToRefs(useUserStore())
      if (!permissions.value) return false
      const group = Object.values(permissions.value)[0]
      if (!group?.permissions) return false
      const 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 the MpPopover block mirroring AssociatedDeals.vue lines 35–53 (two MpPopoverListItems: "Associate existing" + "Create Ticket" v-if="canCreateTicket").
    • Add TicketEmbedPanel block (above v-if="isShowAssociateExisting") with back-arrow header, shown when isShowCreateTicket.
    • Add stub handler: function handleCreateTicketCreated(_payload: unknown) { isShowCreateTicket.value = false; fetchWithReset() /* full wiring in Task 2 */ }.
  7. Go greenpnpm vitest run features/customers/detail/components/AssociatedTickets.spec.ts and pnpm vitest run features/customers/detail/components/TicketEmbedPanel.spec.ts.
  8. Quality gatepnpm lint && pnpm build.

Acceptance criteria

  • "Create Ticket" item visible when TICKET_CREATE_PERMISSION is is_enabled: true and cdp_create_ticket_embed is trueTCKT-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 TicketEmbedPanel with a sandboxed iframe whose src ends in /embed/ticket/createTCKT-S01/AC-2.
  • iframe has sandbox="allow-forms allow-scripts allow-same-origin" and referrerpolicy="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 lint clean, pnpm build succeeds.

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

DisciplineDays
Frontend1.5
Backend
QA0.5
Total2.0

Assumptions: AssociatedDeals.vue pattern is a direct template (verified); useUserStore already imported in AssociatedTickets.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_INIT to the exact CRM origin; when the agent submits the form, TICKET_CREATED triggers a list refresh, success toast, and Mixpanel event; spoofed origins and wrong version values 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

ActionFileWhat changes
extendfeatures/customers/detail/components/TicketEmbedPanel.vueReplace stub handleMessage with typed origin+version guard; add buildEmbedInit() sender on EMBED_READY; handle TICKET_CREATED/TICKET_CREATE_ERROR/EMBED_CLOSE/EMBED_RESIZE
extendfeatures/customers/detail/components/AssociatedTickets.vueReplace stub handleCreateTicketCreated with full handler: fetchWithReset + markContactUpdated + toast + Mixpanel
extendfeatures/customers/detail/components/TicketEmbedPanel.spec.tsReplace stub assertions with typed contract tests
extendfeatures/customers/detail/components/AssociatedTickets.spec.tsAdd @created handler integration tests

Implementation steps

  1. Confirm the frozen CRM contract first — obtain the final EMBED_INIT / TICKET_CREATED / TICKET_CREATE_ERROR / EMBED_CLOSE / EMBED_RESIZE field 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.
  2. Write failing tests (red) — extend features/customers/detail/components/TicketEmbedPanel.spec.ts:
    • vi.spyOn(iframe.contentWindow, 'postMessage') to assert outbound EMBED_INIT.
    • Test: EMBED_READY from CRM origin → postMessage called once with { version: 1, roomId: null, qontakCustomerId: '123', ... } and targetOrigin === CRM_ORIGIN.
    • Test: EMBED_READY from spoofed origin → postMessage NOT called.
    • Test: TICKET_CREATED (version 1, CRM origin) → created emitted.
    • Test: TICKET_CREATED (version 2) → no emit.
    • Test: TICKET_CREATE_ERRORpanelState = 'error', error emitted.
    • Test: EMBED_RESIZE { height: 500 }resize emitted with 500.
    • Test: message listener removed on unmount. Run: pnpm vitest run features/customers/detail/components/TicketEmbedPanel.spec.ts
  3. Implement the message handler in TicketEmbedPanel.vue — replace the // TODO Task 2 stub:
    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) return
    const payload = event.data
    if (!payload || payload.version !== 1) return
    if (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)
    }
    }
  4. Extend AssociatedTickets.vue — add import { useMixpanel } from '~/common/composables/useMixpanel', replace stub with:
    async function handleCreateTicketCreated(payload: TicketCreatedPayload) {
    isShowCreateTicket.value = false
    await 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,
    })
    }
  5. Extend AssociatedTickets.spec.ts — add tests: simulate TicketEmbedPanel emitting created (mock the component), assert fetchTicketsAssociated called, success toast appeared, Mixpanel track called with correct event name.
  6. Go greenpnpm vitest run features/customers/detail/components/TicketEmbedPanel.spec.ts and pnpm vitest run features/customers/detail/components/AssociatedTickets.spec.ts.
  7. Quality gatepnpm lint && pnpm build.

Acceptance criteria

  • On EMBED_READY from new URL(config.CRM_V3_EMBED_URL).origin, iframe.contentWindow.postMessage called once with { version: 1, roomId: null, qontakCustomerId: <route contactId>, ... }TCKT-S02/AC-1.
  • EMBED_INIT NOT posted when EMBED_READY arrives 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_created tracked — TCKT-S03/AC-1, AC-2, AC-3.
  • TICKET_CREATED with 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_RESIZEresize emitted with correct height value.
  • message listener 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

DisciplineDays
Frontend1.0
Backend
QA0.5
Total1.5

Assumptions: panel state machine already scaffolded in Task 1 (handler logic only); useMixpanel verified 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

ActionFileWhat changes
extendfeatures/customers/detail/components/AssociatedTickets.vueAdd fallback associate call inside handleCreateTicketCreated (conditional on OQ-6 decision)
extendfeatures/customers/detail/components/AssociatedTickets.spec.tsFallback path tests using msw mock of the associate endpoint

Implementation steps

  1. Confirm OQ-6 — if CRM auto-associates on the CDP embed path, stop here. If not, proceed.
  2. Write failing tests (red) — extend AssociatedTickets.spec.ts with msw http.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/456 issued.
    • Test (fallback POST returns 500): cdp_ticket_embed_associate_failed tracked, warning toast shown, list still refreshed.
  3. Implement in AssociatedTickets.vue — inside handleCreateTicketCreated, after fetchWithReset():
    if (!payload.isAutoAssociated) { // field name confirmed from OQ-6
    const { $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?" })
    }
    }
  4. Go greenpnpm vitest run features/customers/detail/components/AssociatedTickets.spec.ts.
  5. Quality gatepnpm 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} with ticketId from TICKET_CREATEDTCKT-S04/AC-2.
  • POST failure → cdp_ticket_embed_associate_failed tracked + 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

DisciplineDays
Frontend0.5
Backend
QA0.5
Total1.0

Assumptions: POST /v1/leads/{contactId}/tickets/{ticketId} is the same endpoint already in use at AssociatedTickets.vue L182 — conditional reuse, not new code; useCustomFetch and config.CUSTOMER_360_CRM_URL already in scope.

Run to verify

pnpm vitest run features/customers/detail/components/AssociatedTickets.spec.ts && pnpm lint

Depends on

  • Task 2 (ticketId comes from TICKET_CREATED payload 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: false with 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 ticketId from 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

StoryReason
TCKT-S05 — data_source labels ticket as CDP-originatedCross-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 CRMCross-squad: CRM FE-owned (useTicketPipeline / useTicketLayout); CDP only hosts the iframe