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:linereference in this RFC was verified against the live worktreescontact-serviceandqontak-customer-feon 2026-06-18, and re-grounded on 2026-06-26 for the PRD v2.6 scope additions (first_10k_sortedselection mode, company/org export-history reuse, and the right-side "Download all customers" panel) — additionally againsthub-chat(which serveschat.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 livemobile-qontak-crmrepo (features/crm_misc/...) on 2026-06-18;mobile-qontak-chatwas also checked and does not own the notification center (it has a separate chat FCM/MQTT system and only depends on the sharedqontak_commonlib from the CRM repo).
Metadata
| Field | Value | Notes |
|---|---|---|
| Status | RFC (IDEA) | Human label; YAML status: carries the remapped linter enum draft |
| DRI | Zhelia Alifa | RFC owner (frontmatter dri) |
| Team | cdp | Advisory squad slug carried from PRD / initiative README |
| Author(s) | Zhelia Alifa | Primary author |
| Reviewers | CDP Backend Lead, CDP Frontend Lead, One Notification Team, Mobile Lead | Tech reviewers across affected squads (FE + BE + Mobile) |
| Approver(s) | CDP Tech Lead, InfoSec Approver | Tech leaders + infosec approver |
| Submitted Date | 2026-06-18 | ISO-8601 |
| Last Updated | 2026-06-30 | ISO-8601 — PRD v2.6 verification pass; success-banner copy aligned to EXP-S01/S02 AC-2 (timezone sentence) |
| Target Release | 2026-Q3 | Quarter |
| Target Quarter | 2026-Q3 | Advisory, carried from PRD |
| Related | ../prds/prd-export-customer-data.md | Source 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
- Overview (Design References — FE half; PRD-to-Schema Derivation — BE half; traceability; per-story change map)
- Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide [both layers] → end-to-end mermaid → DDL → APIs → cross-layer contract verification)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (cross-layer rollout matrix, Agent Execution Plan, Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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:
- A
first_10k_sortedselection 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 tocreated_at desc; the gaps are a deterministic_idtie-breaker and a get-by- IDs/criteria batch path (Decision 9, §2.4, OQ-18). Note the FE list cap isMAX_SELECTION = 500today (ListPage.vue:220) — raising the export path to 10,000 is net-new FE work (OQ-19). - Company/org export-history reuse (PRD §6.6, EXP-S08, D-15) — register each
completed export into the existing
chat.qontak.com/reports/exportsurface 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). - A right-side "Download all customers" configuration panel (PRD §6.7,
EXP-S09) with Timezone / Period (last-update) / Source / Layout controls.
Grounded: an
ExportCustomerDrawer.vuealready exists (rightMpDrawer,DEFAULT_TIMEZONE='(GMT+07:00) Asia/Jakarta'), andFilterCheckbox/InputPeriod/timezones.ts+ backendSource[]+start_date/end_dateonupdated_atare 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
- Synchronous/real-time export — all exports are async (PRD §4.1).
- Export for non-customer objects — Customers module only (PRD §4.2).
- Bulk export > 10,000 records — hard cap (PRD §4.3).
- No in-app download center / live progress bar — completion is signalled by email + in-app notification only (PRD §4.4).
- 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).
- Org-level export-template persistence — "save last selected fields" is per-user (PRD §4.6, EXP-S07/AC-4).
- Export via OpenAPI / programmatic trigger — UI-triggered only (PRD §4.7).
- 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 — thefirst_10k_sortedshortcut (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. - Building a CDP-local export-history page or download center — EXP-S08 reuses
the existing
chat.qontak.com/reports/exportsurface (Decision 10); CDP only registers a row. - Building a notification surface, live progress bar, or org-level template persistence — unchanged from PRD §4.
Related Documents
- Source PRD (v2.6):
../prds/prd-export-customer-data.md - Initiative README:
../README.md - Export-history surface (reused):
chat.qontak.com/reports/export— served byhub-chat, backed by the IAG billing service/report/v1/billings/logs/export - Unified Notification Service: https://jurnal.atlassian.net/wiki/spaces/QON/pages/49791664344
- Notification Center on Unified Component: https://jurnal.atlassian.net/wiki/spaces/QON/pages/50203885766
- One Notification — Mobile (Qontak Chat): https://jurnal.atlassian.net/wiki/spaces/QON/pages/50603491444
- Figma master: https://www.figma.com/design/YtCkrXaXMD63i8diuXJ4QC/Customer-Data-Platform?node-id=9132-240403
Assumptions
CUSTOMER_360_URL(FE config) already resolves to the/iagnamespace ofcontact-service: the existingPOST /v1/download_templateFE 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/importcall. (Verified: 0/iagprefixes in customer-fe source.)- OSS (Alibaba OSS Go SDK) used by
excel_service.gois 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_typediscriminator tobulk_upload_jobsneeds no DDL migration (only application code + an optional index migration indb/migrations/). - The Unified Notification Service is documented (per the linked Confluence
pages, and confirmed as the contract the mobile
crm_miscclient consumes) to exposePOST /notif/v1/notifications; whethercontact-serviceshould publish via that REST endpoint or via Kafka is OQ-10. Publishing is net-new forcontact-service(no notification mechanism exists today — grep confirmsNotifyhits are OS-signal handlers only).
Dependencies
| Dependency | Layer / Owner | Availability | Blocking? |
|---|---|---|---|
gocraft/work worker + job registration | BE / CDP | Exists — go.mod:14 v0.5.1; registerJob() internal/worker/worker_service.go:100-135 | Reuse |
excelize XLSX library | BE / CDP | Exists — go.mod:35 xuri/excelize/v2 v2.8.1 | Reuse |
| OSS upload + signed-URL flow | BE / CDP | Exists — excel_service.go:98-112 (Alibaba OSS SDK) | Reuse (new method) |
SendEmailWithAttachment(*os.File) | BE / CDP | Exists — internal/app/service/email/email_service.go:98 | Reuse (3 new wrapper methods) |
bulk_upload_jobs Mongo store | BE / CDP | Exists — repository/bulk_upload_job/base.go:30; no job_type field yet | Extend (add discriminator) |
IAG router + RateLimitMiddleware + RequirePermissionMiddleware | BE / CDP | Exists — internal/server/rest_router.go:117-142 | Reuse |
CustomersCustomersExportKey permission | BE / CDP | Exists, unused — internal/pkg/consts/const.go:29 | Reuse (wire to endpoint) |
field_properties GET endpoint + Layout.NameAlias | BE / CDP | Exists — rest_router.go:173-187; repository/layout_properties/base.go | Reuse |
| Heimdall HTTP client pattern / Kafka publisher (for notif publish) | BE / CDP | Exists — internal/app/api/iag_mekari.go:54 NewIagClient; JobEnqueuer.KafkaPublish | Reuse (new client) |
Qontak Unified Notification Service POST /notif/v1/notifications | One Notification Team | External — ingest channel = OQ-10 confirm | YES (confirm) |
| Web Unified Notification Center | Launchpad / Host shell | External — host-owned; customer-fe holds only the TheNotification.vue stub | YES (confirm) |
| Mobile One Notification V2 center | Mobile (mobile-qontak-crm, features/crm_misc) | Verified — NotificationV2Screen 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) | Mobile | Verified — not involved — separate chat FCM/MQTT system; only depends on shared qontak_common from the CRM repo; no One Notification center here | n/a |
@mekari/pixel3 design system | FE / CDP | Exists — package.json:24 1.0.10-dev.0 | Reuse |
OSS quota for private/exports/ | CDP Infra | Confirm (OQ-4) | YES (confirm) |
| Contact search + sort | BE / CDP | Exists — SearchContacts 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-634 | Reuse (add _id tie-breaker + IDs/criteria batch for first_10k_sorted) |
| Contact source + last-update filters | BE / CDP | Exists — SearchContactRequest.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 endpoint | BE / CDP | Exists — GET /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 / CDP | Exists (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 API | Extend (complete it) for EXP-S09 |
FilterCheckbox / InputPeriod / timezones.ts | FE / CDP | Exists — common/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 service | Exists, billing-only — hub-chat features/report/export/...; IAG POST/GET /report/v1/billings/logs/export; no CDP/"Customer Data" quota type and no cross-service registration path today | YES (design + confirm — OQ-17) |
Design References (frontend half — required)
| PRD-named surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|---|---|---|---|---|
| Export Selected button (bulk-actions popover) | https://www.figma.com/design/YtCkrXaXMD63i8diuXJ4QC/Customer-Data-Platform?node-id=16492-601704 | image-20260617-022708 (happy) | @mekari/pixel3@1.0.10-dev.0 | CDP Design (Figma master DRI) | Adds an MpPopoverListItem next to "Delete selected" (ListTable.vue:51-62) |
| Export Configuration page/drawer | https://www.figma.com/design/YtCkrXaXMD63i8diuXJ4QC/Customer-Data-Platform?node-id=12224-219006 | image-20260608-090001 | @mekari/pixel3@1.0.10-dev.0 | CDP Design | Fork DownloadTemplateModal.vue (an MpDrawer); route-vs-drawer = OQ-8 |
| XLSX/CSV format selector | (within Export Configuration frame above) | — | @mekari/pixel3@1.0.10-dev.0 | CDP Design | MpRadio group; extend FILE_FORMAT_OPTIONS (DownloadTemplateModal.vue:128-130) |
| Export unhappy-path states | https://www.figma.com/design/YtCkrXaXMD63i8diuXJ4QC/Customer-Data-Platform?node-id=19429-139509 | image-20260618-012856 | @mekari/pixel3@1.0.10-dev.0 | CDP Design | Cap 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-734655 | image-20250530-130605 / image-20250604-055859 | n/a — BE email template | CDP Design | Rendered by internal/app/tmpl/ email templates |
| In-app notification | https://www.figma.com/design/9fUk4MHn6KriVf5YT5H1MS/%F0%9F%93%8A-Chat-Panel---Reports?node-id=5409-58952 (v2.6 repoint) | image-20260618-021135 | host-owned (web) / One Notification V2 (mobile) | CDP Design + Notif | No 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-42216 | Chat-Panel·Reports (export menu) | @mekari/pixel3@1.0.10-dev.0 | CDP Design | Extend 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-42216 | Chat-Panel·Reports | n/a — reused chat.qontak.com/reports/export (hub-chat) | CDP Design + Chat/Omni | No CDP render; register-only (OQ-17) |
PRD-to-Schema Derivation (backend half — required)
| PRD entity / attribute / rule | Persisted as (collection.field) | Exposed via (endpoint / event) | Enforced where | Source |
|---|---|---|---|---|
| An export request (≤10 K contact IDs, layout, fields, format) becomes a durable job | bulk_upload_jobs + new job_type="export", format, selected_fields[], layout_id | POST /iag/v1/contacts/export → enqueue ExportContactJobName | ExportService.TriggerExport + RequirePermissionMiddleware + RateLimitMiddleware | PRD §7 #1, §5 |
| Export job status (queued/processing/completed/partial/failed) | bulk_upload_jobs.status, .total_rows, .row_failures, .file_url | GET /iag/v1/contacts/export/status/{job_id} | ExportContactConsumer updates via UpdateBulkUploadJob | PRD §7 #2-3, EXP-S01/AC-2 |
| Selection cap = 10,000 | (validation only) | POST /iag/v1/contacts/export 422 EXPORT_LIMIT_EXCEEDED | MaxExportRow = 10000 (mirror MaxImportRow bulk_import_contact.go:20) | PRD §5, EXP-S01/ERR-1 |
| Format ∈ {xlsx, csv} | bulk_upload_jobs.format | request body format | handler validation; 422 EXPORT_FORMAT_INVALID | PRD §5, §7 #1 |
| Generated file in OSS, 48 h TTL | OSS object private/exports/{company_sso_id}/export_{ts}_{rand}.{xlsx|csv}; URL in bulk_upload_jobs.file_url | signed URL in email + notification | GenerateAndUploadExcelWithData() / CSV serializer; SignURL(..., 172800, ...) | PRD §5, §5.1 |
| Header row uses field display name | reads layout_properties.Layout.NameAlias | n/a — serialization | export serializer | PRD OQ-3 → layout_properties/base.go NameAlias |
IsHidden fields excluded | reads Layout.IsHidden | field list endpoint | FE greys out; BE filters projection | PRD §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 — serialization | export serializer per the Decision 6 formatting table (XLSX cell types / CSV RFC-4180) | EXP-S05 |
| Export-ready in-app notification | external (Unified Notification Service record) | POST /notif/v1/notifications | ExportContactConsumer on completion (non-fatal) | PRD §6.5, §7 #5 |
| Last export config persisted per user | FE localStorage export_field_config_{layout_id} (v1); BE persist = EXP-S07 follow-up | n/a (client-side v1) | FE useCustomFetch/localStorage | EXP-S07, PRD OQ-6 |
| Rate limit (target 5 / company / hour) | (Redis counter via middleware) | POST /iag/v1/contacts/export 429 EXPORT_RATE_LIMIT_EXCEEDED | RateLimitMiddleware (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 gate | external flag store | endpoint 403 FLAG_DISABLED | handler flag check cdp_export_customer_enabled | PRD §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/filter | handler: for first_10k_sorted resolve first 10 K via SearchContacts sort (SortBy, created_at|updated_at) + _id tie-breaker, server-side | PRD §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 — query | SortBy{Field,Direction} (db.go:35-37) + added _id secondary sort | PRD 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 []string | PRD §6.7, EXP-S09/AC-3,AC-4 |
| Timezone (export timestamp rendering) | (request only) | body timezone | net-new BE timezone-aware formatting in serializer | PRD §6.7, EXP-S09/AC-2 |
| Default layout auto-fill | reads default layout | GET /iag/v1/layouts/default | FE pre-selects; BE returns default | PRD §6.7, EXP-S09/AC-2 |
| Export registered in company/org history | external (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 id | FE section / component | BE 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-load | n/a — FE-only |
| EXP-S03/ERR-1 | §2.C Error state ("Could not load fields") | §2.4 field endpoint failure |
| EXP-S04/AC-1 | n/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-2 | n/a — BE email | §2.F failure / partial email (no attachment) |
| EXP-S05/Scenario 1-11 | n/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-1 | n/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 / dependency | PRD composite AC id it serves |
|---|---|
POST /iag/v1/contacts/export | EXP-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 serializer | EXP-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 selectedCustomerIds | EXP-S01/ERR-1, EXP-S02/ERR-1 |
| Notification publish client | EXP-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 cap | EXP-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 controls | EXP-S09/AC-1..AC-7 |
| BE timezone-aware timestamp formatting | EXP-S09/AC-2 |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads (BE) | Required writes (BE) | FE component | Status surface |
|---|---|---|---|---|---|
| Bulk-actions popover "Export Selected" | web | n/a — covered by writes | POST /iag/v1/contacts/export | ListTable.vue | success banner |
| Export Configuration page/drawer | web | GET /iag/v1/contacts/field_properties?layout_id= | POST /iag/v1/contacts/export | ExportCustomerPage/drawer | success banner; job_id |
| Export email | n/a | n/a (BE-sent) | n/a — email template | status (success/partial/failed) | |
| In-app notification (web) | web | GET /notif/v1/notifications (host) | n/a | host shell (not customer-fe) | General tab unread dot |
| In-app notification (mobile) | mobile | GET /notif/v1/notifications | PUT /notif/v1/mark_as_read/{id} | One Notification V2 (mobile repo) | unread badge |
| "Download all customers" right-side panel | web | GET .../field_properties?layout_id=, GET .../layouts/default | POST /iag/v1/contacts/export (+ timezone/period/source[]) | ExportCustomerDrawer.vue (extend) | success banner; job_id |
| Company/org export history | web | GET /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 role | Authorization mechanism | Endpoints permitted (BE) | UI surface visibility (FE) | Cross-tenant? | Audit trail |
|---|---|---|---|---|---|
| CRM Admin / Sales Ops (primary) | IAG JWT + CustomersCustomersExportKey | POST /iag/v1/contacts/export, GET .../export/status/{id}, GET .../field_properties | Export button + config page visible | no — company-scoped | export audit log (90 d), Mixpanel events |
| Marketing Ops (secondary) | IAG JWT + CustomersCustomersExportKey | same as above | same | no | same |
User without CustomersCustomersExportKey | IAG JWT | none (403) | Export button hidden | no | 403 logged |
| Internal Ops / support | (no export role granted in PRD) | n/a — not served this RFC | n/a | n/a | n/a |
PRD Section Coverage
| PRD § | Title | Where covered |
|---|---|---|
| 1 | One-liner + Problem | §1 Overview |
| 2 | What happens if we don't build | §1 Overview (problem) |
| 3 | Target Users + Persona | §1 Detail 1.A Role Coverage |
| 4 | Non-Goals | §1 Out of Scope |
| Scope Changes | affected surfaces | frontmatter scope_changes + §2.I |
| 5 | Constraints | §2 Technical Decisions, §2.4, §3 Security |
| 5.1 | Data Lifecycle | §2.3 per-status lifecycle + §3.D Compliance |
| 6 | New 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) |
| 7 | API & Webhook Behavior | §2.4 APIs (incl. #6 export-history register) |
| 8.1 | System Flow + diagram | §2.2 Sequence diagrams |
| 8.2 | User Stories + ACs (EXP-S01..S09) | §1 Detail 1.A + 1.C |
| 9 | Rollout | §4 Rollout Strategy |
| 10 | Observability | §3 Monitoring & Alerting |
| 11 | Success Metrics | §1 Success Criteria + §3 SLO |
| 12 | Launch Plan & Stage Gates | §4 Rollout Strategy |
| 13 | Dependencies | §1 Dependencies + §2.F.1 Responsibility Boundary |
| 14 | Key Decisions + Alternatives | §2 Technical Decisions (ADR) + §1 Detail 1.B |
| 15 | Open Questions | §5 Concerns / Open Questions |
| App. A | Grounded Code References | §2.0 Repo Reading Guide + Source Verification |
Detail 1.B — Decisions Closed (cross-layer)
| # | Decision | Chosen option | Alternatives rejected | Why rejected | Layer | §2 block |
|---|---|---|---|---|---|---|
| 1 | File format support | XLSX and CSV in v1 | Third-party CSV lib; XLSX-only | encoding/csv is stdlib (no dep); CSV cheap given OSS flow reuse | both | Decision 1 |
| 2 | Sync vs async | Async via gocraft/work | Synchronous HTTP download | 10 K rows × field resolution = 10–60 s; HTTP timeout risk | BE | Decision 2 |
| 3 | Export job store | Reuse bulk_upload_jobs + job_type discriminator | New export_jobs collection | Collection + repo + status fields already exist; Mongo schemaless → no DDL migration (OQ-7) | BE | Decision 3 |
| 4 | New XLSX method | New GenerateAndUploadExcelWithData() | Extend GenerateAndUploadExcel() | Existing method hardcodes private/templates/, template ContentDisposition, 3600 s TTL — conflicting responsibilities | BE | Decision 4 |
| 5 | Buffer→email handling | Write bytes.Buffer→temp *os.File→email→delete | Pass OSS URL to email method | SendEmailWithAttachment requires *os.File (email_service.go:98); changing signature breaks import callers | BE | Decision 5 |
| 6 | Field formatting (10 repo types + number subtypes) | Type-aware serializer keyed on field_properties.field_type+NumberType (Decision 6 table); CSV RFC-4180 | Raw string dump | Number/currency/percent share field_type="number" — must read NumberType; arrays/line-breaks need typed handling (EXP-S05, OQ-11) | BE | Decision 6 |
| 7 | Notification publish channel | HTTP via new heimdall client → POST /notif/v1/notifications (default) | Kafka KafkaPublish | Documented Unified service contract is REST; heimdall client pattern exists (iag_mekari.go). Confirm OQ-10. Reversible. | BE | Decision 7 |
| 8 | Endpoint namespace | POST /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 routing | both | Decision 8 |
| 9 | FE filtered IDs | Resolved client-side, sent in POST body | Server-side filter re-execution | 10 K cap bounds payload; simpler v1 (PRD D-4) | FE | §1 Out of Scope #8 |
| 10 | GET→POST refactor | POST with IDs in body | Keep GET with contact_ids[] query | 10 K × 36-char UUIDs exceed 8 KB URL limit | FE | Decision 8 |
| 11 | Email recipient | Always logged-in user's email; no override | Configurable recipient | Auth-safe; simplest (PRD D-3) | both | no alternative considered — auth-safety |
| 12 | Cap enforcement | Both FE (selectedCustomerIds) and API (MaxExportRow) | API-only | Defense in depth; mirrors MaxImportRow | both | Decision 2 / §3 |
| 13 | Notification job non-fatal | Publish failure logs+alerts, does not fail export | Fail job on notif error | Email is the always-on primary channel (PRD §7 #5) | BE | Decision 7 |
| 14 | "Select all first 10,000" payload | Server-side criterion first_10k_sorted (order_by/order_direction/filter) resolved via SearchContacts sort + _id tie-breaker | Send 10,000 IDs in the body | 10 K × 36-char UUID ≈ 360 KB body; criterion bounds payload (PRD D-14); BE already defaults created_at desc | both | Decision 9 |
| 15 | Export-history surface | Reuse chat.qontak.com/reports/export (IAG billing /report/v1/billings/logs/export); CDP registers a row s2s | Build a CDP-local history/download center | Avoids forking the surface (PRD D-15) — but grounded caveat: no CDP quota type and no cross-service register path exist today (OQ-17) | BE | Decision 10 |
| 16 | Right-side config panel | Extend the existing ExportCustomerDrawer.vue (right MpDrawer) + reuse FilterCheckbox/InputPeriod/timezones.ts | Build a new panel; keep the §6.2 full-page modal | A partial drawer already exists; filters + timezone constants already in repo | FE | Decision 11 |
| 17 | Export timestamp timezone | BE renders timestamps in the request timezone (net-new) | Render in server/UTC only | EXP-S09/AC-2 requires GMT+07:00-style rendering; no BE timezone formatting today | BE | Decision 11 |
Detail 1.C — Per-Story Change Map
| Story id | Title | Layer scope | FE changes | BE changes | Composite AC ids | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|---|---|
| EXP-S01 | Export manually selected customers | FE + BE | "Export Selected" MpPopoverListItem in ListTable.vue; nav to export page; success banner; cap tooltip on selectedCustomerIds | POST /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-5 | Vitest: 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-S02 | Export 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 unselect | same 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-5 | Vitest: ≤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-S03 | Configure export fields by layout | FE + BE | Fork DownloadTemplateModal.vue: layout selector, field groups (Customer Info/Default/Custom), IsHidden greyed; localStorage pre-load | GET /iag/v1/contacts/field_properties?layout_id= returns ordered fields + IsHidden | EXP-S03/AC-1..AC-5, ERR-1 | Vitest: 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-S04 | Receive export email + in-app notification | BE + FE consumes existing (host) | n/a — web center host-owned; FE no render work | 3 email methods reuse SendEmailWithAttachment; notif publish (notif_type=general, notif_category=5, origin=external_url, click_action_url→CDP redirect) on completion | EXP-S04/AC-1, AC-2, AC-3, ERR-1, ERR-2 | go 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-S05 | XLSX/CSV field formatting by type | BE-only | n/a — backend serialization | Type-aware serializer: text, multiline (line breaks), dropdown, multi-select array, URL, GPS, file/signature URL, number/percent/currency; CSV RFC-4180 | EXP-S05/Scenario 1-11 | go 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-NEG | No export without permission / over cap / flag OFF | FE + BE | button hidden without perm | 403 / 422 / 403 FLAG_DISABLED | EXP-S06-NEG/NEG-1, NEG-2 | go 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-S07 | Persist last export configuration | FE + BE | localStorage 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-9 | Vitest: 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-S08 | View export history (company/org) | BE (register) + reused page | n/a — reuse chat.qontak.com/reports/export (hub-chat); no CDP FE render | ExportContactConsumer registers row via IAG billing POST /report/v1/billings/logs/export (s2s, net-new path, non-fatal); needs CDP quota type | EXP-S08/AC-1..AC-4, ERR-1 | go 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-S09 | Download all via right-side panel | FE + BE | Extend 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 → Retry | export 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/default | EXP-S09/AC-1..AC-7, ERR-1, ERR-2 | Vitest: 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")]
| Service | Use cases (this RFC) | Internal calls | External / third-party APIs |
|---|---|---|---|
contact-service (server) | trigger export, job status, field list | RateLimiterService (Redis), RequirePermissionMiddleware, enqueue job | — |
contact-service (worker) | serialize XLSX/CSV, upload, email, notify | ExcelService, EmailService, bulk_upload_job repo, layout_properties repo | Alibaba OSS (CDP Infra); Email provider; Unified Notification Service (Notif team) |
qontak-customer-fe (MFE) | select, configure, trigger; surface success | host 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 onformat; 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/workjob (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+ addJobType 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_jobscollection + 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 toprivate/exports/{company_sso_id}/export_{ts}_{rand}.{ext}, export ContentDisposition, TTL172800s. Pros: no risk to template generation; clear single responsibility. Cons: some OSS code duplicated (small, can be factored into a shareduploadAndSignhelper). - 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
SendEmailWithAttachmentto acceptio.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-S05 | XLSX cell | CSV string (RFC-4180) |
|---|---|---|---|
single_line_text | S1 | text | plain, quote if it contains ,"\n` |
text_area | S2 | text, wrap-text on | quoted cell preserving \n |
dropdown_select | S3 | text (selected label) | plain text |
multiple_select | S4 | text ["a","b"] | quoted "[""a"",""b""]" (JSON-array string) |
url | S5 | text (URL) | plain text |
gps | S6 | text (address or lat,lng) | quoted (contains comma) |
upload | S7 | text (file URL) | plain text |
signature | S8 | text (image URL) | plain text |
number + NumberType=number | S9 | numeric cell | unquoted number |
number + NumberType=percentage | S10 | numeric cell, percent format | unquoted number |
number + NumberType=currency | S11 | numeric cell, currency format (CurrencyCode) | "IDR 1,000,000" quoted formatted string |
date | (PRD gap) | date cell (ISO-8601) | ISO-8601 text |
| default / unknown | — | text 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 viaconfig/load.gogetStringOrPanic) 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):
| Field | Value | Grounding |
|---|---|---|
notif_type | general (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) |
origin | external_url ← routing key | crm_misc/.../widgets/item/notification_item_v2_mixin.dart _navigateToItem() routes on origin; detailModuleRouteMapping has external_url but no cdp/contact360 |
click_action | OPEN_URL (semantic; web) | model field unified_notification_response.dart:42 (free string; mobile router ignores it for routing) |
click_action_url | CDP redirect route → resolves to the 48 h OSS link or an "expired — re-export" page | mobile opens it via UrlLauncherUtil.openWebsite() in an external browser (notification_item_v2_mixin.dart:134-140) |
| title / description | per 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 onorigin == "external_url"(notclick_action). So the publish payload must setorigin="external_url"for tap-through to work today without a mobile code change. Alternatively, the mobile team adds acdp/contact360entry todetailModuleRouteMappingfor a richer in-app route (amobile-qontak-crmchange — 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; thereforeclick_action_urlshould 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 viaCUSTOMER_360_URL. Cons: differs from PRD prose. - Option B —
/iag/v1/customers/exportas written in the PRD. Pros: matches PRD text. Cons: nocustomersroute 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 viaSearchContactswith alimit=10000page 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_idtie-breaker added toSortByand 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-serviceregisters a row by POSTing IAG/report/v1/billings/logs/exportwith a new CDP quota/export type. Pros: reuses the page (PRD D-15). Cons: requires the billing/IAG team to (1) add a CDPquota_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_jobsexport 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; reuseFilterCheckbox/InputPeriod/timezones.ts; threadtimezone/period(orstart_date/end_date)/source[]/layout_id/formatinto the export POST; auto-fill the default layout fromGET /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_idon every query and the OSS pathprivate/exports/{company_sso_id}/(Decision below + §3). Consistency — job status is eventually consistent (worker writesbulk_upload_jobsafter 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
| Layer | Path | Why the agent reads it | What pattern it teaches |
|---|---|---|---|
| BE | internal/server/rest_router.go:117-187 | Where to register the export routes | IAG route group + RequirePermissionMiddleware + RateLimitMiddleware chain (import at :142) |
| BE | internal/app/consumer/send_contact.go | The async consumer to mirror | func (w *SendContactConsumer) BulkImportCustomer(job *work.Job) error: reads job.Args["data"], json-unmarshal into payload, sets JobID |
| BE | internal/worker/worker_service.go:100-135 | How to register the new job | registerJobWithOptions(JobName, opts, consumer.Method, pool) |
| BE | internal/app/service/excel_service.go:35-118 | The method to NOT extend; OSS flow to reuse | GenerateAndUploadExcel headers-only; PutObject+SignURL(…,3600,…) :98-112 |
| BE | internal/app/service/email/email_service.go:98-191 | Email base + naming pattern | SendEmailWithAttachment(…, *os.File, …); SendEmailImportCustomerFullSucceeded/PartialSucceeded/FullFailed |
| BE | internal/app/repository/bulk_upload_job/{base,create,get,update,query}.go | The job store to extend | TableName()="bulk_upload_jobs" :30; InsertBulkUploadJob/UpdateBulkUploadJob/GetBulkUploadJobByID |
| BE | internal/app/repository/layout_properties/base.go | Field/header source | Layout{Name, NameAlias, IsRequired, IsHidden, Order, Type, FieldType} |
| BE | internal/app/api/iag_mekari.go:54-120 | The s2s HTTP client to mirror for notif | NewIagClient + httpclient.NewClient(WithHTTPTimeout); http.NewRequestWithContext |
| BE | internal/app/service/bulk_import_contact.go:20 | Cap constant pattern | const MaxImportRow = 10000 → define MaxExportRow |
| BE | internal/pkg/consts/const.go:29 | Permission key | CustomersCustomersExportKey = "customers_customers_export" |
| BE | config/load.go:200-317 | Config injection | getStringOrPanic/getString; RateLimiterConfig; IAG client wiring |
| FE | features/customers/views/components/ListTable.vue:51-62 | Where the Export button goes | MpPopover+MpPopoverListItem bulk actions; @click emits to parent |
| FE | features/customers/views/ListPage.vue:67,209,270,317-328,436-445 | Selection + export trigger | selectedCustomerIds: ref<Set<string>>; downloadSelectedCustomers(); downloadCustomer() GET→POST target |
| FE | features/customers/bulk-upload/views/components/DownloadTemplateModal.vue | The component to fork | FILE_FORMAT_OPTIONS:128-130, fileFormat ref :132, field accordion :32-76, POST /v1/download_template :147 body {column_headers} |
| FE | common/composables/useCustomFetch.ts | The fetch wrapper | $customFetch(url,{method,body,baseURL}); auth headers + 401 refresh |
| FE | common/composables/useMixpanel.ts | Analytics events | track(eventName, props) auto-adds Company SSO ID |
| FE | layouts/TheNavbar/TheNotification.vue | Why FE does NOT build the center | stub notifications=ref([]); real center host-owned (unified-notifications-popover) |
| BE | internal/app/service/get_contact.go:148 + internal/app/repository/db.go:35-37,60-68 | Selection resolution for first_10k_sorted | SearchContacts(ctx,req,withCount); SortBy{Field,Direction} (asc→1/desc→-1); no _id tie-breaker yet |
| BE | internal/app/payload/search_contact_request.go:25,33,36-37,194-208 | Sort + Source + last-update filters to reuse | OrderBy oneof=created_at updated_at name; Source []string; StartDate/EndDate on updated_at (RFC3339) |
| BE | internal/app/handler/contact_handler.go:633-634 | The no-sort default to mirror | defaults OrderBy=created_at, OrderDirection=desc when none set (resolves PRD OQ-14 no-sort case) |
| BE | internal/app/handler/layout_properties_handler.go:68-76 (rest_router.go:191) | Default-layout auto-fill (EXP-S09) | GET /iag/v1/layouts/default → GetDefaultLayoutProperties(ctx, companySsoID) |
| FE | features/customers/views/components/ExportCustomerDrawer.vue | The panel to extend (EXP-S09) | right MpDrawer; disabled timezone MpAutocomplete; DEFAULT_TIMEZONE='(GMT+07:00) Asia/Jakarta':175; CSV; no export call yet |
| FE | common/components/FilterCheckbox.vue, common/components/InputPeriod.vue, common/constants/timezones.ts | Reusable Source / Period / Timezone controls | multi-select source (ListPage.vue:35); period presets on updated_at; 598 timezones |
| FE | features/customers/views/ListPage.vue:220,450 | FE cap + total gate | MAX_SELECTION = 500 (must raise for export, OQ-19); pagination.value.total gates the "first 10,000" shortcut |
| hub-chat | features/report/export/... (route reports/export); IAG /report/v1/billings/logs/export | The 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)
| Contract | Status | Justification | Owner |
|---|---|---|---|
POST /iag/v1/contacts/export | new-with-justification | No export endpoint exists (grep /export → 0); cannot reuse template/import routes | CDP BE |
GET /iag/v1/contacts/export/status/{job_id} | new-with-justification | No status endpoint; needed for EXP-S01/AC-2 status surface | CDP BE |
GET /iag/v1/contacts/field_properties | reused | Exists rest_router.go:173-187; returns layout fields incl. IsHidden/NameAlias | CDP BE |
bulk_upload_jobs collection | extended | Add job_type/format/layout_id/selected_fields; collection + repo exist | CDP BE |
GenerateAndUploadExcel | extended (sibling method) | New GenerateAndUploadExcelWithData(); do not modify template method | CDP BE |
SendEmailWithAttachment | reused | 3 new export wrappers call it unchanged | CDP BE |
RateLimitMiddleware / RequirePermissionMiddleware | reused | Same 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-service | Notif team |
GET /iag/v1/layouts/default | reused | Exists rest_router.go:191; EXP-S09 default-layout auto-fill | CDP BE |
Contact list sort (SortBy) for first_10k_sorted | extended | Add _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/contact | replaced (for selected-export) | Refactored to POST /iag/v1/contacts/export (URL length); old route stays for any other consumer | CDP FE |
Patterns to Follow
| Layer | Concern | Pattern in repo | Reference file | Deviation? |
|---|---|---|---|---|
| BE | HTTP handler shape | decode → validate → service → respond | internal/app/handler/contact_handler.go (Import), download_template_handler.go:43 | none |
| BE | Repository / DB access | r.mongo.Create/FindOne/Update(ctx, collection, …) via IDbRepo | repository/db.go:113; bulk_upload_job/create.go:10 | none |
| BE | Queue consumer | func (w *Consumer) Method(job *work.Job) error | consumer/send_contact.go (BulkImportCustomer) | none |
| BE | External HTTP (s2s) | heimdall httpclient.Client + http.NewRequestWithContext | api/iag_mekari.go:54-120 | new notification_mekari.go follows it |
| BE | Error wrapping / logging | fmt.Errorf("ctx: %w", err); slog.ErrorContext | consumer/send_contact.go, repository/db.go | none |
| BE | Rate limit / permission | middleware chain on route | rest_router.go:142 | none |
| FE | State management | Pinia setup store + ref status | features/customers/store/CustomerStore.ts; ListPage.vue:209 | none |
| FE | Data fetching | useCustomFetch().$customFetch | useCustomFetch.ts; DownloadTemplateModal.vue:147 | GET→POST for export |
| FE | Error / toast | toastNotify(...) (host-aware) | utils/toast.ts; ListPage.vue:319 | none |
| FE | localStorage | localStorage.get/setItem(KEY, JSON…) | ListPage.vue:168,173,251 | new key export_field_config_{layout_id} |
| Cross | snake_case API ↔ camelCase FE | request body snake_case; FE maps | download_template body column_headers | export body uses contact_ids, layout_id, selected_fields, format |
Reading Order for the Agent
internal/server/rest_router.go:117-187— route group + middleware to extend.internal/app/consumer/send_contact.go— the consumer shape to mirror.internal/worker/worker_service.go:100-135— job registration.internal/app/service/excel_service.go:35-118— OSS flow + the method NOT to extend.internal/app/service/email/email_service.go:98-191— email base + naming.internal/app/repository/bulk_upload_job/base.go+update.go— the store to extend.internal/app/repository/layout_properties/base.go— field/header source.internal/app/api/iag_mekari.go:54-120— notif client pattern.features/customers/views/ListPage.vue(lines 67,209,317-328,436-445) — FE selection + trigger.features/customers/bulk-upload/views/components/DownloadTemplateModal.vue— the fork base.
Source Verification (anti-hallucination — verified 2026-06-18)
| Layer | Anchor / pattern / contract | Verified by | Evidence |
|---|---|---|---|
| BE | CustomersCustomersExportKey | read | const.go:29 = "customers_customers_export"; listed in permission_handler.go:120 getDefaultPermissions() |
| BE | no export endpoint/job | grep | /export,ExportContactJob,MaxExportRow → 0 hits |
| BE | gocraft/work | read | go.mod:14 github.com/gocraft/work v0.5.1; registerJobWithOptions(...) worker_service.go:100-135 |
| BE | consumer pattern | read | send_contact.go BulkImportCustomer(job *work.Job) error; job.Args["data"]; params.JobID = job.ID |
| BE | excelize + OSS | read | go.mod:35 xuri/excelize/v2 v2.8.1; excel_service.go:95 private/templates/...; :104 ContentDisposition; :112 SignURL(...,3600,...) |
| BE | encoding/csv | grep | 0 hits — net-new |
| BE | SendEmailWithAttachment | read | email_service.go:98 signature takes file *os.File; io.ReadAll(file) :120; import wrappers :153/:169/:189 |
| BE | no notif mechanism | grep | Notify = signal.Notify only (worker_ui.go:40, consumer.go:91, rest.go:104) |
| BE | heimdall client | read | iag_mekari.go:54 NewIagClient; httpclient.NewClient(WithHTTPTimeout) :69; config/load.go:306 getStringOrPanic |
| BE | rate limit | read | rest_router.go:142 RateLimitMiddleware(h.RateLimiterService); config RATE_LIMITER_MAX_REQUESTS/WINDOW_SECONDS load.go:264 |
| BE | MaxImportRow | read | bulk_import_contact.go:20 const MaxImportRow = 10000 |
| BE | bulk_upload_jobs store | read | bulk_upload_job/base.go:30 TableName()="bulk_upload_jobs"; struct fields enumerated; no job_type; iface Insert/Update/GetBulkUploadJobByID/HasPendingJobByCompanySsoID |
| BE | field_properties endpoint | read | rest_router.go:173-187 GET /iag/v1/contacts/field_properties; download_template :231-233 |
| BE | Layout.NameAlias/IsHidden | read | layout_properties/base.go Layout{… NameAlias, IsHidden, Order, FieldType}; used excel_service.go:126 |
| BE | namespaces | read | /iag/v1/ IAGMiddleware :117-118; /api/v1 BasicAuth :279-280 |
| BE | commands | read | Makefile: 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/) |
| FE | downloadSelectedCustomers/downloadCustomer | read | ListPage.vue:317-328 + listener :67; :436-445 GET /api/core/v1/${orgId}/contacts/download/contact params {email,'contact_ids[]'} via $customFetch |
| FE | bulk-actions popover | read | ListTable.vue:51-62 only "Delete selected" MpPopoverListItem |
| FE | DownloadTemplateModal | read | path 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} |
| FE | notification stub + MFE | read | TheNotification.vue:89 notifications=ref([]), Inbox-only :40; __mfePixelToast toast.ts:5; unified-notifications-popover TheNotification.vue:2 |
| FE | localStorage | read | ListPage.vue:168 COOKIE_NAME, read :173, write :251 |
| FE | selection Set + 10K | read/grep | selectedCustomerIds: ref<Set<string>> :209; getter :270; 10000 cap NOT FOUND — net-new FE |
| FE | useCustomFetch / config | read | useCustomFetch.ts $customFetch; config.CUSTOMER_360_URL/API_BASE_URL |
| FE | useMixpanel | read | useMixpanel.ts:39 track(...) auto-adds Company SSO ID |
| FE | commands + DS | read | package.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 |
| Mobile | One Notification V2 center | read | mobile-qontak-crm features/crm_misc/lib/src/presentation/screens/notification_v2/notification_v2_screen.dart NotificationV2Screen; General/Approval tabs |
| Mobile | notif_type general=1 / category downloadUpload="5" | read | crm_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) |
| Mobile | unified endpoints + response model | read | crm_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 |
| Mobile | flag gating | read | crm_core/.../feature_flag_constant.dart:78-82 flag_one_notification default false; profile useQontakOneNotif bottom_navigation_screen_mixin.dart:64-78 |
| Mobile | tap-through routes on origin (not click_action); no cdp/contact360 mapping | read/grep | crm_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) |
| Mobile | no existing export/download notification | grep | customer.*export/download.*contact in notif+customer layers → NOT FOUND (net-new) |
| Mobile (chat) | mobile-qontak-chat not involved | read/grep | no 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 — | |||
| BE | contact list sort | read | SearchContacts 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 |
| BE | sort/source/date filters | read | search_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 |
| BE | no-sort default | read | contact_handler.go:633-634 (and :745-746) sets OrderBy=created_at, OrderDirection=desc when both empty |
| BE | no get-by-IDs | grep | no $in-by-_id / GetByIDs on contact repo — net-new batch for export |
| BE | default-layout endpoint | read | rest_router.go:191 GET /default; layout_properties_handler.go:68-76 GetDefaultLayoutProperties; S2S variant :359 |
| BE | no timezone formatting in export/template | grep | export/template handlers take no timezone; only qontak_launchpad.go carries a Timezone field — net-new for export |
| FE | ExportCustomerDrawer.vue exists (incomplete) | read | right MpDrawer :2 placement="right"; DEFAULT_TIMEZONE='(GMT+07:00) Asia/Jakarta' :175; selectedTimezone :192; disabled timezone MpAutocomplete; shows toast, no export API call |
| FE | reusable filter controls | read | common/components/FilterCheckbox.vue (source, used ListPage.vue:35); common/components/InputPeriod.vue (presets, filters updated_at); common/constants/timezones.ts (598 zones) |
| FE | cap + total gate | read | ListPage.vue:220 MAX_SELECTION = 500; :227-231 add-time cap; :280-295 select-all = current page only; :450 pagination.value.total |
| FE | source enum gap | read | sources.ts: Telegram/Facebook/Livechat/Instagram/Line/Twitter/IG comment/Mobile chat/Tokopedia/Shopee/GMB — no Advertisement/Email |
| hub-chat | export-history surface | read | route pages/reports/export/*; features/report/export/.../FormLogsExportQuotaPage.vue (File name/Quota type/Exporter/Export date/Status/Download); perm report_omnichannel_view |
| hub-chat / IAG | export-history API + model | read | useQuotaUsageExportHistory.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 / IAG | quota types billing-only; UI-created; no s2s | read/grep | ExportQuotaUsageDrawer.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 frame | Implementing file | Reuse vs new | Tokens | Backing API | Deviation |
|---|---|---|---|---|---|
| Export Selected button | features/customers/views/components/ListTable.vue | extended (add MpPopoverListItem) | text.danger sibling; default text | POST /iag/v1/contacts/export | none |
| Export Configuration page/drawer | new features/customers/export/views/ExportCustomerPage.vue (fork DownloadTemplateModal.vue) | new (forked) | Pixel3 MpDrawer/MpAccordion/MpRadio | GET .../field_properties, POST .../export | route-vs-drawer = OQ-8 |
| XLSX/CSV selector | within ExportCustomerPage | extended (FILE_FORMAT_OPTIONS + CSV) | MpRadio | n/a (body format) | none |
| Success message | ExportCustomerPage | reuse existing pattern | toastNotify (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/a | none |
| In-app notification | host shell (not customer-fe) | n/a — host-owned | host tokens | GET /notif/v1/notifications | no 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_rowsmay 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):
| Status | Visibility | Retention | Restore semantics | Transitions allowed |
|---|---|---|---|---|
queued | internal (status API) | 7 d | n/a | → processing |
processing | internal | 7 d | n/a | → completed/partial/failed |
completed | internal + link in email/notif | status 7 d; file 48 h | re-export only (no restore) | terminal |
partial | internal + email/notif | status 7 d; file 48 h | re-export | terminal |
failed | internal + failure email/notif | 7 d | re-export | terminal |
- 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)
| Endpoint | Method | AuthN/AuthZ | Request schema | Response schema | Status codes | Idempotency | Versioning | Reuse? |
|---|---|---|---|---|---|---|---|---|
/iag/v1/contacts/export | POST | IAGMiddleware + 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_EXCEEDED | none (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} | GET | IAGMiddleware + RequirePermissionMiddleware(CustomersCustomersExportKey) | path job_id | {job_id, status, total_records, success_count, failed_count} | 200; 404 EXPORT_JOB_NOT_FOUND; 403 | n/a (read) | /iag/v1/ | new-with-justification |
/iag/v1/contacts/field_properties | GET | IAGMiddleware | query 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 state | n/a | /iag/v1/ | reused (rest_router.go:173-187) |
/iag/v1/layouts/default | GET | IAGMiddleware | — (company from JWT) | default layout object (id + props) | 200; 5xx → FE falls back to layout list | n/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
Contactstruct 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) whereCustomField.Key == name, then read.Value(interface{}), with.CurrencyCode+ the field'sNumberType/Datatypefeeding 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— usecontact_ids[]from the body directly ($in-by-_idbatch as above).first_10k_sorted/ panel (EXP-S09) — callSearchContacts(service/get_contact.go:148) with the requestfilter(+ panelstart_date/end_dateonupdated_at+source[]),SortBy{Field: order_by or "created_at", Direction: order_direction or "desc"}(db.go:35-37) plus a secondary_idsort (net-new —db.gohas no tie-breaker today),limit = MaxExportRow (10000), page 1. The resolved ids feed the same projection path. The added_idtie-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/Filterreuse the grounded list-sort path (SearchContacts+SortBy,oneof=created_at updated_at name);StartDate/EndDate/SourcereuseSearchContactRequest(range onupdated_at,Source []string). Validation:selection_moderequired;idsrequires non-emptycontact_ids≤MaxExportRow;first_10k_sortedignorescontact_idsand resolves server-side (cap 10 K); panel exports requiretimezone+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)
| Endpoint | Method | AuthN/AuthZ | Source | Schema | Status | Idempotency | Versioning |
|---|---|---|---|---|---|---|---|
| — | — | — | — | — | — | — | 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(+ reusedFieldCheckboxGroupfrom 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) +useCustomFetchfor field load + submit; selection comes fromListPage(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 firecdp_export_customer_cap_exceeded{attempted_count}. - Conditional rendering: field list states (loading/empty/error/success — §2.C);
IsHiddenfields rendered disabled. - Slots/children: field-group accordion (Customer Info / Default / Custom).
- A11y:
MpRadiogroup labelled "File format"; focus moves to first field group on load; submit buttonaria-disabledwhile loading or selection invalid.
ExportSelected button (ListTable.vue)
- Figma: node 16492-601704.
- Implementation: add
MpPopoverListItem(automation-labelbulk-export) besidebulk-delete(ListTable.vue:57-59) →@clickemitsdownload-selected-customers(listener existsListPage.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):
| Control | Source / component | Required | Default | Maps to request |
|---|---|---|---|---|
| Info banner | static — "only the top 10,000 will be downloaded" | — | — | — |
| File format | MpRadio (FILE_FORMAT_OPTIONS + CSV) | yes | XLSX | format |
| Timezone | enable 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) | no | All time | period or start_date/end_date (RFC3339) |
| Source | reuse common/components/FilterCheckbox.vue (multi-select; used ListPage.vue:35) | no | All source | source[] |
| Layout | layout MpAutocomplete; auto-fill default via GET /v1/layouts/default | yes | "Default view" | layout_id |
| Select data | MpAccordion + FieldCheckboxGroup (Customers info / Default / Custom; IsHidden excluded); "N of N selected" + duration note | — | per layout | selected_fields[] |
| Persist last config | localStorage (EXP-S07) — pre-loads timezone/period/source/layout/fields/format | — | last used | — |
| Download | disabled until Timezone + Layout valid → same async flow as §6.1 | — | — | POST .../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
MpAutocompletelabelled + required; Downloadaria-disableduntil valid; focus order banner → format → timezone → period → source → layout → fields → Download. - Source enum caveat (OQ-21): the FE
sources.tsenum (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_URLalready includes the/iagsegment — every existing FE call uses/v1/...with no/iagprefix (verified:DownloadTemplateModal.vue:147POST /v1/download_template,ListPage.vue:559/v1/contacts/field_properties,/v1/contacts/import; grepiag/v1in 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/defaulton panel open to pre-select "Default view"; falls back to the layout list on 5xx. - Panel filters (EXP-S09): Source via
FilterCheckbox; Period viaInputPeriod(emitsstart_date/end_dateonupdated_at, ISO); Timezone viaMpAutocompletetimezones.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/importcall; single shot; server returnsjob_id). For the panel / "first 10,000" shortcut the body carries the criterion (selection_mode,order_by/order_directionorperiod/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_propertiesexposes 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
| Surface | Loading | Empty | Error | Partial | Success |
|---|---|---|---|---|---|
| Field list (ExportCustomerPage) | skeleton while fetching field_properties | "No fields available for the selected layout." | "Could not load fields. Please try again." + Export disabled | n/a | grouped 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 disabled | n/a | grouped checkboxes; default "Default view" pre-filled |
| Export submit | button spinner; disabled | n/a | inline error (422/403/429 mapped — §3.C) | n/a | banner "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/a | Download disabled until Timezone + Layout valid | n/a | n/a | Download enabled |
Selection counter (ListTable) | n/a | "0 selected" (Export disabled) | n/a | cap 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 path | Transaction scope | Partial failure | Idempotency key + TTL | Consistency | Duplicate-event handling | Stale-read handling |
|---|---|---|---|---|---|---|
| Insert export job (handler) | single Mongo insert | insert fails → 5xx, no enqueue | none (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 _id | retryable update | job_id | eventual (after side effects) | gocraft retry re-runs consumer → guard: skip if status already terminal | read job by id before update |
| OSS upload | single PutObject | failure → status=failed + failure email | object key includes {ts}_{rand} (unique) | n/a | retry overwrites same key safely | n/a |
| Email send | single API call | failure logged; status reflects attempt | n/a | n/a | terminal-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
| Resource | Writers | Collision scenario | Resolution | Behavior on conflict |
|---|---|---|---|---|
bulk_upload_jobs doc (one export) | single consumer per job_id | gocraft retry runs consumer twice | terminal-status guard: consumer no-ops if status ∈ {completed,partial,failed} | second run skips side effects |
| Export rate window (company) | many users same company | 6 concurrent submits | RateLimitMiddleware (Redis atomic counter) | 6th+ → 429 (PRD ERR-5; concurrency QA item §3) |
| OSS object key | worker | two jobs same ms | key has {rand} suffix | distinct objects |
Detail 2.F — Async Job / Event Consumer Spec
| Job/Consumer | Trigger | Input shape | Retry | DLQ | Concurrency | Idempotency key | Per-msg timeout | Poison handling |
|---|---|---|---|---|---|---|---|---|
ExportContactConsumer.ProcessExportContactJob | EnqueueJob(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 jobs | gocraft dead pool (Redis) | worker pool default (worker_service.go) | job_id + terminal-status guard | bounded 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 / service | Inbound trigger | Outbound effect | Failure handler | PRD anchor |
|---|---|---|---|---|---|
| 1. Trigger + validate + enqueue | CDP BE (contact-service api) | POST /iag/v1/contacts/export | job row + enqueue | 4xx to FE | §7 #1, EXP-S01 |
| 2. Serialize + upload | CDP BE (worker) | dequeued job | OSS object + signed URL | status=failed + failure email | §7 #2, EXP-S05 |
| 3. Email | CDP BE (worker) | post-upload | email + 48 h link | log; status reflects | §6.3, EXP-S04 |
| 4. Publish notification | CDP BE (worker) → Notif team service | post-email | notification record | non-fatal: log + alert (§2.1 branch) | §6.5, §7 #5 |
| 4b. Register export-history row | CDP BE (worker) → IAG billing service (hub-chat surface) | post-notification | row in /report/v1/billings/logs/export | non-fatal: log + alert; blocked until OQ-17 (no CDP quota type + s2s path today) | §6.6, §7 #6, EXP-S08 |
| 5. Render web notification | Launchpad host shell | GET /notif/v1/notifications | unread dot, General tab | host-owned | §6.5, EXP-S04/AC-2 |
| 6. Render mobile notification | Mobile (mobile-qontak-crm, crm_misc NotificationV2Screen) | GET /notif/v1/notifications | One 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_url | UrlLauncherUtil.openWebsite() (external browser) | non-external_url origin → "cannot redirect" toast; expired link handled by CDP redirect route | OQ-13 |
Disagreement flag (now grounded): the renderer is
mobile-qontak-crm(crm_misc), verified — not the chat app (mobile-qontak-chatonly sharesqontak_common). Web rendering is launchpad-host-owned. Neither iscontact-servicework beyond publishing the payload withorigin="external_url". The only optional mobile change is adding acdp/contact360route todetailModuleRouteMappingfor in-app navigation instead of an external browser (OQ-12).
Detail 2.F.2 — State Surface Contract
| Entity | State field / event | Default | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| Export job | bulk_upload_jobs.status | queued | consumer | GET .../export/status/{job_id} | seconds (polling not required for v1) |
| Export notification | notification read_at / unread | unread | Notification Service on create; user on open | GET /notif/v1/notifications | host/mobile cache |
| Export file link | file_url (48 h) | empty until upload | consumer | email + notification | 48 h TTL |
| Export-history row | IAG status (Active→Expired) | none until register | consumer registers; IAG expires at 48 h | GET /report/v1/billings/logs/export (hub-chat) | 48 h TTL; OQ-17 |
Detail 2.G — Cross-Layer Contract Verification
| Endpoint | BE response schema | FE expected schema | Match? | Gaps |
|---|---|---|---|---|
POST /iag/v1/contacts/export | {job_id, status, email} (snake_case) | {job_id, status, email} | yes | FE 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 checkbox | partial | FE 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} | same | yes | format 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 existingDownloadTemplateModalfield-grouping transform — no contract change needed.
Detail 2.H — End-to-End Data Flow
User selects ≤10K + Export → ListTable emits download-selected-customers →
ListPage.downloadSelectedCustomers() → navigate to ExportCustomerPage →
GET 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 + EnqueueJob
→ 200 {job_id,queued} → FE success banner. Async:
ExportContactConsumer → fetch contacts + layout_properties → formatValue
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, behindExportHistoryRegistrariface — gated OQ-17), payload struct (internal/app/payload/,ExportContactRequestincl.selection_mode/order_by/order_direction/filter/timezone/period/source[]),ExportContactJobNameconstant,MaxExportRow, timezone formatter (in the serializer), API docdocs/EXPORT_CONTACT_SERVICE.md(markdown — no repo OpenAPI spec). - BE modify:
rest_router.go(register 2 routes),worker_service.go(register job),bulk_upload_jobrepo/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_idsecondary sort + a get-by-IDs/criteria batch forfirst_10k_sorted), optional index migration. - BE NOT touched:
GenerateAndUploadExcel(template), existingSendEmailImportCustomer*,download_templatehandler,/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 ontotal ≥ 10000),ListPage.vue(raise/replaceMAX_SELECTION=500for export, cap at 10,000,first_10k_sortedcriterion, refactordownloadCustomerGET→POST or route to export page),ExportCustomerDrawer.vue(complete the right-side panel — enable timezone, wire Period/Source/Layout + export API; EXP-S09),DownloadTemplateModal.vueonly as the fork source (no behavior change to template flow). - FE NOT touched:
TheNotification.vuestub (host-owned center), template download flow. - Shared modules:
useCustomFetch,useMixpanel,FieldCheckboxGroup,toastNotify— reused read-only.
Detail 2.J — Asset Inventory
| Asset | Type | Source | Format & sizes | Path in repo |
|---|---|---|---|---|
| (none new) | — | @mekari/pixel3 components/icons only | — | n/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/workerpods ×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_urltoken, or fulllist_failed_rowscontent; 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 pathprivate/exports/{company_sso_id}/.
Role × Endpoint Authorization Matrix
| Role | Endpoint(s) | Methods | Tenant scope | UI visibility | Constraint | Audit |
|---|---|---|---|---|---|---|
| CRM Admin / Sales Ops | /iag/v1/contacts/export, .../export/status/{id}, .../field_properties | POST/GET | own company | button + page visible | 5 jobs/h | audit log + Mixpanel |
| Marketing Ops | same | POST/GET | own company | visible | 5 jobs/h | same |
No CustomersCustomersExportKey | none | — | — | button hidden | 403 | 403 logged |
| Internal Ops / support | none (not served) | — | — | n/a | n/a | n/a |
- Ownership validation:
company_sso_idfrom IAG JWT filters contact fetch and OSS path; rejectcontact_idsoutside the caller's company (QA item: cross-company IDs). - Input validation:
contact_ids≤ 10 000, each a UUID;format ∈ {xlsx,csv};layout_id/selected_fieldsvalidated against the layout's field set (drop unknown keys);IsHiddenfields 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 byRATE_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); FEeslint ..
Detail 3.A — Failure Mode Catalog (merged)
| Surface | FE behavior on failure | BE response | Code-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-After | yes |
| Field load 5xx | "Could not load fields. Please try again." + disable Export | 5xx | yes |
| Job runtime failure | (no live UI) → failure email + notif | status=failed | n/a (out-of-band) |
| Notif publish failure | none | non-fatal; log + alert | yes (job stays completed) |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit | User-visible? |
|---|---|---|---|---|
| Notification publish disabled / fails | worker, post-email | skip notif; email still sent; job not failed | _notification_sent result=failed + alert | no |
IsHidden field | FE render + BE projection | field excluded from output | n/a | yes (greyed) |
Mobile flag_one_notification OFF | mobile client | notif not shown on mobile; email still delivered | n/a | yes (mobile only) |
| Deleted contact id at job time (OQ-1) | worker per-row | skip row, count as failed → partial email | row_failures | yes (partial email) |
Detail 3.B — Error Response Catalog (BE)
Shape: { "error": "CODE", "message": "...", "details": {} }
| Endpoint | Code | HTTP | Message | When | User-facing? |
|---|---|---|---|---|---|
| export | EXPORT_LIMIT_EXCEEDED | 422 | selection exceeds 10,000 | len(contact_ids) > MaxExportRow | yes |
| export | EXPORT_FORMAT_INVALID | 422 | format must be xlsx or csv | format ∉ {xlsx,csv} | yes |
| export | EXPORT_SELECTION_MODE_INVALID | 422 | selection_mode must be ids or first_10k_sorted | selection_mode ∉ {ids,first_10k_sorted} (or ids with empty contact_ids) | yes |
| export | FLAG_DISABLED | 403 | feature not enabled | flag OFF | yes |
| export | FORBIDDEN | 403 | no permission | missing CustomersCustomersExportKey | yes |
| export | EXPORT_RATE_LIMIT_EXCEEDED | 429 | too many requests | > 5/company/hour | yes |
| status | EXPORT_JOB_NOT_FOUND | 404 | job not found | unknown/forbidden job_id | yes |
Detail 3.C — Error Message Catalog (FE)
| Code | User-facing message (i18n key) | Surface | User-facing? |
|---|---|---|---|
| cap exceeded | "You can only select up to 10,000 customers" (export.cap) | tooltip | yes |
EXPORT_RATE_LIMIT_EXCEEDED | "Too many export requests. Please wait before trying again." (export.rateLimit) | banner/toast | yes |
| field load error | "Could not load fields. Please try again." (export.fieldsError) | inline | yes |
| panel field load error | "Unable to load data — please click retry to reload data" (export.panelFieldsError) + Retry | panel data section | yes |
| 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) | banner | yes |
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 (thePOST .../exportresponse 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.
| Field | Classification | Legal basis | Retention | Encryption | Access audit | Right-to-delete |
|---|---|---|---|---|---|---|
| OSS export file (contact data) | PII | UU PDP / legitimate business use by company admin | 48 h (auto-expire via signed URL TTL) | TLS in transit; OSS at-rest | export audit log 90 d | file auto-expires; source contact deletion handled by existing contact lifecycle |
bulk_upload_jobs.file_url | link to PII | — | 7 d status retention | TLS | audit | n/a (expires with file) |
list_failed_rows | may contain contact ids | — | 7 d | at-rest | audit | counts-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
MpRadioreachable 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. Respectprefers-reduced-motionfor drawer transitions.
4. Backwards Compatibility and Rollout Plan
Compatibility
- BE: all routes are additive (
/iag/v1/contacts/export*).bulk_upload_jobsgains optional fields; existing import queries use nojob_typefilter so they are unaffected (legacy rows lack the field — see Decision 3; backfill only if a caller must isolate imports viajob_type:"import").GenerateAndUploadExcel,SendEmailImportCustomer*, anddownload_templateare untouched → no API version bump. - FE:
downloadCustomerGET 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 byflag_one_notification(OFF). Single primary flag; kill-switch = flip OFF (endpoint returns 403FLAG_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_enabledOFF (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
| Scenario | FE | BE | Works? | Mitigation |
|---|---|---|---|---|
| Pre-deploy | Old | Old | yes | baseline |
| Backend first | Old | New | yes | endpoint dormant; flag OFF; FE doesn't call yet |
| Frontend first | New | Old | no | FE would 404 → deploy BE first (deploy order avoids this) |
| Both deployed | New | New | yes | target state, flag-gated |
| Backend rollback | New | Old | no | flip flag OFF first; FE hides Export when flag OFF (read flag in FE) |
| Frontend rollback | Old | New | yes | endpoint dormant; harmless |
Detail 4.B — Configuration Contract
| Layer | Env var / flag | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|---|
| BE | cdp_export_customer_enabled | flag (per-company) | OFF | yes | Ops/flag service | no |
| BE | MaxExportRow | const | 10000 | yes | code | no |
| BE | RATE_LIMITER_MAX_REQUESTS / _WINDOW_SECONDS | int | repo default 1 / 60 (config/load.go:265-266); export target 5 / 3600 needs a per-route limit (OQ-14) | yes | config/load.go | no |
| BE | OSS export TTL | const | 172800 (48 h) | yes | code | no |
| BE | NOTIFICATION_API_ROOT_URL | string | — | yes (for notif) | config/load.go getStringOrPanic | no |
| BE | NOTIFICATION_API_CLIENT_ID / _SECRET | string | — | yes (for notif) | config/load.go | yes |
| BE | EXPORT_HISTORY_API_ROOT_URL (IAG billing) | string | — | only if EXP-S08 wired (OQ-17) | config/load.go | no |
| BE | EXPORT_HISTORY_API_CLIENT_ID / _SECRET | string | — | only if EXP-S08 wired (OQ-17) | config/load.go | yes |
| BE | default export timezone | const | (GMT+07:00) Asia/Jakarta | yes (EXP-S09) | code (OQ-20) | no |
| Mobile | flag_one_notification | flag | OFF | n/a (mobile) | mobile config | no |
| FE | (reads cdp_export_customer_enabled via app config) | flag | OFF | yes | host/app config | no |
Detail 4.C — Test Plan (commands sourced from repo)
| Layer | Command (source) | What it must prove |
|---|---|---|
| BE unit | go 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 full | make test (go test -race -tags dynamic -coverprofile=coverage.out.tmp ./internal/... ./config/..., source Makefile:82) | no regression across service |
| BE lint | make lint (staticcheck ./...) | static analysis clean |
| BE build | make build | compiles with -tags dynamic |
| BE migration | make migrate-up (db/migrations/) | optional job_type index applies + rolls back (make migrate-down) |
| FE unit | pnpm 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 coverage | pnpm test:coverage (package.json:17) | coverage report |
| FE lint | pnpm lint (package.json:18 eslint .) | lint clean |
| FE build | pnpm build (package.json:6) | builds |
| Cross-layer | manual/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
| Order | Layer | Chunk | Files to modify/create | Commands | Acceptance criteria |
|---|---|---|---|---|---|
| 1 | BE | Constants + payload | internal/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.go | make build | builds; MaxExportRow/ExportContactJobName exported |
| 2 | BE | Extend job store | repository/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 imports | make migrate-up && make migrate-down | struct compiles; index up/down works; export queries use job_type:"export" (legacy rows excluded without backfill) |
| 3 | BE | OSS helper + XLSX-with-data | internal/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 |
| 4 | BE | CSV serializer | internal/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 |
| 5 | BE | Type formatter | internal/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 |
| 6 | BE | 3 email methods | internal/app/service/email/email_service.go | go test ... ./internal/app/service/email/ | success/partial pass *os.File; failure none; mirror import wrappers |
| 7 | BE | Notification client | internal/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 |
| 8 | BE | Export consumer | internal/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 |
| 9 | BE | Service + handler + routes + selection resolution + rate-limit decision | internal/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 §5 | make build && make lint && make test | handler 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 |
| 10 | BE | API doc | No 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 convention | ls docs/EXPORT_CONTACT_SERVICE.md | a markdown doc describing the 2 new endpoints exists |
| 11 | FE | Field mapping + page | features/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} envelope | pnpm test -- features/customers/export | renders 3 derived groups; is_hidden disabled; name used as the selected_fields[] value; paginates field list |
| 12 | FE | Format radio + body | ExportCustomerPage (FILE_FORMAT_OPTIONS+CSV, thread format) | pnpm test -- features/customers/export | body includes format lowercased; CSV selectable |
| 13 | FE | Export button | features/customers/views/components/ListTable.vue | pnpm test -- features/customers | popover shows "Export Selected"; emits download-selected-customers |
| 14 | FE | Selection cap + GET→POST | features/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 |
| 15 | FE | localStorage persist (EXP-S07) | ExportCustomerPage (export_field_config_{layout_id}) | pnpm test -- features/customers/export | save/auto-load/reset; missing-field guard; format change keeps fields; no auto-download |
| 16 | FE | Mixpanel events | ExportCustomerPage | pnpm test -- features/customers/export | cdp_export_customer_triggered/_cap_exceeded fire with payload |
| 17 | Mobile (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.dart | cd features/crm_misc && fvm flutter test test/main_test.dart | tapping a CDP export notif navigates in-app (not "cannot redirect" toast) |
| 18 | BE | Timezone 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 |
| 19 | BE | Export-history registrar (EXP-S08) — gated OQ-17 | internal/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 |
| 20 | FE | "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 lint | shortcut hidden below 10 K total; sends criterion (not IDs); no manual unselect in shortcut mode; export cap = 10,000 |
| 21 | FE | Right-side panel completion (EXP-S09) | features/customers/views/components/ExportCustomerDrawer.vue (enable timezone MpAutocomplete; wire InputPeriod→start_date/end_date, FilterCheckbox→source[], default-layout auto-fill via GET /v1/layouts/default; field-load error → Retry; persist config; call POST /v1/contacts/export) | pnpm test -- features/customers | panel 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 lint2)make test3)make build - FE: 1)
pnpm lint2)pnpm test3)pnpm build
- BE: 1)
- Post-deploy signals: Mixpanel
cdp_export_customer_completedcount > 0 in Stage 1;#cdp-opsalert quiet (failure rate < 5% rolling 1 h, no email-delivery alert); job duration metric < 10 min for ≤ 10 K; OSS objects appear underprivate/exports/and expire at 48 h. - Rollback (in order):
- Flip
cdp_export_customer_enabledOFF (endpoint → 403FLAG_DISABLED, no new jobs). - If FE shipped, FE hides Export when flag OFF (no redeploy needed).
- If a bad migration:
make migrate-down(index only; data untouched). - Revert the offending PR; confirm
#cdp-opsfailure rate < 5% over next 15 min and import flows unaffected (job_type:"import"queries intact).
- Flip
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_typediscriminator (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_miscNotificationV2Screen);mobile-qontak-chatis not involved (separate chat FCM/MQTT; shares onlyqontak_common). - Mobile tap-through key → routes on
origin="external_url"(notclick_action); payload corrected (Decision 7).notif_typegeneral=1,notif_categorydownloadUpload="5"confirmed.
Open — adopted PRD default, confirm before/at the relevant stage:
| # | Question | Adopted default | Owner | Blocks? |
|---|---|---|---|---|
| OQ-10 | Notif ingest = HTTP or Kafka? (review REV-8 — also pin the NotificationPublisher Go signature + notif timeout/retry) | HTTP heimdall client (Decision 7), behind NotificationPublisher iface | CDP BE + Notif team | Confirm before chunk 7; reversible |
| OQ-1 | Contact id deleted at job time | skip row, count failed → partial email | CDP Eng | no (handled) |
| OQ-2 | Partial-email error verbosity | user-friendly summary; full error in logs | PM + Eng | no |
| OQ-4 | OSS quota for private/exports/ (review REV-10) | confirm ~5–15 MB/job, 48 h | CDP Infra | Stage 0 gate |
| OQ-5 | Concurrent exports per user | allow (per job_id); 5/h backstop | CDP Eng | no |
| OQ-6 | localStorage per-layout vs global | per layout (export_field_config_{layout_id}) | CDP FE | no |
| OQ-8 | Route vs full-screen modal | full-screen drawer (consistent w/ DownloadTemplateModal) | CDP FE + Figma | no (Figma confirm) |
| OQ-12 | Mobile notif gating + tap-through route | Grounded: 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 browser | PM + Mobile | no (email always-on) |
| OQ-13 | Expired-link tap UX | Grounded: 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 URL | PM + Eng | no |
| OQ-14 | Per-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 BE | decide in chunk 9 |
| OQ-15 | EXP-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-up | PM + CDP FE | sign-off before GA |
| OQ-16 | OSS 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 PII | InfoSec + CDP Infra | confirm at Stage 0 |
| OQ-17 | Export-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/Omni | before EXP-S08 build; not a core-pipeline blocker |
| OQ-18 | first_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 + FE | decide in chunk 9 |
| OQ-19 | FE 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 FE | decide in chunk 20 |
| OQ-20 | Timezone-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 + FE | confirm before chunk 18/21 |
| OQ-21 | Source 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 + FE | confirm 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
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-06-18 | rfc-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-18 | rfc-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-18 | rfc-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-18 | Mermaid 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-26 | rfc-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-30 | rfc-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
NotificationPublisherinterface; 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 existingExportCustomerDrawer.vue); the §6.2 full-page is dropped. - OQ-18 (first_10k_sorted ordering) — mostly resolved by Decision 9; finalize the
_idtie-breaker in chunk 9. - OQ-20 (timezone formatting) / OQ-21 (source enum) — confirm before chunks 18/21; do not block the explicit-ID pipeline.
- OQ-10 (notif channel) — default HTTP heimdall behind a
- 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/ exportsurface is the IAG billing service with no CDP quota type and no service-to-service register today. It is fully specified behind anExportHistoryRegistrarinterface (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;
mmdcrender 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
partialresolved 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-reviewerfor a second-pass score (recommended, given the v2.6 scope additions), thenrfc-task-breakdownto refresh the co-locatedrfc-export-customer-data.task-breakdown.mdfor the new chunks.