Skip to main content

Task Breakdown — Export Customer Data (Horizontal Mode)

RFC: rfc-export-customer-data.md Mode: Horizontal — Phase 1: all UI work (mocked APIs) → Phase 2: all API integration (BE + FE wiring)


Pre-step: Component Audit

Distinct components/screens touched across all stories, with current status:

ComponentPhase 1 statusRFC planned pathActual repo path
ExportCustomerDrawer.vueExists — bugs to fixexport/views/ExportCustomerPage.vuefeatures/customers/views/components/ExportCustomerDrawer.vue
FloatingBulkAction.vueExists — completeListTable.vue popover itemfeatures/customers/views/components/FloatingBulkAction.vue
ListPage.vueExists — permission TODOsamesame
BE pipeline (all)Net-newchunks 1–10contact-service/internal/...

What's already built (FE): ExportCustomerDrawer.vue has the drawer shell, file format selector (XLSX/CSV), layout selector, field groups (Customers Info / Default / Custom), field-load states (loading/error/empty/ready), and pagination of field_properties. FloatingBulkAction.vue has the "Download selected" popover item with canExport prop. ListPage.vue has selectedCustomerIds: ref<Set<string>>, MAX_SELECTION cap, isExportDrawerOpen, and handleExportSelected.

Bugs/gaps (FE): handleSubmit is mocked (no real API call); field watchers use f.name_alias instead of f.name for the selected_fields[] values; canExport computed is commented out (TODO at ListPage.vue:560); no localStorage persistence; no Mixpanel events.

BE: Zero export-related code in contact-service — everything is net-new (grep for /export, ExportContactJob, MaxExportRow → 0 hits).

v2.6 refresh (2026-06-30) — chunks 18–21 added: this breakdown was extended to cover the PRD-v2.6 scope. New tasks: 1.3 (FE first_10k_sorted "Select all first 10,000" shortcut + export cap raise, chunk 20), 1.4 (FE right-side panel completion — Timezone/Period/Source/Layout + Retry, chunk 21), 2.7 (BE timezone-aware timestamp formatting, chunk 18), 2.8 (BE export-history registrar, chunk 19 — blocked on OQ-17). Grounded against the live repos: ExportCustomerDrawer.vue timezone MpAutocomplete is is-disabled today (:36); InputPeriod.vue/FilterCheckbox.vue/timezones.ts exist in common/; ListPage.vue MAX_SELECTION = 500 (:220), pagination.value.total (:450); contact-service has no timezone formatter and no export-history/billings client (both net-new). Tasks 1.3/1.4 share ExportCustomerDrawer.vue / selection surfaces with Tasks 1.1/2.6 — sequence 1.4 immediately after 1.1 (same branch/PR).


Effort Summary

Phase / AreaFE daysBE daysQA daysTotal
Phase 1 — UI (mocked)628
Phase 2 — API integration1.513.5318
Grand total7.513.5526
TaskFEBEQATotal
1.1 Fix ExportCustomerDrawer field-key + localStorage1.50.52
1.2 Wire canExport permission + feature flag0.50.51
1.3 FE first_10k_sorted shortcut + export cap raise (chunk 20)1.50.52
1.4 FE right-side panel completion — Timezone/Period/Source/Layout + Retry (chunk 21)2.50.53
2.1 BE Foundation — constants, structs, job store11
2.2 BE Serializers — XLSX, CSV, type formatter30.53.5
2.3 BE Email methods + Notification client20.52.5
2.4 BE Export Consumer — full async pipeline30.53.5
2.5 BE Handler, routes, service, rate-limit2.50.53
2.6 FE Wire real POST + error handling + Mixpanel1.50.52
2.7 BE timezone-aware timestamp formatting (chunk 18)10.51.5
2.8 BE export-history registrar — 🚫 blocked OQ-17 (chunk 19)11

Confidence: medium. Key assumptions: OQ-10 (notification ingest channel) is resolved as HTTP heimdall default — switching to Kafka would add ~1 BE day to Task 2.3. The consumer (Task 2.4) is the highest-risk task. For the v2.6 chunks (18–21): Task 2.8 (chunk 19) is blocked on OQ-17 — the 1-day estimate covers only the interface scaffold + non-fatal wiring; the real cross-team register path (CDP quota type + s2s endpoint on the IAG billing surface) is not built, so full EXP-S08 delivery is out of this estimate. Task 2.7 (chunk 18) assumes OQ-20 is pinned to an IANA timezone value (Asia/Jakarta) with the FE mapping its display label on send. Tasks 1.3/1.4 assume the criterion-based BE resolution (first_10k_sorted + period/source filters) lands as a Task 2.5 extension (chunk-9 work, OQ-18).


Phase 1 — UI (APIs mocked)

Task 1.1: Fix ExportCustomerDrawer — field-key mapping + localStorage persist (EXP-S03, EXP-S07)

Users see the correct export fields for the selected layout, their last selection is restored on reopen, and the submit body will use the right field keys — all without a live backend.

Status: ✅ Actionable

What to build

Fix the name_aliasname bug in the three field-checked watchers (the POST body's selected_fields[] must use name, the field key — not name_alias, the display label). Add localStorage pre-load / save-on-submit / Reset logic. The drawer shell and UI states are already complete.

Implementation Plan

ActionFileWhat changes
fixfeatures/customers/views/components/ExportCustomerDrawer.vue:278-287Change all three field watchers: .map((f) => f.name_alias).map((f) => f.name) so the checked values are field keys
extendsameOn isOpen=true, after fields load: read localStorage.getItem('export_field_config_{selectedLayout}'), parse {selected_fields, format}, intersect saved selected_fields with loaded field names (missing-field guard, EXP-S07/AC-6), restore checked state and fileFormat
extendsameIn handleSubmit: before emitting, write localStorage.setItem('export_field_config_{selectedLayout}', JSON.stringify({selected_fields: [...checkedCustomerInfoFields, ...checkedDefaultFields, ...checkedCustomFields], format: fileFormat}))
extendsameAdd Reset button to MpDrawerFooter — calls localStorage.removeItem(key) + resets all checked arrays to full field list + resets fileFormat to 'XLSX'
extendfeatures/customers/views/components/ExportCustomerDrawer.spec.tsNew test cases: field watchers use name not name_alias; localStorage restores on reopen; missing-field guard silently drops unknown keys; Reset clears storage and restores defaults; format change keeps field selection

Acceptance criteria

  • Each checkbox model value is the field's name property (not name_alias); all three field watchers use f.name
  • On first open, all fields default-checked
  • On reopen after prior submit, localStorage restores both selected_fields and format
  • Fields in localStorage not present in the loaded field list are silently dropped (EXP-S07/AC-6)
  • Reset button clears export_field_config_{layout_id} and restores all-checked / XLSX defaults
  • Switching format XLSX↔CSV does not clear the current field selection
  • Spec covers all scenarios above; existing mocked handleSubmit path still passes

Test strategy

Mock $customFetch to return a 2-page fixture field list (Customer Info / Default / Custom groups). Assert checkedDefaultFields values equal fields.map(f => f.name). Spy on localStorage.setItem after submit. Assert format toggle does not mutate checkedDefaultFields.

Effort estimate

DisciplineDays
Frontend1.5
Backend
QA0.5
Total2

Assumptions: drawer shell, field-load states, and pagination are already implemented; localStorage API is available; no design changes required.

Implementation steps

  1. Write failing tests (red) — Extend ExportCustomerDrawer.spec.ts. Add 5 tests: (a) field watcher sets checkedDefaultFields to field.name values (not name_alias); (b) localStorage.getItem is called on second open and restores selected_fields + format; (c) a name in localStorage absent from the live field list is silently dropped; (d) Reset button clears localStorage and restores all-checked defaults; (e) switching fileFormat leaves checkedDefaultFields unchanged. Run pnpm test -- ExportCustomerDrawer.spec.ts and confirm all 5 fail.
  2. Fix the three field watchers — In ExportCustomerDrawer.vue:278-287, change each watcher from .map((f) => f.name_alias) to .map((f) => f.name). These values become the selected_fields[] keys in the POST body.
  3. Add the storage key helper — Near the top of <script setup>, define const storageKey = computed(() => \export_field_config_${selectedLayout.value}`)`.
  4. Implement pre-load — Inside the watch(() => props.isOpen) handler, after fetchFieldProperties() resolves: read localStorage.getItem(storageKey.value), parse {selected_fields, format}, intersect selected_fields with customerProperties.value.map(f => f.name) (missing-field guard), then restore checkedCustomerInfoFields.value, checkedDefaultFields.value, checkedCustomFields.value, and fileFormat.value. Wrap in try/catch — if localStorage is corrupt, keep defaults.
  5. Implement save on submit — At the top of handleSubmit, before the toast, call localStorage.setItem(storageKey.value, JSON.stringify({ selected_fields: [...checkedCustomerInfoFields.value, ...checkedDefaultFields.value, ...checkedCustomFields.value], format: fileFormat.value })).
  6. Add Reset button — In MpDrawerFooter, add a third MpButton variant="ghost" labelled "Reset" between Cancel and Download. Its @click handler: localStorage.removeItem(storageKey.value), then reset all three checked arrays to fields.map(f => f.name) of their respective computed groups, reset fileFormat.value = 'XLSX'.
  7. Go green — Run pnpm test -- features/customers/views/components/ExportCustomerDrawer until all tests pass.
  8. Quality gate — Run pnpm lint && pnpm build.

Run to verify

pnpm test -- features/customers/views/components/ExportCustomerDrawer

Depends on

None — UI-only fix, no BE contract needed.


Task 1.2: Wire canExport permission + feature flag in ListPage.vue (EXP-S06-NEG)

Users without the export permission key or with the feature flag OFF never see the Export button.

Status: ✅ Actionable

What to build

Uncomment and implement canExport computed at ListPage.vue:560-566. Wire to userStore.hasAssociatedAccess('customers_customers_export') AND the feature flag store for cdp_export_customer_enabled. Confirm the <FloatingBulkAction :can-export="canExport"> binding is already wired (it is, at line 67).

Implementation Plan

ActionFileWhat changes
fixfeatures/customers/views/ListPage.vue:560-566Uncomment canExport computed; combine both conditions: featureFlagStore.flags['cdp_export_customer_enabled'] && userStore.hasAssociatedAccess('customers_customers_export')
verifysameConfirm <FloatingBulkAction :can-export="canExport"> is already bound (line 67 — no change needed)
extendfeatures/customers/views/components/FloatingBulkAction.spec.tsTests: canExport=false → Export list item absent from DOM; canExport=true → item present and clickable

Acceptance criteria

  • Export button absent when user lacks customers_customers_export permission
  • Export button absent when cdp_export_customer_enabled flag is OFF
  • Export button visible only when both conditions are true
  • Spec covers hidden and visible states

Test strategy

Mount FloatingBulkAction with canExport=false and assert [automation-label="floating-bulk-export"] is not rendered. Re-mount with canExport=true and assert it is rendered and emits exportSelected on click.

Effort estimate

DisciplineDays
Frontend0.5
Backend
QA0.5
Total1

Assumptions: useFeatureFlagStore and userStore are already imported in ListPage.vue; canExport is literally commented out — this is a one-line uncomment plus wiring, not a new composable.

Implementation steps

  1. Write failing tests (red) — Extend FloatingBulkAction.spec.ts. Add 2 tests: canExport=false[automation-label="floating-bulk-export"] absent from DOM; canExport=true → element present and emits exportSelected on click. Run pnpm test -- FloatingBulkAction.spec.ts and confirm both fail.
  2. Check existing imports in ListPage.vue — Confirm useFeatureFlagStore and the user permissions store are already imported. If the user store import is missing, add it following the existing store import pattern.
  3. Implement canExport computed — At ListPage.vue:560-566, replace the commented-out block with: const canExport = computed(() => featureFlagStore.flags['cdp_export_customer_enabled'] && userStore.hasAssociatedAccess('customers_customers_export')).
  4. Verify the existing binding — Check that <FloatingBulkAction :can-export="canExport" ...> is already on line 67. No template change needed if it's there.
  5. Go green — Run pnpm test -- features/customers/views/components/FloatingBulkAction && pnpm test -- features/customers/views/ListPage until all tests pass.
  6. Quality gate — Run pnpm lint && pnpm build.

Run to verify

pnpm test -- features/customers/views/components/FloatingBulkAction
pnpm test -- features/customers/views/ListPage

Depends on

None — uses existing FeatureFlagStore and userStore pattern already imported in ListPage.vue.


Task 1.3: [FE] "Select all first 10,000" shortcut + export cap raise (EXP-S02)

When a customer list has 10,000+ rows, the user can one-click select the first 10,000 in their current sort order and export them — without the FE ever collecting 10,000 IDs.

Status: ⚠️ Partially blocked — the shortcut UI, the total ≥ 10,000 gate, the cap raise, and the first_10k_sorted criterion assembly are all actionable now against a mocked submit. The real POST + the BE first_10k_sorted resolution (server resolves the first 10K via SearchContacts + SortBy + _id tie-breaker) is a Task 2.5 extension (chunk-9 work, OQ-18/REV-13) — wire the live call once that lands.

Design reference: https://www.figma.com/design/YtCkrXaXMD63i8diuXJ4QC/Customer-Data-Platform?node-id=16492-601704 · DS version: @mekari/pixel3@1.0.10-dev.0 · Frame: image-20260617-022708 (bulk-actions) · Design QA: CDP Design

What to build

Add a "Select all first 10,000" shortcut to the selection surface, shown only when pagination.total ≥ 10,000. In that mode the FE marks the first 10,000 selected, auto-disables row 10,001+ (no manual unselect/swap), and builds a server-side criterion object ({selection_mode: 'first_10k_sorted', order_by, order_direction, filter}) from the list's current sort/filter — instead of enumerating IDs. Raise/replace the MAX_SELECTION = 500 ceiling for the export path only.

Implementation Plan

ActionFileWhat changes
extendfeatures/customers/views/ListPage.vue:220Replace the hard MAX_SELECTION = 500 for the export flow: introduce EXPORT_MAX_SELECTION = 10000 (keep 500 for other bulk actions if intended); gate by an isExportSelection mode flag
extendfeatures/customers/views/ListPage.vue:283-297Add selectFirst10kSorted(): set an exportSelectionMode = 'first_10k_sorted' flag and build exportCriterion = { selection_mode: 'first_10k_sorted', order_by: orderKey.order_key (default 'created_at'), order_direction: orderKey.order_direction (default 'desc'), filter: currentFilter } (mirror the params built at :421); do not enumerate IDs
extendfeatures/customers/views/components/FloatingBulkAction.vue:18Add a "Select all first 10,000" MpPopoverListItem/button (automation-label="floating-select-first-10k"), rendered only via a showFirst10kShortcut prop; emits selectFirst10k
extendfeatures/customers/views/ListPage.vue:119Bind :show-first10k-shortcut="pagination.total >= 10000" and @select-first10k="selectFirst10kSorted" onto the selection surface; pass the criterion (not IDs) into ExportCustomerDrawer when in shortcut mode
extendfeatures/customers/views/components/FloatingBulkAction.spec.tsShortcut hidden when total < 10000; visible at >= 10000; emits selectFirst10k on click
extendfeatures/customers/views/ListPage.spec.ts (create if absent — match *.spec.ts convention)selectFirst10kSorted builds the criterion with current order_by/order_direction (not IDs); 10,001+ disabled; cap = 10,000 in export mode

Implementation steps

  1. Explore — Open features/customers/views/ListPage.vue and read the existing selection logic at :220-297 (note MAX_SELECTION, selectedCustomerIds, the :283-290 select-all-current-page block) and the param assembly at :421 (params.order_by = orderKey.order_key). Open FloatingBulkAction.vue:18 to see the existing canExport popover-item pattern to mirror.
  2. Write failing tests (red) — Add the FloatingBulkAction.spec.ts shortcut-visibility/emit tests and the ListPage criterion tests above. Run pnpm test -- features/customers/views/components/FloatingBulkAction and confirm they fail.
  3. Add the cap mode — In ListPage.vue, add EXPORT_MAX_SELECTION = 10000 and an export-selection mode flag; apply it in the add-selection guard (:227) and select-all (:283) so export tops out at 10,000.
  4. Build the criterion — Implement selectFirst10kSorted() assembling {selection_mode, order_by, order_direction, filter} from the current sort (default created_at/desc) — no ID enumeration; auto-disable rows beyond 10,000.
  5. Wire the shortcut control — Add the MpPopoverListItem in FloatingBulkAction.vue behind showFirst10kShortcut, emit selectFirst10k; bind visibility to pagination.total >= 10000 in ListPage.vue. Pass the criterion into the drawer when in shortcut mode (mock the submit for now — real POST in Task 2.6 ext).
  6. Go green — Run pnpm test -- features/customers until green.
  7. Quality gatepnpm lint && pnpm build.

Acceptance criteria

  • Shortcut is hidden when pagination.total < 10,000 and visible at >= 10,000
  • Activating it selects the first 10,000 in the current sort order and auto-disables 10,001+ (no manual unselect in this mode)
  • The FE sends a criterion {selection_mode: 'first_10k_sorted', order_by, order_direction, filter}never 10,000 IDs
  • order_by defaults to created_at/desc when no column is actively sorted
  • Export selection cap is 10,000 (not 500) in export mode; other bulk actions keep their existing cap
  • (Pending Task 2.5 ext / OQ-18) end-to-end: criterion POST resolves the first 10,000 server-side

Test strategy

Mount FloatingBulkAction with totalData below and at/above 10,000 and assert [automation-label="floating-select-first-10k"] visibility + selectFirst10k emit. In the ListPage spec, spy on the drawer props and assert the criterion object shape (no contact_ids array) and the default sort fallback.

Effort estimate

DisciplineDays
Frontend1.5
Backend
QA0.5
Total2

Assumptions: the list already exposes pagination.total (:450) and order_by (:421); the criterion is consumed by the existing drawer submit; raising the cap is a guard-condition change, not a new selection engine.

Run to verify

pnpm test -- features/customers/views/components/FloatingBulkAction && pnpm lint

Depends on

  • [Task 1.2] — selection surface (FloatingBulkAction canExport) in place
  • (for the live criterion POST) [Task 2.5 extension] — BE first_10k_sorted resolution (chunk 9, OQ-18)

Task 1.4: [FE] Right-side panel completion — Timezone / Period / Source / Layout + Retry (EXP-S09)

From the customer list, a user opens "Download all customers", picks a timezone, a last-updated period, source channels, and a layout, and exports the matching set (top 10,000) without leaving the list.

Status: ✅ Actionable (UI against mocked submit). ⚠️ Shares ExportCustomerDrawer.vue with Task 1.1 — develop in the same branch, sequenced immediately after Task 1.1 so the field-key/localStorage work merges cleanly. Real POST body (timezone/period/source[]) is wired in the Task 2.6 extension; tz value format (OQ-20/REV-15) and source enum (OQ-21/REV-16) must be confirmed before the live wire.

Design reference: https://www.figma.com/design/9fUk4MHn6KriVf5YT5H1MS/%F0%9F%93%8A-Chat-Panel---Reports?node-id=1705-42216 · DS version: @mekari/pixel3@1.0.10-dev.0 · Frame: Chat-Panel·Reports (export menu) · Design QA: CDP Design

What to build

Complete the existing ExportCustomerDrawer.vue: enable the currently is-disabled timezone MpAutocomplete (backed by timezones.ts, default (GMT+07:00) Asia/Jakarta); add a Period filter (reuse InputPeriod.vue, last-update range, default "All time"); add a Source multi-select (reuse FilterCheckbox.vue, default "All source"); auto-fill the default layout via GET /v1/layouts/default; add the field-load error → "Unable to load data — please click retry to reload data" + Retry state; gate Download until Timezone + Layout are valid; persist timezone/period/source alongside the Task 1.1 localStorage config.

Implementation Plan

ActionFileWhat changes
extendfeatures/customers/views/components/ExportCustomerDrawer.vue:31-36Remove is-disabled from the timezone MpAutocomplete; bind :data to timezones.ts options; keep selectedTimezone default DEFAULT_TIMEZONE (:175,:192); mark required
extendfeatures/customers/views/components/ExportCustomerDrawer.vueImport + render InputPeriod (Period, last-update; default All time → emits start_date/end_date RFC3339) and FilterCheckbox (Source multi-select; default All source) from common/components/
extendfeatures/customers/views/components/ExportCustomerDrawer.vue:45On open, call GET {CUSTOMER_360_URL}/v1/layouts/default to pre-select "Default view"; fall back to the layout list on 5xx
extendfeatures/customers/views/components/ExportCustomerDrawer.vueField-load error → "Unable to load data — please click retry to reload data" + a Retry button that re-fetches; Download stays disabled until fields load
extendfeatures/customers/views/components/ExportCustomerDrawer.vue:129isSubmitDisabled also requires a valid Timezone + Layout
extendfeatures/customers/views/components/ExportCustomerDrawer.vueExtend the Task 1.1 localStorage payload to {selected_fields, format, timezone, period, source} (read on open, write on submit, clear on Reset)
extendfeatures/customers/views/components/ExportCustomerDrawer.spec.tsTimezone selector enabled + required; Period/Source render with defaults and update on change; default-layout auto-fill; field-load error shows Retry and re-fetches; Download disabled until Timezone+Layout valid; persist round-trips tz/period/source

Implementation steps

  1. Explore — Open features/customers/views/components/ExportCustomerDrawer.vue and read the disabled timezone block (:31-36), DEFAULT_TIMEZONE/selectedTimezone (:175,:192), layout autocomplete (:45), isSubmitDisabled (:129), and handleSubmit (:289). Open common/components/InputPeriod.vue and common/components/FilterCheckbox.vue to learn their props/emits, and common/constants/timezones.ts for the option shape.
  2. Write failing tests (red) — Extend ExportCustomerDrawer.spec.ts with the cases above. Run pnpm test -- features/customers/views/components/ExportCustomerDrawer.spec.ts and confirm they fail.
  3. Enable timezone — Remove is-disabled, bind :data to the timezones.ts options, keep the (GMT+07:00) Asia/Jakarta default, mark required.
  4. Add Period + Source — Import and render InputPeriod (last-update, default All time) and FilterCheckbox (Source, default All source); hold their values in refs to feed the submit body / criterion.
  5. Default-layout auto-fill — On isOpen, GET /v1/layouts/default via useCustomFetch; pre-select the returned layout; on 5xx fall back to the layout list.
  6. Retry state — Wrap the field-properties fetch so a failure renders "Unable to load data — please click retry to reload data" + a Retry button that re-invokes the fetch; keep Download disabled until fields load.
  7. Gate + persist — Extend isSubmitDisabled to require Timezone + Layout; extend the Task 1.1 localStorage payload with timezone/period/source.
  8. Go greenpnpm test -- features/customers/views/components/ExportCustomerDrawer until green.
  9. Quality gatepnpm lint && pnpm build.

Acceptance criteria

  • Timezone selector is enabled, required, defaults to (GMT+07:00) Asia/Jakarta
  • Period (last-update) defaults to "All time"; presets + Custom scope the export by updated_at range
  • Source defaults to "All source"; multi-select scopes by channel
  • Layout auto-fills the default ("Default view") via GET /v1/layouts/default; falls back to the layout list on error
  • Field-load failure shows "Unable to load data — please click retry to reload data" + a working Retry button; Download stays disabled until fields load
  • Download is disabled until both Timezone and Layout are valid
  • localStorage round-trips timezone/period/source in addition to fields/format
  • (Pending Task 2.6 ext) the submit body carries timezone/period(or start_date/end_date)/source[]; tz format per OQ-20, source enum per OQ-21

Test strategy

Mock $customFetch: one stub for /v1/layouts/default (returns a default layout), one for /v1/contacts/field_properties (a 2-page field fixture, and a rejection case for the Retry path). Assert the timezone control is not disabled, Period/Source defaults render, the default layout pre-selects, Retry re-invokes the fetch, and Download is disabled until Timezone+Layout are set.

Effort estimate

DisciplineDays
Frontend2.5
Backend
QA0.5
Total3

Assumptions: InputPeriod/FilterCheckbox/timezones.ts are reused as-is (no new control built); the drawer shell + field groups already exist (Task 1.1); /v1/layouts/default is a live endpoint (verified, used by MainModalLayoutContent). Sequenced after Task 1.1 on the same file.

Run to verify

pnpm test -- features/customers/views/components/ExportCustomerDrawer && pnpm lint

Depends on

  • [Task 1.1] — same file (ExportCustomerDrawer.vue); do in the same branch, after 1.1
  • (for the live submit) [Task 2.6 extension] — POST body accepts timezone/period/source[]; [Task 2.7] — BE renders timestamps in the chosen timezone

Phase 2 — API Integration

Task 2.1: BE Foundation — constants, payload structs, extend job store (EXP-S01, EXP-S03)

All other BE tasks compile: MaxExportRow, ExportContactJobName, the request payload struct, and the extended BulkUploadJob model are in place.

Status: ✅ Actionable

What to build

Define the two constants, the request/job payload structs, and extend BulkUploadJob with the five new export-only fields. Add an optional compound index migration. No DDL migration required — MongoDB is schemaless; existing import queries use no job_type filter so they remain unaffected.

Implementation Plan

ActionFileWhat changes
createinternal/app/service/export_contact.goconst MaxExportRow = 10000 (mirrors bulk_import_contact.go:20)
modifyinternal/app/service/job_enqueuer.goAdd ExportContactJobName = "export_contact" alongside existing job-name consts
createinternal/app/payload/export_contact_request.goExportContactRequest{ContactIDs []string, LayoutID string, SelectedFields []string, Format string} + ExportJobPayload (marshalled into job.Args["data"])
modifyinternal/app/repository/bulk_upload_job/base.go:30Add to BulkUploadJob struct: JobType string, Format string, LayoutID string, SelectedFields []string, FailureReason string
createdb/migrations/NNN_index_bulk_upload_jobs_job_type.up.jsonCompound index {company_sso_id:1, job_type:1, created_at:-1}
createdb/migrations/NNN_index_bulk_upload_jobs_job_type.down.jsonDrop index

Acceptance criteria

  • make build passes with all new constants and struct fields
  • MaxExportRow = 10000 and ExportContactJobName are exported symbols
  • BulkUploadJob compiles with 5 new fields; existing import callers unaffected (legacy rows have no job_type — export queries filter by job_type:"export" so they correctly exclude them with no backfill)
  • make migrate-up && make migrate-down runs cleanly

Test strategy

make build is the primary gate. Struct-field compilation is validated transitively by the consumer and handler tasks that depend on these types.

Effort estimate

DisciplineDays
Frontend
Backend1
QA
Total1

Assumptions: MongoDB is schemaless so no DDL migration; existing BulkUploadJob struct pattern and migration JSON format are well-established; no user-facing behavior, so no QA allocation.

Implementation steps

  1. Create export_contact.go — In internal/app/service/, create the file with package service and const MaxExportRow = 10000. Confirm it mirrors the pattern at internal/app/service/bulk_import_contact.go:20.
  2. Add ExportContactJobName — In internal/app/service/job_enqueuer.go, add ExportContactJobName = "export_contact" as a package-level const alongside the existing job-name consts.
  3. Create payload file — Create internal/app/payload/export_contact_request.go with: ExportContactRequest{ContactIDs []string \json:"contact_ids"`, LayoutID string `json:"layout_id"`, SelectedFields []string `json:"selected_fields"`, Format string `json:"format"`}andExportJobPayload(extends withJobID, Email, UserSSOID, CompanySSOIDfor the worker to consume fromjob.Args["data"]`).
  4. Extend BulkUploadJob — In internal/app/repository/bulk_upload_job/base.go:30, add five fields to the struct: JobType string \bson:"job_type"`, Format string `bson:"format"`, LayoutID string `bson:"layout_id"`, SelectedFields []string `bson:"selected_fields"`, FailureReason string `bson:"failure_reason"``. Do not change any existing fields.
  5. Create index migrations — Create db/migrations/NNN_index_bulk_upload_jobs_job_type.up.json and .down.json. Look at an existing migration in db/migrations/ to confirm the JSON format used by make migrate-up. The up migration creates {company_sso_id:1, job_type:1, created_at:-1}; the down migration drops the same index.
  6. Build gate — Run make build and fix any compilation errors before proceeding.
  7. Migration gate — Run make migrate-up && make migrate-down and confirm the index applies and rolls back without errors.

Run to verify

make build && make migrate-up && make migrate-down

Depends on

None — foundation task.


Task 2.2: BE Serializers — XLSX-with-data, CSV, type formatter (EXP-S05)

The worker can serialize any contact dataset into a correct XLSX or CSV file, with proper per-type formatting and formula-injection protection for all 10 repo field types.

Status: ✅ Actionable

What to build

New GenerateAndUploadExcelWithData() sibling method (48h TTL, private/exports/ path, do not touch the template method); CSV serializer using encoding/csv (RFC-4180 + formula-injection guard); formatValue() switch implementing the full Decision 6 table.

Implementation Plan

ActionFileWhat changes
modifyinternal/app/service/excel_service.goAdd GenerateAndUploadExcelWithData(ctx, rows [][]interface{}, headers []string, companySSOID, format string) (string, error) + private uploadAndSign(ctx, buf, path, disposition string, ttl int64) (string, error) helper (shared by XLSX + CSV); path = private/exports/{company_sso_id}/export_{ts}_{rand}.{ext}; TTL = 172800; do not modify GenerateAndUploadExcel
createinternal/app/service/export_csv.goSerializeToCSV(rows [][]string, headers []string) (*bytes.Buffer, error) using encoding/csv; RFC-4180 quoting; formula-injection guard: any cell starting with = + - @ is prefixed with '
createinternal/app/service/export_format.goformatValue(fieldType, numberType string, value interface{}, currencyCode string, isXLSX bool) interface{} — Decision 6 switch covering all 10 repo field_type constants + date + default
createinternal/app/service/export_format_test.go24 table-driven cases (all Decision 6 rows × XLSX + CSV) + formula-injection guard cases (=, +, -, @ prefix)
createinternal/app/service/excel_service_export_test.goGenerateAndUploadExcelWithData: asserts OSS path contains private/exports/, TTL=172800, ContentDisposition=export; asserts template method GenerateAndUploadExcel behavior unchanged

Acceptance criteria

  • formatValue handles all 10 repo field_type values: single_line_text, text_area, dropdown_select, number, date, multiple_select, url, upload, gps, signature
  • number subtype keyed on NumberType (not field_type): number → numeric cell, percentage → percent format, currency"IDR 1,000,000" quoted CSV string
  • text_area preserves \n (wrap-text XLSX cell; quoted CSV cell preserving newline)
  • multiple_select → XLSX text ["a","b"]; CSV quoted "[""a"",""b""]"
  • gps → quoted CSV (contains comma)
  • Formula-injection guard: cells starting with = + - @ are prefixed with '
  • date → ISO-8601 XLSX date cell; ISO-8601 CSV text (PRD gap resolved)
  • All 24 table-driven test cases pass
  • GenerateAndUploadExcelWithData uploads to private/exports/{co}/... with TTL=172800
  • Existing GenerateAndUploadExcel template method is not modified and all its existing tests pass
  • go test -race -tags dynamic ./internal/app/service/... green

Test strategy

Table-driven unit tests in export_format_test.go — each row is one field_type (+ number subtypes) × {xlsx, csv} expected output. For GenerateAndUploadExcelWithData, mock the OSS client and assert PutObject is called with the correct path prefix and SignURL is called with TTL=172800.

Effort estimate

DisciplineDays
Frontend
Backend3
QA0.5
Total3.5

Assumptions: excelize library and OSS client are already in use; Decision 6 field-type table in the RFC is complete and won't expand; 24-case test suite drives most of the writing time.

Implementation steps

  1. Write failing tests (red) — Create internal/app/service/export_format_test.go. Write 24 table-driven test cases: one row per field_type constant (text, number/currency/percentage, date, email, phone, url, text_area, boolean, multiple_select, gps) × {xlsx, csv} expected output. Add cases for formula-injection guard (=foo'=foo). Run go test -race -tags dynamic ./internal/app/service/... and confirm all fail.
  2. Create internal/app/service/export_format.go — Define formatValue(fieldType, numberType string, raw interface{}) interface{} with a switch on fieldType. For "number", branch on numberType (number/currency/percentage). For "multiple_select", return fmt.Sprintf("%v", raw). For "gps", format as "lat,lng". For "date", return ISO-8601 string. Apply formula-injection guard: if fmt.Sprintf("%v", result) starts with = + - @, prefix with '.
  3. Create SerializeToCSV — In internal/app/service/export_contact.go (or a new export_csv.go), implement SerializeToCSV(headers []string, rows [][]interface{}) ([]byte, error) using encoding/csv. Quote cells containing commas or newlines. Preserve \n in text_area fields by quoting.
  4. Create GenerateAndUploadExcelWithData — In internal/app/service/excel_service.go (alongside existing GenerateAndUploadExcel), implement GenerateAndUploadExcelWithData(ctx, headers []string, rows [][]interface{}, companySSO, fileName string) (signedURL string, err error). Build the XLSX with excelize, upload to private/exports/{companySSO}/{fileName} via ossClient.PutObject, then call ossClient.SignURL with TTL=172800. Do not modify the existing GenerateAndUploadExcel method.
  5. Go green — Run go test -race -tags dynamic ./internal/app/service/... until all 24 cases pass.
  6. Quality gate — Run make lint && make build.

Run to verify

go test -race -tags dynamic ./internal/app/service/...

Depends on

  • [Task 2.1] — MaxExportRow const (for the cap constant file)

Task 2.3: BE Email methods + Notification client (EXP-S04)

The worker can send three export email variants and publish a non-fatal in-app notification on job completion.

Status: ✅ Actionable — OQ-10 (notif ingest channel) adopts HTTP heimdall default; reversible via NotificationPublisher interface if Kafka is confirmed before Task 2.3 is merged.

What to build

Three email wrappers mirroring SendEmailImportCustomer*; a NotificationPublisher interface + heimdall HTTP implementation posting to POST /notif/v1/notifications with the grounded mobile-verified payload (origin="external_url", notif_type=1, notif_category="5"); notif config added to config/load.go.

Implementation Plan

ActionFileWhat changes
modifyinternal/app/service/email/email_service.go:98Add three methods mirroring the import wrappers: SendEmailExportCustomerSucceeded(ctx, toEmail, fileName string, file *os.File, signedURL string) error, SendEmailExportCustomerPartialSucceeded(ctx, toEmail string, file *os.File, successCount, failedCount int) error, SendEmailExportCustomerFailed(ctx, toEmail string) error; success/partial pass *os.File; failure passes nil (no attachment)
createinternal/app/api/notification_mekari.goNotificationPublisher interface {Publish(ctx context.Context, payload NotificationPayload) error}; NotificationClient struct with heimdall HTTP client (mirrors iag_mekari.go:54-120); POST /notif/v1/notifications with {notif_type:1, notif_category:"5", origin:"external_url", click_action:"OPEN_URL", click_action_url, title, description}
modifyconfig/load.goAdd NOTIFICATION_API_ROOT_URL (getStringOrPanic), NOTIFICATION_API_CLIENT_ID, NOTIFICATION_API_SECRET following the IAG client pattern at :306
createinternal/app/api/notification_mekari_test.goTests: client constructs correct JSON payload with origin="external_url" and notif_category="5"; publish failure returns error (non-fatal, does not panic)
createinternal/app/service/email/email_export_test.goTests: success/partial pass non-nil *os.File to SendEmailWithAttachment; failure method passes nil (no attachment)

Acceptance criteria

  • Three email methods compile and mirror the naming convention of SendEmailImportCustomer*
  • Success and partial email methods pass the temp *os.File to SendEmailWithAttachment
  • Failure email sends no attachment (nil file argument)
  • Notification client POSTs to /notif/v1/notifications with origin="external_url" and notif_category="5"
  • NotificationPublisher interface is the only type the consumer depends on (channel swap = one implementation)
  • config/load.go adds 3 notif env vars without breaking existing config loading
  • make build && go test -race -tags dynamic ./internal/app/api/... ./internal/app/service/email/... green

Test strategy

Mock the heimdall HTTP transport. Assert the marshalled JSON body contains origin: "external_url" and notif_category: "5". Test that a publish error is returned as error and does not cause the consumer to panic.

Effort estimate

DisciplineDays
Frontend
Backend2
QA0.5
Total2.5

Assumptions: email service and heimdall HTTP client patterns are established (iag_mekari.go is the reference); OQ-10 resolved as HTTP default — switching to Kafka after merge adds ~1 day.

Implementation steps

  1. Write failing tests (red) — Create internal/app/service/email/email_export_test.go with 3 tests (success method passes non-nil *os.File, partial method passes non-nil *os.File, failure method passes nil). Create internal/app/api/notification_mekari_test.go with 2 tests (correct JSON body shape; publish error returns error without panic). Run go test -race -tags dynamic ./internal/app/api/... ./internal/app/service/email/... and confirm all fail.
  2. Add three email methods — In internal/app/service/email/email_service.go:98, add below the existing import methods: SendEmailExportCustomerSucceeded(ctx, toEmail, fileName string, file *os.File, signedURL string) error, SendEmailExportCustomerPartialSucceeded(ctx, toEmail string, file *os.File, successCount, failedCount int) error, SendEmailExportCustomerFailed(ctx, toEmail string) error. Mirror the signature and body pattern of SendEmailImportCustomer*; failure method passes nil as the file argument to SendEmailWithAttachment.
  3. Create NotificationPublisher interface — Create internal/app/api/notification_mekari.go. Define type NotificationPublisher interface { Publish(ctx context.Context, payload NotificationPayload) error }. Define NotificationPayload struct with NotifType int, NotifCategory string, Origin string, ClickAction string, ClickActionURL string, Title string, Description string.
  4. Implement NotificationClient — In the same file, create NotificationClient struct with a heimdall HTTP client (copy the client setup from iag_mekari.go:54-120, swapping the base URL for NOTIFICATION_API_ROOT_URL). Implement Publish: marshal payloadPOST /notif/v1/notifications with notif_type:1, notif_category:"5", origin:"external_url".
  5. Add config vars — In config/load.go, add NOTIFICATION_API_ROOT_URL (getStringOrPanic), NOTIFICATION_API_CLIENT_ID, NOTIFICATION_API_SECRET following the IAG client pattern at :306.
  6. Go green — Run go test -race -tags dynamic ./internal/app/api/... ./internal/app/service/email/... && make build until all tests pass and build succeeds.
  7. Quality gate — Run make lint && make build.

Run to verify

go test -race -tags dynamic ./internal/app/api/... ./internal/app/service/email/...
make build

Depends on

  • [Task 2.1] — ExportJobPayload struct for the notification payload fields

Task 2.4: BE Export Consumer — serialize → upload → email → notify → status (EXP-S01, EXP-S04, EXP-S05)

The async worker processes an export job end-to-end: fetches contacts in batches, resolves default vs. custom fields, serializes, uploads to OSS, sends email, publishes notification, and updates job status — with full failure handling on every path.

Status: ✅ Actionable (depends on Tasks 2.1, 2.2, 2.3)

What to build

ExportContactConsumer.ProcessExportContactJob mirroring send_contact.go:BulkImportCustomer; registered in worker_service.go with noRetry; contact fetch via SearchByIDs (new repo method using bson.M{"_id":{"$in":batch}}); per-field resolution applying the default-vs-custom algorithm from Detail 2.4; temp-file lifecycle via defer os.Remove.

Implementation Plan

ActionFileWhat changes
createinternal/app/consumer/export_contact.goExportContactConsumer struct + ProcessExportContactJob(job *work.Job) error; reads job.Args["data"], unmarshals ExportJobPayload; terminal-status guard (return nil if status ∈ {completed,partial,failed}); contact-fetch loop in batches; per-field resolution: default fields from top-level Contact struct fields, custom fields from Contact.CustomFields[] where CustomField.Key == fieldName; IsHidden=true fields excluded; formatValue per type; GenerateAndUploadExcelWithData/SerializeToCSV; buffer→os.CreateTemp→email→defer os.Remove; NotificationPublisher.Publish (non-fatal); final status update
modifyinternal/worker/worker_service.go:100-135Register ExportContactJobName with noRetryOpt (one-shot — failure email already sent on terminal failure)
extendinternal/app/repository/contact/base.goAdd SearchByIDs(ctx context.Context, ids []primitive.ObjectID, page, limit int) ([]Contact, error) using bson.M{"_id":{"$in":ids}} with pagination
createinternal/app/consumer/export_contact_test.go(1) Happy path: all contacts found → status=completed, email+notif+temp-delete called; (2) Partial: some IDs missing → status=partial, row_failures counted, partial email sent; (3) Serializer/OSS error → status=failed, failure email sent, no attachment; (4) Terminal-status guard: consumer returns nil without side effects on already-terminal job; (5) Notif publish failure: job stays status=completed, consumer returns nil

Acceptance criteria

  • job.Args["data"] correctly unmarshalled into ExportJobPayload
  • Default fields read from Contact struct top-level; custom fields from Contact.CustomFields[] by Key
  • IsHidden=true fields excluded from all output (server-side enforcement, never trust FE)
  • Temp file always cleaned up via defer os.Remove on all code paths (success, partial, failure, recovered panic)
  • Notification publish failure logs the error and does NOT fail the job (return nil)
  • Terminal-status guard: consumer returns nil without triggering any side effects if job already in a terminal state
  • go test -race -tags dynamic ./internal/app/consumer/... green

Test strategy

Inject mock repos (contacts returning fixture data, bulk_upload_job tracking status calls), mock ExcelService, mock EmailService, mock NotificationPublisher. For test (5), make mock Publish return an error and assert the consumer still returns nil and the job repo received a status=completed update.

Effort estimate

DisciplineDays
Frontend
Backend3
QA0.5
Total3.5

Assumptions: all interfaces from Tasks 2.1–2.3 are merged before starting; contact repo batch-fetch pattern is established; default/custom field resolution algorithm in RFC Detail 2.4 is final.

Implementation steps

  1. Write failing tests (red) — Create internal/app/consumer/export_contact_test.go with 5 tests: (1) happy path → status=completed, email+notif+temp-delete called; (2) partial → status=partial, row_failures counted, partial email sent; (3) serializer/OSS error → status=failed, failure email, no attachment; (4) terminal-status guard → consumer returns nil without side effects; (5) notif failure → consumer still returns nil, status=completed. Run go test -race -tags dynamic ./internal/app/consumer/... and confirm all fail.
  2. Add SearchByIDs to contact repo — In internal/app/repository/contact/base.go, add SearchByIDs(ctx context.Context, ids []primitive.ObjectID, page, limit int) ([]Contact, error) using bson.M{"_id":{"$in":ids}} with skip/limit pagination.
  3. Scaffold ExportContactConsumer — Create internal/app/consumer/export_contact.go with ExportContactConsumer struct (fields: ContactRepo, BulkUploadJobRepo, ExcelService, EmailService, NotificationPublisher) and stub ProcessExportContactJob(job *work.Job) error returning nil.
  4. Implement ProcessExportContactJob — In order: unmarshal job.Args["data"]ExportJobPayload; terminal-status guard (return nil if current status ∈ {completed,partial,failed}); contact-fetch loop in batches of 500 via SearchByIDs; per-field resolution (default fields from top-level Contact struct, custom fields from Contact.CustomFields[] by Key; skip IsHidden=true); call formatValue per type; call GenerateAndUploadExcelWithData or SerializeToCSV by format; write temp file → pass to email method → defer os.Remove; call NotificationPublisher.Publish (non-fatal: log error, do not return it); update BulkUploadJob.Status to completed/partial/failed.
  5. Register in worker — In internal/worker/worker_service.go:100-135, register ExportContactJobName with noRetryOpt (same pattern as other one-shot consumers).
  6. Go green — Run go test -race -tags dynamic ./internal/app/consumer/... && go test -race -tags dynamic ./internal/app/repository/contact/... until all tests pass.
  7. Quality gate — Run make lint && make build.

Run to verify

go test -race -tags dynamic ./internal/app/consumer/...
go test -race -tags dynamic ./internal/app/repository/contact/...

Depends on

  • [Task 2.1] — constants, payload structs, BulkUploadJob fields
  • [Task 2.2] — GenerateAndUploadExcelWithData, SerializeToCSV, formatValue
  • [Task 2.3] — email methods, NotificationPublisher interface

Task 2.5: BE Handler, routes, service, rate-limit resolution (EXP-S01, EXP-S02, EXP-S06-NEG)

The two export endpoints are live behind IAG auth, permission check, and a dedicated export rate-limit counter (5/company/hour) that is separate from the shared import counter.

Status: ✅ Actionable — OQ-14 resolved here: extend RateLimitMiddleware with per-route (key, maxRequests, windowSeconds) so export uses ("export", 5, 3600) distinct from import. Current middleware hardcodes 1/60s and is shared — this task fixes both.

What to build

ExportHandler (POST trigger + GET status), ExportService.TriggerExport, register 2 routes under /contacts in rest_router.go, extend RateLimitMiddleware with per-route params, wire into initializer.go.

Implementation Plan

ActionFileWhat changes
createinternal/app/service/export_service.goExportService{JobEnqueuer, BulkUploadJobRepo, FlagService}; TriggerExport(ctx, req ExportContactRequest, userSSOID, companySSOID, email string) (ExportTriggerResponse, error): insert job (job_type:"export", status:"queued") → EnqueueJob(ExportContactJobName, payload) → return {job_id, status:"queued", email}
createinternal/app/handler/export_handler.goExportHandler; TriggerExport: decode body → validate flag (403 FLAG_DISABLED) → validate perm (403 FORBIDDEN) → validate len(contact_ids) > MaxExportRow (422 EXPORT_LIMIT_EXCEEDED) → validate format ∈ {xlsx,csv} (422 EXPORT_FORMAT_INVALID) → call ExportService.TriggerExport → respond 200; GetExportStatus: read bulk_upload_job by job_id → return {job_id, status, total_records, success_count, failed_count}
modifyinternal/server/rest_router.go:142Under /contacts route group: POST /export with RequirePermissionMiddleware(CustomersCustomersExportKey) + RateLimitMiddleware("export", 5, 3600); GET /export/status/{job_id} with permission middleware
modifyinternal/pkg/middleware/rate_limit_middleware.goExtend to accept (key string, maxRequests, windowSeconds int) — export counter keyed {companySSOID}:export; update import usage to explicit ("import", existingMax, existingWindow) so counters are independent
modifycmd/initializer.goWire ExportService, ExportHandler, ExportContactConsumer into the dependency graph
createinternal/app/handler/export_handler_test.goTests: valid request → 200 {job_id,"queued",email}; >10K IDs → 422 EXPORT_LIMIT_EXCEEDED; format="pdf" → 422 EXPORT_FORMAT_INVALID; flag OFF → 403 FLAG_DISABLED; no perm → 403 FORBIDDEN; rate exceeded → 429 EXPORT_RATE_LIMIT_EXCEEDED
createdocs/EXPORT_CONTACT_SERVICE.mdMarkdown doc for both new endpoints — request/response schemas, error codes, auth requirements (follows docs/WEBHOOK_DELIVERY_SERVICE.md convention; no OpenAPI spec exists in repo)

Acceptance criteria

  • POST /iag/v1/contacts/export returns 200 {job_id, status:"queued", email} for a valid request
  • Returns 422 EXPORT_LIMIT_EXCEEDED when len(contact_ids) > 10000
  • Returns 422 EXPORT_FORMAT_INVALID when format ∉ {"xlsx","csv"}
  • Returns 403 FLAG_DISABLED when cdp_export_customer_enabled is OFF
  • Returns 403 FORBIDDEN without CustomersCustomersExportKey
  • Returns 429 EXPORT_RATE_LIMIT_EXCEEDED from a dedicated export counter (does not share the import counter)
  • Import rate-limit behavior unchanged after middleware extension (OQ-14 resolved)
  • make build && make lint && make test fully green

Test strategy

Unit test ExportHandler.TriggerExport with a mock ExportService and RateLimiterService. For each error path, assert the exact HTTP status code and error-code string in the response body. For the rate-limit path, mock RateLimiterService.Exceeded("export") returning true and verify 429 is returned without a job being created.

Effort estimate

DisciplineDays
Frontend
Backend2.5
QA0.5
Total3

Assumptions: handler/router/middleware patterns are established; OQ-14 rate-limit approach is resolved (parameterised middleware); API doc is a simple markdown write — no OpenAPI spec in repo.

Implementation steps

  1. Write failing tests (red) — Create internal/app/handler/export_handler_test.go with 6 tests: valid request → 200 {job_id,"queued",email}; >10K IDs → 422 EXPORT_LIMIT_EXCEEDED; format="pdf" → 422 EXPORT_FORMAT_INVALID; flag OFF → 403 FLAG_DISABLED; no perm → 403 FORBIDDEN; rate exceeded → 429 EXPORT_RATE_LIMIT_EXCEEDED. Run go test -race -tags dynamic ./internal/app/handler/... and confirm all fail.
  2. Extend RateLimitMiddleware — In internal/pkg/middleware/rate_limit_middleware.go, refactor the middleware factory to accept (key string, maxRequests, windowSeconds int). The Redis/in-memory counter key becomes {companySSOID}:{key}. Update the existing import usage to RateLimitMiddleware("import", existingMax, existingWindow) so the counters remain independent.
  3. Create ExportService — Create internal/app/service/export_service.go with ExportService{JobEnqueuer, BulkUploadJobRepo, FlagService} and TriggerExport(ctx, req ExportContactRequest, userSSOID, companySSOID, email string) (ExportTriggerResponse, error): insert job (job_type:"export", status:"queued") → EnqueueJob(ExportContactJobName, payload) → return {job_id, status:"queued", email}.
  4. Create ExportHandler — Create internal/app/handler/export_handler.go with TriggerExport (decode body → validate flag → validate perm → validate len(contact_ids) > MaxExportRow → validate format ∈ {xlsx,csv} → call service → 200) and GetExportStatus (read job by job_id → return {job_id, status, total_records, success_count, failed_count}).
  5. Register routes — In internal/server/rest_router.go:142, under the /contacts group add: POST /export with RequirePermissionMiddleware(CustomersCustomersExportKey) + RateLimitMiddleware("export", 5, 3600); GET /export/status/{job_id} with permission middleware.
  6. Wire into initializer — In cmd/initializer.go, instantiate and inject ExportService, ExportHandler, ExportContactConsumer into the dependency graph following the pattern of existing services.
  7. Write API doc — Create docs/EXPORT_CONTACT_SERVICE.md following the structure of docs/WEBHOOK_DELIVERY_SERVICE.md: request/response schemas for both endpoints, all error codes, auth requirements.
  8. Go green — Run make build && make lint && make test until fully green.
  9. Quality gatemake build && make lint && make test (same command — build + lint + test all pass).

Run to verify

make build && make lint && make test

Depends on

  • [Task 2.1] — MaxExportRow, ExportContactJobName, payload structs, job store

Task 2.6: FE — Wire real POST + error handling + Mixpanel (EXP-S01, EXP-S02, EXP-S04, EXP-S06-NEG)

Clicking Download fires POST /v1/contacts/export with the correct body, shows a success banner with the user's email address, or shows the correct inline error message for each 403/422/429 response.

Status: ⚠️ Partially blocked — POST /v1/contacts/export endpoint contract (Task 2.5). What IS actionable now: implement handleSubmit with $customFetch against the RFC contract and cover it with mocked unit tests. Wire to live endpoint once Task 2.5 is in staging.

What to build

Replace the mocked handleSubmit in ExportCustomerDrawer.vue with a real $customFetch POST; map each error code to the user-facing message from Detail 3.C; fire Mixpanel events.

Implementation Plan

ActionFileWhat changes
modifyfeatures/customers/views/components/ExportCustomerDrawer.vue:289-299Replace mock with const { $customFetch } = useCustomFetch(); POST {CUSTOMER_360_URL}/v1/contacts/export with body {contact_ids: props.selectedIds, layout_id: selectedLayout.value, selected_fields: [...checkedCustomerInfoFields, ...checkedDefaultFields, ...checkedCustomFields], format: fileFormat.value.toLowerCase()}; on 200 toastNotify success showing response.email; write localStorage (reuse Task 1.1 logic)
extendsamecatch block: map EXPORT_LIMIT_EXCEEDED/EXPORT_FORMAT_INVALID → inline error ref; EXPORT_RATE_LIMIT_EXCEEDED → rate-limit message; FLAG_DISABLED/FORBIDDEN → "Export not available"; render error via MpText color="text.danger" below the submit button
extendsameFire useMixpanel().track('cdp_export_customer_triggered', {contact_count: props.selectedIds.length, field_count: totalChecked.value, layout_id: selectedLayout.value, format: fileFormat.value.toLowerCase()}) on submit
extendfeatures/customers/views/ListPage.vueFire useMixpanel().track('cdp_export_customer_cap_exceeded', {attempted_count}) in the selection handler when >10K is attempted
extendfeatures/customers/views/components/ExportCustomerDrawer.spec.tsTests: POST body has correct shape (contact_ids, layout_id, selected_fields with name keys, format lowercased); 200 → toastNotify with response.email; 422 EXPORT_LIMIT_EXCEEDED → inline error shown; 429 → rate-limit message; cdp_export_customer_triggered event fires

Acceptance criteria

  • POST body: {contact_ids: string[], layout_id: string, selected_fields: string[] (field name keys), format: "xlsx"\|"csv"} — format value is lowercased before sending
  • 200: toastNotify success shows response.email address
  • 422 EXPORT_LIMIT_EXCEEDED: inline "You can only select up to 10,000 customers"
  • 429 EXPORT_RATE_LIMIT_EXCEEDED: "Too many export requests. Please wait before trying again."
  • 403 FLAG_DISABLED or FORBIDDEN: "Export not available"
  • cdp_export_customer_triggered fires with {contact_count, field_count, layout_id, format}
  • cdp_export_customer_cap_exceeded fires when >10K selection is attempted
  • (Pending Task 2.5) End-to-end smoke: POST reaches BE, receives {job_id,"queued",email}, email appears in toast

Test strategy

Mock $customFetch via vi.mock('~/common/composables/useCustomFetch'). Provide a resolved response for the 200 case and rejected responses with the error-code strings for each error case. Assert toastNotify is called with the email from the mocked response. Assert each error renders the correct message text.

Effort estimate

DisciplineDays
Frontend1.5
Backend
QA0.5
Total2

Assumptions: useCustomFetch and useMixpanel composable patterns are established; error code strings confirmed per RFC Detail 3.C; localStorage logic reused from Task 1.1 (no re-implementation).

Implementation steps

  1. Write failing tests (red) — Extend features/customers/views/components/ExportCustomerDrawer.spec.ts. Add 5 tests: (a) POST body has correct shape: contact_ids, layout_id, selected_fields using name keys (not name_alias), format lowercased; (b) 200 → toastNotify called with response.email; (c) 422 EXPORT_LIMIT_EXCEEDED → inline error "You can only select up to 10,000 customers" rendered; (d) 429 → "Too many export requests. Please wait before trying again."; (e) cdp_export_customer_triggered Mixpanel event fires with {contact_count, field_count, layout_id, format}. Run pnpm test -- features/customers/views/components/ExportCustomerDrawer.spec.ts and confirm all 5 fail.
  2. Replace mock handleSubmit — In ExportCustomerDrawer.vue:289-299, remove the placeholder and implement: const { $customFetch } = useCustomFetch(); POST to ${CUSTOMER_360_URL}/v1/contacts/export with body {contact_ids: props.selectedIds, layout_id: selectedLayout.value, selected_fields: [...checkedCustomerInfoFields.value, ...checkedDefaultFields.value, ...checkedCustomFields.value], format: fileFormat.value.toLowerCase()}. On 200: call toastNotify with response.email; write localStorage (reuse the storageKey + setItem logic from Task 1.1).
  3. Implement error mapping — In the catch block, read the error-code string from the response body and map: EXPORT_LIMIT_EXCEEDED / EXPORT_FORMAT_INVALID → assign submitError.value = "You can only select up to 10,000 customers"; EXPORT_RATE_LIMIT_EXCEEDED → "Too many export requests. Please wait before trying again."; FLAG_DISABLED / FORBIDDEN → "Export not available". Render submitError.value via <MpText color="text.danger"> below the Download button.
  4. Add Mixpanel events — After a successful POST, call useMixpanel().track('cdp_export_customer_triggered', {contact_count: props.selectedIds.length, field_count: totalChecked.value, layout_id: selectedLayout.value, format: fileFormat.value.toLowerCase()}).
  5. Add cap-exceeded event in ListPage — In features/customers/views/ListPage.vue, in the selection handler where the >10K guard triggers, add useMixpanel().track('cdp_export_customer_cap_exceeded', {attempted_count}).
  6. Go green — Run pnpm test -- features/customers/views/components/ExportCustomerDrawer until all tests pass.
  7. Quality gate — Run pnpm lint && pnpm build.

Run to verify

pnpm test -- features/customers/views/components/ExportCustomerDrawer
pnpm lint

Depends on

  • [Task 1.1] — field mapping fix and localStorage (must be correct before wiring the real call)
  • (for staging smoke test) [Task 2.5] — live BE endpoint

Task 2.7: [BE] Timezone-aware timestamp formatting (EXP-S09, chunk 18)

Exported date/timestamp cells are rendered in the timezone the user picked in the panel (default GMT+07:00), so a Jakarta admin sees Jakarta-local times in the file.

Status: ✅ Actionable — but OQ-20/REV-15 must be pinned first: the request timezone value should be an IANA id (Asia/Jakarta) because Go's time.LoadLocation needs IANA, with the FE mapping its display label (GMT+07:00) Asia/Jakarta → IANA on send. Default if unconfirmed: parse IANA, fall back to Asia/Jakarta.

What to build

Extend the formatValue serializer (net-new from Task 2.2) to accept a resolved *time.Location and render date/timestamp cells in it; plumb the request timezone from ExportContactRequestExportJobPayload → the consumer → the formatter; default to Asia/Jakarta when absent.

Implementation Plan

ActionFileWhat changes
extendinternal/app/service/export_format.goformatValue(...) gains a loc *time.Location param (or a timezone string); the date case (and any timestamp cell) formats via t.In(loc); default loc = Asia/Jakarta when nil
extendinternal/app/payload/export_contact_request.goConfirm Timezone string is on ExportContactRequest (per RFC §2.4 struct) and carried into ExportJobPayload; document the expected IANA value (OQ-20)
extendinternal/app/consumer/export_contact.goResolve loc, err := time.LoadLocation(payload.Timezone) once (fallback Asia/Jakarta on empty/error), pass loc into every formatValue call
extendinternal/app/service/export_format_test.goCases: a date value renders in Asia/Jakarta vs UTC; empty/invalid timezone falls back to Asia/Jakarta; non-date types are unaffected by loc

Implementation steps

  1. Explore — Open internal/app/service/export_format.go (the Decision-6 formatter from Task 2.2) and the date case; open internal/app/api/qontak_launchpad.go to see how a timezone is already handled elsewhere in the service (the only existing Timezone usage) for a consistent parse approach.
  2. Write failing tests (red) — Add the timezone cases to export_format_test.go. Run go test -race -tags dynamic ./internal/app/service/... and confirm they fail.
  3. Add loc to the formatter — Thread a *time.Location into formatValue; in the date/timestamp branch render with t.In(loc); leave non-temporal types untouched.
  4. Plumb the timezone — Ensure Timezone is on the request/job payload (RFC §2.4) and resolve it once in export_contact.go via time.LoadLocation with an Asia/Jakarta fallback; pass loc to the formatter.
  5. Go greengo test -race -tags dynamic ./internal/app/service/... ./internal/app/consumer/....
  6. Quality gatemake lint && make build.

Acceptance criteria

  • A date/timestamp cell renders in the request timezone (e.g. Asia/Jakarta → +07:00 offset)
  • Empty or invalid timezone falls back to Asia/Jakarta (no error to the user)
  • Non-temporal field types are byte-identical regardless of loc
  • timezone is documented as an IANA value end-to-end (OQ-20 resolution recorded)
  • go test -race -tags dynamic ./internal/app/service/... ./internal/app/consumer/... green

Test strategy

Table-driven: one time.Time fixture formatted under Asia/Jakarta, UTC, and "" (→ fallback), asserting the rendered string. Assert a non-date type returns the same value under any loc.

Effort estimate

DisciplineDays
Frontend
Backend1
QA0.5
Total1.5

Assumptions: the Decision-6 formatter (Task 2.2) exists and is the only place dates are rendered; OQ-20 pins an IANA value; no new dependency (time stdlib).

Run to verify

go test -race -tags dynamic ./internal/app/service/... ./internal/app/consumer/...

Depends on

  • [Task 2.2] — formatValue / export_format.go exists
  • [Task 2.4] — consumer plumbs the payload into the formatter
  • (contract) OQ-20/REV-15 — timezone value format (IANA vs label) pinned before wiring the FE panel (Task 1.4)

Task 2.8: [BE] Export-history registrar (EXP-S08, chunk 19)

Each completed export is registered into the company/org export-history at chat.qontak.com/reports/export so admins can see who exported what and re-download within 48h — reusing the existing surface, not a CDP-local one.

Status: 🚫 Blocked (OQ-17/REV-12) — the chat.qontak.com/reports/export surface is the IAG billing service (/report/v1/billings/logs/export); its quota types are billing-only and rows are created only by the hub-chat UI. There is no CDP "Customer Data" quota type and no service-to-service register path today (verified: grep for billings/logs/export in contact-service → 0 hits). Actionable scaffold only: define the ExportHistoryRegistrar interface + a non-fatal no-op/flagged implementation so the consumer compiles and calls it; the real registration cannot be built until billing/IAG + Chat/Omni deliver (1) a CDP quota_type/billing_code, (2) an s2s create accepting a CDP-supplied file/link, (3) company/org visibility. EXP-S08 is a PRD Should-Have — this does not gate the core release.

What to build

The interface + heimdall client scaffold (mirroring iag_mekari.go) behind which the consumer calls a non-fatal Register(...). Until OQ-17 resolves, ship a no-op (or feature-flagged) implementation that logs intent; wire the real POST /report/v1/billings/logs/export once the cross-team contract exists.

Implementation Plan

ActionFileWhat changes
createinternal/app/api/export_history_mekari.goExportHistoryRegistrar interface {Register(ctx, ExportHistoryRow) error}; a heimdall HTTP impl (mirrors iag_mekari.go:54-120) targeting IAG POST /report/v1/billings/logs/exportleft unwired/no-op until OQ-17
extendconfig/load.goAdd EXPORT_HISTORY_API_ROOT_URL / _CLIENT_ID / _SECRET (only required once EXP-S08 is wired) following the IAG client pattern
extendinternal/app/consumer/export_contact.goAfter email + notification, call ExportHistoryRegistrar.Register(...) non-fatally (log + alert on failure, never fail the job) — mirrors the notification policy (Decision 13)
extendinternal/app/consumer/export_contact_test.goRegister failure / no-op is non-fatal: job stays completed, consumer returns nil
create(observability)Emit cdp_export_history_registered {result} (PRD §10) on the register attempt

Implementation steps

  1. Explore — Open internal/app/api/iag_mekari.go:54-120 (heimdall client to mirror) and internal/app/consumer/export_contact.go (where the non-fatal notification call already lives — add the register call beside it with the same policy).
  2. Define the interface — Create export_history_mekari.go with ExportHistoryRegistrar + an ExportHistoryRow payload (file name, export type "Customer Data", exporter, date, link, status). Provide a no-op implementation as the default binding so nothing calls a non-existent endpoint.
  3. Wire non-fatally — In the consumer, call Register(...) after the notification publish; on error, log + alert #cdp-ops and continue (do not fail the job). Emit cdp_export_history_registered.
  4. Tests — Assert the consumer stays green when Register errors or is the no-op.
  5. Stop at the boundary — Do not implement the live POST /report/v1/billings/logs/export call: it requires the OQ-17 cross-team path. Leave a // TODO(OQ-17) and the config keys unwired.
  6. Quality gatemake lint && make build.

Acceptance criteria

  • ExportHistoryRegistrar interface + ExportHistoryRow payload defined; default binding is a non-fatal no-op
  • Consumer calls Register(...) after notification; a register error/no-op never fails the job (status stays completed/partial)
  • cdp_export_history_registered {result} event emitted
  • 🚫 Blocked, not in this task: the live IAG billing POST — requires a CDP quota type + s2s path (OQ-17)
  • make build && go test -race -tags dynamic ./internal/app/consumer/... ./internal/app/api/... green

Test strategy

Inject a mock ExportHistoryRegistrar whose Register returns an error; assert the consumer still returns nil and the job repo received status=completed. Verify the default no-op binding makes no HTTP call.

Effort estimate

DisciplineDays
Frontend
Backend1
QA
Total1

Assumptions: estimate covers only the interface + non-fatal scaffold so the pipeline compiles and ships; the real cross-team register path (OQ-17) is out of this estimate and tracked separately. No user-facing behavior until unblocked → no QA allocation.

Run to verify

make build && go test -race -tags dynamic ./internal/app/consumer/... ./internal/app/api/...

Depends on

  • [Task 2.4] — consumer exists to host the non-fatal call
  • External (blocking the real call): OQ-17/REV-12 — billing/IAG CDP quota type + s2s register endpoint + company/org visibility (PM + CDP BE + Billing/IAG + Chat/Omni)

Ordering rationale

  • FE Phase 1 tasks (1.1, 1.2) start immediately in parallel — no BE dependency; fix the existing drawer bugs and permission gate while all BE tasks proceed independently.
  • Task 2.1 is the BE critical path entry point — constants, payload structs, and the BulkUploadJob extension are imported by every downstream BE task. Nothing else compiles without it.
  • Tasks 2.2 and 2.3 run in parallel after 2.1 is merged — the serializer stack and the email/notification client are fully independent; running them concurrently saves a sprint.
  • Task 2.4 (consumer) gates on both 2.2 and 2.3 — it orchestrates the serializer output, the email methods, and the notification client interface; all three must compile first.
  • Task 2.5 (handler/routes) can start in parallel with 2.2/2.3 after 2.1 — the handler needs only the job store and constants, not the serializer or email methods.
  • Confirm OQ-10 (notif ingest = HTTP vs Kafka) with the One Notification team before Task 2.3 is merged — the NotificationPublisher interface keeps the channel swap to one file if Kafka is mandated.
  • Task 2.6 (FE POST wire) can be written immediately against the RFC contract and unit-tested with mocks. Integration test awaits Task 2.5 in staging.

v2.6 chunks (18–21) ordering:

  • Task 1.3 (first_10k_sorted shortcut) and Task 1.4 (panel) start in Phase 1 alongside 1.1/1.2. Task 1.4 must follow Task 1.1 on the same branch — both edit ExportCustomerDrawer.vue; doing 1.4 first would collide with the field-key/localStorage fix.
  • Task 2.7 (timezone formatter) gates on Task 2.2 (the formatter must exist first) and feeds Task 1.4's live submit — pin OQ-20 (IANA vs label) before wiring either side.
  • The criterion-based BE resolution that Task 1.3/1.4 depend on is a Task 2.5 extension (chunk-9 first_10k_sorted + period/source handling, OQ-18) — schedule that extension before the FE panels' live integration, or they stay mock-only.
  • Task 2.8 (export-history) is off the critical path and blocked (OQ-17). Build only the non-fatal scaffold so the consumer compiles; drive the cross-team register path with billing/IAG + Chat/Omni in parallel — EXP-S08 is a Should-Have and lights up when that lands. Do not let it hold the core release.

Skipped stories

StoryReason
EXP-S08 — export-history register (real path)Blocked on OQ-17/REV-12chat.qontak.com/reports/export is the IAG billing service with no CDP "Customer Data" quota type and no service-to-service register endpoint today (verified). Task 2.8 ships only the non-fatal interface scaffold; the live registration awaits a billing/IAG + Chat/Omni cross-team path. PRD Should-Have — does not gate the core release.
EXP-S04 — web in-app notification renderHost-shell-owned (launchpad). customer-fe has only the TheNotification.vue stub (notifications=ref([])). No FE render work for the CDP team.
EXP-S04 — mobile One Notification V2 rendermobile-qontak-crm (crm_misc) renders the notification automatically when the payload carries origin="external_url" (verified against live repo). No CDP team task. Optional: mobile adds a cdp/contact360 entry to detailModuleRouteMapping for in-app routing instead of external browser (OQ-12) — requires a mobile-qontak-crm PR, out of scope here.
EXP-S09 (live submit), EXP-S02 (live criterion)Actionable as UI now (Tasks 1.3/1.4, mocked); the live POST depends on the Task 2.5 BE-resolution extension (OQ-18) + Task 2.7 timezone formatter + OQ-20/OQ-21 confirms. Listed here so the blocked integration portion is visible; the UI portion is in the actionable task list.