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:
| Component | Phase 1 status | RFC planned path | Actual repo path |
|---|---|---|---|
ExportCustomerDrawer.vue | Exists — bugs to fix | export/views/ExportCustomerPage.vue | features/customers/views/components/ExportCustomerDrawer.vue |
FloatingBulkAction.vue | Exists — complete | ListTable.vue popover item | features/customers/views/components/FloatingBulkAction.vue |
ListPage.vue | Exists — permission TODO | same | same |
| BE pipeline (all) | Net-new | chunks 1–10 | contact-service/internal/... |
What's already built (FE):
ExportCustomerDrawer.vuehas 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 offield_properties.FloatingBulkAction.vuehas the "Download selected" popover item withcanExportprop.ListPage.vuehasselectedCustomerIds: ref<Set<string>>,MAX_SELECTIONcap,isExportDrawerOpen, andhandleExportSelected.Bugs/gaps (FE):
handleSubmitis mocked (no real API call); field watchers usef.name_aliasinstead off.namefor theselected_fields[]values;canExportcomputed is commented out (TODO atListPage.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.vuetimezoneMpAutocompleteisis-disabledtoday (:36);InputPeriod.vue/FilterCheckbox.vue/timezones.tsexist incommon/;ListPage.vueMAX_SELECTION = 500(:220),pagination.value.total(:450);contact-servicehas no timezone formatter and no export-history/billings client (both net-new). Tasks 1.3/1.4 shareExportCustomerDrawer.vue/ selection surfaces with Tasks 1.1/2.6 — sequence 1.4 immediately after 1.1 (same branch/PR).
Effort Summary
| Phase / Area | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Phase 1 — UI (mocked) | 6 | — | 2 | 8 |
| Phase 2 — API integration | 1.5 | 13.5 | 3 | 18 |
| Grand total | 7.5 | 13.5 | 5 | 26 |
| Task | FE | BE | QA | Total |
|---|---|---|---|---|
1.1 Fix ExportCustomerDrawer field-key + localStorage | 1.5 | — | 0.5 | 2 |
1.2 Wire canExport permission + feature flag | 0.5 | — | 0.5 | 1 |
1.3 FE first_10k_sorted shortcut + export cap raise (chunk 20) | 1.5 | — | 0.5 | 2 |
| 1.4 FE right-side panel completion — Timezone/Period/Source/Layout + Retry (chunk 21) | 2.5 | — | 0.5 | 3 |
| 2.1 BE Foundation — constants, structs, job store | — | 1 | — | 1 |
| 2.2 BE Serializers — XLSX, CSV, type formatter | — | 3 | 0.5 | 3.5 |
| 2.3 BE Email methods + Notification client | — | 2 | 0.5 | 2.5 |
| 2.4 BE Export Consumer — full async pipeline | — | 3 | 0.5 | 3.5 |
| 2.5 BE Handler, routes, service, rate-limit | — | 2.5 | 0.5 | 3 |
| 2.6 FE Wire real POST + error handling + Mixpanel | 1.5 | — | 0.5 | 2 |
| 2.7 BE timezone-aware timestamp formatting (chunk 18) | — | 1 | 0.5 | 1.5 |
| 2.8 BE export-history registrar — 🚫 blocked OQ-17 (chunk 19) | — | 1 | — | 1 |
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/sourcefilters) 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_alias → name 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
| Action | File | What changes |
|---|---|---|
| fix | features/customers/views/components/ExportCustomerDrawer.vue:278-287 | Change all three field watchers: .map((f) => f.name_alias) → .map((f) => f.name) so the checked values are field keys |
| extend | same | On 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 |
| extend | same | In handleSubmit: before emitting, write localStorage.setItem('export_field_config_{selectedLayout}', JSON.stringify({selected_fields: [...checkedCustomerInfoFields, ...checkedDefaultFields, ...checkedCustomFields], format: fileFormat})) |
| extend | same | Add Reset button to MpDrawerFooter — calls localStorage.removeItem(key) + resets all checked arrays to full field list + resets fileFormat to 'XLSX' |
| extend | features/customers/views/components/ExportCustomerDrawer.spec.ts | New 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
nameproperty (notname_alias); all three field watchers usef.name - On first open, all fields default-checked
- On reopen after prior submit, localStorage restores both
selected_fieldsandformat - 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
handleSubmitpath 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
| Discipline | Days |
|---|---|
| Frontend | 1.5 |
| Backend | — |
| QA | 0.5 |
| Total | 2 |
Assumptions: drawer shell, field-load states, and pagination are already implemented; localStorage API is available; no design changes required.
Implementation steps
- Write failing tests (red) — Extend
ExportCustomerDrawer.spec.ts. Add 5 tests: (a) field watcher setscheckedDefaultFieldstofield.namevalues (notname_alias); (b)localStorage.getItemis called on second open and restoresselected_fields+format; (c) anamein localStorage absent from the live field list is silently dropped; (d) Reset button clears localStorage and restores all-checked defaults; (e) switchingfileFormatleavescheckedDefaultFieldsunchanged. Runpnpm test -- ExportCustomerDrawer.spec.tsand confirm all 5 fail. - 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 theselected_fields[]keys in the POST body. - Add the storage key helper — Near the top of
<script setup>, defineconst storageKey = computed(() => \export_field_config_${selectedLayout.value}`)`. - Implement pre-load — Inside the
watch(() => props.isOpen)handler, afterfetchFieldProperties()resolves: readlocalStorage.getItem(storageKey.value), parse{selected_fields, format}, intersectselected_fieldswithcustomerProperties.value.map(f => f.name)(missing-field guard), then restorecheckedCustomerInfoFields.value,checkedDefaultFields.value,checkedCustomFields.value, andfileFormat.value. Wrap in try/catch — if localStorage is corrupt, keep defaults. - Implement save on submit — At the top of
handleSubmit, before the toast, calllocalStorage.setItem(storageKey.value, JSON.stringify({ selected_fields: [...checkedCustomerInfoFields.value, ...checkedDefaultFields.value, ...checkedCustomFields.value], format: fileFormat.value })). - Add Reset button — In
MpDrawerFooter, add a thirdMpButtonvariant="ghost"labelled "Reset" between Cancel and Download. Its@clickhandler:localStorage.removeItem(storageKey.value), then reset all three checked arrays tofields.map(f => f.name)of their respective computed groups, resetfileFormat.value = 'XLSX'. - Go green — Run
pnpm test -- features/customers/views/components/ExportCustomerDraweruntil all tests pass. - 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
| Action | File | What changes |
|---|---|---|
| fix | features/customers/views/ListPage.vue:560-566 | Uncomment canExport computed; combine both conditions: featureFlagStore.flags['cdp_export_customer_enabled'] && userStore.hasAssociatedAccess('customers_customers_export') |
| verify | same | Confirm <FloatingBulkAction :can-export="canExport"> is already bound (line 67 — no change needed) |
| extend | features/customers/views/components/FloatingBulkAction.spec.ts | Tests: canExport=false → Export list item absent from DOM; canExport=true → item present and clickable |
Acceptance criteria
- Export button absent when user lacks
customers_customers_exportpermission - Export button absent when
cdp_export_customer_enabledflag 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
| Discipline | Days |
|---|---|
| Frontend | 0.5 |
| Backend | — |
| QA | 0.5 |
| Total | 1 |
Assumptions:
useFeatureFlagStoreanduserStoreare already imported inListPage.vue;canExportis literally commented out — this is a one-line uncomment plus wiring, not a new composable.
Implementation steps
- 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 emitsexportSelectedon click. Runpnpm test -- FloatingBulkAction.spec.tsand confirm both fail. - Check existing imports in
ListPage.vue— ConfirmuseFeatureFlagStoreand the user permissions store are already imported. If the user store import is missing, add it following the existing store import pattern. - Implement
canExportcomputed — AtListPage.vue:560-566, replace the commented-out block with:const canExport = computed(() => featureFlagStore.flags['cdp_export_customer_enabled'] && userStore.hasAssociatedAccess('customers_customers_export')). - Verify the existing binding — Check that
<FloatingBulkAction :can-export="canExport" ...>is already on line 67. No template change needed if it's there. - Go green — Run
pnpm test -- features/customers/views/components/FloatingBulkAction && pnpm test -- features/customers/views/ListPageuntil all tests pass. - 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
| Action | File | What changes |
|---|---|---|
| extend | features/customers/views/ListPage.vue:220 | Replace 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 |
| extend | features/customers/views/ListPage.vue:283-297 | Add 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 |
| extend | features/customers/views/components/FloatingBulkAction.vue:18 | Add a "Select all first 10,000" MpPopoverListItem/button (automation-label="floating-select-first-10k"), rendered only via a showFirst10kShortcut prop; emits selectFirst10k |
| extend | features/customers/views/ListPage.vue:119 | Bind :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 |
| extend | features/customers/views/components/FloatingBulkAction.spec.ts | Shortcut hidden when total < 10000; visible at >= 10000; emits selectFirst10k on click |
| extend | features/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
- Explore — Open
features/customers/views/ListPage.vueand read the existing selection logic at:220-297(noteMAX_SELECTION,selectedCustomerIds, the:283-290select-all-current-page block) and the param assembly at:421(params.order_by = orderKey.order_key). OpenFloatingBulkAction.vue:18to see the existingcanExportpopover-item pattern to mirror. - Write failing tests (red) — Add the
FloatingBulkAction.spec.tsshortcut-visibility/emit tests and theListPagecriterion tests above. Runpnpm test -- features/customers/views/components/FloatingBulkActionand confirm they fail. - Add the cap mode — In
ListPage.vue, addEXPORT_MAX_SELECTION = 10000and an export-selection mode flag; apply it in the add-selection guard (:227) and select-all (:283) so export tops out at 10,000. - Build the criterion — Implement
selectFirst10kSorted()assembling{selection_mode, order_by, order_direction, filter}from the current sort (defaultcreated_at/desc) — no ID enumeration; auto-disable rows beyond 10,000. - Wire the shortcut control — Add the
MpPopoverListIteminFloatingBulkAction.vuebehindshowFirst10kShortcut, emitselectFirst10k; bind visibility topagination.total >= 10000inListPage.vue. Pass the criterion into the drawer when in shortcut mode (mock the submit for now — real POST in Task 2.6 ext). - Go green — Run
pnpm test -- features/customersuntil green. - Quality gate —
pnpm lint && pnpm build.
Acceptance criteria
- Shortcut is hidden when
pagination.total < 10,000and 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_bydefaults tocreated_at/descwhen 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
| Discipline | Days |
|---|---|
| Frontend | 1.5 |
| Backend | — |
| QA | 0.5 |
| Total | 2 |
Assumptions: the list already exposes
pagination.total(:450) andorder_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 (
FloatingBulkActioncanExport) in place - (for the live criterion POST) [Task 2.5 extension] — BE
first_10k_sortedresolution (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
| Action | File | What changes |
|---|---|---|
| extend | features/customers/views/components/ExportCustomerDrawer.vue:31-36 | Remove is-disabled from the timezone MpAutocomplete; bind :data to timezones.ts options; keep selectedTimezone default DEFAULT_TIMEZONE (:175,:192); mark required |
| extend | features/customers/views/components/ExportCustomerDrawer.vue | Import + 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/ |
| extend | features/customers/views/components/ExportCustomerDrawer.vue:45 | On open, call GET {CUSTOMER_360_URL}/v1/layouts/default to pre-select "Default view"; fall back to the layout list on 5xx |
| extend | features/customers/views/components/ExportCustomerDrawer.vue | Field-load error → "Unable to load data — please click retry to reload data" + a Retry button that re-fetches; Download stays disabled until fields load |
| extend | features/customers/views/components/ExportCustomerDrawer.vue:129 | isSubmitDisabled also requires a valid Timezone + Layout |
| extend | features/customers/views/components/ExportCustomerDrawer.vue | Extend the Task 1.1 localStorage payload to {selected_fields, format, timezone, period, source} (read on open, write on submit, clear on Reset) |
| extend | features/customers/views/components/ExportCustomerDrawer.spec.ts | Timezone 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
- Explore — Open
features/customers/views/components/ExportCustomerDrawer.vueand read the disabled timezone block (:31-36),DEFAULT_TIMEZONE/selectedTimezone(:175,:192), layout autocomplete (:45),isSubmitDisabled(:129), andhandleSubmit(:289). Opencommon/components/InputPeriod.vueandcommon/components/FilterCheckbox.vueto learn their props/emits, andcommon/constants/timezones.tsfor the option shape. - Write failing tests (red) — Extend
ExportCustomerDrawer.spec.tswith the cases above. Runpnpm test -- features/customers/views/components/ExportCustomerDrawer.spec.tsand confirm they fail. - Enable timezone — Remove
is-disabled, bind:datato thetimezones.tsoptions, keep the(GMT+07:00) Asia/Jakartadefault, mark required. - Add Period + Source — Import and render
InputPeriod(last-update, default All time) andFilterCheckbox(Source, default All source); hold their values in refs to feed the submit body / criterion. - Default-layout auto-fill — On
isOpen,GET /v1/layouts/defaultviauseCustomFetch; pre-select the returned layout; on 5xx fall back to the layout list. - 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.
- Gate + persist — Extend
isSubmitDisabledto require Timezone + Layout; extend the Task 1.1 localStorage payload withtimezone/period/source. - Go green —
pnpm test -- features/customers/views/components/ExportCustomerDraweruntil green. - Quality gate —
pnpm 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_atrange - 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/sourcein addition to fields/format - (Pending Task 2.6 ext) the submit body carries
timezone/period(orstart_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
| Discipline | Days |
|---|---|
| Frontend | 2.5 |
| Backend | — |
| QA | 0.5 |
| Total | 3 |
Assumptions:
InputPeriod/FilterCheckbox/timezones.tsare reused as-is (no new control built); the drawer shell + field groups already exist (Task 1.1);/v1/layouts/defaultis a live endpoint (verified, used byMainModalLayoutContent). 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 extendedBulkUploadJobmodel 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
| Action | File | What changes |
|---|---|---|
| create | internal/app/service/export_contact.go | const MaxExportRow = 10000 (mirrors bulk_import_contact.go:20) |
| modify | internal/app/service/job_enqueuer.go | Add ExportContactJobName = "export_contact" alongside existing job-name consts |
| create | internal/app/payload/export_contact_request.go | ExportContactRequest{ContactIDs []string, LayoutID string, SelectedFields []string, Format string} + ExportJobPayload (marshalled into job.Args["data"]) |
| modify | internal/app/repository/bulk_upload_job/base.go:30 | Add to BulkUploadJob struct: JobType string, Format string, LayoutID string, SelectedFields []string, FailureReason string |
| create | db/migrations/NNN_index_bulk_upload_jobs_job_type.up.json | Compound index {company_sso_id:1, job_type:1, created_at:-1} |
| create | db/migrations/NNN_index_bulk_upload_jobs_job_type.down.json | Drop index |
Acceptance criteria
-
make buildpasses with all new constants and struct fields -
MaxExportRow = 10000andExportContactJobNameare exported symbols -
BulkUploadJobcompiles with 5 new fields; existing import callers unaffected (legacy rows have nojob_type— export queries filter byjob_type:"export"so they correctly exclude them with no backfill) -
make migrate-up && make migrate-downruns 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1 |
| QA | — |
| Total | 1 |
Assumptions: MongoDB is schemaless so no DDL migration; existing
BulkUploadJobstruct pattern and migration JSON format are well-established; no user-facing behavior, so no QA allocation.
Implementation steps
- Create
export_contact.go— Ininternal/app/service/, create the file withpackage serviceandconst MaxExportRow = 10000. Confirm it mirrors the pattern atinternal/app/service/bulk_import_contact.go:20. - Add
ExportContactJobName— Ininternal/app/service/job_enqueuer.go, addExportContactJobName = "export_contact"as a package-level const alongside the existing job-name consts. - Create payload file — Create
internal/app/payload/export_contact_request.gowith: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"]`). - Extend
BulkUploadJob— Ininternal/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. - Create index migrations — Create
db/migrations/NNN_index_bulk_upload_jobs_job_type.up.jsonand.down.json. Look at an existing migration indb/migrations/to confirm the JSON format used bymake migrate-up. The up migration creates{company_sso_id:1, job_type:1, created_at:-1}; the down migration drops the same index. - Build gate — Run
make buildand fix any compilation errors before proceeding. - Migration gate — Run
make migrate-up && make migrate-downand 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
| Action | File | What changes |
|---|---|---|
| modify | internal/app/service/excel_service.go | Add 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 |
| create | internal/app/service/export_csv.go | SerializeToCSV(rows [][]string, headers []string) (*bytes.Buffer, error) using encoding/csv; RFC-4180 quoting; formula-injection guard: any cell starting with = + - @ is prefixed with ' |
| create | internal/app/service/export_format.go | formatValue(fieldType, numberType string, value interface{}, currencyCode string, isXLSX bool) interface{} — Decision 6 switch covering all 10 repo field_type constants + date + default |
| create | internal/app/service/export_format_test.go | 24 table-driven cases (all Decision 6 rows × XLSX + CSV) + formula-injection guard cases (=, +, -, @ prefix) |
| create | internal/app/service/excel_service_export_test.go | GenerateAndUploadExcelWithData: asserts OSS path contains private/exports/, TTL=172800, ContentDisposition=export; asserts template method GenerateAndUploadExcel behavior unchanged |
Acceptance criteria
-
formatValuehandles all 10 repofield_typevalues:single_line_text,text_area,dropdown_select,number,date,multiple_select,url,upload,gps,signature -
numbersubtype keyed onNumberType(notfield_type):number→ numeric cell,percentage→ percent format,currency→"IDR 1,000,000"quoted CSV string -
text_areapreserves\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
-
GenerateAndUploadExcelWithDatauploads toprivate/exports/{co}/...with TTL=172800 - Existing
GenerateAndUploadExceltemplate 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 3 |
| QA | 0.5 |
| Total | 3.5 |
Assumptions:
excelizelibrary 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
- Write failing tests (red) — Create
internal/app/service/export_format_test.go. Write 24 table-driven test cases: one row perfield_typeconstant (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). Rungo test -race -tags dynamic ./internal/app/service/...and confirm all fail. - Create
internal/app/service/export_format.go— DefineformatValue(fieldType, numberType string, raw interface{}) interface{}with aswitchonfieldType. For"number", branch onnumberType(number/currency/percentage). For"multiple_select", returnfmt.Sprintf("%v", raw). For"gps", format as"lat,lng". For"date", return ISO-8601 string. Apply formula-injection guard: iffmt.Sprintf("%v", result)starts with= + - @, prefix with'. - Create
SerializeToCSV— Ininternal/app/service/export_contact.go(or a newexport_csv.go), implementSerializeToCSV(headers []string, rows [][]interface{}) ([]byte, error)usingencoding/csv. Quote cells containing commas or newlines. Preserve\nintext_areafields by quoting. - Create
GenerateAndUploadExcelWithData— Ininternal/app/service/excel_service.go(alongside existingGenerateAndUploadExcel), implementGenerateAndUploadExcelWithData(ctx, headers []string, rows [][]interface{}, companySSO, fileName string) (signedURL string, err error). Build the XLSX withexcelize, upload toprivate/exports/{companySSO}/{fileName}viaossClient.PutObject, then callossClient.SignURLwith TTL=172800. Do not modify the existingGenerateAndUploadExcelmethod. - Go green — Run
go test -race -tags dynamic ./internal/app/service/...until all 24 cases pass. - Quality gate — Run
make lint && make build.
Run to verify
go test -race -tags dynamic ./internal/app/service/...
Depends on
- [Task 2.1] —
MaxExportRowconst (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
| Action | File | What changes |
|---|---|---|
| modify | internal/app/service/email/email_service.go:98 | Add 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) |
| create | internal/app/api/notification_mekari.go | NotificationPublisher 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} |
| modify | config/load.go | Add NOTIFICATION_API_ROOT_URL (getStringOrPanic), NOTIFICATION_API_CLIENT_ID, NOTIFICATION_API_SECRET following the IAG client pattern at :306 |
| create | internal/app/api/notification_mekari_test.go | Tests: client constructs correct JSON payload with origin="external_url" and notif_category="5"; publish failure returns error (non-fatal, does not panic) |
| create | internal/app/service/email/email_export_test.go | Tests: 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.FiletoSendEmailWithAttachment - Failure email sends no attachment (nil file argument)
- Notification client POSTs to
/notif/v1/notificationswithorigin="external_url"andnotif_category="5" -
NotificationPublisherinterface is the only type the consumer depends on (channel swap = one implementation) -
config/load.goadds 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: email service and heimdall HTTP client patterns are established (
iag_mekari.gois the reference); OQ-10 resolved as HTTP default — switching to Kafka after merge adds ~1 day.
Implementation steps
- Write failing tests (red) — Create
internal/app/service/email/email_export_test.gowith 3 tests (success method passes non-nil*os.File, partial method passes non-nil*os.File, failure method passes nil). Createinternal/app/api/notification_mekari_test.gowith 2 tests (correct JSON body shape; publish error returnserrorwithout panic). Rungo test -race -tags dynamic ./internal/app/api/... ./internal/app/service/email/...and confirm all fail. - 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 ofSendEmailImportCustomer*; failure method passesnilas the file argument toSendEmailWithAttachment. - Create
NotificationPublisherinterface — Createinternal/app/api/notification_mekari.go. Definetype NotificationPublisher interface { Publish(ctx context.Context, payload NotificationPayload) error }. DefineNotificationPayloadstruct withNotifType int,NotifCategory string,Origin string,ClickAction string,ClickActionURL string,Title string,Description string. - Implement
NotificationClient— In the same file, createNotificationClientstruct with a heimdall HTTP client (copy the client setup fromiag_mekari.go:54-120, swapping the base URL forNOTIFICATION_API_ROOT_URL). ImplementPublish: marshalpayload→POST /notif/v1/notificationswithnotif_type:1, notif_category:"5", origin:"external_url". - Add config vars — In
config/load.go, addNOTIFICATION_API_ROOT_URL(getStringOrPanic),NOTIFICATION_API_CLIENT_ID,NOTIFICATION_API_SECRETfollowing the IAG client pattern at:306. - Go green — Run
go test -race -tags dynamic ./internal/app/api/... ./internal/app/service/email/... && make builduntil all tests pass and build succeeds. - 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] —
ExportJobPayloadstruct 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
| Action | File | What changes |
|---|---|---|
| create | internal/app/consumer/export_contact.go | ExportContactConsumer 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 |
| modify | internal/worker/worker_service.go:100-135 | Register ExportContactJobName with noRetryOpt (one-shot — failure email already sent on terminal failure) |
| extend | 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 pagination |
| create | internal/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 intoExportJobPayload - Default fields read from
Contactstruct top-level; custom fields fromContact.CustomFields[]byKey -
IsHidden=truefields excluded from all output (server-side enforcement, never trust FE) - Temp file always cleaned up via
defer os.Removeon 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
nilwithout 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 3 |
| QA | 0.5 |
| Total | 3.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
- Write failing tests (red) — Create
internal/app/consumer/export_contact_test.gowith 5 tests: (1) happy path →status=completed, email+notif+temp-delete called; (2) partial →status=partial,row_failurescounted, 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. Rungo test -race -tags dynamic ./internal/app/consumer/...and confirm all fail. - Add
SearchByIDsto contact repo — Ininternal/app/repository/contact/base.go, addSearchByIDs(ctx context.Context, ids []primitive.ObjectID, page, limit int) ([]Contact, error)usingbson.M{"_id":{"$in":ids}}with skip/limit pagination. - Scaffold
ExportContactConsumer— Createinternal/app/consumer/export_contact.gowithExportContactConsumerstruct (fields:ContactRepo,BulkUploadJobRepo,ExcelService,EmailService,NotificationPublisher) and stubProcessExportContactJob(job *work.Job) errorreturning nil. - Implement
ProcessExportContactJob— In order: unmarshaljob.Args["data"]→ExportJobPayload; terminal-status guard (return nil if currentstatus ∈ {completed,partial,failed}); contact-fetch loop in batches of 500 viaSearchByIDs; per-field resolution (default fields from top-levelContactstruct, custom fields fromContact.CustomFields[]byKey; skipIsHidden=true); callformatValueper type; callGenerateAndUploadExcelWithDataorSerializeToCSVby format; write temp file → pass to email method →defer os.Remove; callNotificationPublisher.Publish(non-fatal: log error, do not return it); updateBulkUploadJob.Statustocompleted/partial/failed. - Register in worker — In
internal/worker/worker_service.go:100-135, registerExportContactJobNamewithnoRetryOpt(same pattern as other one-shot consumers). - Go green — Run
go test -race -tags dynamic ./internal/app/consumer/... && go test -race -tags dynamic ./internal/app/repository/contact/...until all tests pass. - 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,
BulkUploadJobfields - [Task 2.2] —
GenerateAndUploadExcelWithData,SerializeToCSV,formatValue - [Task 2.3] — email methods,
NotificationPublisherinterface
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
| Action | File | What changes |
|---|---|---|
| create | internal/app/service/export_service.go | ExportService{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} |
| create | internal/app/handler/export_handler.go | ExportHandler; 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} |
| modify | internal/server/rest_router.go:142 | Under /contacts route group: POST /export with RequirePermissionMiddleware(CustomersCustomersExportKey) + RateLimitMiddleware("export", 5, 3600); GET /export/status/{job_id} with permission middleware |
| modify | internal/pkg/middleware/rate_limit_middleware.go | Extend to accept (key string, maxRequests, windowSeconds int) — export counter keyed {companySSOID}:export; update import usage to explicit ("import", existingMax, existingWindow) so counters are independent |
| modify | cmd/initializer.go | Wire ExportService, ExportHandler, ExportContactConsumer into the dependency graph |
| create | internal/app/handler/export_handler_test.go | 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 |
| create | docs/EXPORT_CONTACT_SERVICE.md | Markdown 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/exportreturns 200{job_id, status:"queued", email}for a valid request - Returns 422
EXPORT_LIMIT_EXCEEDEDwhenlen(contact_ids) > 10000 - Returns 422
EXPORT_FORMAT_INVALIDwhenformat ∉ {"xlsx","csv"} - Returns 403
FLAG_DISABLEDwhencdp_export_customer_enabledis OFF - Returns 403
FORBIDDENwithoutCustomersCustomersExportKey - Returns 429
EXPORT_RATE_LIMIT_EXCEEDEDfrom 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 testfully 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2.5 |
| QA | 0.5 |
| Total | 3 |
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
- Write failing tests (red) — Create
internal/app/handler/export_handler_test.gowith 6 tests: valid request → 200{job_id,"queued",email}; >10K IDs → 422EXPORT_LIMIT_EXCEEDED; format="pdf" → 422EXPORT_FORMAT_INVALID; flag OFF → 403FLAG_DISABLED; no perm → 403FORBIDDEN; rate exceeded → 429EXPORT_RATE_LIMIT_EXCEEDED. Rungo test -race -tags dynamic ./internal/app/handler/...and confirm all fail. - Extend
RateLimitMiddleware— Ininternal/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 toRateLimitMiddleware("import", existingMax, existingWindow)so the counters remain independent. - Create
ExportService— Createinternal/app/service/export_service.gowithExportService{JobEnqueuer, BulkUploadJobRepo, FlagService}andTriggerExport(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}. - Create
ExportHandler— Createinternal/app/handler/export_handler.gowithTriggerExport(decode body → validate flag → validate perm → validatelen(contact_ids) > MaxExportRow→ validateformat ∈ {xlsx,csv}→ call service → 200) andGetExportStatus(read job byjob_id→ return{job_id, status, total_records, success_count, failed_count}). - Register routes — In
internal/server/rest_router.go:142, under the/contactsgroup add:POST /exportwithRequirePermissionMiddleware(CustomersCustomersExportKey)+RateLimitMiddleware("export", 5, 3600);GET /export/status/{job_id}with permission middleware. - Wire into initializer — In
cmd/initializer.go, instantiate and injectExportService,ExportHandler,ExportContactConsumerinto the dependency graph following the pattern of existing services. - Write API doc — Create
docs/EXPORT_CONTACT_SERVICE.mdfollowing the structure ofdocs/WEBHOOK_DELIVERY_SERVICE.md: request/response schemas for both endpoints, all error codes, auth requirements. - Go green — Run
make build && make lint && make testuntil fully green. - Quality gate —
make 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/exportwith 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
| Action | File | What changes |
|---|---|---|
| modify | features/customers/views/components/ExportCustomerDrawer.vue:289-299 | Replace 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) |
| extend | same | catch 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 |
| extend | same | Fire 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 |
| extend | features/customers/views/ListPage.vue | Fire useMixpanel().track('cdp_export_customer_cap_exceeded', {attempted_count}) in the selection handler when >10K is attempted |
| extend | features/customers/views/components/ExportCustomerDrawer.spec.ts | Tests: 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[] (fieldnamekeys), format: "xlsx"\|"csv"}— format value is lowercased before sending - 200:
toastNotifysuccess showsresponse.emailaddress - 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_DISABLEDorFORBIDDEN: "Export not available" -
cdp_export_customer_triggeredfires with{contact_count, field_count, layout_id, format} -
cdp_export_customer_cap_exceededfires 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
| Discipline | Days |
|---|---|
| Frontend | 1.5 |
| Backend | — |
| QA | 0.5 |
| Total | 2 |
Assumptions:
useCustomFetchanduseMixpanelcomposable patterns are established; error code strings confirmed per RFC Detail 3.C; localStorage logic reused from Task 1.1 (no re-implementation).
Implementation steps
- 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_fieldsusingnamekeys (notname_alias),formatlowercased; (b) 200 →toastNotifycalled withresponse.email; (c) 422EXPORT_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_triggeredMixpanel event fires with{contact_count, field_count, layout_id, format}. Runpnpm test -- features/customers/views/components/ExportCustomerDrawer.spec.tsand confirm all 5 fail. - Replace mock
handleSubmit— InExportCustomerDrawer.vue:289-299, remove the placeholder and implement:const { $customFetch } = useCustomFetch(); POST to${CUSTOMER_360_URL}/v1/contacts/exportwith body{contact_ids: props.selectedIds, layout_id: selectedLayout.value, selected_fields: [...checkedCustomerInfoFields.value, ...checkedDefaultFields.value, ...checkedCustomFields.value], format: fileFormat.value.toLowerCase()}. On 200: calltoastNotifywithresponse.email; write localStorage (reuse thestorageKey+setItemlogic from Task 1.1). - Implement error mapping — In the
catchblock, read the error-code string from the response body and map:EXPORT_LIMIT_EXCEEDED/EXPORT_FORMAT_INVALID→ assignsubmitError.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". RendersubmitError.valuevia<MpText color="text.danger">below the Download button. - 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()}). - Add cap-exceeded event in ListPage — In
features/customers/views/ListPage.vue, in the selection handler where the >10K guard triggers, adduseMixpanel().track('cdp_export_customer_cap_exceeded', {attempted_count}). - Go green — Run
pnpm test -- features/customers/views/components/ExportCustomerDraweruntil all tests pass. - 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 ExportContactRequest → ExportJobPayload → the consumer → the formatter; default to Asia/Jakarta when absent.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/service/export_format.go | formatValue(...) 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 |
| extend | internal/app/payload/export_contact_request.go | Confirm Timezone string is on ExportContactRequest (per RFC §2.4 struct) and carried into ExportJobPayload; document the expected IANA value (OQ-20) |
| extend | internal/app/consumer/export_contact.go | Resolve loc, err := time.LoadLocation(payload.Timezone) once (fallback Asia/Jakarta on empty/error), pass loc into every formatValue call |
| extend | internal/app/service/export_format_test.go | Cases: 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
- Explore — Open
internal/app/service/export_format.go(the Decision-6 formatter from Task 2.2) and thedatecase; openinternal/app/api/qontak_launchpad.goto see how a timezone is already handled elsewhere in the service (the only existingTimezoneusage) for a consistent parse approach. - Write failing tests (red) — Add the timezone cases to
export_format_test.go. Rungo test -race -tags dynamic ./internal/app/service/...and confirm they fail. - Add
locto the formatter — Thread a*time.LocationintoformatValue; in thedate/timestamp branch render witht.In(loc); leave non-temporal types untouched. - Plumb the timezone — Ensure
Timezoneis on the request/job payload (RFC §2.4) and resolve it once inexport_contact.goviatime.LoadLocationwith anAsia/Jakartafallback; passlocto the formatter. - Go green —
go test -race -tags dynamic ./internal/app/service/... ./internal/app/consumer/.... - Quality gate —
make lint && make build.
Acceptance criteria
- A
date/timestamp cell renders in the request timezone (e.g.Asia/Jakarta→ +07:00 offset) - Empty or invalid
timezonefalls back toAsia/Jakarta(no error to the user) - Non-temporal field types are byte-identical regardless of
loc -
timezoneis 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1 |
| QA | 0.5 |
| Total | 1.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 (
timestdlib).
Run to verify
go test -race -tags dynamic ./internal/app/service/... ./internal/app/consumer/...
Depends on
- [Task 2.2] —
formatValue/export_format.goexists - [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/exportso 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
| Action | File | What changes |
|---|---|---|
| create | internal/app/api/export_history_mekari.go | ExportHistoryRegistrar interface {Register(ctx, ExportHistoryRow) error}; a heimdall HTTP impl (mirrors iag_mekari.go:54-120) targeting IAG POST /report/v1/billings/logs/export — left unwired/no-op until OQ-17 |
| extend | config/load.go | Add EXPORT_HISTORY_API_ROOT_URL / _CLIENT_ID / _SECRET (only required once EXP-S08 is wired) following the IAG client pattern |
| extend | internal/app/consumer/export_contact.go | After email + notification, call ExportHistoryRegistrar.Register(...) non-fatally (log + alert on failure, never fail the job) — mirrors the notification policy (Decision 13) |
| extend | internal/app/consumer/export_contact_test.go | Register 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
- Explore — Open
internal/app/api/iag_mekari.go:54-120(heimdall client to mirror) andinternal/app/consumer/export_contact.go(where the non-fatal notification call already lives — add the register call beside it with the same policy). - Define the interface — Create
export_history_mekari.gowithExportHistoryRegistrar+ anExportHistoryRowpayload (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. - Wire non-fatally — In the consumer, call
Register(...)after the notification publish; on error, log + alert#cdp-opsand continue (do not fail the job). Emitcdp_export_history_registered. - Tests — Assert the consumer stays green when
Registererrors or is the no-op. - Stop at the boundary — Do not implement the live
POST /report/v1/billings/logs/exportcall: it requires the OQ-17 cross-team path. Leave a// TODO(OQ-17)and the config keys unwired. - Quality gate —
make lint && make build.
Acceptance criteria
-
ExportHistoryRegistrarinterface +ExportHistoryRowpayload defined; default binding is a non-fatal no-op - Consumer calls
Register(...)after notification; a register error/no-op never fails the job (status stayscompleted/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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1 |
| QA | — |
| Total | 1 |
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
BulkUploadJobextension 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
NotificationPublisherinterface 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_sortedshortcut) 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 editExportCustomerDrawer.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/sourcehandling, 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
| Story | Reason |
|---|---|
| EXP-S08 — export-history register (real path) | Blocked on OQ-17/REV-12 — chat.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 render | Host-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 render | mobile-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. |