Skip to main content

RFC: Export Customer Data with Layout (XLSX/CSV, async, email + in-app notification)

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory.

It is also agent-execution-ready: §1 Design References (FE half) + §1 PRD-to-Schema Derivation (BE half), §2 Repo Reading Guide (Detail 2.0) for both layers, mermaid diagrams, §2.G Cross-Layer Contract Verification, and §4 Agent Execution Plan + Verification & Rollback Recipe are complete.

The YAML frontmatter at the top is the machine-readable index. The Metadata table below is the human-readable governance record. Both agree on every shared field.

Grounding note (anti-hallucination): every path:line reference in this RFC was verified against the live worktrees contact-service and qontak-customer-fe on 2026-06-18, and re-grounded on 2026-06-26 for the PRD v2.6 scope additions (first_10k_sorted selection mode, company/org export-history reuse, and the right-side "Download all customers" panel) — additionally against hub-chat (which serves chat.qontak.com/reports/export). See Detail 2.0 Source Verification. Where the PRD's assumed path differed from the repo, the repo wins and the deviation is called out (notably the API namespace: /iag/v1/contacts/*, not /iag/v1/customers/*; and the export-history surface is owned by the IAG billing service via hub-chat, with no cross-service registration path today — see Decision 9 / OQ-17). The mobile One Notification V2 center was verified against the live mobile-qontak-crm repo (features/crm_misc/...) on 2026-06-18; mobile-qontak-chat was also checked and does not own the notification center (it has a separate chat FCM/MQTT system and only depends on the shared qontak_common lib from the CRM repo).

Metadata

FieldValueNotes
StatusRFC (IDEA)Human label; YAML status: carries the remapped linter enum draft
DRIZhelia AlifaRFC owner (frontmatter dri)
TeamcdpAdvisory squad slug carried from PRD / initiative README
Author(s)Zhelia AlifaPrimary author
ReviewersCDP Backend Lead, CDP Frontend Lead, One Notification Team, Mobile LeadTech reviewers across affected squads (FE + BE + Mobile)
Approver(s)CDP Tech Lead, InfoSec ApproverTech leaders + infosec approver
Submitted Date2026-06-18ISO-8601
Last Updated2026-06-30ISO-8601 — PRD v2.6 verification pass; success-banner copy aligned to EXP-S01/S02 AC-2 (timezone sentence)
Target Release2026-Q3Quarter
Target Quarter2026-Q3Advisory, carried from PRD
Related../prds/prd-export-customer-data.mdSource PRD v2.6
Discussion#cdp-ops (Slack)Alerts + discussion channel

Type: full-stack Frontend sub-type: new-feature Backend sub-type: new-feature

Sections at a Glance

  1. Overview (Design References — FE half; PRD-to-Schema Derivation — BE half; traceability; per-story change map)
  2. Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide [both layers] → end-to-end mermaid → DDL → APIs → cross-layer contract verification)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (cross-layer rollout matrix, Agent Execution Plan, Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

CDP (the contact-service backend + qontak-customer-fe web app) has no customer-data export capability today — verified: grep for /export, ExportContactJob, MaxExportRow returns zero matches in contact-service, and the only FE export entry point (downloadSelectedCustomers() at features/customers/views/ListPage.vue:317-328) is wired to a listener (ListPage.vue:67) but no button emits it, so the flow is unreachable. CRM export is one of the most-used operational features (PRD §1: 895 export events / 3 months across 122 CIDs); CRM-migrating clients lose it on day one of CDP migration.

This RFC specifies a net-new asynchronous export pipeline: a user selects up to 10,000 customers, picks a layout + fields + format (XLSX or CSV), and the backend generates the file off-request via a gocraft/work job, stores it in OSS with a 48 h signed URL, then notifies the user by email and an in-app notification (Qontak Unified Notification Service). It is a comprehensive specification of net-new work that reuses documented infrastructure (excelize, the OSS upload/sign flow, SendEmailWithAttachment, the gocraft/work worker, the IAG router + rate-limit middleware, the bulk_upload_jobs store) rather than building those from scratch.

Scope synced to PRD v2.6. Beyond the original explicit-ID export, this RFC now covers three additions introduced in PRD v2.3–v2.6, each grounded against the live repos on 2026-06-26:

  1. A first_10k_sorted selection mode (PRD §5/§6.1, D-14) — when the list total is ≥ 10,000 the FE offers a "Select all first 10,000" shortcut that sends a server-side selection criterion (selection_mode + order_by/ order_direction + filter), not 10,000 IDs, so the backend resolves the first 10,000 in the user's current sort order. Grounded: SearchContacts + SortBy{Field,Direction} already exist and the handler already defaults to created_at desc; the gaps are a deterministic _id tie-breaker and a get-by- IDs/criteria batch path (Decision 9, §2.4, OQ-18). Note the FE list cap is MAX_SELECTION = 500 today (ListPage.vue:220) — raising the export path to 10,000 is net-new FE work (OQ-19).
  2. Company/org export-history reuse (PRD §6.6, EXP-S08, D-15) — register each completed export into the existing chat.qontak.com/reports/export surface rather than build a CDP-local history. Grounded honestly: that surface is hub-chat → IAG billing service (/report/v1/billings/logs/export) with billing-only quota types and no cross-service registration path today (Decision 10, OQ-17).
  3. A right-side "Download all customers" configuration panel (PRD §6.7, EXP-S09) with Timezone / Period (last-update) / Source / Layout controls. Grounded: an ExportCustomerDrawer.vue already exists (right MpDrawer, DEFAULT_TIMEZONE='(GMT+07:00) Asia/Jakarta'), and FilterCheckbox/ InputPeriod/timezones.ts + backend Source[] + start_date/end_date on updated_at are reusable; timezone-aware timestamp formatting is net-new on the backend (Decision 11, OQ-20).

Success Criteria

  • Export job success rate ≥ 98% (completed / (completed + failed)) — PRD §11.
  • Email delivery rate ≥ 99.5% of completed jobs — PRD §11.
  • Feature adoption ≥ 60% of flag-ON companies trigger ≥ 1 export within 30 days of GA — PRD §11.
  • A 10,000-record × 30-field export completes in < 10 min (alert threshold; PRD §10) and produces a correct XLSX and CSV (Stage-1 QA gate, PRD §12).
  • Hard selection cap of 10,000 enforced at both FE and API; no silent truncation.

Out of Scope

  1. Synchronous/real-time export — all exports are async (PRD §4.1).
  2. Export for non-customer objects — Customers module only (PRD §4.2).
  3. Bulk export > 10,000 records — hard cap (PRD §4.3).
  4. No in-app download center / live progress bar — completion is signalled by email + in-app notification only (PRD §4.4).
  5. No new notification surface — reuse the existing Unified Notification Service
    • Notification Center (web host shell + mobile One Notification V2). This RFC does not build a notification center (PRD §4.5).
  6. Org-level export-template persistence — "save last selected fields" is per-user (PRD §4.6, EXP-S07/AC-4).
  7. Export via OpenAPI / programmatic trigger — UI-triggered only (PRD §4.7).
  8. Server-side filter re-execution for explicit-ID selection — for the manual/filtered ID path (EXP-S01/S02 selection_mode=ids) the FE still resolves IDs client-side and sends them in the body; 10 K cap bounds the payload (PRD D-4). Note: this is now scoped narrowly — the first_10k_sorted shortcut (EXP-S02 v2.4) and the right-side panel (EXP-S09: Period/Source filters) do resolve their set server-side from a criterion. What stays out of scope is re-running an arbitrary ad-hoc list filter server-side for the explicit-ID path.
  9. Building a CDP-local export-history page or download center — EXP-S08 reuses the existing chat.qontak.com/reports/export surface (Decision 10); CDP only registers a row.
  10. Building a notification surface, live progress bar, or org-level template persistence — unchanged from PRD §4.

Assumptions

  • CUSTOMER_360_URL (FE config) already resolves to the /iag namespace of contact-service: the existing POST /v1/download_template FE call (DownloadTemplateModal.vue:147, baseURL: config.CUSTOMER_360_URL) reaches the backend handler registered at /iag/v1/download_template (rest_router.go:231-233). So FE export calls use /v1/contacts/export (not /iag/v1/...) — same base + auth path as the working /v1/contacts/import call. (Verified: 0 /iag prefixes in customer-fe source.)
  • OSS (Alibaba OSS Go SDK) used by excel_service.go is assumed to have quota headroom for ~5–15 MB export files at 48 h retention — unconfirmed, confirm at Stage 0 (OQ-4).
  • The logged-in user always has a registered email ($auth.user.email, ListPage.vue:441); it is the sole export recipient (PRD D-3).
  • MongoDB is schemaless, so adding a job_type discriminator to bulk_upload_jobs needs no DDL migration (only application code + an optional index migration in db/migrations/).
  • The Unified Notification Service is documented (per the linked Confluence pages, and confirmed as the contract the mobile crm_misc client consumes) to expose POST /notif/v1/notifications; whether contact-service should publish via that REST endpoint or via Kafka is OQ-10. Publishing is net-new for contact-service (no notification mechanism exists today — grep confirms Notify hits are OS-signal handlers only).

Dependencies

DependencyLayer / OwnerAvailabilityBlocking?
gocraft/work worker + job registrationBE / CDPExistsgo.mod:14 v0.5.1; registerJob() internal/worker/worker_service.go:100-135Reuse
excelize XLSX libraryBE / CDPExistsgo.mod:35 xuri/excelize/v2 v2.8.1Reuse
OSS upload + signed-URL flowBE / CDPExistsexcel_service.go:98-112 (Alibaba OSS SDK)Reuse (new method)
SendEmailWithAttachment(*os.File)BE / CDPExistsinternal/app/service/email/email_service.go:98Reuse (3 new wrapper methods)
bulk_upload_jobs Mongo storeBE / CDPExistsrepository/bulk_upload_job/base.go:30; no job_type field yetExtend (add discriminator)
IAG router + RateLimitMiddleware + RequirePermissionMiddlewareBE / CDPExistsinternal/server/rest_router.go:117-142Reuse
CustomersCustomersExportKey permissionBE / CDPExists, unusedinternal/pkg/consts/const.go:29Reuse (wire to endpoint)
field_properties GET endpoint + Layout.NameAliasBE / CDPExistsrest_router.go:173-187; repository/layout_properties/base.goReuse
Heimdall HTTP client pattern / Kafka publisher (for notif publish)BE / CDPExistsinternal/app/api/iag_mekari.go:54 NewIagClient; JobEnqueuer.KafkaPublishReuse (new client)
Qontak Unified Notification Service POST /notif/v1/notificationsOne Notification TeamExternal — ingest channel = OQ-10 confirmYES (confirm)
Web Unified Notification CenterLaunchpad / Host shellExternal — host-owned; customer-fe holds only the TheNotification.vue stubYES (confirm)
Mobile One Notification V2 centerMobile (mobile-qontak-crm, features/crm_misc)VerifiedNotificationV2Screen renders the service; gated by flag_one_notification (default OFF, crm_core/.../feature_flag_constant.dart:78-82) + profile useQontakOneNotif. Tap-through routes on origin="external_url"YES (payload origin/redirect, confirm)
Mobile chat app (mobile-qontak-chat)MobileVerified — not involved — separate chat FCM/MQTT system; only depends on shared qontak_common from the CRM repo; no One Notification center heren/a
@mekari/pixel3 design systemFE / CDPExistspackage.json:24 1.0.10-dev.0Reuse
OSS quota for private/exports/CDP InfraConfirm (OQ-4)YES (confirm)
Contact search + sortBE / CDPExistsSearchContacts service/get_contact.go:148; SortBy{Field,Direction} repository/db.go:35-37; valid sorts oneof=created_at updated_at name payload/search_contact_request.go:33; handler defaults created_at desc contact_handler.go:633-634Reuse (add _id tie-breaker + IDs/criteria batch for first_10k_sorted)
Contact source + last-update filtersBE / CDPExistsSearchContactRequest.Source []string :25; StartDate/EndDate on updated_at :36-37 + filter logic :194-208 (RFC3339)Reuse for EXP-S09 Source/Period; source enum gap (OQ-21)
Default-layout endpointBE / CDPExistsGET /iag/v1/layouts/default (rest_router.go:191, layout_properties_handler.go:68-76)Reuse for EXP-S09 layout auto-fill
ExportCustomerDrawer.vue (right-side panel)FE / CDPExists (incomplete)features/customers/views/components/ExportCustomerDrawer.vue right MpDrawer; disabled timezone MpAutocomplete; DEFAULT_TIMEZONE='(GMT+07:00) Asia/Jakarta' :175; CSV option; does not yet call the export APIExtend (complete it) for EXP-S09
FilterCheckbox / InputPeriod / timezones.tsFE / CDPExistscommon/components/FilterCheckbox.vue (source multi-select), common/components/InputPeriod.vue (period presets on updated_at), common/constants/timezones.ts (598 zones)Reuse for EXP-S09 Source/Period/Timezone
Company/org export-history (chat.qontak.com/reports/export)hub-chat + IAG billing serviceExists, billing-onlyhub-chat features/report/export/...; IAG POST/GET /report/v1/billings/logs/export; no CDP/"Customer Data" quota type and no cross-service registration path todayYES (design + confirm — OQ-17)

Design References (frontend half — required)

PRD-named surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
Export Selected button (bulk-actions popover)https://www.figma.com/design/YtCkrXaXMD63i8diuXJ4QC/Customer-Data-Platform?node-id=16492-601704image-20260617-022708 (happy)@mekari/pixel3@1.0.10-dev.0CDP Design (Figma master DRI)Adds an MpPopoverListItem next to "Delete selected" (ListTable.vue:51-62)
Export Configuration page/drawerhttps://www.figma.com/design/YtCkrXaXMD63i8diuXJ4QC/Customer-Data-Platform?node-id=12224-219006image-20260608-090001@mekari/pixel3@1.0.10-dev.0CDP DesignFork DownloadTemplateModal.vue (an MpDrawer); route-vs-drawer = OQ-8
XLSX/CSV format selector(within Export Configuration frame above)@mekari/pixel3@1.0.10-dev.0CDP DesignMpRadio group; extend FILE_FORMAT_OPTIONS (DownloadTemplateModal.vue:128-130)
Export unhappy-path stateshttps://www.figma.com/design/YtCkrXaXMD63i8diuXJ4QC/Customer-Data-Platform?node-id=19429-139509image-20260618-012856@mekari/pixel3@1.0.10-dev.0CDP DesignCap tooltip, error banner, disabled states
Export email (success/failure)https://www.figma.com/design/YtCkrXaXMD63i8diuXJ4QC/Customer-Data-Platform?node-id=19216-734654 · https://www.figma.com/design/YtCkrXaXMD63i8diuXJ4QC/Customer-Data-Platform?node-id=19216-734655image-20250530-130605 / image-20250604-055859n/a — BE email templateCDP DesignRendered by internal/app/tmpl/ email templates
In-app notificationhttps://www.figma.com/design/9fUk4MHn6KriVf5YT5H1MS/%F0%9F%93%8A-Chat-Panel---Reports?node-id=5409-58952 (v2.6 repoint)image-20260618-021135host-owned (web) / One Notification V2 (mobile)CDP Design + NotifNo customer-fe rendering work — host-owned center
"Download all customers" right-side panel (EXP-S09)https://www.figma.com/design/9fUk4MHn6KriVf5YT5H1MS/%F0%9F%93%8A-Chat-Panel---Reports?node-id=1705-42216Chat-Panel·Reports (export menu)@mekari/pixel3@1.0.10-dev.0CDP DesignExtend ExportCustomerDrawer.vue; Timezone/Period/Source/Layout + field groups + Retry state
Export history (EXP-S08)https://www.figma.com/design/9fUk4MHn6KriVf5YT5H1MS/%F0%9F%93%8A-Chat-Panel---Reports?node-id=1705-42216Chat-Panel·Reportsn/a — reused chat.qontak.com/reports/export (hub-chat)CDP Design + Chat/OmniNo CDP render; register-only (OQ-17)

PRD-to-Schema Derivation (backend half — required)

PRD entity / attribute / rulePersisted as (collection.field)Exposed via (endpoint / event)Enforced whereSource
An export request (≤10 K contact IDs, layout, fields, format) becomes a durable jobbulk_upload_jobs + new job_type="export", format, selected_fields[], layout_idPOST /iag/v1/contacts/export → enqueue ExportContactJobNameExportService.TriggerExport + RequirePermissionMiddleware + RateLimitMiddlewarePRD §7 #1, §5
Export job status (queued/processing/completed/partial/failed)bulk_upload_jobs.status, .total_rows, .row_failures, .file_urlGET /iag/v1/contacts/export/status/{job_id}ExportContactConsumer updates via UpdateBulkUploadJobPRD §7 #2-3, EXP-S01/AC-2
Selection cap = 10,000(validation only)POST /iag/v1/contacts/export 422 EXPORT_LIMIT_EXCEEDEDMaxExportRow = 10000 (mirror MaxImportRow bulk_import_contact.go:20)PRD §5, EXP-S01/ERR-1
Format ∈ {xlsx, csv}bulk_upload_jobs.formatrequest body formathandler validation; 422 EXPORT_FORMAT_INVALIDPRD §5, §7 #1
Generated file in OSS, 48 h TTLOSS object private/exports/{company_sso_id}/export_{ts}_{rand}.{xlsx|csv}; URL in bulk_upload_jobs.file_urlsigned URL in email + notificationGenerateAndUploadExcelWithData() / CSV serializer; SignURL(..., 172800, ...)PRD §5, §5.1
Header row uses field display namereads layout_properties.Layout.NameAliasn/a — serializationexport serializerPRD OQ-3 → layout_properties/base.go NameAlias
IsHidden fields excludedreads Layout.IsHiddenfield list endpointFE greys out; BE filters projectionPRD §5, EXP-S03/AC-3
Per-type field formatting (10 repo types + number subtypes)reads field_properties.field_type + Validation.Number.NumberType + contact.CustomField.{Value,CurrencyCode,Datatype}n/a — serializationexport serializer per the Decision 6 formatting table (XLSX cell types / CSV RFC-4180)EXP-S05
Export-ready in-app notificationexternal (Unified Notification Service record)POST /notif/v1/notificationsExportContactConsumer on completion (non-fatal)PRD §6.5, §7 #5
Last export config persisted per userFE localStorage export_field_config_{layout_id} (v1); BE persist = EXP-S07 follow-upn/a (client-side v1)FE useCustomFetch/localStorageEXP-S07, PRD OQ-6
Rate limit (target 5 / company / hour)(Redis counter via middleware)POST /iag/v1/contacts/export 429 EXPORT_RATE_LIMIT_EXCEEDEDRateLimitMiddleware (RATE_LIMITER_MAX_REQUESTS/WINDOW_SECONDS, default 1/60s, shared with import — dedicated export limit needs a per-route param, OQ-14)PRD §5, EXP-S01/ERR-5
Feature flag gateexternal flag storeendpoint 403 FLAG_DISABLEDhandler flag check cdp_export_customer_enabledPRD §5, EXP-S01/ERR-4
Selection mode (ids | first_10k_sorted)(request only; not persisted)POST /iag/v1/contacts/export body selection_mode + order_by/order_direction/filterhandler: for first_10k_sorted resolve first 10 K via SearchContacts sort (SortBy, created_at|updated_at) + _id tie-breaker, server-sidePRD §5, §6.1, §7 #1, D-14
Sort + deterministic order (the "first 10 K" the user saw)reads contact.created_at/updated_at (contact/base.go:87-90)n/a — querySortBy{Field,Direction} (db.go:35-37) + added _id secondary sortPRD OQ-14, EXP-S02
Period (last-update range) + Source filters (panel)(request only)body period/start_date/end_date + source[]reuse SearchContactRequest.StartDate/EndDate (on updated_at) + Source []stringPRD §6.7, EXP-S09/AC-3,AC-4
Timezone (export timestamp rendering)(request only)body timezonenet-new BE timezone-aware formatting in serializerPRD §6.7, EXP-S09/AC-2
Default layout auto-fillreads default layoutGET /iag/v1/layouts/defaultFE pre-selects; BE returns defaultPRD §6.7, EXP-S09/AC-2
Export registered in company/org historyexternal (IAG billing /report/v1/billings/logs/export store)POST /report/v1/billings/logs/export (s2s, net-new path)ExportContactConsumer on completion (non-fatal)PRD §6.6, §7 #6, EXP-S08, D-15

Every §2.3 collection field and every §2.4 endpoint traces back to a row here.

Detail 1.A — PRD Traceability (cross-layer)

Forward (PRD AC → RFC):

PRD composite AC idFE section / componentBE section / endpoint
EXP-S01/AC-1§2.A ExportSelected button + nav; cap display§2.4 POST /iag/v1/contacts/export (perm check)
EXP-S01/AC-2§2.A success banner; §2.C Success state§2.4 returns {job_id,status:"queued",email}
EXP-S01/ERR-1§2.A cap-to-10K + tooltip (selectedCustomerIds)§2.4 422 EXPORT_LIMIT_EXCEEDED (§3.B)
EXP-S01/ERR-2§3 Role × Endpoint (button hidden)§2.4 403 (perm middleware)
EXP-S01/ERR-3§2.C Error state§2.F failure path → failure email (§2.2 failure)
EXP-S01/ERR-4§3.A FE handles 403§2.4 403 FLAG_DISABLED
EXP-S01/ERR-5§3.C FE 429 message§2.4 429 EXPORT_RATE_LIMIT_EXCEEDED
EXP-S02/AC-1, AC-2§2.A filtered-select → same flow; "Select all first 10,000" shortcut (shown only when total ≥ 10,000) sends a criterion not IDs§2.4 same endpoint; selection_mode=first_10k_sorted resolved server-side (Decision 9)
EXP-S02/ERR-1..5(= EXP-S01 error set)(= EXP-S01 error set)
EXP-S03/AC-1..AC-4§2.A layout selector + field groups§2.4 GET /iag/v1/contacts/field_properties
EXP-S03/AC-5§2.B localStorage pre-loadn/a — FE-only
EXP-S03/ERR-1§2.C Error state ("Could not load fields")§2.4 field endpoint failure
EXP-S04/AC-1n/a — BE email§2.F SendEmailExportCustomerSucceeded
EXP-S04/AC-2, AC-3§2.F.2 notification state (host-owned)§2.4 #5 notif publish (General tab, unread)
EXP-S04/ERR-1, ERR-2n/a — BE email§2.F failure / partial email (no attachment)
EXP-S05/Scenario 1-11n/a — BE serialization§2 Decision 6 + serializer (XLSX/CSV formatting)
EXP-S06-NEG/NEG-1, NEG-2§3 button hidden§2.4 403 / 403 FLAG_DISABLED
EXP-S07/AC-1..AC-9§2.A config persistence + Reset§1 PRD-to-Schema (BE persist deferred; v1 localStorage)
EXP-S08/AC-1..AC-4(reused page — chat.qontak.com/reports/export, no CDP FE render)§2.4 #6 register export in IAG billing history (Decision 10)
EXP-S08/ERR-1n/a — reused page§5 OQ-17 (confirm CDP export type supported)
EXP-S09/AC-1..AC-7§2.A.1 right-side panel (Timezone/Period/Source/Layout) — extend ExportCustomerDrawer.vue§2.4 export endpoint + period/source[]/timezone/layout_id; GET .../layouts/default
EXP-S09/ERR-1§2.C panel field-load error → "Unable to load data" + Retry§2.4 field endpoint failure
EXP-S09/ERR-2§3 entry point hidden without perm§2.4 403 (perm middleware)

Reverse (RFC → PRD AC):

New FE component / BE endpoint / dependencyPRD composite AC id it serves
POST /iag/v1/contacts/exportEXP-S01/AC-2, EXP-S02/AC-2
GET /iag/v1/contacts/export/status/{job_id}no direct PRD AC — out-of-band status surface (v1 FE does not poll, §2.G); supports EXP-S04 delivery; new-with-justification
ExportContactConsumer (serialize+upload+email+notify)EXP-S04/AC-1, AC-2, AC-3, ERR-1, ERR-2; EXP-S05/*
GenerateAndUploadExcelWithData() + CSV serializerEXP-S04/AC-1, EXP-S05/*
ExportSelected button (ListTable.vue)EXP-S01/AC-1, EXP-S02/AC-1
ExportCustomerPage/drawer (fork DownloadTemplateModal.vue)EXP-S03/AC-1..AC-5, EXP-S07/*
10K cap on selectedCustomerIdsEXP-S01/ERR-1, EXP-S02/ERR-1
Notification publish clientEXP-S04/AC-2, AC-3
first_10k_sorted server-side resolution (SearchContacts sort + _id tie-breaker)EXP-S02/AC-1, AC-2 (shortcut path)
"Select all first 10,000" shortcut (gated on total ≥ 10,000) + raised export capEXP-S02/AC-1
Export-history registration client (IAG billing POST /report/v1/billings/logs/export)EXP-S08/AC-1..AC-4
Right-side panel (extend ExportCustomerDrawer.vue) + Timezone/Period/Source/Layout controlsEXP-S09/AC-1..AC-7
BE timezone-aware timestamp formattingEXP-S09/AC-2

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired reads (BE)Required writes (BE)FE componentStatus surface
Bulk-actions popover "Export Selected"webn/a — covered by writesPOST /iag/v1/contacts/exportListTable.vuesuccess banner
Export Configuration page/drawerwebGET /iag/v1/contacts/field_properties?layout_id=POST /iag/v1/contacts/exportExportCustomerPage/drawersuccess banner; job_id
Export emailemailn/an/a (BE-sent)n/a — email templatestatus (success/partial/failed)
In-app notification (web)webGET /notif/v1/notifications (host)n/ahost shell (not customer-fe)General tab unread dot
In-app notification (mobile)mobileGET /notif/v1/notificationsPUT /notif/v1/mark_as_read/{id}One Notification V2 (mobile repo)unread badge
"Download all customers" right-side panelwebGET .../field_properties?layout_id=, GET .../layouts/defaultPOST /iag/v1/contacts/export (+ timezone/period/source[])ExportCustomerDrawer.vue (extend)success banner; job_id
Company/org export historywebGET /report/v1/billings/logs/export (hub-chat)POST /report/v1/billings/logs/export (CDP s2s register)hub-chat reports/export (reused, no CDP FE)row status Active/Expired

Role Coverage

PRD roleAuthorization mechanismEndpoints permitted (BE)UI surface visibility (FE)Cross-tenant?Audit trail
CRM Admin / Sales Ops (primary)IAG JWT + CustomersCustomersExportKeyPOST /iag/v1/contacts/export, GET .../export/status/{id}, GET .../field_propertiesExport button + config page visibleno — company-scopedexport audit log (90 d), Mixpanel events
Marketing Ops (secondary)IAG JWT + CustomersCustomersExportKeysame as abovesamenosame
User without CustomersCustomersExportKeyIAG JWTnone (403)Export button hiddenno403 logged
Internal Ops / support(no export role granted in PRD)n/a — not served this RFCn/an/an/a

PRD Section Coverage

PRD §TitleWhere covered
1One-liner + Problem§1 Overview
2What happens if we don't build§1 Overview (problem)
3Target Users + Persona§1 Detail 1.A Role Coverage
4Non-Goals§1 Out of Scope
Scope Changesaffected surfacesfrontmatter scope_changes + §2.I
5Constraints§2 Technical Decisions, §2.4, §3 Security
5.1Data Lifecycle§2.3 per-status lifecycle + §3.D Compliance
6New Features (6.1–6.7)§2.A (6.1, 6.2), §2.F (6.3), §2.B (6.4), §2.4 #5 + §2.F.2 (6.5), §2.4 #6 + Decision 10 (6.6), §2.A.1 + Decision 11 (6.7)
7API & Webhook Behavior§2.4 APIs (incl. #6 export-history register)
8.1System Flow + diagram§2.2 Sequence diagrams
8.2User Stories + ACs (EXP-S01..S09)§1 Detail 1.A + 1.C
9Rollout§4 Rollout Strategy
10Observability§3 Monitoring & Alerting
11Success Metrics§1 Success Criteria + §3 SLO
12Launch Plan & Stage Gates§4 Rollout Strategy
13Dependencies§1 Dependencies + §2.F.1 Responsibility Boundary
14Key Decisions + Alternatives§2 Technical Decisions (ADR) + §1 Detail 1.B
15Open Questions§5 Concerns / Open Questions
App. AGrounded Code References§2.0 Repo Reading Guide + Source Verification

Detail 1.B — Decisions Closed (cross-layer)

#DecisionChosen optionAlternatives rejectedWhy rejectedLayer§2 block
1File format supportXLSX and CSV in v1Third-party CSV lib; XLSX-onlyencoding/csv is stdlib (no dep); CSV cheap given OSS flow reusebothDecision 1
2Sync vs asyncAsync via gocraft/workSynchronous HTTP download10 K rows × field resolution = 10–60 s; HTTP timeout riskBEDecision 2
3Export job storeReuse bulk_upload_jobs + job_type discriminatorNew export_jobs collectionCollection + repo + status fields already exist; Mongo schemaless → no DDL migration (OQ-7)BEDecision 3
4New XLSX methodNew GenerateAndUploadExcelWithData()Extend GenerateAndUploadExcel()Existing method hardcodes private/templates/, template ContentDisposition, 3600 s TTL — conflicting responsibilitiesBEDecision 4
5Buffer→email handlingWrite bytes.Buffer→temp *os.File→email→deletePass OSS URL to email methodSendEmailWithAttachment requires *os.File (email_service.go:98); changing signature breaks import callersBEDecision 5
6Field formatting (10 repo types + number subtypes)Type-aware serializer keyed on field_properties.field_type+NumberType (Decision 6 table); CSV RFC-4180Raw string dumpNumber/currency/percent share field_type="number" — must read NumberType; arrays/line-breaks need typed handling (EXP-S05, OQ-11)BEDecision 6
7Notification publish channelHTTP via new heimdall client → POST /notif/v1/notifications (default)Kafka KafkaPublishDocumented Unified service contract is REST; heimdall client pattern exists (iag_mekari.go). Confirm OQ-10. Reversible.BEDecision 7
8Endpoint namespacePOST /iag/v1/contacts/export (IAG auth)/iag/v1/customers/export (PRD), /api/core/v1/... (existing GET)Repo uses /iag/v1/contacts/* (import at rest_router.go:142); aligns with existing routingbothDecision 8
9FE filtered IDsResolved client-side, sent in POST bodyServer-side filter re-execution10 K cap bounds payload; simpler v1 (PRD D-4)FE§1 Out of Scope #8
10GET→POST refactorPOST with IDs in bodyKeep GET with contact_ids[] query10 K × 36-char UUIDs exceed 8 KB URL limitFEDecision 8
11Email recipientAlways logged-in user's email; no overrideConfigurable recipientAuth-safe; simplest (PRD D-3)bothno alternative considered — auth-safety
12Cap enforcementBoth FE (selectedCustomerIds) and API (MaxExportRow)API-onlyDefense in depth; mirrors MaxImportRowbothDecision 2 / §3
13Notification job non-fatalPublish failure logs+alerts, does not fail exportFail job on notif errorEmail is the always-on primary channel (PRD §7 #5)BEDecision 7
14"Select all first 10,000" payloadServer-side criterion first_10k_sorted (order_by/order_direction/filter) resolved via SearchContacts sort + _id tie-breakerSend 10,000 IDs in the body10 K × 36-char UUID ≈ 360 KB body; criterion bounds payload (PRD D-14); BE already defaults created_at descbothDecision 9
15Export-history surfaceReuse chat.qontak.com/reports/export (IAG billing /report/v1/billings/logs/export); CDP registers a row s2sBuild a CDP-local history/download centerAvoids forking the surface (PRD D-15) — but grounded caveat: no CDP quota type and no cross-service register path exist today (OQ-17)BEDecision 10
16Right-side config panelExtend the existing ExportCustomerDrawer.vue (right MpDrawer) + reuse FilterCheckbox/InputPeriod/timezones.tsBuild a new panel; keep the §6.2 full-page modalA partial drawer already exists; filters + timezone constants already in repoFEDecision 11
17Export timestamp timezoneBE renders timestamps in the request timezone (net-new)Render in server/UTC onlyEXP-S09/AC-2 requires GMT+07:00-style rendering; no BE timezone formatting todayBEDecision 11

Detail 1.C — Per-Story Change Map

Story idTitleLayer scopeFE changesBE changesComposite AC idsAcceptance criteria (verifiable)RFC anchors
EXP-S01Export manually selected customersFE + BE"Export Selected" MpPopoverListItem in ListTable.vue; nav to export page; success banner; cap tooltip on selectedCustomerIdsPOST /iag/v1/contacts/export (perm+flag+cap+rate+format); enqueue ExportContactJobName; return {job_id,status,email}EXP-S01/AC-1, AC-2, ERR-1..ERR-5Vitest: button emits download-selected-customers; $customFetch POSTs body (not query). go test: handler returns {job_id,"queued"} for valid req; 422/403/429 for each guard§2.A · §2.4 row 1 · §4.D chunks 5,12-14 · §1 PRD-to-Schema rows 1,3,4,11,12
EXP-S02Export from filtered result (+ "Select all first 10,000" shortcut)FE + BE"Select all filtered" resolves IDs client-side → POST body; shortcut (shown only when total ≥ 10,000) sends selection_mode=first_10k_sorted + order_by/order_direction + filter (no IDs); 10,001+ auto-disabled, no manual unselectsame endpoint; for first_10k_sorted resolve first 10 K via SearchContacts sort + _id tie-breaker (Decision 9)EXP-S02/AC-1, AC-2, ERR-1..ERR-5Vitest: ≤10 K IDs in body; shortcut visible only at total≥10000, sends criterion not IDs. go test: first_10k_sorted returns exactly first 10 K in order_by order, stable under _id tie-breaker§2.A · §2.4 row 1 · Decision 9 · §4.D chunks 9,20
EXP-S03Configure export fields by layoutFE + BEFork DownloadTemplateModal.vue: layout selector, field groups (Customer Info/Default/Custom), IsHidden greyed; localStorage pre-loadGET /iag/v1/contacts/field_properties?layout_id= returns ordered fields + IsHiddenEXP-S03/AC-1..AC-5, ERR-1Vitest: hidden field disabled; checked keys → selected_fields[]; reload restores selection. go test: field list ordered, IsHidden flagged§2.A · §2.B · §2.4 row 4 · §4.D chunks 11,14
EXP-S04Receive export email + in-app notificationBE + FE consumes existing (host)n/a — web center host-owned; FE no render work3 email methods reuse SendEmailWithAttachment; notif publish (notif_type=general, notif_category=5, origin=external_url, click_action_url→CDP redirect) on completionEXP-S04/AC-1, AC-2, AC-3, ERR-1, ERR-2go test: success→SendEmailExportCustomerSucceeded called with temp *os.File; partial/fail→no attachment; notif payload asserted; publish failure does not fail job§2.F · §2.4 row 5 · §2.F.2 · §4.D chunks 8,9
EXP-S05XLSX/CSV field formatting by typeBE-onlyn/a — backend serializationType-aware serializer: text, multiline (line breaks), dropdown, multi-select array, URL, GPS, file/signature URL, number/percent/currency; CSV RFC-4180EXP-S05/Scenario 1-11go test table-driven: one case per scenario asserts cell/CSV output for XLSX and CSV§2 Decision 6 · §4.D chunks 6,7
EXP-S06-NEGNo export without permission / over cap / flag OFFFE + BEbutton hidden without perm403 / 422 / 403 FLAG_DISABLEDEXP-S06-NEG/NEG-1, NEG-2go test: no perm→403 + no job; flag OFF→403 FLAG_DISABLED + no job; FE: button hidden§3 Role × Endpoint · §3.A.1 · §2.4 row 1
EXP-S07Persist last export configurationFE + BElocalStorage export_field_config_{layout_id} save/auto-load/reset; format change keeps fields; panel also persists timezone/period/source (EXP-S09/AC-6)v1 client-side; durable per-user persist = follow-up (PRD D-8)EXP-S07/AC-1..AC-9Vitest: save on download; auto-load on open; first-time→default; missing fields ignored; reset clears; format change keeps field selection; no auto-download§2.B · §1 PRD-to-Schema row "last export config" · §4.D chunk 15
EXP-S08View export history (company/org)BE (register) + reused pagen/a — reuse chat.qontak.com/reports/export (hub-chat); no CDP FE renderExportContactConsumer registers row via IAG billing POST /report/v1/billings/logs/export (s2s, net-new path, non-fatal); needs CDP quota typeEXP-S08/AC-1..AC-4, ERR-1go test: on completion a register call fires with file name/type/exporter/date/link/status; failure is non-fatal (job stays completed). Blocked on OQ-17 (no CDP quota type + no s2s register path today)§2.4 #6 · Decision 10 · §4.D chunk 19
EXP-S09Download all via right-side panelFE + BEExtend ExportCustomerDrawer.vue: enable Timezone (required, GMT+07:00 default), Period (last-update presets + Custom), Source (multi-select via FilterCheckbox), Layout auto-fill (default "Default view"), field groups, field-load error → Retryexport endpoint accepts timezone/period(or start_date/end_date)/source[]/layout_id; reuse SearchContactRequest Source + updated_at range; net-new timezone-aware timestamp formatting; GET /iag/v1/layouts/defaultEXP-S09/AC-1..AC-7, ERR-1, ERR-2Vitest: panel opens without nav; Timezone+Layout required gate Download; Period/Source scope request; persist pre-loads; field-load error shows Retry. go test: export honours source + last-update range; timestamps rendered in timezone§2.A.1 · Decision 11 · §2.4 row 1 · §4.D chunks 9,18,21

Coverage: all 9 PRD stories present exactly once. EXP-S04 web-render = n/a (host-owned). EXP-S08 reuses the hub-chat history page (no CDP FE render) and is gated by OQ-17. EXP-S07 durable BE persistence is deferred to a follow-up; v1 ships localStorage (PRD OQ-6 default).


2. Technical Design

Infrastructure Topology

Deployment topology

flowchart TB
user([CDP web user]) -->|HTTPS| lb[API Gateway / IAG]
lb -->|HTTP| api["contact-service server pods ×N<br/>(cmd/server, Chi router /iag/v1/)"]
api -->|read/write| mongo[("MongoDB primary<br/>(bulk_upload_jobs, contacts,<br/>layout_properties)")]
api -->|read| mongoR[("MongoDB replica<br/>(read projections)")]
api -->|rate-limit counter| redis[("Redis<br/>(RateLimiterService)")]
api -->|enqueue ExportContactJobName| q[["gocraft/work queue<br/>(Redis-backed)"]]
q -->|consume| worker["contact-service worker pods ×M<br/>(cmd/worker, ExportContactConsumer)"]
worker -->|read contacts/fields| mongo
worker -->|PutObject + SignURL 48h| oss[("Alibaba OSS<br/>private/exports/")]
worker -->|HTTPS SendEmailWithAttachment| email(["Email provider<br/>(SendGrid via email_service)"])
worker -->|HTTPS POST /notif/v1/notifications| notif(["Qontak Unified<br/>Notification Service"])
fe[qontak-customer-fe MFE] -->|POST /iag/v1/contacts/export| lb
notif -.->|GET /notif/v1/notifications| host([Launchpad host center / Mobile One Notif V2])

Per-service responsibility

flowchart LR
subgraph cs["contact-service (CDP Backend)"]
ep1["POST /iag/v1/contacts/export<br/>(trigger export job)"]
ep2["GET /iag/v1/contacts/export/status/{id}<br/>(job status)"]
ep3["GET /iag/v1/contacts/field_properties<br/>(layout fields)"]
cons["ExportContactConsumer<br/>(serialize → upload → email → notify)"]
end
ep1 -->|enqueue| cons
cons -->|"HTTPS — PutObject/SignURL"| oss(["Alibaba OSS (CDP Infra)"])
cons -->|"HTTPS — attachment email"| em(["Email provider"])
cons -->|"HTTPS — REST publish"| nt(["Unified Notification Service (Notif team)"])
ep1 -->|read/write| db[("MongoDB: bulk_upload_jobs")]
ep3 -->|read| lp[("MongoDB: layout_properties")]
ServiceUse cases (this RFC)Internal callsExternal / third-party APIs
contact-service (server)trigger export, job status, field listRateLimiterService (Redis), RequirePermissionMiddleware, enqueue job
contact-service (worker)serialize XLSX/CSV, upload, email, notifyExcelService, EmailService, bulk_upload_job repo, layout_properties repoAlibaba OSS (CDP Infra); Email provider; Unified Notification Service (Notif team)
qontak-customer-fe (MFE)select, configure, trigger; surface successhost shell (notification center)

Technical Decisions (ADR-format — the engineering heart)

Decision 1: XLSX and CSV in v1

Context PRD §5 requires user-selected XLSX or CSV. No CSV path exists today (grep encoding/csv → 0 hits in contact-service). XLSX uses excelize (go.mod:35).

Options considered

  • Option A — XLSX + CSV via stdlib encoding/csv: branch the serializer on format; share the OSS upload/sign flow. Pros: one stdlib import, no new dependency; CSV is high-value for downstream integrations. Cons: CSV has no cell typing → needs explicit escaping rules (Decision 6).
  • Option B — XLSX only (defer CSV): Pros: smallest surface. Cons: misses a primary PRD requirement; CSV is cheap given the shared OSS flow.

Decision Option A.

Rationale encoding/csv is Go stdlib — zero go.mod change. The OSS upload + SignURL flow (excel_service.go:98-112) is format-agnostic; only the serializer, file extension, and ContentDisposition branch on format.

Consequences Two serializers to test (EXP-S05 × XLSX/CSV). CSV formatting of arrays/line-breaks/currency must be specified (Decision 6, OQ-11).

Reversibility High — dropping CSV is removing one branch + the radio option.


Decision 2: Async export via gocraft/work (no synchronous download)

Context Up to 10 K records × field-value resolution + file rendering takes 10–60 s; synchronous HTTP would time out.

Options considered

  • Option A — async gocraft/work job (already used: worker_service.go:100-135, BulkImportContactJobName). Pros: proven pattern; survives request lifecycle; natural fit for email/notify side effects. Cons: needs durable status store + out-of-band delivery (email/notif).
  • Option B — synchronous endpoint streaming the file. Pros: no job store. Cons: timeout risk; ties up an API pod; no partial-failure handling.

Decision Option A — enqueue ExportContactJobName; the endpoint returns {job_id, status:"queued"} immediately.

Rationale Mirrors the existing bulk-import pipeline (handler → service → gocraft/work consumer SendContactConsumer.BulkImportCustomer, internal/app/consumer/send_contact.go). Cap (10 K) + rate limit (5/h) bound worker load.

Consequences Requires a status store (Decision 3) and the buffer→temp-file email flow (Decision 5).

Reversibility Medium — would require a synchronous handler and removing the consumer; not planned.


Decision 3: Reuse bulk_upload_jobs with a job_type discriminator (OQ-7)

Context Export needs durable job status. bulk_upload_jobs already exists (repository/bulk_upload_job/base.go:30) with Status, FileName, FileUrl, FilePath, TotalRows, RowFailures, ListFailedRows, UserSSOID, CompanySSOID, timestamps — but no job_type field.

Options considered

  • **Option A — reuse bulk_upload_jobs + add JobType string ("import"/"export")
    • Format, LayoutID, SelectedFields[]**. Pros: collection + repo (InsertBulkUploadJob/UpdateBulkUploadJob/GetBulkUploadJobByID) already exist; Mongo is schemaless so no DDL migration (only an optional index). Cons: shared collection mixes two job kinds (mitigated by the discriminator + filtered queries).
  • Option B — new export_jobs collection + new repo. Pros: clean separation. Cons: duplicates repo plumbing; more code; no functional gain.

Decision Option A.

Rationale Lowest-cost, reuses a verified store. Existing HasPendingJobByCompanySsoID and status fields map directly to export needs. Existing rows have no job_type field at all (a Go struct default does not retro-populate stored Mongo docs). Two safe rules: (a) export queries filter job_type:"export" — legacy rows correctly never match; (b) existing import queries today use no job_type filter, so they keep returning all (currently import-only) rows unchanged. Only if new code must isolate imports via job_type:"import" is a one-time backfill required: db.bulk_upload_jobs.updateMany({job_type:{$exists:false}},{$set:{job_type:"import"}}).

Consequences All bulk-upload queries that must stay import-only get a job_type:"import" filter (audit existing callers). One optional Mongo index migration (db/migrations/NNN_index_bulk_upload_jobs_job_type.up.json).

Reversibility High — extract export rows into export_jobs later is a one-time copy; the discriminator makes the split trivial.


Decision 4: New GenerateAndUploadExcelWithData() (do not extend the template method)

Context GenerateAndUploadExcel(ctx, fields []string, companySSOID, layoutID) (excel_service.go:35) writes headers only and hardcodes: path private/templates/template_%d_%s.xlsx (:95), ContentDisposition attachment; filename=bulk_upload_template_customer.xlsx (:104), and TTL 3600 s (:112).

Options considered

  • Option A — new method GenerateAndUploadExcelWithData(ctx, rows, headers, companySSOID, format) writing data rows to private/exports/{company_sso_id}/export_{ts}_{rand}.{ext}, export ContentDisposition, TTL 172800 s. Pros: no risk to template generation; clear single responsibility. Cons: some OSS code duplicated (small, can be factored into a shared uploadAndSign helper).
  • Option B — extend the existing method with flags. Pros: less duplication. Cons: branches a template path vs an export path inside one method → fragile; risks breaking download_template.

Decision Option A, factoring the shared OSS PutObject+SignURL into a private helper so XLSX and CSV both reuse it.

Rationale The template method's three hardcoded assumptions are all wrong for export; a separate method keeps the (live) template flow untouched.

Consequences Two callers of the OSS helper (XLSX, CSV).

Reversibility High — internal method, no contract exposure.


Decision 5: Buffer → temp *os.File → email → delete

Context Serializers produce a bytes.Buffer, but SendEmailWithAttachment(ctx, toEmail, subject, plainText, html, file *os.File, fileName) (email_service.go:98) reads an *os.File (io.ReadAll(file) at :120).

Options considered

  • Option A — write buffer to a temp *os.File (os.CreateTemp), pass to the email method, defer os.Remove. Pros: no signature change; matches existing import email callers. Cons: transient disk I/O on the worker pod.
  • Option B — change SendEmailWithAttachment to accept io.Reader/[]byte. Pros: no temp file. Cons: breaks the three existing import callers (SendEmailImportCustomer*).

Decision Option A.

Rationale Lower-risk; reuses the proven attachment path without touching shipped import emails.

Consequences Worker cleans up the temp file via defer os.Remove on every in-process path (success, partial, failure, recovered panic); a crashed pod relies on ephemeral-disk reclamation / temp-dir reaping, not defer. Temp dir disk pressure bounded by worker concurrency × 15 MB cap.

Reversibility High — swap to a streaming reader later behind the same wrapper.


Decision 6: Type-aware serialization (EXP-S05); CSV RFC-4180 (OQ-11)

Context EXP-S05 lists 11 presentation scenarios, but the repo has 10 custom field type constants (internal/pkg/consts/const.go:50-59: single_line_text, text_area, dropdown_select, number, date, multiple_select, url, upload, gps, signature). EXP-S05 splits number into number / percentage / currency and omits date — both must be reconciled. Critical grounding: the number/percentage/currency distinction is not in field_type (all three are field_type="number"); the discriminator is field_properties.Validation.Number.NumberType ∈ {number,currency,percentage} (internal/app/repository/field_properties/base.go:415-418) plus the per-value CustomField.CurrencyCode on the contact. So the serializer cannot key only on layout_properties.Layout.FieldType — it must read the field_properties record (for field_type + NumberType) and the contact's CustomField (for Value, CurrencyCode, Datatype).

Options considered

  • Option A — one formatValue(fieldType, numberType, value, currencyCode, format) switch over the 10 repo types (+ number subtypes + a default), producing XLSX-native cells and CSV RFC-4180 strings. Pros: single tested unit. Cons: must enumerate every type incl. date + default.
  • Option B — dump everything as strings. Pros: trivial. Cons: fails the numeric/array/currency scenarios.

Decision Option A, keyed on the grounded type metadata above.

Field formatting table (repo field_type [+ NumberType] → XLSX cell → CSV string; this is the implementation contract for chunks 5–7):

Repo field_type (+subtype)EXP-S05XLSX cellCSV string (RFC-4180)
single_line_textS1textplain, quote if it contains ,"\n`
text_areaS2text, wrap-text onquoted cell preserving \n
dropdown_selectS3text (selected label)plain text
multiple_selectS4text ["a","b"]quoted "[""a"",""b""]" (JSON-array string)
urlS5text (URL)plain text
gpsS6text (address or lat,lng)quoted (contains comma)
uploadS7text (file URL)plain text
signatureS8text (image URL)plain text
number + NumberType=numberS9numeric cellunquoted number
number + NumberType=percentageS10numeric cell, percent formatunquoted number
number + NumberType=currencyS11numeric cell, currency format (CurrencyCode)"IDR 1,000,000" quoted formatted string
date(PRD gap)date cell (ISO-8601)ISO-8601 text
default / unknowntext via fmt.Sprint(value)plain text

CSV formula-injection guard: any cell beginning = + - @ is prefixed with ' (see §3 Security).

Rationale EXP-S05 is Must-Have; the table + grounded type source is the only way an agent can build/verify the serializer without guessing.

Consequences Table-driven test: one case per row × {xlsx, csv} (24 cases) plus the formula-injection guard. date formatting added (PRD omission resolved here).

Reversibility High — formatting is internal.


Decision 7: Notification publish via HTTP heimdall client to POST /notif/v1/notifications (OQ-10)

Context contact-service has no notification mechanism (grep: Notify hits are OS-signal handlers only). The Unified Notification Service is documented to expose a REST endpoint (POST /notif/v1/notifications), and the mobile crm_misc client consumes that path — but whether contact-service should publish via REST or Kafka is unconfirmed (OQ-10). contact-service has both a Kafka publisher (JobEnqueuer.KafkaPublish) and a heimdall HTTP-client pattern (internal/app/api/iag_mekari.go:54) available.

Options considered

  • Option A — new heimdall HTTP client (internal/app/api/notification_mekari.go, base URL/secrets via config/load.go getStringOrPanic) POSTing /notif/v1/notifications. Pros: matches the documented REST contract; synchronous result for logging/alerting; mirrors the existing IAG client. Cons: one more outbound dependency in the worker.
  • Option B — Kafka publish via KafkaPublish. Pros: reuses an existing publisher; decoupled. Cons: only correct if the Notif service consumes Kafka — unconfirmed; topic/schema unknown.

Notification payload (grounded against mobile-qontak-crm, verified 2026-06-18):

FieldValueGrounding
notif_typegeneral (enum value 1)crm_misc/.../enum/notif_type_enum.dart NotifType.general(1)
notif_category"5" (downloadUpload)crm_misc/.../util/notif_category.dart:59 NotifCategoryV2.downloadUpload = '5' (the V2 class; distinct from the older NotifCategory.download/.upload)
originexternal_url ← routing keycrm_misc/.../widgets/item/notification_item_v2_mixin.dart _navigateToItem() routes on origin; detailModuleRouteMapping has external_url but no cdp/contact360
click_actionOPEN_URL (semantic; web)model field unified_notification_response.dart:42 (free string; mobile router ignores it for routing)
click_action_urlCDP redirect route → resolves to the 48 h OSS link or an "expired — re-export" pagemobile opens it via UrlLauncherUtil.openWebsite() in an external browser (notification_item_v2_mixin.dart:134-140)
title / descriptionper success / partial / failure

Correction to the PRD assumption. The PRD said mobile tap-through requires click_action=OPEN_URL. The verified mobile router actually keys on origin == "external_url" (not click_action). So the publish payload must set origin="external_url" for tap-through to work today without a mobile code change. Alternatively, the mobile team adds a cdp/contact360 entry to detailModuleRouteMapping for a richer in-app route (a mobile-qontak-crm change — see §5 OQ-12). Because the mobile handler opens the URL in an external browser, a raw expired OSS signed URL would show an OSS error page; therefore click_action_url should point to a CDP web redirect route that checks job status and serves a fresh link or an "expired — re-export" message (resolves OQ-13 for both web and mobile).

Decision Option A as the default, pending One Notification team confirmation of the ingest channel (OQ-10). Publish is non-fatal (Decision 13): failure logs + alerts to #cdp-ops but does not fail the export job.

Rationale The documented service contract is REST; the heimdall pattern is proven in-repo; synchronous call gives a clear result for the cdp_export_customer_notification_sent event.

Consequences If the Notif team mandates Kafka, swap the client for KafkaPublish — isolated behind a NotificationPublisher interface so the consumer is unaffected. The payload must carry origin="external_url" (mobile routing key) and a click_action_url pointing at a CDP redirect route (not the raw OSS URL) so expired links degrade gracefully on both web and mobile.

Reversibility High — publisher hidden behind an interface; channel swap is one implementation.


Decision 8: Endpoint namespace POST /iag/v1/contacts/export (repo correction)

Context PRD writes /iag/v1/customers/export. The repo registers all contact routes under /iag/v1/contacts/* — e.g. POST /iag/v1/contacts/import (rest_router.go:142), guarded by IAGMiddleware (:117-118) + RequirePermissionMiddleware + RateLimitMiddleware.

Options considered

  • Option A — /iag/v1/contacts/export (+ /iag/v1/contacts/export/status/{id}). Pros: consistent with existing routing + middleware stack; the FE already hits this base via CUSTOMER_360_URL. Cons: differs from PRD prose.
  • Option B — /iag/v1/customers/export as written in the PRD. Pros: matches PRD text. Cons: no customers route group exists; would fork the convention.

Decision Option A. Documented as a grounded deviation; the PRD prose is treated as advisory and the repo convention wins.

Rationale Anti-hallucination grounding: the live router uses contacts. New endpoints must slot into the existing r2 := r1.Route("/contacts", …) group so they inherit IAG auth + the permission/rate-limit middleware.

Consequences PRD and downloadCustomer refactor target contacts/export; FE config base unchanged.

Reversibility Trivial pre-launch; a deployed path is harder to change (additive only).


Decision 9: "Select all first 10,000" sends a server-side criterion (first_10k_sorted), not 10,000 IDs

Context PRD §5/§6.1 + D-14: when the list total is ≥ 10,000 the FE shows a "Select all first 10,000" shortcut. Sending 10,000 explicit IDs is a ~360 KB body (10,000 × 36-char UUID). The set must be the first 10,000 the user saw, in their current sort. Grounded: the contact list already sorts via SearchContacts(ctx, req, withCount) (service/get_contact.go:148) → SortBy{Field,Direction} (repository/db.go:35-37, asc→1/desc→-1 :64-68); valid sort fields are oneof=created_at updated_at name (payload/search_contact_request.go:33); and the handler already defaults to created_at desc when no sort is set (contact_handler.go:633-634). The FE sends order_by/order_direction only when a column is actively sorted (ListPage.vue:419-423), single-column. Gaps: there is no _id tie-breaker in the sort builder and no get-by-IDs/criteria batch on the contact repo (no $in-by-_id).

Options considered

  • Option A — FE sends selection_mode=first_10k_sorted + order_by/ order_direction + the current filter; BE resolves the first 10,000 via SearchContacts with a limit=10000 page and an added secondary sort on _id. Pros: tiny request body; reuses the existing sort path; matches the rows the user saw; the no-sort case already has a defined default (created_at desc). Cons: needs an _id tie-breaker added to SortBy and a 10 K-row fetch path.
  • Option B — FE sends all 10,000 IDs. Pros: no BE resolution. Cons: ~360 KB body; the FE would first have to page the whole list to collect IDs.

Decision Option A. selection_mode ∈ {ids, first_10k_sorted}. For first_10k_sorted the handler builds filter (from the request) + sort (order_by defaulting to created_at desc) + a deterministic secondary sort on _id and fetches the first 10,000; for ids it reads contact_ids[] from the body as today.

Rationale Bounds the payload (PRD D-14), reuses the grounded sort path, and the deterministic _id tie-breaker makes "first 10,000" stable across the resolve and any re-resolve.

Consequences Add an _id secondary sort to the sort builder (or a dedicated export query); add a SearchByIDsOrCriteria path (the contact fetch in §2.4 already needs $in-by-IDs). The FE shortcut is gated on pagination.total ≥ 10000 (ListPage.vue:450), and in this mode 10,001+ are auto-disabled with no manual unselect. The FE list selection cap is MAX_SELECTION = 500 today (ListPage.vue:220) — the export path must raise/replace this to reach 10,000 (OQ-19).

Reversibility High — the criterion path is additive; ids mode is unchanged.


Decision 10: Reuse chat.qontak.com/reports/export for company/org history — register a row s2s (grounded caveat)

Context PRD §6.6/EXP-S08/D-15: register each completed export into the existing company/org export-history page instead of building a CDP-local one. Grounded (2026-06-26): that page is served by hub-chat (features/report/export/..., route reports/export, permission report_omnichannel_view) and backed by the IAG billing service at /report/v1/billings/logs/export (GET list / POST create / /{id}/download), model QuotaUsageExportData{export_id, file_name, quota_type, exporter_name, export_time, status:pending|in_progress|completed|expired, object_name, date_range}. Today its quota_types are billing-only (wa_balance, muv, chatbot_ai, voice_call_balance, quota_management) — no CDP/"Customer Data" type — and there is no service-to-service registration path; rows are created by the hub-chat UI POSTing to IAG.

Options considered

  • Option A — contact-service registers a row by POSTing IAG /report/v1/billings/logs/export with a new CDP quota/export type. Pros: reuses the page (PRD D-15). Cons: requires the billing/IAG team to (1) add a CDP quota_type/billing_code, (2) accept a server-to-server (non-UI) create with a CDP-supplied download link/object, (3) confirm company/org visibility for it — none exist today (OQ-17). Heavier than "reuse for free."
  • Option B — build a CDP-local export-history list (e.g. over bulk_upload_jobs export rows). Pros: no cross-team dependency. Cons: forks the surface (PRD rejected this).

Decision Option A as the target, behind a ExportHistoryRegistrar interface, with registration non-fatal (mirrors the notification policy, Decision 13): a register failure logs + alerts but does not fail the export job (email + notification remain primary). Gated on OQ-17 — until the billing/IAG team confirms a CDP quota type + an s2s register endpoint, EXP-S08 is specified but not buildable; the interface lets CDP ship the pipeline and wire registration when the path lands.

Rationale Honors the PRD reuse decision while recording the real cross-team dependency rather than asserting a non-existent "register for free."

Consequences New net-new dependency on billing/IAG (OQ-17); a new outbound client (internal/app/api/export_history_mekari.go) following the heimdall pattern; new cdp_export_history_registered event (PRD §10). EXP-S08 is a Should Have (PRD) and does not block the core pipeline.

Reversibility High — registrar is behind an interface; if the cross-team path is declined, fall back to Option B or drop EXP-S08 without touching the pipeline.


Decision 11: Right-side "Download all customers" panel — extend the existing ExportCustomerDrawer.vue; timezone formatting is net-new BE

Context PRD §6.7/EXP-S09 (v2.5/v2.6): a "Download all customers" entry point opens a right-side panel with Timezone (required, GMT+07:00 default), Period (date-range on last update; presets + Custom; default "All time"), Source (multi-select channels; default "All source"), Layout (required, auto-fills the default "Default view"), field groups, and a field-load error → Retry state. Grounded (2026-06-26): a partial ExportCustomerDrawer.vue already exists (features/customers/views/components/ExportCustomerDrawer.vue — right MpDrawer, a disabled timezone MpAutocomplete, DEFAULT_TIMEZONE='(GMT+07:00) Asia/Jakarta' :175, a layout MpAutocomplete, a CSV option) but does not yet call the export API (shows a toast). Reusable: common/components/ FilterCheckbox.vue (source multi-select, used on the list ListPage.vue:35), common/components/InputPeriod.vue (presets All time/Today/Last 7/30/Per day/week/ Custom, filters updated_at), common/constants/timezones.ts (598 zones). Backend SearchContactRequest already supports Source []string (:25) and StartDate/EndDate on updated_at (:36-37, logic :194-208, RFC3339); the default layout is GET /iag/v1/layouts/default (rest_router.go:191). No BE timezone-aware timestamp formatting exists (grep: export/template handlers take no timezone).

Options considered

  • Option A — complete ExportCustomerDrawer.vue; reuse FilterCheckbox/ InputPeriod/timezones.ts; thread timezone/period(or start_date/ end_date)/source[]/layout_id/format into the export POST; auto-fill the default layout from GET /iag/v1/layouts/default; add a BE timezone formatter. Pros: most of the FE shell + filters already exist; BE filters are reused. Cons: enabling the disabled timezone selector + net-new BE timezone formatting; source enum gap (Advertisement/Email are FE-only today — OQ-21).
  • Option B — keep the §6.2 full-page modal and drop the panel. Cons: contradicts PRD v2.5/v2.6; ignores the half-built drawer.

Decision Option A. The panel resolves its set server-side from the Period/Source criterion (top-10,000 cap applied server-side), so it shares the first_10k_sorted-style resolution (Decision 9) rather than the explicit-ID path.

Rationale Reuses the existing drawer + filter components and the existing BE updated_at-range/source filters; only timezone formatting and the export wiring are net-new.

Consequences Enable + wire the timezone MpAutocomplete; add a BE timezone formatter applied to exported timestamps (Decision 6 date row honours it); reconcile the source enum (OQ-21); persist panel config (timezone/period/source) in the same localStorage key family (EXP-S07/EXP-S09 AC-6).

Reversibility Medium — the panel is additive; timezone formatting is internal; the source-filter path reuses existing query code.

Minimum-coverage notes: Caching — n/a — exports are one-shot generated files; no read cache; signed URL is the artifact. Multi-tenancy — enforced by IAG JWT company scope + company_sso_id on every query and the OSS path private/exports/{company_sso_id}/ (Decision below + §3). Consistency — job status is eventually consistent (worker writes bulk_upload_jobs after side effects); strong consistency not required. Reuse vs new — every new endpoint tagged in §2.4.


Detail 2.0 — Repo Reading Guide

Repo Map (both layers)

flowchart LR
subgraph fe["qontak-customer-fe/features/customers/"]
lt["views/components/ListTable.vue<br/>(bulk-actions popover)"]
lp["views/ListPage.vue<br/>(selection, downloadCustomer)"]
dtm["bulk-upload/views/components/<br/>DownloadTemplateModal.vue (fork)"]
cf["common/composables/useCustomFetch.ts"]
end
subgraph be["contact-service/internal/"]
rr["server/rest_router.go<br/>(/iag/v1/contacts)"]
h["app/handler/ (export handler)"]
svc["app/service/ (ExportService,<br/>excel_service.go, email/)"]
cons["app/consumer/ (ExportContactConsumer)"]
repo["app/repository/bulk_upload_job/,<br/>layout_properties/"]
api["app/api/ (notification client)"]
end
subgraph infra
mongo[("MongoDB")]
redis[("Redis (rate limit + work queue)")]
oss[("Alibaba OSS")]
end
lt --> lp --> cf --> rr --> h --> svc --> repo --> mongo
svc --> cons --> oss
cons --> api
rr --> redis

Existing Code Anchors

LayerPathWhy the agent reads itWhat pattern it teaches
BEinternal/server/rest_router.go:117-187Where to register the export routesIAG route group + RequirePermissionMiddleware + RateLimitMiddleware chain (import at :142)
BEinternal/app/consumer/send_contact.goThe async consumer to mirrorfunc (w *SendContactConsumer) BulkImportCustomer(job *work.Job) error: reads job.Args["data"], json-unmarshal into payload, sets JobID
BEinternal/worker/worker_service.go:100-135How to register the new jobregisterJobWithOptions(JobName, opts, consumer.Method, pool)
BEinternal/app/service/excel_service.go:35-118The method to NOT extend; OSS flow to reuseGenerateAndUploadExcel headers-only; PutObject+SignURL(…,3600,…) :98-112
BEinternal/app/service/email/email_service.go:98-191Email base + naming patternSendEmailWithAttachment(…, *os.File, …); SendEmailImportCustomerFullSucceeded/PartialSucceeded/FullFailed
BEinternal/app/repository/bulk_upload_job/{base,create,get,update,query}.goThe job store to extendTableName()="bulk_upload_jobs" :30; InsertBulkUploadJob/UpdateBulkUploadJob/GetBulkUploadJobByID
BEinternal/app/repository/layout_properties/base.goField/header sourceLayout{Name, NameAlias, IsRequired, IsHidden, Order, Type, FieldType}
BEinternal/app/api/iag_mekari.go:54-120The s2s HTTP client to mirror for notifNewIagClient + httpclient.NewClient(WithHTTPTimeout); http.NewRequestWithContext
BEinternal/app/service/bulk_import_contact.go:20Cap constant patternconst MaxImportRow = 10000 → define MaxExportRow
BEinternal/pkg/consts/const.go:29Permission keyCustomersCustomersExportKey = "customers_customers_export"
BEconfig/load.go:200-317Config injectiongetStringOrPanic/getString; RateLimiterConfig; IAG client wiring
FEfeatures/customers/views/components/ListTable.vue:51-62Where the Export button goesMpPopover+MpPopoverListItem bulk actions; @click emits to parent
FEfeatures/customers/views/ListPage.vue:67,209,270,317-328,436-445Selection + export triggerselectedCustomerIds: ref<Set<string>>; downloadSelectedCustomers(); downloadCustomer() GET→POST target
FEfeatures/customers/bulk-upload/views/components/DownloadTemplateModal.vueThe component to forkFILE_FORMAT_OPTIONS:128-130, fileFormat ref :132, field accordion :32-76, POST /v1/download_template :147 body {column_headers}
FEcommon/composables/useCustomFetch.tsThe fetch wrapper$customFetch(url,{method,body,baseURL}); auth headers + 401 refresh
FEcommon/composables/useMixpanel.tsAnalytics eventstrack(eventName, props) auto-adds Company SSO ID
FElayouts/TheNavbar/TheNotification.vueWhy FE does NOT build the centerstub notifications=ref([]); real center host-owned (unified-notifications-popover)
BEinternal/app/service/get_contact.go:148 + internal/app/repository/db.go:35-37,60-68Selection resolution for first_10k_sortedSearchContacts(ctx,req,withCount); SortBy{Field,Direction} (asc→1/desc→-1); no _id tie-breaker yet
BEinternal/app/payload/search_contact_request.go:25,33,36-37,194-208Sort + Source + last-update filters to reuseOrderBy oneof=created_at updated_at name; Source []string; StartDate/EndDate on updated_at (RFC3339)
BEinternal/app/handler/contact_handler.go:633-634The no-sort default to mirrordefaults OrderBy=created_at, OrderDirection=desc when none set (resolves PRD OQ-14 no-sort case)
BEinternal/app/handler/layout_properties_handler.go:68-76 (rest_router.go:191)Default-layout auto-fill (EXP-S09)GET /iag/v1/layouts/defaultGetDefaultLayoutProperties(ctx, companySsoID)
FEfeatures/customers/views/components/ExportCustomerDrawer.vueThe panel to extend (EXP-S09)right MpDrawer; disabled timezone MpAutocomplete; DEFAULT_TIMEZONE='(GMT+07:00) Asia/Jakarta':175; CSV; no export call yet
FEcommon/components/FilterCheckbox.vue, common/components/InputPeriod.vue, common/constants/timezones.tsReusable Source / Period / Timezone controlsmulti-select source (ListPage.vue:35); period presets on updated_at; 598 timezones
FEfeatures/customers/views/ListPage.vue:220,450FE cap + total gateMAX_SELECTION = 500 (must raise for export, OQ-19); pagination.value.total gates the "first 10,000" shortcut
hub-chatfeatures/report/export/... (route reports/export); IAG /report/v1/billings/logs/exportThe reused export-history surface (EXP-S08)QuotaUsageExportData{file_name,quota_type,exporter_name,export_time,status,object_name}; billing-only quota types; perm report_omnichannel_view; no s2s register / CDP type today

Existing Contracts to Reuse, Extend, or Replace (BE)

ContractStatusJustificationOwner
POST /iag/v1/contacts/exportnew-with-justificationNo export endpoint exists (grep /export → 0); cannot reuse template/import routesCDP BE
GET /iag/v1/contacts/export/status/{job_id}new-with-justificationNo status endpoint; needed for EXP-S01/AC-2 status surfaceCDP BE
GET /iag/v1/contacts/field_propertiesreusedExists rest_router.go:173-187; returns layout fields incl. IsHidden/NameAliasCDP BE
bulk_upload_jobs collectionextendedAdd job_type/format/layout_id/selected_fields; collection + repo existCDP BE
GenerateAndUploadExcelextended (sibling method)New GenerateAndUploadExcelWithData(); do not modify template methodCDP BE
SendEmailWithAttachmentreused3 new export wrappers call it unchangedCDP BE
RateLimitMiddleware / RequirePermissionMiddlewarereusedSame chain as import (rest_router.go:142)CDP BE
POST /notif/v1/notifications (Unified Notification Service)reused (external)Documented service; publish is net-new for contact-serviceNotif team
GET /iag/v1/layouts/defaultreusedExists rest_router.go:191; EXP-S09 default-layout auto-fillCDP BE
Contact list sort (SortBy) for first_10k_sortedextendedAdd _id secondary sort + a get-by-IDs/criteria batch (no $in-by-_id today)CDP BE
POST /report/v1/billings/logs/export (IAG billing export-history)new-with-justification (external)Reuse the page (PRD D-15) but no CDP quota type / s2s register path exists — needs billing/IAG work (OQ-17)Billing/IAG + Chat/Omni
Existing downloadCustomer GET /api/core/v1/{org_id}/contacts/download/contactreplaced (for selected-export)Refactored to POST /iag/v1/contacts/export (URL length); old route stays for any other consumerCDP FE

Patterns to Follow

LayerConcernPattern in repoReference fileDeviation?
BEHTTP handler shapedecode → validate → service → respondinternal/app/handler/contact_handler.go (Import), download_template_handler.go:43none
BERepository / DB accessr.mongo.Create/FindOne/Update(ctx, collection, …) via IDbReporepository/db.go:113; bulk_upload_job/create.go:10none
BEQueue consumerfunc (w *Consumer) Method(job *work.Job) errorconsumer/send_contact.go (BulkImportCustomer)none
BEExternal HTTP (s2s)heimdall httpclient.Client + http.NewRequestWithContextapi/iag_mekari.go:54-120new notification_mekari.go follows it
BEError wrapping / loggingfmt.Errorf("ctx: %w", err); slog.ErrorContextconsumer/send_contact.go, repository/db.gonone
BERate limit / permissionmiddleware chain on routerest_router.go:142none
FEState managementPinia setup store + ref statusfeatures/customers/store/CustomerStore.ts; ListPage.vue:209none
FEData fetchinguseCustomFetch().$customFetchuseCustomFetch.ts; DownloadTemplateModal.vue:147GET→POST for export
FEError / toasttoastNotify(...) (host-aware)utils/toast.ts; ListPage.vue:319none
FElocalStoragelocalStorage.get/setItem(KEY, JSON…)ListPage.vue:168,173,251new key export_field_config_{layout_id}
Crosssnake_case API ↔ camelCase FErequest body snake_case; FE mapsdownload_template body column_headersexport body uses contact_ids, layout_id, selected_fields, format

Reading Order for the Agent

  1. internal/server/rest_router.go:117-187 — route group + middleware to extend.
  2. internal/app/consumer/send_contact.go — the consumer shape to mirror.
  3. internal/worker/worker_service.go:100-135 — job registration.
  4. internal/app/service/excel_service.go:35-118 — OSS flow + the method NOT to extend.
  5. internal/app/service/email/email_service.go:98-191 — email base + naming.
  6. internal/app/repository/bulk_upload_job/base.go + update.go — the store to extend.
  7. internal/app/repository/layout_properties/base.go — field/header source.
  8. internal/app/api/iag_mekari.go:54-120 — notif client pattern.
  9. features/customers/views/ListPage.vue (lines 67,209,317-328,436-445) — FE selection + trigger.
  10. features/customers/bulk-upload/views/components/DownloadTemplateModal.vue — the fork base.

Source Verification (anti-hallucination — verified 2026-06-18)

LayerAnchor / pattern / contractVerified byEvidence
BECustomersCustomersExportKeyreadconst.go:29 = "customers_customers_export"; listed in permission_handler.go:120 getDefaultPermissions()
BEno export endpoint/jobgrep/export,ExportContactJob,MaxExportRow → 0 hits
BEgocraft/workreadgo.mod:14 github.com/gocraft/work v0.5.1; registerJobWithOptions(...) worker_service.go:100-135
BEconsumer patternreadsend_contact.go BulkImportCustomer(job *work.Job) error; job.Args["data"]; params.JobID = job.ID
BEexcelize + OSSreadgo.mod:35 xuri/excelize/v2 v2.8.1; excel_service.go:95 private/templates/...; :104 ContentDisposition; :112 SignURL(...,3600,...)
BEencoding/csvgrep0 hits — net-new
BESendEmailWithAttachmentreademail_service.go:98 signature takes file *os.File; io.ReadAll(file) :120; import wrappers :153/:169/:189
BEno notif mechanismgrepNotify = signal.Notify only (worker_ui.go:40, consumer.go:91, rest.go:104)
BEheimdall clientreadiag_mekari.go:54 NewIagClient; httpclient.NewClient(WithHTTPTimeout) :69; config/load.go:306 getStringOrPanic
BErate limitreadrest_router.go:142 RateLimitMiddleware(h.RateLimiterService); config RATE_LIMITER_MAX_REQUESTS/WINDOW_SECONDS load.go:264
BEMaxImportRowreadbulk_import_contact.go:20 const MaxImportRow = 10000
BEbulk_upload_jobs storereadbulk_upload_job/base.go:30 TableName()="bulk_upload_jobs"; struct fields enumerated; no job_type; iface Insert/Update/GetBulkUploadJobByID/HasPendingJobByCompanySsoID
BEfield_properties endpointreadrest_router.go:173-187 GET /iag/v1/contacts/field_properties; download_template :231-233
BELayout.NameAlias/IsHiddenreadlayout_properties/base.go Layout{… NameAlias, IsHidden, Order, FieldType}; used excel_service.go:126
BEnamespacesread/iag/v1/ IAGMiddleware :117-118; /api/v1 BasicAuth :279-280
BEcommandsreadMakefile: make build (go build -tags dynamic), make test (go test -race -tags dynamic ./internal/... ./config/...), make lint (staticcheck), make migrate-up (JSON migrations db/migrations/)
FEdownloadSelectedCustomers/downloadCustomerreadListPage.vue:317-328 + listener :67; :436-445 GET /api/core/v1/${orgId}/contacts/download/contact params {email,'contact_ids[]'} via $customFetch
FEbulk-actions popoverreadListTable.vue:51-62 only "Delete selected" MpPopoverListItem
FEDownloadTemplateModalreadpath features/customers/bulk-upload/views/components/; FILE_FORMAT_OPTIONS:128-130 (XLSX only); fileFormat:132; accordion :32-76; POST /v1/download_template :147 body {column_headers}
FEnotification stub + MFEreadTheNotification.vue:89 notifications=ref([]), Inbox-only :40; __mfePixelToast toast.ts:5; unified-notifications-popover TheNotification.vue:2
FElocalStoragereadListPage.vue:168 COOKIE_NAME, read :173, write :251
FEselection Set + 10Kread/grepselectedCustomerIds: ref<Set<string>> :209; getter :270; 10000 cap NOT FOUND — net-new FE
FEuseCustomFetch / configreaduseCustomFetch.ts $customFetch; config.CUSTOMER_360_URL/API_BASE_URL
FEuseMixpanelreaduseMixpanel.ts:39 track(...) auto-adds Company SSO ID
FEcommands + DSreadpackage.json: test(:16 vitest), test:coverage(:17), build(:6), lint(:18), lint:fix(:19); @mekari/pixel3@1.0.10-dev.0(:24); Vitest+happy-dom
MobileOne Notification V2 centerreadmobile-qontak-crm features/crm_misc/lib/src/presentation/screens/notification_v2/notification_v2_screen.dart NotificationV2Screen; General/Approval tabs
Mobilenotif_type general=1 / category downloadUpload="5"readcrm_misc/.../enum/notif_type_enum.dart NotifType.general(1); crm_misc/.../util/notif_category.dart:59 NotifCategoryV2.downloadUpload = '5' (the V2 class; distinct from the older NotifCategory.download/.upload)
Mobileunified endpoints + response modelreadcrm_misc/.../endpoint/unified_notification_endpoint.dart /notif/v1/notifications (:10), unread/count, mark_all_as_read, markAsReadById; unified_notification_response.dart has origin/notif_type/notif_category/click_action/click_action_url/read_at
Mobileflag gatingreadcrm_core/.../feature_flag_constant.dart:78-82 flag_one_notification default false; profile useQontakOneNotif bottom_navigation_screen_mixin.dart:64-78
Mobiletap-through routes on origin (not click_action); no cdp/contact360 mappingread/grepcrm_misc/.../widgets/item/notification_item_v2_mixin.dart:92-151 _navigateToItem(); detailModuleRouteMapping = deal/contact/lead/task/company/expense/external_url; grep cdp/contact360 → ABSENT; external_url opens via UrlLauncherUtil.openWebsite() (:134-140)
Mobileno existing export/download notificationgrepcustomer.*export/download.*contact in notif+customer layers → NOT FOUND (net-new)
Mobile (chat)mobile-qontak-chat not involvedread/grepno notification_v2//notif/v1/notifications/flag_one_notification; chat FCM/MQTT only; depends on qontak_common from CRM repo (features/chat_notification/pubspec.yaml)
— v2.6 scope, verified 2026-06-26 —
BEcontact list sortreadSearchContacts get_contact.go:148; SortBy{Field,Direction} db.go:35-37; direction switch db.go:64-68 (asc→1/desc→-1); no _id tie-breaker
BEsort/source/date filtersreadsearch_contact_request.go:33 OrderBy validate:"omitempty,oneof=created_at updated_at name"; :25 Source []string; :36-37 StartDate/EndDate; range logic on updated_at :194-208
BEno-sort defaultreadcontact_handler.go:633-634 (and :745-746) sets OrderBy=created_at, OrderDirection=desc when both empty
BEno get-by-IDsgrepno $in-by-_id / GetByIDs on contact repo — net-new batch for export
BEdefault-layout endpointreadrest_router.go:191 GET /default; layout_properties_handler.go:68-76 GetDefaultLayoutProperties; S2S variant :359
BEno timezone formatting in export/templategrepexport/template handlers take no timezone; only qontak_launchpad.go carries a Timezone field — net-new for export
FEExportCustomerDrawer.vue exists (incomplete)readright MpDrawer :2 placement="right"; DEFAULT_TIMEZONE='(GMT+07:00) Asia/Jakarta' :175; selectedTimezone :192; disabled timezone MpAutocomplete; shows toast, no export API call
FEreusable filter controlsreadcommon/components/FilterCheckbox.vue (source, used ListPage.vue:35); common/components/InputPeriod.vue (presets, filters updated_at); common/constants/timezones.ts (598 zones)
FEcap + total gatereadListPage.vue:220 MAX_SELECTION = 500; :227-231 add-time cap; :280-295 select-all = current page only; :450 pagination.value.total
FEsource enum gapreadsources.ts: Telegram/Facebook/Livechat/Instagram/Line/Twitter/IG comment/Mobile chat/Tokopedia/Shopee/GMB — no Advertisement/Email
hub-chatexport-history surfacereadroute pages/reports/export/*; features/report/export/.../FormLogsExportQuotaPage.vue (File name/Quota type/Exporter/Export date/Status/Download); perm report_omnichannel_view
hub-chat / IAGexport-history API + modelreaduseQuotaUsageExportHistory.ts:40-86 GET/POST /report/v1/billings/logs/export + /{id}/download; model QuotaUsageExportData{export_id,file_name,quota_type,exporter_name,export_time,status,object_name,date_range}
hub-chat / IAGquota types billing-only; UI-created; no s2sread/grepExportQuotaUsageDrawer.vue:135-176 types wa_balance/muv/chatbot_ai/voice_call_balance/quota_management; no CDP/"Customer Data" type; rows created by UI POST; no cross-service register path (none in contact-service)

Design ↔ Code Mapping (frontend half)

Figma frameImplementing fileReuse vs newTokensBacking APIDeviation
Export Selected buttonfeatures/customers/views/components/ListTable.vueextended (add MpPopoverListItem)text.danger sibling; default textPOST /iag/v1/contacts/exportnone
Export Configuration page/drawernew features/customers/export/views/ExportCustomerPage.vue (fork DownloadTemplateModal.vue)new (forked)Pixel3 MpDrawer/MpAccordion/MpRadioGET .../field_properties, POST .../exportroute-vs-drawer = OQ-8
XLSX/CSV selectorwithin ExportCustomerPageextended (FILE_FORMAT_OPTIONS + CSV)MpRadion/a (body format)none
Success messageExportCustomerPagereuse existing patterntoastNotify (grounded — utils/toast.ts, used at ListPage.vue:319); a persistent MpAlert banner only if Pixel3 provides it (not currently imported in the repo — verify)n/anone
In-app notificationhost shell (not customer-fe)n/a — host-ownedhost tokensGET /notif/v1/notificationsno FE work

Detail 2.1 — Architecture (mermaid)

End-to-end component diagram

flowchart TB
user([User]) --> page["ExportCustomerPage.vue (FE)"]
page --> client["useCustomFetch().$customFetch"]
client --> gw{{IAG / API Gateway}}
gw --> handler[/"contact-service: ExportHandler<br/>/iag/v1/contacts/export"/]
handler --> svc["ExportService.TriggerExport"]
svc --> repo[("bulk_upload_job repo<br/>bulk_upload_jobs")]
svc --> enq[["JobEnqueuer.EnqueueJob<br/>ExportContactJobName"]]
enq --> queue[["gocraft/work (Redis)"]]
queue --> cons["ExportContactConsumer.ProcessExportContactJob"]
cons --> excel["ExcelService.GenerateAndUploadExcelWithData / CSV serializer"]
cons --> mail["EmailService.SendEmailExportCustomer*"]
cons --> notif["NotificationClient.Publish → /notif/v1/notifications"]
cons --> repo
excel --> oss[("Alibaba OSS private/exports/")]

Data model (erDiagram)

erDiagram
BULK_UPLOAD_JOBS {
objectid _id PK
string job_type "NEW: import|export (default import)"
string status "queued|processing|completed|partial|failed"
string format "NEW: xlsx|csv (export only)"
string layout_id "NEW (export only)"
array selected_fields "NEW (export only)"
string file_name
string file_url "OSS signed URL (48h)"
string file_path "private/exports/{company_sso_id}/..."
int total_rows
int row_failures
array list_failed_rows
string user_sso_id
string company_sso_id
datetime created_at
datetime updated_at
bool is_deleted
}
LAYOUT_PROPERTIES {
string id PK
string name
string name_alias "header display (OQ-3)"
bool is_required
bool is_hidden "excluded from export"
int order
string field_type "field kind (not the number subtype)"
}
FIELD_PROPERTIES {
string name "selected_fields key + projection key"
string field_type
string number_type "Validation.Number.NumberType: number|currency|percentage (drives S9-S11)"
bool is_default "default vs custom group"
bool is_hidden
}
CONTACT_CUSTOMFIELD {
string key "matches field_properties.name"
any value
string currency_code "for currency formatting"
string datatype
}
BULK_UPLOAD_JOBS ||..|| LAYOUT_PROPERTIES : "layout_id resolves fields"
FIELD_PROPERTIES ||..o{ CONTACT_CUSTOMFIELD : "name == key"

State machine — bulk_upload_jobs.status (export)

stateDiagram-v2
[*] --> queued: POST /export accepted
queued --> processing: consumer picks up
processing --> completed: all rows ok, success email + notif
processing --> partial: some rows failed, partial email + notif
processing --> failed: serialize/storage/temp-file error, failure email + notif
completed --> [*]
partial --> [*]
failed --> [*]

Branch & skip flow — notification publish (non-error policy branch)

flowchart TD
done(["export job finished: success/partial/failed"]) --> email["send result email - always"]
email --> pub{"notif publish enabled?"}
pub -- ok --> ok["record cdp_export_customer_notification_sent result=ok"]
pub -- publish fails --> skip["log + alert cdp-ops channel, job NOT failed"]
ok --> reg{"export-history register available? OQ-17"}
skip --> reg
reg -- ok --> rok["record cdp_export_history_registered result=ok"]
reg -- fails or not wired --> rskip["log + alert cdp-ops channel, job NOT failed"]
rok --> fin(["consumer returns nil"])
rskip --> fin

Detail 2.2 — Sequence (end-to-end, incl. failure paths)

Happy path — trigger + async generate + deliver

sequenceDiagram
actor U as User
participant FE as customer-fe
participant LB as IAG Gateway
participant API as contact-service api
participant RL as Redis (rate limit)
participant DB as MongoDB (bulk_upload_jobs)
participant Q as gocraft/work (Redis)
participant W as ExportContactConsumer (worker)
participant OSS as Alibaba OSS
participant EM as Email provider
participant NT as Unified Notification Service
participant HX as IAG export-history (hub-chat surface)

U->>FE: select IDs OR "first 10,000" OR panel (timezone/period/source) + Export
FE->>LB: POST /iag/v1/contacts/export {selection_mode, ids OR criterion, layout_id, selected_fields, format, timezone?}
LB->>API: IAG auth + permission(CustomersCustomersExportKey)
API->>RL: check 5/hour/company
alt within limit & valid
opt selection_mode = first_10k_sorted / panel
API->>DB: SearchContacts(filter + sort order_by + _id tie-breaker, limit 10000) resolve ids
end
API->>DB: insert bulk_upload_jobs {job_type:export,status:queued}
API->>Q: EnqueueJob(ExportContactJobName, payload)
API-->>FE: 200 {job_id,status:"queued",email}
FE-->>U: "Customer download started — data generated in {timezone} (default GMT+07), check email at {email}"
Note over Q,W: async — worker picks up
W->>DB: update status=processing
W->>DB: fetch contacts + layout fields (NameAlias, IsHidden)
W->>W: serialize by format (excelize / encoding/csv), timestamps in request timezone → bytes.Buffer
W->>W: write buffer → temp *os.File
W->>OSS: PutObject private/exports/{co}/export_{ts}.{ext}, then SignURL TTL 172800
W->>EM: SendEmailExportCustomerSucceeded(*os.File, link)
W->>NT: POST /notif/v1/notifications (general / category 5 / origin=external_url + click_action_url)
W->>HX: POST /report/v1/billings/logs/export (register row — non-fatal, OQ-17)
W->>W: os.Remove(temp file)
W->>DB: update status=completed,total,file_url
EM-->>U: email + 48h link
NT-->>U: in-app notification (web host + mobile)
HX-->>U: row in chat.qontak.com/reports/export (Active until 48h)
else over cap / no perm / flag off / rate limit
API-->>FE: 422 / 403 / 403 FLAG_DISABLED / 429
FE-->>U: inline error / tooltip
end

Failure path — serializer / OSS / temp-file error

sequenceDiagram
participant W as ExportContactConsumer
participant OSS as Alibaba OSS
participant EM as Email provider
participant NT as Unified Notification Service
participant DB as MongoDB

W->>W: serialize / write temp file
alt serialize or temp-file write fails
W->>W: defer os.Remove(temp) (best-effort)
W->>EM: SendEmailExportCustomerFailed() (no attachment)
W->>NT: POST /notif/v1/notifications (export-failed)
W->>DB: update status=failed, failure_reason
Note over W: policy — send failure email, set status=failed, return nil (no retry), user re-exports
else OSS upload fails
W->>EM: SendEmailExportCustomerFailed()
W->>DB: status=failed
else notif publish fails (non-fatal)
W->>W: log + alert cdp-ops channel
W->>DB: status stays completed/partial (email already sent)
end

Failure path — partial row failures

sequenceDiagram
participant W as ExportContactConsumer
participant EM as Email provider
participant NT as Unified Notification Service
participant DB as MongoDB
W->>W: serialize rows, some contact IDs missing/deleted (OQ-1)
W->>W: skip failed rows, count row_failures, continue
W->>EM: SendEmailExportCustomerPartialSucceeded(*os.File, success/failed counts)
W->>NT: POST /notif/v1/notifications (partial)
W->>DB: status=partial, total_rows, row_failures, list_failed_rows

Detail 2.3 — Database Model (DDL / Mongo)

MongoDB (schemaless). Extend the existing bulk_upload_jobs collection (repository/bulk_upload_job/base.go). No DDL migration required for new fields; add an optional index for export queries.

// New fields on bulk_upload_jobs (application struct additions):
// job_type string // "import" | "export" (default "import" for legacy rows)
// format string // "xlsx" | "csv" (export only)
// layout_id string // export only
// selected_fields []string // export only
// failure_reason string // export failure summary (user-friendly; OQ-2)

// Optional index migration: db/migrations/NNN_index_bulk_upload_jobs_job_type.up.json
db.bulk_upload_jobs.createIndex(
{ company_sso_id: 1, job_type: 1, created_at: -1 }
); // supports: list/rate-window export jobs per company; status lookups
  • Cardinality / growth: ≤ 5 export jobs/company/hour (rate limit); status rows retained 7 days (PRD §5.1) → bounded. File bodies live in OSS, not Mongo.
  • Example row: { job_type:"export", status:"completed", format:"csv", layout_id:"lay_123", selected_fields:["full_name","email","tags"], file_url:"https://oss/.../export_173..._a1.csv", total_rows:8421, row_failures:3, company_sso_id:"co_1", user_sso_id:"u_9" }.
  • PII classification: file_url → link to a PII file (contact data); the OSS object body is PII. list_failed_rows may contain contact identifiers (PII). user_sso_id/company_sso_id → internal identifiers. See §3.D.
  • Retention: job status 7 d (PRD §5.1); OSS file 48 h (signed-URL TTL 172800 s); export audit log 90 d.

Per-status lifecycle (bulk_upload_jobs.status, export rows):

StatusVisibilityRetentionRestore semanticsTransitions allowed
queuedinternal (status API)7 dn/a→ processing
processinginternal7 dn/a→ completed/partial/failed
completedinternal + link in email/notifstatus 7 d; file 48 hre-export only (no restore)terminal
partialinternal + email/notifstatus 7 d; file 48 hre-exportterminal
failedinternal + failure email/notif7 dre-exportterminal
  • Partition/sharding: none — bounded volume.
  • NoSQL note: already MongoDB; reuse is the point of Decision 3.

Detail 2.4 — APIs

Outbound endpoints (consumers call us)

EndpointMethodAuthN/AuthZRequest schemaResponse schemaStatus codesIdempotencyVersioningReuse?
/iag/v1/contacts/exportPOSTIAGMiddleware + RequirePermissionMiddleware(CustomersCustomersExportKey) + RateLimitMiddleware{selection_mode:"ids"|"first_10k_sorted", contact_ids:[]string (mode ids), filter/order_by/order_direction (mode first_10k_sorted), layout_id:string, selected_fields:[]string, format:"xlsx"|"csv", timezone?:string, period?/start_date?/end_date?, source?:[]string} (body){job_id:string, status:"queued", email:string}200; 422 EXPORT_LIMIT_EXCEEDED/EXPORT_FORMAT_INVALID/EXPORT_SELECTION_MODE_INVALID; 403 FORBIDDEN/FLAG_DISABLED; 429 EXPORT_RATE_LIMIT_EXCEEDEDnone (each submit = new job_id; rate limit is the backstop, OQ-5)path /iag/v1/new-with-justification (no export route)
/iag/v1/contacts/export/status/{job_id}GETIAGMiddleware + RequirePermissionMiddleware(CustomersCustomersExportKey)path job_id{job_id, status, total_records, success_count, failed_count}200; 404 EXPORT_JOB_NOT_FOUND; 403n/a (read)/iag/v1/new-with-justification
/iag/v1/contacts/field_propertiesGETIAGMiddlewarequery layout_id (paginated)envelope {data: FieldPropertiesSerializer[], pagination} — each item: {id, name, name_alias, field_type, datatype, order, ancestry, required, is_hidden, is_default, type, validation:{number:{number_type}}, dropdown[]} (grounded: field_properties/serializer.go:7-32; no key/group fields)200; 5xx → FE error staten/a/iag/v1/reused (rest_router.go:173-187)
/iag/v1/layouts/defaultGETIAGMiddleware— (company from JWT)default layout object (id + props)200; 5xx → FE falls back to layout listn/a/iag/v1/reused (rest_router.go:191, layout_properties_handler.go:68-76) — EXP-S09 layout auto-fill

Status field mapping (API ← collection): total_records = total_rows, failed_count = row_failures, success_count = total_rows − row_failures (the collection stores total_rows/row_failures; the API exposes the friendlier names).

Contact fetch & field-resolution algorithm (grounded — implementation contract for chunks 8–9). There is no get-by-IDs method on the contact repo (grep: no GetByIDs/$in). Reuse ContactRepository.SearchWithFilters(ctx, req bson.M, limit, page, sort) (internal/app/repository/contact/base.go) driven with req = bson.M{"_id": {"$in": batchOfIDs}}, paginating in batches (IDs may be up to 10 K) — or add an explicit SearchByIDs repo method as part of chunk 9. For each selected field key (= field_properties.name):

  • default field (top-level Contact struct field — name/email/phone/source/ status/etc., contact/base.go:55-75): read the struct field directly;
  • custom field: find Contact.CustomFields[] (*[]CustomField, contact/base.go:62) where CustomField.Key == name, then read .Value (interface{}), with .CurrencyCode + the field's NumberType/Datatype feeding the Decision 6 formatter. A selected field absent from both → empty cell (not an error). This default-vs- custom split is mandatory; the serializer cannot read everything from one place.

Selection resolution (grounded — Decision 9). The handler resolves the id set before enqueue per selection_mode:

  • ids — use contact_ids[] from the body directly ($in-by-_id batch as above).
  • first_10k_sorted / panel (EXP-S09) — call SearchContacts (service/get_contact.go:148) with the request filter (+ panel start_date/ end_date on updated_at + source[]), SortBy{Field: order_by or "created_at", Direction: order_direction or "desc"} (db.go:35-37) plus a secondary _id sort (net-new — db.go has no tie-breaker today), limit = MaxExportRow (10000), page 1. The resolved ids feed the same projection path. The added _id tie-breaker is what makes "the first 10,000" stable and equal to the rows the user saw.

Request payload (ExportContactRequest — new struct, mirrors PRD §7):

type ExportContactRequest struct {
SelectionMode string `json:"selection_mode"` // "ids" | "first_10k_sorted"
ContactIDs []string `json:"contact_ids,omitempty"` // mode "ids": IDs in BODY (not query)
Filter any `json:"filter,omitempty"` // mode "first_10k_sorted": list filter context
OrderBy string `json:"order_by,omitempty"` // "created_at" | "updated_at" (validate oneof; default created_at)
OrderDirection string `json:"order_direction,omitempty"` // "asc" | "desc" (default desc); BE adds an _id tie-breaker
LayoutID string `json:"layout_id"`
SelectedFields []string `json:"selected_fields"`
Format string `json:"format"` // "xlsx" | "csv"
// EXP-S09 panel (optional — "Download all customers"):
Timezone string `json:"timezone,omitempty"` // e.g. "(GMT+07:00) Asia/Jakarta"; REQUIRED for panel exports
Period string `json:"period,omitempty"` // "all_time" | preset key; or use start_date/end_date
StartDate string `json:"start_date,omitempty"` // RFC3339; maps to SearchContactRequest.StartDate (updated_at)
EndDate string `json:"end_date,omitempty"` // RFC3339; maps to SearchContactRequest.EndDate (updated_at)
Source []string `json:"source,omitempty"` // multi-select channels; maps to SearchContactRequest.Source
}

OrderBy/OrderDirection/Filter reuse the grounded list-sort path (SearchContacts + SortBy, oneof=created_at updated_at name); StartDate/ EndDate/Source reuse SearchContactRequest (range on updated_at, Source []string). Validation: selection_mode required; ids requires non-empty contact_idsMaxExportRow; first_10k_sorted ignores contact_ids and resolves server-side (cap 10 K); panel exports require timezone + layout_id.

Request size / limits: ids body ≤ ~360 KB (10 K × 36-char UUID) — well under limits; first_10k_sorted/panel bodies are tiny (criterion only). Rate limit target 5/company/hour subject to the shared limiter caveat (OQ-14). Example requests:

// explicit IDs (EXP-S01/S02)
POST /iag/v1/contacts/export
{ "selection_mode":"ids", "contact_ids":["8f...","2a..."], "layout_id":"lay_123",
"selected_fields":["full_name","email","tags"], "format":"csv" }

// "Select all first 10,000" shortcut (EXP-S02)
POST /iag/v1/contacts/export
{ "selection_mode":"first_10k_sorted", "order_by":"updated_at", "order_direction":"desc",
"filter":{ /* current list filter */ }, "layout_id":"lay_123",
"selected_fields":["full_name","email"], "format":"xlsx" }

// right-side panel "Download all customers" (EXP-S09)
POST /iag/v1/contacts/export
{ "selection_mode":"first_10k_sorted", "timezone":"(GMT+07:00) Asia/Jakarta",
"start_date":"2026-06-01T00:00:00+07:00", "end_date":"2026-06-26T23:59:59+07:00",
"source":["instagram","facebook"], "layout_id":"lay_default",
"selected_fields":["full_name","email"], "format":"xlsx" }

Example response: { "job_id":"665a...","status":"queued","email":"ops@acme.com" }

Inbound webhooks (other services call us)

EndpointMethodAuthN/AuthZSourceSchemaStatusIdempotencyVersioning
n/a — n/a — no inbound webhook; contact-service is the publisher to the Notification Service, not a receiver

Detail 2.A — UI Contract

ExportCustomerPage (forked from DownloadTemplateModal.vue)

  • Figma: Export Configuration frame (node 12224-219006).
  • Implementation: features/customers/export/views/ExportCustomerPage.vue (+ reused FieldCheckboxGroup from bulk-upload).
  • Props:
interface ExportCustomerPageProps {
selectedCustomerIds: string[] // required — from ListPage selection
defaultLayoutId?: string // optional — pre-select org default
}
  • State ownership: local refs (layoutId, checkedDefaultFields, checkedCustomFields, fileFormat) + useCustomFetch for field load + submit; selection comes from ListPage (selectedCustomerIds: ref<Set<string>>).
  • Events (analytics): on submit fire cdp_export_customer_triggered (useMixpanel().track) with {contact_count, field_count, layout_id, format}; on >10 K fire cdp_export_customer_cap_exceeded {attempted_count}.
  • Conditional rendering: field list states (loading/empty/error/success — §2.C); IsHidden fields rendered disabled.
  • Slots/children: field-group accordion (Customer Info / Default / Custom).
  • A11y: MpRadio group labelled "File format"; focus moves to first field group on load; submit button aria-disabled while loading or selection invalid.

ExportSelected button (ListTable.vue)

  • Figma: node 16492-601704.
  • Implementation: add MpPopoverListItem (automation-label bulk-export) beside bulk-delete (ListTable.vue:57-59) → @click emits download-selected-customers (listener exists ListPage.vue:67).
  • A11y: keyboard-reachable within MpPopoverList.

Detail 2.A.1 — "Download all customers" right-side panel (EXP-S09)

Grounded base: extend the existing ExportCustomerDrawer.vue (features/customers/views/components/ExportCustomerDrawer.vue — already a right MpDrawer, placement="right", with a disabled timezone MpAutocomplete, a layout MpAutocomplete, and a CSV option; DEFAULT_TIMEZONE='(GMT+07:00) Asia/Jakarta' :175; it currently shows a toast and does not call the export API). Wire it to POST /v1/contacts/export (FE path; backend /iag/v1/contacts/export).

  • Figma: Chat-Panel·Reports (node 1705-42216).
  • Entry point: "Download all customers" — hidden without CustomersCustomersExportKey (EXP-S09/ERR-2). Opens the drawer without navigating away from the list.
  • Controls (PRD §6.7):
ControlSource / componentRequiredDefaultMaps to request
Info bannerstatic — "only the top 10,000 will be downloaded"
File formatMpRadio (FILE_FORMAT_OPTIONS + CSV)yesXLSXformat
Timezoneenable the disabled MpAutocomplete + common/constants/timezones.ts (598 zones)yes(GMT+07:00) Asia/Jakarta (:175)timezone
Period (last update)reuse common/components/InputPeriod.vue (presets All time/Today/Last 7/30/Per day/week/Custom; filters updated_at)noAll timeperiod or start_date/end_date (RFC3339)
Sourcereuse common/components/FilterCheckbox.vue (multi-select; used ListPage.vue:35)noAll sourcesource[]
Layoutlayout MpAutocomplete; auto-fill default via GET /v1/layouts/defaultyes"Default view"layout_id
Select dataMpAccordion + FieldCheckboxGroup (Customers info / Default / Custom; IsHidden excluded); "N of N selected" + duration noteper layoutselected_fields[]
Persist last configlocalStorage (EXP-S07) — pre-loads timezone/period/source/layout/fields/formatlast used
Downloaddisabled until Timezone + Layout valid → same async flow as §6.1POST .../export (selection_mode=first_10k_sorted resolution)
  • Field-load error (EXP-S09/ERR-1): the data section shows "Unable to load data — please click retry to reload data" + a Retry button; Download stays disabled until fields load (§2.C).
  • Selection model: the panel does not use explicit IDs; it submits a Period/Source criterion that the backend resolves server-side (top 10,000), sharing Decision 9's resolution path.
  • A11y: timezone/layout MpAutocomplete labelled + required; Download aria-disabled until valid; focus order banner → format → timezone → period → source → layout → fields → Download.
  • Source enum caveat (OQ-21): the FE sources.ts enum (Telegram, Facebook, Livechat, Instagram, Line, Twitter, IG comment, Mobile chat, Tokopedia, Shopee, Google My Business) does not include the PRD's "Advertisement"/"Email" — reconcile before build.

Detail 2.B — Data-Fetching Strategy

  • Library: useCustomFetch().$customFetch (auth headers + 401 refresh + 3 retries) — common/composables/useCustomFetch.ts.

FE base-path rule (grounded): config.CUSTOMER_360_URL already includes the /iag segment — every existing FE call uses /v1/... with no /iag prefix (verified: DownloadTemplateModal.vue:147 POST /v1/download_template, ListPage.vue:559 /v1/contacts/field_properties, /v1/contacts/import; grep iag/v1 in customer-fe → 0 hits). So the FE call paths are /v1/... even though the backend route is registered at /iag/v1/contacts/export. Using /iag/v1/... from the FE would double-prefix → 404.

  • Field load: GET {CUSTOMER_360_URL}/v1/contacts/field_properties?layout_id= on layout change; no SWR — fetch on mount + on layout switch; page the envelope.
  • Default-layout auto-fill (EXP-S09): GET {CUSTOMER_360_URL}/v1/layouts/default on panel open to pre-select "Default view"; falls back to the layout list on 5xx.
  • Panel filters (EXP-S09): Source via FilterCheckbox; Period via InputPeriod (emits start_date/end_date on updated_at, ISO); Timezone via MpAutocomplete
    • timezones.ts. These are sent in the export POST body, not as separate calls.
  • Submit: POST {CUSTOMER_360_URL}/v1/contacts/export (mirrors the existing /v1/contacts/import call; single shot; server returns job_id). For the panel / "first 10,000" shortcut the body carries the criterion (selection_mode, order_by/order_direction or period/source[]), not IDs.
  • localStorage (EXP-S07): key export_field_config_{layout_id} stores {selected_fields, format}; read on open, write on submit, clear on Reset (pattern: ListPage.vue:168/173/251). Missing-field guard: intersect saved fields with the loaded field list (AC-6). The EXP-S09 panel extends the same key family with {timezone, period, source} (§2.A.1). PRD EXP-S07/AC-1 also lists "Associations" among the persisted field groups — there is no grounded "associations" field group in the repo field model today (field_properties exposes default/custom fields only), so it is not persisted in v1; confirm whether Associations is an exportable group when EXP-S07's durable store is scoped (tracked under OQ-15).

Detail 2.C — UI State Matrix

SurfaceLoadingEmptyErrorPartialSuccess
Field list (ExportCustomerPage)skeleton while fetching field_properties"No fields available for the selected layout.""Could not load fields. Please try again." + Export disabledn/agrouped checkboxes; default layout pre-selected
Field list (right-side panel, EXP-S09)skeleton while fetching"No fields available for the selected layout.""Unable to load data — please click retry to reload data" + Retry button; Download disabledn/agrouped checkboxes; default "Default view" pre-filled
Export submitbutton spinner; disabledn/ainline error (422/403/429 mapped — §3.C)n/abanner "Customer download started — your data is generated in {timezone} (default GMT +07:00). Please check your email at {email} to download the file." (PRD EXP-S01/S02 AC-2)
Panel required fields (EXP-S09)n/aDownload disabled until Timezone + Layout validn/an/aDownload enabled
Selection counter (ListTable)n/a"0 selected" (Export disabled)n/acap tooltip on >10 K; "Select all first 10,000" shown only when total ≥ 10,000"{n} / 10,000 max"

Detail 2.D — Data Integrity Matrix

Write pathTransaction scopePartial failureIdempotency key + TTLConsistencyDuplicate-event handlingStale-read handling
Insert export job (handler)single Mongo insertinsert fails → 5xx, no enqueuenone (new job_id each submit; rate limit backstop, OQ-5)strong (single doc)duplicate submit = separate job (allowed, PRD OQ-5)n/a
Update job status (consumer)single Mongo update by _idretryable updatejob_ideventual (after side effects)gocraft retry re-runs consumer → guard: skip if status already terminalread job by id before update
OSS uploadsingle PutObjectfailure → status=failed + failure emailobject key includes {ts}_{rand} (unique)n/aretry overwrites same key safelyn/a
Email sendsingle API callfailure logged; status reflects attemptn/an/aterminal-status guard prevents re-send: a retried consumer no-ops when status ∈ {completed,partial,failed}, so email fires once on the first terminal transition; consumer returns nil after a terminal email (no gocraft retry)n/a

Detail 2.E — Concurrency Collision Map

ResourceWritersCollision scenarioResolutionBehavior on conflict
bulk_upload_jobs doc (one export)single consumer per job_idgocraft retry runs consumer twiceterminal-status guard: consumer no-ops if status ∈ {completed,partial,failed}second run skips side effects
Export rate window (company)many users same company6 concurrent submitsRateLimitMiddleware (Redis atomic counter)6th+ → 429 (PRD ERR-5; concurrency QA item §3)
OSS object keyworkertwo jobs same mskey has {rand} suffixdistinct objects

Detail 2.F — Async Job / Event Consumer Spec

Job/ConsumerTriggerInput shapeRetryDLQConcurrencyIdempotency keyPer-msg timeoutPoison handling
ExportContactConsumer.ProcessExportContactJobEnqueueJob(ExportContactJobName){job_id, company_sso_id, user_sso_id, contact_ids, layout_id, selected_fields, format, email} (via job.Args["data"])gocraft default for ExportContactJobName; prefer no-retry on terminal failure (failure email already sent) — register with noRetryOpt like other one-shot jobsgocraft dead pool (Redis)worker pool default (worker_service.go)job_id + terminal-status guardbounded by 10 K cap + 15 MB; budget < 10 min (alert)after N attempts → status=failed + failure email; do not loop

Email methods (new, in email_service.go, mirror SendEmailImportCustomer*): SendEmailExportCustomerSucceeded, SendEmailExportCustomerPartialSucceeded, SendEmailExportCustomerFailed — all call SendEmailWithAttachment (success/partial pass the temp *os.File; failure passes none).

Detail 2.F.1 — Responsibility Boundary Matrix

Step (execution order)Owning squad / serviceInbound triggerOutbound effectFailure handlerPRD anchor
1. Trigger + validate + enqueueCDP BE (contact-service api)POST /iag/v1/contacts/exportjob row + enqueue4xx to FE§7 #1, EXP-S01
2. Serialize + uploadCDP BE (worker)dequeued jobOSS object + signed URLstatus=failed + failure email§7 #2, EXP-S05
3. EmailCDP BE (worker)post-uploademail + 48 h linklog; status reflects§6.3, EXP-S04
4. Publish notificationCDP BE (worker) → Notif team servicepost-emailnotification recordnon-fatal: log + alert (§2.1 branch)§6.5, §7 #5
4b. Register export-history rowCDP BE (worker) → IAG billing service (hub-chat surface)post-notificationrow in /report/v1/billings/logs/exportnon-fatal: log + alert; blocked until OQ-17 (no CDP quota type + s2s path today)§6.6, §7 #6, EXP-S08
5. Render web notificationLaunchpad host shellGET /notif/v1/notificationsunread dot, General tabhost-owned§6.5, EXP-S04/AC-2
6. Render mobile notificationMobile (mobile-qontak-crm, crm_misc NotificationV2Screen)GET /notif/v1/notificationsOne Notification V2 item (General tab, category 5)flag-gated: flag_one_notification (OFF) + useQontakOneNotif§6.5, OQ-12
7. Tap-through (mobile)Mobile (notification_item_v2_mixin.dart)origin="external_url" + click_action_urlUrlLauncherUtil.openWebsite() (external browser)non-external_url origin → "cannot redirect" toast; expired link handled by CDP redirect routeOQ-13

Disagreement flag (now grounded): the renderer is mobile-qontak-crm (crm_misc), verified — not the chat app (mobile-qontak-chat only shares qontak_common). Web rendering is launchpad-host-owned. Neither is contact-service work beyond publishing the payload with origin="external_url". The only optional mobile change is adding a cdp/contact360 route to detailModuleRouteMapping for in-app navigation instead of an external browser (OQ-12).

Detail 2.F.2 — State Surface Contract

EntityState field / eventDefaultUpdated byRead viaStale window
Export jobbulk_upload_jobs.statusqueuedconsumerGET .../export/status/{job_id}seconds (polling not required for v1)
Export notificationnotification read_at / unreadunreadNotification Service on create; user on openGET /notif/v1/notificationshost/mobile cache
Export file linkfile_url (48 h)empty until uploadconsumeremail + notification48 h TTL
Export-history rowIAG status (Active→Expired)none until registerconsumer registers; IAG expires at 48 hGET /report/v1/billings/logs/export (hub-chat)48 h TTL; OQ-17

Detail 2.G — Cross-Layer Contract Verification

EndpointBE response schemaFE expected schemaMatch?Gaps
POST /iag/v1/contacts/export{job_id, status, email} (snake_case){job_id, status, email}yesFE reads email to render banner; casing snake — no transform needed
GET .../export/status/{job_id}{job_id,status,total_records,success_count,failed_count}optional (v1 doesn't poll; status surface via email)yes (partial use)FE may ignore in v1 (no progress bar — §1 Out of Scope #4)
GET .../field_properties?layout_id={data:[{name,name_alias,field_type,is_hidden,is_default,type,validation…}], pagination} (no key/group){label,value,group,disabled} per checkboxpartialFE maps name_alias→label, name→value (the field key sent in selected_fields[]), is_hidden→disabled; group is derived, not a field: Customer Info = built-in defaults, Default = is_default==true, Custom = type=="custom" (LayoutCustomFieldType); FE must page the envelope (mirror DownloadTemplateModal.vue:192-209)
Request body{contact_ids,layout_id,selected_fields,format}sameyesformat lowercased to "xlsx"|"csv" on send (radio value is XLSX/CSV)

The one partial (field_properties) is resolved by an explicit FE mapping layer reusing the existing DownloadTemplateModal field-grouping transform — no contract change needed.

Detail 2.H — End-to-End Data Flow

User selects ≤10K + ExportListTable emits download-selected-customersListPage.downloadSelectedCustomers() → navigate to ExportCustomerPageGET field_properties (render groups) → submit → useCustomFetch POST {CUSTOMER_360_URL}/v1/contacts/export {…,format} (FE path is /v1/...; backend route /iag/v1/contacts/export) → ExportHandler (validate flag/perm/cap/rate/format) → ExportService insert job + EnqueueJob200 {job_id,queued} → FE success banner. Async: ExportContactConsumer → fetch contacts + layout_propertiesformatValue per type → GenerateAndUploadExcelWithData/CSV → temp file → OSS upload+sign → SendEmailExportCustomer* → publish notification → delete temp → update job status.

  • Side effects: email, in-app notification, Mixpanel events, audit log.
  • Ownership: FE (steps to submit) = CDP FE; API+worker = CDP BE; notification render = host/mobile; OSS = CDP Infra; notification service = Notif team.

Detail 2.I — Scope Boundaries

  • BE create: export handler (internal/app/handler/), ExportService (internal/app/service/), ExportContactConsumer (internal/app/consumer/), CSV serializer + GenerateAndUploadExcelWithData (excel_service.go), 3 email methods (email/email_service.go), notification client (internal/app/api/notification_mekari.go), export-history registrar client (internal/app/api/export_history_mekari.go, behind ExportHistoryRegistrar iface — gated OQ-17), payload struct (internal/app/payload/, ExportContactRequest incl. selection_mode/order_by/order_direction/ filter/timezone/period/source[]), ExportContactJobName constant, MaxExportRow, timezone formatter (in the serializer), API doc docs/EXPORT_CONTACT_SERVICE.md (markdown — no repo OpenAPI spec).
  • BE modify: rest_router.go (register 2 routes), worker_service.go (register job), bulk_upload_job repo/struct (job_type/format/layout_id/ selected_fields/failure_reason), config/load.go (notif + export-history client config), contact sort path (repository/db.go — add _id secondary sort + a get-by-IDs/criteria batch for first_10k_sorted), optional index migration.
  • BE NOT touched: GenerateAndUploadExcel (template), existing SendEmailImportCustomer*, download_template handler, /api/core/v1/ download route.
  • FE create: ExportCustomerPage.vue (+ export feature folder).
  • FE modify: ListTable.vue (Export button + "Select all first 10,000" shortcut, gated on total ≥ 10000), ListPage.vue (raise/replace MAX_SELECTION=500 for export, cap at 10,000, first_10k_sorted criterion, refactor downloadCustomer GET→POST or route to export page), ExportCustomerDrawer.vue (complete the right-side panel — enable timezone, wire Period/Source/Layout + export API; EXP-S09), DownloadTemplateModal.vue only as the fork source (no behavior change to template flow).
  • FE NOT touched: TheNotification.vue stub (host-owned center), template download flow.
  • Shared modules: useCustomFetch, useMixpanel, FieldCheckboxGroup, toastNotify — reused read-only.

Detail 2.J — Asset Inventory

AssetTypeSourceFormat & sizesPath in repo
(none new)@mekari/pixel3 components/icons onlyn/a — no new assets; reuse Pixel3 DS icons

3. High-Availability & Security

Exports are async and bounded (10 K cap, 5/h rate limit), so they add negligible synchronous load to API pods. Worker failure is isolated per job_id; a failed job emits a failure email and is independently re-triggerable by the user. Dependencies degrade gracefully: OSS/serializer failure → failure email; Notif service failure → non-fatal (email is the always-on channel).

Performance Requirement

  • Frontend: ExportCustomerPage is a small forked drawer/page — no measurable bundle/LCP impact; field list fetch < 1 s typical; browser support = existing Nuxt app matrix; a11y AA (§3.E).
  • Backend: export endpoint p99 < 300 ms (validate + insert + enqueue; no file work on-request). Worker: a 10 K × 30-field job target < 10 min (alert threshold, PRD §10). Scale workers horizontally (cmd/worker pods ×M). No new read load on API hot paths.
  • Load test: 5 concurrent companies × 5 jobs (rate-limit ceiling) of 10 K rows; assert worker pool drains < 10 min/job and OSS upload success.

Monitoring & Alerting

Mixpanel/observability events (PRD §10) — names preserved exactly: cdp_export_customer_triggered, _completed, _partial, _failed, _cap_exceeded, _email_sent, _notification_sent, cdp_export_history_registered (EXP-S08; {job_id, company_sso_id, user_sso_id, export_type, result}). BE structured logs via slog (existing convention, send_contact.go). Alerts → #cdp-ops: job failure rate > 5% rolling 1 h; email delivery failure; notification publish failure; export-history register failure; job duration

10 min for ≤ 10 K. SLO: export job success ≥ 98%; email delivery ≥ 99.5% (PRD §11).

Logging

  • BE fields: job_id, company_sso_id, user_sso_id, format, total_rows, row_failures, duration_seconds, error_code (slog.*Context).
  • FE: Mixpanel events only (no PII beyond standard auto-props).
  • PII scrubbed: never log contact field values, file_url token, or full list_failed_rows content; log counts + IDs only.

Security Implications

  • Threat model: (a) unauthorized export of another company's contacts; (b) PII leakage via the signed URL; (c) export-as-DoS (large/abusive volume); (d) SSRF/injection via field values; (e) over-exposure of hidden fields.
  • AuthN/AuthZ: every endpoint behind IAGMiddleware + permission key; company scope enforced on every Mongo query and on the OSS path private/exports/{company_sso_id}/.

Role × Endpoint Authorization Matrix

RoleEndpoint(s)MethodsTenant scopeUI visibilityConstraintAudit
CRM Admin / Sales Ops/iag/v1/contacts/export, .../export/status/{id}, .../field_propertiesPOST/GETown companybutton + page visible5 jobs/haudit log + Mixpanel
Marketing OpssamePOST/GETown companyvisible5 jobs/hsame
No CustomersCustomersExportKeynonebutton hidden403403 logged
Internal Ops / supportnone (not served)n/an/an/a
  • Ownership validation: company_sso_id from IAG JWT filters contact fetch and OSS path; reject contact_ids outside the caller's company (QA item: cross-company IDs).
  • Input validation: contact_ids ≤ 10 000, each a UUID; format ∈ {xlsx,csv}; layout_id/selected_fields validated against the layout's field set (drop unknown keys); IsHidden fields excluded server-side too (never trust FE).
  • Injection: contact values written via excelize/encoding/csv (no shell); CSV formula-injection guard — prefix cells beginning with = + - @ with a ' (or quote) to prevent spreadsheet formula execution on open. Outbound notif URL is our own OSS signed URL (no user-controlled SSRF).
  • Secrets: notif client base URL/credentials via config/load.go (getStringOrPanic) — no hardcoding; same pattern as IAG client.
  • Audit logging: export trigger + completion logged with actor + counts (90 d, PRD §5.1).
  • Rate limiting: reuse RateLimitMiddleware (per-company, Redis). Caveat (grounded): the existing limiter is a single global window driven by RATE_LIMITER_MAX_REQUESTS/RATE_LIMITER_WINDOW_SECONDS (default 1 / 60 s, config/load.go:265-266) and is shared with the import route. A dedicated "5/hour" export limit therefore needs either a per-route limit parameter on the middleware (verify it accepts one, else add it) or export inherits the shared global limit — see OQ-14.
  • Static analysis: staticcheck ./... (make lint); FE eslint ..

Detail 3.A — Failure Mode Catalog (merged)

SurfaceFE behavior on failureBE responseCode-shape consistency
Export submit 422 (cap/format)inline error / cap tooltip{error:"EXPORT_LIMIT_EXCEEDED"|"EXPORT_FORMAT_INVALID"}yes — FE maps both codes
Export submit 403 (perm/flag)button hidden / "not available"{error:"FORBIDDEN"|"FLAG_DISABLED"}yes
Export submit 429"Too many export requests. Please wait…"{error:"EXPORT_RATE_LIMIT_EXCEEDED"} + Retry-Afteryes
Field load 5xx"Could not load fields. Please try again." + disable Export5xxyes
Job runtime failure(no live UI) → failure email + notifstatus=failedn/a (out-of-band)
Notif publish failurenonenon-fatal; log + alertyes (job stays completed)

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAuditUser-visible?
Notification publish disabled / failsworker, post-emailskip notif; email still sent; job not failed_notification_sent result=failed + alertno
IsHidden fieldFE render + BE projectionfield excluded from outputn/ayes (greyed)
Mobile flag_one_notification OFFmobile clientnotif not shown on mobile; email still deliveredn/ayes (mobile only)
Deleted contact id at job time (OQ-1)worker per-rowskip row, count as failed → partial emailrow_failuresyes (partial email)

Detail 3.B — Error Response Catalog (BE)

Shape: { "error": "CODE", "message": "...", "details": {} }

EndpointCodeHTTPMessageWhenUser-facing?
exportEXPORT_LIMIT_EXCEEDED422selection exceeds 10,000len(contact_ids) > MaxExportRowyes
exportEXPORT_FORMAT_INVALID422format must be xlsx or csvformat ∉ {xlsx,csv}yes
exportEXPORT_SELECTION_MODE_INVALID422selection_mode must be ids or first_10k_sortedselection_mode ∉ {ids,first_10k_sorted} (or ids with empty contact_ids)yes
exportFLAG_DISABLED403feature not enabledflag OFFyes
exportFORBIDDEN403no permissionmissing CustomersCustomersExportKeyyes
exportEXPORT_RATE_LIMIT_EXCEEDED429too many requests> 5/company/houryes
statusEXPORT_JOB_NOT_FOUND404job not foundunknown/forbidden job_idyes

Detail 3.C — Error Message Catalog (FE)

CodeUser-facing message (i18n key)SurfaceUser-facing?
cap exceeded"You can only select up to 10,000 customers" (export.cap)tooltipyes
EXPORT_RATE_LIMIT_EXCEEDED"Too many export requests. Please wait before trying again." (export.rateLimit)banner/toastyes
field load error"Could not load fields. Please try again." (export.fieldsError)inlineyes
panel field load error"Unable to load data — please click retry to reload data" (export.panelFieldsError) + Retrypanel data sectionyes
success"Customer download started — your data is generated in {timezone} (default GMT +07:00). Please check your email at {email} to download the file." (export.started)banneryes

Banner copy (grounded to PRD EXP-S01/S02 AC-2). The success banner carries the PRD's GMT-timezone sentence{timezone} interpolates the EXP-S09 panel's selected timezone and falls back to the export default (GMT+07:00) Asia/Jakarta (Decision 11 / OQ-20) for the EXP-S01/S02 paths (which have no timezone selector). This is FE copy only — no new BE field is required (the POST .../export response is unchanged; the FE renders the timezone from panel state or the default). Resolves the prior review's banner-copy Partial (the earlier draft dropped the AC-2 timezone sentence).

Detail 3.D — Compliance & Data Governance

Triggered — exports contain contact PII.

FieldClassificationLegal basisRetentionEncryptionAccess auditRight-to-delete
OSS export file (contact data)PIIUU PDP / legitimate business use by company admin48 h (auto-expire via signed URL TTL)TLS in transit; OSS at-restexport audit log 90 dfile auto-expires; source contact deletion handled by existing contact lifecycle
bulk_upload_jobs.file_urllink to PII7 d status retentionTLSauditn/a (expires with file)
list_failed_rowsmay contain contact ids7 dat-restauditcounts-only logging

Controls: company-scoped access, short-lived signed URL, no PII in logs, CSV formula-injection guard, OSS path namespaced per company.

Detail 3.E — Accessibility

  • WCAG AA. Keyboard: popover → Export item → page; field checkboxes and MpRadio reachable in order. Focus moves to first field group on load and back to trigger on close. ARIA labels on the format radio group and disabled hidden fields. Color contrast via Pixel3 tokens. Respect prefers-reduced-motion for drawer transitions.

4. Backwards Compatibility and Rollout Plan

Compatibility

  • BE: all routes are additive (/iag/v1/contacts/export*). bulk_upload_jobs gains optional fields; existing import queries use no job_type filter so they are unaffected (legacy rows lack the field — see Decision 3; backfill only if a caller must isolate imports via job_type:"import"). GenerateAndUploadExcel, SendEmailImportCustomer*, and download_template are untouched → no API version bump.
  • FE: downloadCustomer GET path remains for any other caller; the selected-export flow switches to POST. localStorage adds a new namespaced key.
  • Cross-layer: new endpoint + new FE consumer ship behind one flag.

Rollout Strategy

  • Deploy order: BE first (endpoint + worker + flag default OFF), then FE (button/page consume it). FE without BE would 404; BE without FE is dormant (no caller) — safe.
  • Feature flag: cdp_export_customer_enabled (default OFF, per-company by Ops). Mobile notif additionally gated by flag_one_notification (OFF). Single primary flag; kill-switch = flip OFF (endpoint returns 403 FLAG_DISABLED; no jobs created).
  • Stages (PRD §12): Stage 0 prerequisites (eng only, all BE+FE deliverables in staging) → Stage 1 internal QA (2 test companies; XLSX+CSV, 3 email variants, web+mobile notif, cap, 429, 403, per-type formatting (Decision 6 table), 48 h expiry, OSS path) → Stage 2 pilot (5–10 CRM-migrating clients, 2 wks; ≥98% success, zero email failures) → Stage 3 GA progressive (≥60% adoption/30 d; failure < 5%).
  • Stop conditions: job failure rate > 5% rolling 1 h, or any email-delivery failure spike → halt rollout, flag OFF.
  • Rollback mechanism: flip cdp_export_customer_enabled OFF (instant, no data migration); in-flight jobs already enqueued complete and email; no new jobs accepted.
  • Blast radius: worst case = flag-ON companies only; export is isolated from read/write contact paths.
  • PIC/timeline: Stage 0 CDP Eng (1–2 sprints); Stage 1 QA (1 wk); Stage 2 PM+CSM (2 wks); Stage 3 PM+Ops (ongoing).

Detail 4.A — Cross-Layer Rollout Compatibility Matrix

ScenarioFEBEWorks?Mitigation
Pre-deployOldOldyesbaseline
Backend firstOldNewyesendpoint dormant; flag OFF; FE doesn't call yet
Frontend firstNewOldnoFE would 404 → deploy BE first (deploy order avoids this)
Both deployedNewNewyestarget state, flag-gated
Backend rollbackNewOldnoflip flag OFF first; FE hides Export when flag OFF (read flag in FE)
Frontend rollbackOldNewyesendpoint dormant; harmless

Detail 4.B — Configuration Contract

LayerEnv var / flagTypeDefaultRequiredProvisionerSecret?
BEcdp_export_customer_enabledflag (per-company)OFFyesOps/flag serviceno
BEMaxExportRowconst10000yescodeno
BERATE_LIMITER_MAX_REQUESTS / _WINDOW_SECONDSintrepo default 1 / 60 (config/load.go:265-266); export target 5 / 3600 needs a per-route limit (OQ-14)yesconfig/load.gono
BEOSS export TTLconst172800 (48 h)yescodeno
BENOTIFICATION_API_ROOT_URLstringyes (for notif)config/load.go getStringOrPanicno
BENOTIFICATION_API_CLIENT_ID / _SECRETstringyes (for notif)config/load.goyes
BEEXPORT_HISTORY_API_ROOT_URL (IAG billing)stringonly if EXP-S08 wired (OQ-17)config/load.gono
BEEXPORT_HISTORY_API_CLIENT_ID / _SECRETstringonly if EXP-S08 wired (OQ-17)config/load.goyes
BEdefault export timezoneconst(GMT+07:00) Asia/Jakartayes (EXP-S09)code (OQ-20)no
Mobileflag_one_notificationflagOFFn/a (mobile)mobile configno
FE(reads cdp_export_customer_enabled via app config)flagOFFyeshost/app configno

Detail 4.C — Test Plan (commands sourced from repo)

LayerCommand (source)What it must prove
BE unitgo test -race -tags dynamic ./internal/app/service/... ./internal/app/consumer/... (source: Makefile make test)handler validation (cap/perm/flag/format/rate/selection_mode); first_10k_sorted resolves first 10 K via sort + _id tie-breaker; serializer per-type (EXP-S05 × xlsx/csv); timezone formatting; buffer→temp-file→email; status transitions; notif + export-history register non-fatal
BE fullmake test (go test -race -tags dynamic -coverprofile=coverage.out.tmp ./internal/... ./config/..., source Makefile:82)no regression across service
BE lintmake lint (staticcheck ./...)static analysis clean
BE buildmake buildcompiles with -tags dynamic
BE migrationmake migrate-up (db/migrations/)optional job_type index applies + rolls back (make migrate-down)
FE unitpnpm test -- features/customers (source: package.json:16 "test":"vitest")button emits event; cap tooltip; POST body (not query); field mapping; localStorage save/load/reset; format-change keeps fields; "Select all first 10,000" shown only at total ≥ 10,000 + sends criterion not IDs; panel (EXP-S09): Timezone+Layout gate Download, Period/Source in body, Retry on field-load error
FE coveragepnpm test:coverage (package.json:17)coverage report
FE lintpnpm lint (package.json:18 eslint .)lint clean
FE buildpnpm build (package.json:6)builds
Cross-layermanual/staged: FE POST → BE 200 {job_id} → email + notif (Stage 1)end-to-end across the API boundary
Mobile (optional)cd features/crm_misc && fvm flutter test test/main_test.dart + melos run analyze (source: mobile-qontak-crm/AGENTS.md, melos.yaml)only if the optional cdp route mapping chunk is taken (otherwise mobile renders for free)

Detail 4.D — Agent Execution Plan

OrderLayerChunkFiles to modify/createCommandsAcceptance criteria
1BEConstants + payloadinternal/app/service/export_contact.go (new — const MaxExportRow = 10000 mirroring bulk_import_contact.go:20; ExportContactJobName alongside the existing job-name consts in job_enqueuer.go); internal/app/payload/export_contact_request.gomake buildbuilds; MaxExportRow/ExportContactJobName exported
2BEExtend job storerepository/bulk_upload_job/base.go (+job_type,format,layout_id,selected_fields,failure_reason); db/migrations/NNN_index_..._job_type.up.json+down; conditional backfill migration updateMany({job_type:{$exists:false}},{$set:{job_type:"import"}}) only if a caller must isolate importsmake migrate-up && make migrate-downstruct compiles; index up/down works; export queries use job_type:"export" (legacy rows excluded without backfill)
3BEOSS helper + XLSX-with-datainternal/app/service/excel_service.go (GenerateAndUploadExcelWithData, shared uploadAndSign helper, TTL 172800, private/exports/...)go test -race -tags dynamic ./internal/app/service/test: file at export path; ContentDisposition export; 48 h TTL; template method unchanged
4BECSV serializerinternal/app/service/export_csv.go (encoding/csv, RFC-4180, formula-injection guard)go test ... ./internal/app/service/test: arrays/line-breaks/currency per OQ-11; cells starting = + - @ escaped
5BEType formatterinternal/app/service/export_format.go (formatValue(fieldType, numberType, value, currencyCode, format) per the Decision 6 table)go test ... ./internal/app/service/24 table cases (Decision 6 rows × xlsx/csv) pass incl. number/currency/percentage keyed on NumberType, date, and the formula-injection guard
6BE3 email methodsinternal/app/service/email/email_service.gogo test ... ./internal/app/service/email/success/partial pass *os.File; failure none; mirror import wrappers
7BENotification clientinternal/app/api/notification_mekari.go; config/load.go (notif config)make build && go test ... ./internal/app/api/client POSTs /notif/v1/notifications payload; behind NotificationPublisher iface
8BEExport consumerinternal/app/consumer/export_contact.go (ProcessExportContactJob); internal/worker/worker_service.go (register, prefer noRetry)go test ... ./internal/app/consumer/test: success→upload+email+notif+temp delete+status; partial; failure email; terminal-status guard; notif non-fatal
9BEService + handler + routes + selection resolution + rate-limit decisioninternal/app/service/export_service.go (contact fetch via SearchWithFilters/new SearchByIDs with _id $in + pagination + the default-vs-custom field-resolution algorithm; + selection_mode resolution: ids reads body; first_10k_sorted/panel calls SearchContacts with filter + SortBy(order_by default created_at desc) + _id tie-breaker, limit 10 K — Decision 9); internal/app/repository/db.go (add _id secondary sort); internal/app/handler/export_handler.go; internal/server/rest_router.go (2 routes under /contacts, perm+rate middleware); OQ-14: extend IRateLimiterService+RateLimitMiddleware with per-route (key,max,window) for ("export",5,3600) or adopt the shared limiter + amend PRD §5make build && make lint && make testhandler validates flag/perm/cap/format/selection_mode; first_10k_sorted returns exactly first 10 K in order_by order, stable under _id tie-breaker; returns {job_id,queued,email}; rate-limit per chosen OQ-14 option; routes guarded; full suite green
10BEAPI docNo OpenAPI/Swagger spec exists in the repo (verified — docs/ holds only WEBHOOK_DELIVERY_SERVICE.md; AGENTS.md's "OpenAPI in docs/" is aspirational). Add docs/EXPORT_CONTACT_SERVICE.md following the existing markdown-doc conventionls docs/EXPORT_CONTACT_SERVICE.mda markdown doc describing the 2 new endpoints exists
11FEField mapping + pagefeatures/customers/export/views/ExportCustomerPage.vue (fork DownloadTemplateModal.vue), reuse FieldCheckboxGroup; map name_alias→label, name→value, derive groups (is_default/type=="custom"), page the {data,pagination} envelopepnpm test -- features/customers/exportrenders 3 derived groups; is_hidden disabled; name used as the selected_fields[] value; paginates field list
12FEFormat radio + bodyExportCustomerPage (FILE_FORMAT_OPTIONS+CSV, thread format)pnpm test -- features/customers/exportbody includes format lowercased; CSV selectable
13FEExport buttonfeatures/customers/views/components/ListTable.vuepnpm test -- features/customerspopover shows "Export Selected"; emits download-selected-customers
14FESelection cap + GET→POSTfeatures/customers/views/ListPage.vue (cap selectedCustomerIds, route to export page / POST)pnpm test -- features/customers && pnpm lint>10 K capped + tooltip; POST body (not query); cap event fired
15FElocalStorage persist (EXP-S07)ExportCustomerPage (export_field_config_{layout_id})pnpm test -- features/customers/exportsave/auto-load/reset; missing-field guard; format change keeps fields; no auto-download
16FEMixpanel eventsExportCustomerPagepnpm test -- features/customers/exportcdp_export_customer_triggered/_cap_exceeded fire with payload
17Mobile (optional)CDP tap-through route (only if richer in-app nav wanted; else publish origin="external_url" and skip)mobile-qontak-crm features/crm_misc/.../route/qontak_app_route.dart (detailModuleRouteMapping + cdp/contact360), notification_item_v2_mixin.dartcd features/crm_misc && fvm flutter test test/main_test.darttapping a CDP export notif navigates in-app (not "cannot redirect" toast)
18BETimezone formatter (EXP-S09)internal/app/service/export_format.go (apply request timezone to date/timestamp cells — net-new; default (GMT+07:00) Asia/Jakarta)go test ... ./internal/app/service/timestamps render in the request timezone; default applied when absent
19BEExport-history registrar (EXP-S08) — gated OQ-17internal/app/api/export_history_mekari.go (behind ExportHistoryRegistrar iface, heimdall pattern → IAG POST /report/v1/billings/logs/export); config/load.go (base URL/secrets); wire into consumer (non-fatal)make build && go test ... ./internal/app/api/registrar POSTs the row payload; failure is non-fatal (job stays completed); do not start until OQ-17 confirms a CDP quota type + s2s path
20FE"Select all first 10,000" shortcut + cap (EXP-S02)features/customers/views/components/ListTable.vue (shortcut shown only when total ≥ 10000); features/customers/views/ListPage.vue (raise/replace MAX_SELECTION=500 for export; build first_10k_sorted criterion with current order_by/order_direction; 10,001+ auto-disabled)pnpm test -- features/customers && pnpm lintshortcut hidden below 10 K total; sends criterion (not IDs); no manual unselect in shortcut mode; export cap = 10,000
21FERight-side panel completion (EXP-S09)features/customers/views/components/ExportCustomerDrawer.vue (enable timezone MpAutocomplete; wire InputPeriodstart_date/end_date, FilterCheckboxsource[], default-layout auto-fill via GET /v1/layouts/default; field-load error → Retry; persist config; call POST /v1/contacts/export)pnpm test -- features/customerspanel opens without nav; Timezone+Layout gate Download; Period/Source in body; Retry on field-load error; persist pre-loads

Detail 4.E — Verification & Rollback Recipe

  • Pre-merge (in order):
    • BE: 1) make lint 2) make test 3) make build
    • FE: 1) pnpm lint 2) pnpm test 3) pnpm build
  • Post-deploy signals: Mixpanel cdp_export_customer_completed count > 0 in Stage 1; #cdp-ops alert quiet (failure rate < 5% rolling 1 h, no email-delivery alert); job duration metric < 10 min for ≤ 10 K; OSS objects appear under private/exports/ and expire at 48 h.
  • Rollback (in order):
    1. Flip cdp_export_customer_enabled OFF (endpoint → 403 FLAG_DISABLED, no new jobs).
    2. If FE shipped, FE hides Export when flag OFF (no redeploy needed).
    3. If a bad migration: make migrate-down (index only; data untouched).
    4. Revert the offending PR; confirm #cdp-ops failure rate < 5% over next 15 min and import flows unaffected (job_type:"import" queries intact).

Detail 4.F — Resource & Cost Notes

  • Compute: +M worker pods bounded by 5/h/company rate limit; negligible API-pod delta. DB: a few hundred small status docs/day; one new index. Network egress: OSS upload (~5–15 MB/job) + email attachment + one notif POST per job. Storage: OSS export objects auto-expire 48 h → near-zero steady-state growth. No new infra components.

5. Concern, Questions, or Known Limitations

Resolved by grounding (closed in this RFC):

  • OQ-3 (header names) → use Layout.NameAlias (layout_properties/base.go).
  • OQ-7 (job store) → reuse bulk_upload_jobs + job_type discriminator (Decision 3).
  • OQ-9 (field-properties endpoint) → exists at GET /iag/v1/contacts/field_properties (rest_router.go:173-187).
  • OQ-11 (CSV formatting) → RFC-4180 quoting + formula-injection guard (Decision 6, §3 Security).
  • PRD namespace /iag/v1/customers/* → corrected to /iag/v1/contacts/* (Decision 8).
  • Mobile renderer → verified in mobile-qontak-crm (crm_misc NotificationV2Screen); mobile-qontak-chat is not involved (separate chat FCM/MQTT; shares only qontak_common).
  • Mobile tap-through key → routes on origin="external_url" (not click_action); payload corrected (Decision 7). notif_type general=1, notif_category downloadUpload="5" confirmed.

Open — adopted PRD default, confirm before/at the relevant stage:

#QuestionAdopted defaultOwnerBlocks?
OQ-10Notif ingest = HTTP or Kafka? (review REV-8 — also pin the NotificationPublisher Go signature + notif timeout/retry)HTTP heimdall client (Decision 7), behind NotificationPublisher ifaceCDP BE + Notif teamConfirm before chunk 7; reversible
OQ-1Contact id deleted at job timeskip row, count failed → partial emailCDP Engno (handled)
OQ-2Partial-email error verbosityuser-friendly summary; full error in logsPM + Engno
OQ-4OSS quota for private/exports/ (review REV-10)confirm ~5–15 MB/job, 48 hCDP InfraStage 0 gate
OQ-5Concurrent exports per userallow (per job_id); 5/h backstopCDP Engno
OQ-6localStorage per-layout vs globalper layout (export_field_config_{layout_id})CDP FEno
OQ-8Route vs full-screen modalfull-screen drawer (consistent w/ DownloadTemplateModal)CDP FE + Figmano (Figma confirm)
OQ-12Mobile notif gating + tap-through routeGrounded: renders in mobile-qontak-crm crm_misc when flag_one_notification (OFF) + useQontakOneNotif ON; tap-through works today only if payload origin="external_url". Optional: mobile adds a cdp/contact360 entry to detailModuleRouteMapping for in-app routing instead of external browserPM + Mobileno (email always-on)
OQ-13Expired-link tap UXGrounded: mobile opens click_action_url in an external browser, so a raw expired OSS URL shows an OSS error. Default: click_action_url → a CDP redirect route that serves a fresh link or "link expired — re-export"; do not embed the raw OSS URLPM + Engno
OQ-14Per-route rate limit (real design work, not a confirm) (review REV-5)Grounded & decisive: RateLimitMiddleware(rateLimiterService) takes only the service, keys solely on companySsoID, hardcodes X-RateLimit-Limit: 1 and the message "Too many import requests", and uses one global RATE_LIMITER_MAX_REQUESTS/WINDOW_SECONDS (default 1/60s, config/load.go:265-266) shared with import (internal/pkg/middleware/rate_limit_middleware.go:16-55). It cannot express PRD §5's "5 exports/company/hour" as-is, and reusing it couples export+import on one counter. v1 decision: extend IRateLimiterService + RateLimitMiddleware with an additive per-route (key, maxRequests, windowSeconds) param (export = ("export", 5, 3600), distinct counter, generic 429 message); fallback if descoped: export inherits the shared limiter and PRD §5 is amended to the shared value. Not a "confirm" — pick one in chunk 9.CDP BEdecide in chunk 9
OQ-15EXP-S07 scope: localStorage v1 vs durable per-user store (from review REV-7)v1 ships FE localStorage export_field_config_{layout_id}; PRD marks all EXP-S07 ACs "Must Have" (incl. AC-4 cross-device user-level scope, Timezone/Associations). Needs PM sign-off that localStorage v1 satisfies the story with a durable per-user store (PRD D-8 "new API endpoint") as a tracked follow-upPM + CDP FEsign-off before GA
OQ-16OSS data residency for the PII export body (from review REV-9)§3.D covers retention/encryption but not OSS bucket region vs UU PDP cross-border transfer. InfoSec to confirm the export bucket region is compliant for exported contact PIIInfoSec + CDP Infraconfirm at Stage 0
OQ-17Export-history reuse (EXP-S08, PRD OQ-15) — real cross-team design, not a confirm. (review REV-12) The chat.qontak.com/reports/export surface is hub-chat → 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.Default: register behind an ExportHistoryRegistrar iface, non-fatal, and ship the pipeline without it; the billing/IAG + Chat/Omni teams must add (1) a CDP quota_type/billing_code, (2) an s2s create accepting a CDP-supplied file/link, (3) company/org visibility for it. Until then EXP-S08 is specified but not buildable (chunk 19 blocked).PM + CDP BE + Billing/IAG + Chat/Omnibefore EXP-S08 build; not a core-pipeline blocker
OQ-18first_10k_sorted ordering (PRD OQ-14). (review REV-13) The set must equal the rows the user saw in their current sort. Grounded: sort path exists; handler already defaults created_at desc; but no _id tie-breaker and no get-by-IDs/criteria batch exist.Default (mostly resolved by Decision 9): resolve via SearchContacts + SortBy(order_by default created_at desc) + added _id secondary sort, limit 10 K; confirm the _id tie-breaker is acceptable and add the batch path in chunk 9.CDP BE + FEdecide in chunk 9
OQ-19FE MAX_SELECTION = 500 vs the 10,000 export cap. (review REV-14) The list selection is capped at 500 today (ListPage.vue:220); explicit-ID export needs up to 10,000 and the shortcut needs the criterion path.Default: raise/replace MAX_SELECTION for the export flow only (keep 500 for other actions if intended), and prefer the first_10k_sorted criterion over collecting 10 K IDs client-side. Confirm the desired explicit-ID ceiling.CDP FEdecide in chunk 20
OQ-20Timezone-aware export timestamps (EXP-S09). (review REV-15 — confirm the timezone value format: IANA Asia/Jakarta vs label (GMT+07:00) Asia/Jakarta, and which cells are tz-rendered) No BE timezone formatting exists today; the FE ExportCustomerDrawer timezone selector is currently disabled.Default: BE formats date/timestamp cells in the request timezone (default (GMT+07:00) Asia/Jakarta); FE enables the existing selector. Confirm the timezone value format passed (IANA vs "(GMT+07:00) Asia/Jakarta" label) and which fields are timezone-rendered.CDP BE + FEconfirm before chunk 18/21
OQ-21Source filter enum gap (EXP-S09). (review REV-16) PRD lists "Advertisement"/"Email" sources, but the FE sources.ts enum and the contact Source field do not include them.Default: reconcile the source vocabulary before build — either extend the enum or scope the panel's Source options to the existing channels.PM + CDP BE + FEconfirm before chunk 21

Known limitations: 10 K hard cap (no chunked/streamed large exports v1); client-side filtered-ID resolution for the explicit-ID path (the first_10k_sorted shortcut and the EXP-S09 panel do resolve server-side); EXP-S08 export-history reuse is blocked on a net-new cross-team path (OQ-17 — no CDP quota type / s2s register on the IAG billing surface today), so it ships behind an interface and lights up when that lands; the FE list cap is MAX_SELECTION=500 today and must be raised for export (OQ-19); BE timezone-aware timestamp formatting is net-new (OQ-20) and the Source enum lacks Advertisement/Email (OQ-21); EXP-S07 durable per-user persistence deferred (v1 = localStorage); mobile tap-through (verified in mobile-qontak-crm crm_misc) only routes when the payload uses origin="external_url" and opens the link in an external browser — so a custom in-app "expired link" screen requires either a cdp/contact360 route mapping in the mobile app or a CDP web redirect route behind click_action_url (OQ-12/OQ-13). Future: server-side filter re-execution + a durable export-config store + a richer mobile in-app route for CDP origins.


6. Comment logs

DateComment(s) FromAction Item(s)
2026-06-18rfc-starter (initial draft, grounded vs contact-service + qontak-customer-fe)Confirm OQ-10 notif channel with One Notification team; CDP Infra confirm OSS quota (OQ-4); Figma confirm route-vs-drawer (OQ-8)
2026-06-18rfc-starter (mobile grounding pass vs mobile-qontak-crm + mobile-qontak-chat)Verified One Notification V2 lives in mobile-qontak-crm crm_misc; chat app not involved. Payload corrected: tap-through routes on origin="external_url" (not click_action); click_action_url should be a CDP redirect route (OQ-13). Optional mobile chunk: add cdp route to detailModuleRouteMapping (OQ-12)
2026-06-18rfc-starter (adversarial reverify — 4 subagents vs all 4 repos + doc-logic)Fixed: rate-limit shared-limiter caveat + OQ-14 (middleware default 1/60s, not 5/h); job_type legacy-row backfill clarified (struct default ≠ stored docs); notif REST contract & OSS quota made conditional (OQ-10/OQ-4); idempotency/email single policy; sequence-diagram + Detail 1.C OPEN_URL→origin=external_url; FE success uses toastNotify (no MpAlert in repo); chunk 10 corrected (no OpenAPI spec exists → markdown doc); status field mapping added; NotifCategoryV2 precision
2026-06-18Mermaid validator + rfc-reviewer skill (full-stack rubric) + hostile cross-review (skeptical subagents)Mermaid: fixed render-breakers (;/#/unquoted : in branch flowchart; unicode in state labels). rfc-reviewer scored 8.0 Strong / PROCEED-with-notes (report co-located at rfc-export-customer-data-review.md). Fixed hostile findings: B1 EXP-S05 — added Decision 6 formatting table + grounded type source (field_properties.NumberType, not layout.field_type; date added); M1 FE path /iag/v1//v1/ (CUSTOMER_360_URL already includes /iag, double-prefix 404); M2 field_properties shape (name not key, no group, {data,pagination} envelope, FE pages); M3 contact fetch (SearchWithFilters+$in) + default-vs-custom resolution algorithm; M4 rate-limit made a real chunk-9 decision; m1 chunk-1 file named
2026-06-26rfc-starter (sync to PRD v2.6; re-grounded vs contact-service, qontak-customer-fe, hub-chat)Brought the RFC from PRD v2.2 → v2.6. Added (1) first_10k_sorted selection mode (Decision 9; struct selection_mode/order_by/order_direction/filter; grounded SearchContacts+SortBy, handler default created_at desc, net-new _id tie-breaker + get-by-IDs batch; OQ-18; FE MAX_SELECTION=500 conflict → OQ-19; chunks 9,20). (2) Export-history reuse EXP-S08 (Decision 10; grounded honestly — chat.qontak.com/reports/export is hub-chat→IAG billing, no CDP quota type / no s2s register path today; non-fatal registrar behind iface; OQ-17; consumer step 4b; cdp_export_history_registered event; chunk 19 blocked on OQ-17). (3) Right-side panel EXP-S09 (Decision 11; grounded — ExportCustomerDrawer.vue already exists with disabled timezone + DEFAULT_TIMEZONE; reuse FilterCheckbox/InputPeriod/timezones.ts + BE Source[]/updated_at range; net-new timezone formatting OQ-20; source enum gap OQ-21; Detail 2.A.1; chunks 18,21). Updated frontmatter/metadata (PRD v2.6, last_updated), Out-of-Scope, dependencies, PRD-to-Schema, traceability (EXP-S08/S09), Detail 1.B/1.C, §2.4 schema+example, sequence + branch/skip diagrams, source-verification (12 new rows). The 2 edited mermaid blocks (happy-path sequence + branch/skip flowchart) were manually re-checked against the pitfall list (no ;, special chars quoted, additive nodes follow already-validated patterns); the automated mmdc parser was unavailable in this session (npx proxy-intercepted, no network), so a mmdc/Docusaurus render pass is a pre-merge TODO before publish. The 8 unedited blocks were parser-validated in the 2026-06-18 pass.
2026-06-30rfc-starter (verification pass vs PRD v2.6 — "include the latest PRD updates")Verified the RFC already covers all PRD v2.6 scope: first_10k_sorted selection mode (Decision 9, OQ-18/19), EXP-S08 export-history reuse (Decision 10, OQ-17), the EXP-S09 right-side panel (Decision 11, OQ-20/21), traceability EXP-S01–S09, and chunks 18–21 — no scope gaps found. Fixed one carried-over PRD-contract gap: the success banner now carries PRD EXP-S01/S02 AC-2 copy — "Customer download started … data is generated in {timezone} (default GMT +07:00) … check your email at {email}" — with {timezone} interpolating the EXP-S09 selected timezone and defaulting to (GMT+07:00) Asia/Jakarta; FE copy only, no BE contract change. Resolves the prior review's banner-copy Partial. Edited §2.C, §3.C (+ grounding note), the happy-path sequence message; added the §2.B "Associations" persistence note (PRD EXP-S07/AC-1 → OQ-15). Mermaid: only the happy-path sequence message text changed (no structural edit — braces/em-dash/parens match the already-validated line it replaced); mmdc/Docusaurus render remains the pre-merge TODO carried from 2026-06-26.

7. Ready for agent execution

  • yes — for the core BE export pipeline + FE surfaces (EXP-S01–S07, S09). The PRD v2.6 additions are scoped, grounded, and chunked. Confirm-items carry adopted, reversible defaults and do not block starting the core pipeline:
    • OQ-10 (notif channel) — default HTTP heimdall behind a NotificationPublisher interface; confirm before chunk 7. Channel swap is one implementation.
    • OQ-4 (OSS quota) — Stage 0 infra confirm; does not block BE/FE code.
    • OQ-8 (route vs drawer) — superseded by EXP-S09: the panel is a right MpDrawer (extend the existing ExportCustomerDrawer.vue); the §6.2 full-page is dropped.
    • OQ-18 (first_10k_sorted ordering) — mostly resolved by Decision 9; finalize the _id tie-breaker in chunk 9.
    • OQ-20 (timezone formatting) / OQ-21 (source enum) — confirm before chunks 18/21; do not block the explicit-ID pipeline.
  • One scope item is NOT yet buildable — EXP-S08 (export-history reuse): it depends on a net-new cross-team path (OQ-17) — the chat.qontak.com/reports/ export surface is the IAG billing service with no CDP quota type and no service-to-service register today. It is fully specified behind an ExportHistoryRegistrar interface (non-fatal), so the core pipeline ships and EXP-S08 lights up once billing/IAG + Chat/Omni deliver the register path. EXP-S08 is a PRD Should Have, so this does not gate the core release.

Execution-readiness gates (all met unless noted):

  • §1 Design References (FE) — Figma frames + DS version + QA contact: yes (incl. Chat-Panel·Reports for EXP-S08/S09).
  • §1 PRD-to-Schema (BE) — every entity/rule mapped to field + endpoint + enforcement: yes (incl. selection_mode, period/source/timezone, export-history).
  • Detail 1.C Per-Story Change Map — all 9 stories (EXP-S01–S09), layer scope, FE+BE, verifiable AC: yes (EXP-S08 flagged blocked on OQ-17).
  • Repo Reading Guide (both layers + hub-chat) + contracts classified: yes.
  • Source Verification table — concrete evidence per anchor, incl. v2.6 scope (re-grounded 2026-06-26 vs contact-service, qontak-customer-fe, hub-chat): yes.
  • Design ↔ Code Mapping + Asset Inventory (no new assets): yes.
  • Mermaid: topology, per-service, component, ER, state, branch/skip, sequence (happy + 2 failure paths): yes (2 edited blocks manually checked; mmdc render is a pre-merge TODO — see Comment log 2026-06-26).
  • DDL/collection with per-status lifecycle; every field traces to a PRD-to-Schema row: yes.
  • APIs outbound (4, tagged reused/new) + inbound (n/a — publisher, not receiver): yes.
  • Data Integrity + Concurrency + Async Job spec: yes.
  • Responsibility Boundary + State Surface + Cross-Layer Contract Verification (one partial resolved via FE mapping): yes.
  • Failure Mode + Branch & Skip + Error catalogs (BE + FE) aligned: yes.
  • Cross-Layer Rollout Matrix + deploy order (BE-first) chosen: yes.
  • Configuration Contract + flag coordination: yes.
  • Agent Execution Plan (21 chunks, files + commands + verifiable AC; chunk 19 gated on OQ-17): yes.
  • Verification & Rollback Recipe (commands runnable per layer; signals named): yes.

Optional next step: hand to rfc-reviewer for a second-pass score (recommended, given the v2.6 scope additions), then rfc-task-breakdown to refresh the co-located rfc-export-customer-data.task-breakdown.md for the new chunks.