[PRD] Communication Consent — Meta Marketing Opt-in/out (CDP Customer Detail)
This is a NEW PRD under PRD CDP Q4 2026. It builds directly on the Q2 backend that already persists marketing opt-in/out per customer number × account-channel in
contact-service. Scope this round: display Meta-service marketing opt-in/out on the CDP Customer Detail page. WhatsApp Call consent is explicitly out of scope.
HEADER BLOCK
| Field | Value |
|---|---|
| PM | Zhelia Alifa |
| PRD Version | 1.11 |
| Status | DRAFT |
| PRD Type | NEW |
| Squad | CDP Squad |
| Parent | PRD CDP Q4 2026 |
| RFC Link | TBD |
| Figma | Communication consent state — DRAFT, needs adjustment (see §11) |
| Labels | epic:qontak-cdp | module:customers | feature:communication-consent |
| Last Updated | 2026-07-27 |
Table of Contents
- 1. One-liner + Problem
- 2. What Happens If We Don't Build This
- 3. Target Users + Persona Context
- 4. Non-Goals
- 5. Scope Changes
- 6. Constraints
- 7. Success Metrics
- 8. Dependencies
- 9. Code Grounding & Feasibility (contact-service)
- 10. API & Behavior
- 11. Feature & UI — from Figma (draft)
- 12. System Flow + User Stories + ACs
- 13. Open Questions
- 14. Rollout
- 15. Observability
- 16. Launch Plan & Stage Gates
- 17. Key Decisions + Alternatives Rejected
- 18. Appendix — Current Payloads (evidence / recall with engineers)
- PRD CHANGELOG
1. One-liner + Problem
One-liner: Show each customer's marketing opt-in/out per phone number × Meta account-channel on the CDP Customer Detail page, so teams target only consented contacts.
Problem: In Q2, contact-service started persisting marketing opt-in/out consent per customer number × account-channel (updated via a Kafka event from Broadcast/Chat). But that consent is invisible in the product — it lives only in the chat_data documents and is not rendered anywhere for a user. As a result, agents and marketers cannot tell which of a customer's numbers, under which business account, have opted in or out of marketing. They risk broadcasting to opted-out numbers (compliance/spam risk) or wasting spend on numbers that never consented, and CS agents have no consent context during a conversation. The data is captured but not usable.
Second gap — no way to acquire consent from the profile. When a customer has never been asked (marketing_opt_in = null → shown as "-"), there is no action on Customer Detail to request it. To collect consent an agent must leave the profile, switch to the Chat app, manually build a recipient list containing that one customer, find the right opt-in template, and start a broadcast — several manual steps in another product. So the profile shows "no consent yet" but offers no way to fix it. This round adds a "request consent via broadcast" action to the empty state that seeds the recipient list and opens the WhatsApp broadcast composer pre-filled for that customer.
2. What Happens If We Don't Build This
Theme: data transparency. The consent is captured in Q2 but is not visible where decisions are made — on the customer's own profile.
- No consent status on the customer detail page. Today there is no indication on Customer Detail of whether a customer's number is opted in or out. To find out, a user must leave the profile, go to the Customers index, and filter/segment by "marketing opt-in" — an indirect, list-level workaround that never surfaces the per-number × per-account status (or the opt-in timestamp) for the customer in front of them.
- Compliance & deliverability risk — ongoing, every campaign. Without per-customer visibility, agents/marketers keep messaging opted-out numbers → spam reports, WhatsApp quality-rating drops, and wasted broadcast spend on each send, not once.
- Wasted effort per interaction. CS/Sales agents context-switch to the index + filter mid-conversation just to answer "can we message this number?", or skip the check entirely and guess.
- The Q2 backend stays write-only. Consent is written but never read back into the product — no ROI on shipped work — and it blocks the broader "Communication consent" tab (Email, future channels) from its first slice.
- The transparency gap widens over time. As more numbers and Meta accounts accrue opt-in/out events, the distance between what the system knows and what the user can see keeps growing, and the profile becomes less trustworthy as a single source of truth.
3. Target Users + Persona Context
| Persona | Role | Goal | Pain | Workaround |
|---|---|---|---|---|
| Primary — Marketing / Campaign Manager | Runs WhatsApp/Meta broadcasts from Qontak | Only target numbers that have opted in, per business account | Cannot see per-number/per-account consent before sending | Guesswork or exporting broadcast logs manually |
| Secondary — CS / Sales Agent | Handles the customer on the CDP detail page | Know a customer's marketing consent while interacting | Consent is invisible on the profile | Ask the customer again / ignore |
4. Non-Goals
- WhatsApp Call consent is out of scope — the "WhatsApp call" column in the Figma is not built this round (no backing field in
chat_datatoday). - No manual editing/toggling of opt-in from CDP. Consent is system-managed and set only via the Kafka event pipeline (see §9). CDP is read-only for consent this phase.
- Email marketing consent (the "Email" section in the Figma) is out of scope this phase — different data model; future slice.
- Producing opt-in events for Instagram DM / Facebook Messenger is out of scope — only WhatsApp currently emits marketing opt-in events (see §9 gap G2).
- No changes to the Broadcast/Chat producer side or the consent write path.
- No bulk export of consent in this phase.
- CDP does not build a broadcast composer. The "Request consent via broadcast" action reuses the existing WhatsApp broadcast create page in the Chat app; CDP only creates the single-customer recipient list and opens that page pre-filled.
- CDP does not send the broadcast. The user reviews and sends it on the broadcast page — CDP never auto-sends, and does not track delivery/consent outcome (the resulting opt-in/out flows back via the existing Q2 Kafka pipeline → §9).
- Bulk / multi-customer consent requests are out of scope — the action seeds a list with only the current customer (single
contact_id).
5. Scope Changes
Controlled vocab: Backend · Frontend · Mobile · Infra · Data · Design · Docs · None.
- Backend —
contact-service: a read contract that returnschat_dataconsent grouped by account-channel, filtered to Meta channels, includingaccount_uniq_id,account_business_name,channel_target,phone_number,marketing_opt_in,marketing_opt_in_timestamp. (Data already exists; today's serializer exposes only a flat WhatsApp list — see §9 gap G1.) - Frontend —
qontak-customer-fe: new "Communication consent" tab on Customer Detail → "META services" section with the Marketing opt-in view (grouped list + expand + filter + loading/empty states). No consent-editing UI. - Frontend — Customer Detail Activity log tab: render marketing opt-in / opt-out change entries (phone number · status · channel · account · timestamp), shown whenever a consent status change is recorded.
- Backend —
contact-service: clarify the opt-in/out activity-log title copy (see §9 G5). The log itself is already emitted per event; the corrected read contract (G1) also fixes thephone_numbermapping (G7). - Frontend —
qontak-customer-fe: in the "no consent yet" empty state (customer never asked,marketing_opt_in = null), add a "Request consent via broadcast" action. On click, CDP calls the Core API to create a single-customer recipient list, then opens the WhatsApp broadcast composer in a new tab (cross-app →chat.qontak.com) with that recipient list pre-selected. The agent picks the message template manually on the composer. (The cross-app new-tab pattern already exists —DrawerSendCampaign.vue:98.) - Backend / integration — reuse the Qontak Core API
POST /contacts/contact_lists/direct_select(recipient-list create — exists) and the WA broadcast create page/campaign/whatsapp/create(exists). No new CDP endpoint. - Frontend (cross-squad,
hub-chat) — the broadcast create page must read a query param (contact_list_id) and pre-populate one field: the Recipient list = the just-created single-customer list. This is net-new — the page ignores the URL today (see §9 gap G9). Owned by the Chat/Broadcast squad. Message-template auto-fill is NOT in scope this phase (not possible yet) — the agent selects the opt-in template manually; a future enhancement can auto-select the most recentis_marketing_opt_intemplate (§9.1, §13 OQ-7). - Design — Figma frames need adjustment (§11) before FE build.
6. Constraints
| Constraint | Value |
|---|---|
| Platform | Qontak CDP — Web only. |
| Read/write | Read-only consent display; gated by existing customer-view permission (customers_customers_view ≥ OWNED ONLY). |
| Data freshness | Consent reflects whatever the Q2 Kafka pipeline has written; near-real-time on the write side, no new SLA introduced here. |
| Channels in scope | Meta services only — whatsapp (has data today), instagram_dm, facebook_messenger (grouping ready, data pending — G2). |
| Feature flag | cdp_communication_consent_tab — default OFF; flipped per rollout stage (§14). |
| Performance | Consent-tab render p95 ≤ 3s; consent read API p95 ≤ 2s. |
| Plan / tier | Growth & Enterprise (Qontak One). |
7. Success Metrics
| Metric | Definition | Baseline | Target |
|---|---|---|---|
| ⭐ Consent visibility | % of Customer Detail views (for customers with ≥1 Meta chat account) where the consent tab renders opt-in data without error | 0% (not shown) | ≥ 99% render success |
| Adoption | Weekly active users opening the Communication consent tab | 0 | Growing WoW post-GA |
8. Dependencies
| Dependency | Owner | Needed | Blocking? |
|---|---|---|---|
Q2 consent write pipeline (Kafka send_marketing_opt_in → chat_data) | CDP (contact-service) | Already shipped — source of the data | YES (met) |
| Read contract extension (grouped-by-account, Meta filter) | CDP (contact-service) | New — see §9/§10 | YES |
| Figma finalization | Design | Adjust draft frames (§11) | YES for FE |
| IG DM / Messenger opt-in event production | Chat/Broadcast squads | For non-WhatsApp Meta channels to show data | No (deferred, G2) |
Recipient-list create API (POST /contacts/contact_lists/direct_select) | Qontak Core API | Already shipped — CDP calls it to create a single-customer list (§9/§10 #4) | YES (met) for OPT-S08 |
WA broadcast create page (/campaign/whatsapp/create, chat.qontak.com) | Chat/Broadcast (hub-chat) | Already shipped — CDP opens it in a new tab (§10 #5) | YES (met) for OPT-S08 |
Broadcast page query-param prefill (contact_list_id → preselect recipient list) | Chat/Broadcast (hub-chat) | NET-NEW — page ignores the URL today (G9); needed to preselect the recipient list | YES — blocks OPT-S08 recipient auto-fill |
| Opt-in-request WhatsApp template (agent-selected) | Marketing / CS ops (per CID) | The agent picks an opt-in template manually on the composer; a template must exist to send, but CDP does not pre-select it this phase | No (agent selects manually) |
9. Code Grounding & Feasibility (contact-service)
Grounded against
contact-service@master(pulled 2026-07-24). Evidence inpath:line.
What already exists (reuse — do NOT rebuild):
- Consent data model —
internal/app/repository/contact/base.go:117-133.ChatDatacarrieschannel_id,account_uniq_id,channel_target,phone_number,account_business_name,marketing_opt_in *bool(nullable → tri-state:null/true/false),marketing_opt_in_timestamp *time.Time. The consent is stored perchat_dataelement = per (number × account-channel) — exactly the granularity this PRD needs.In-code comment (base.go:126-127): "These fields are system-managed (read-only from CDP) and updated via Kafka consumer events." → confirms Non-Goal #2.
- Write path (shipped Q2) — Kafka topic
send_marketing_opt_in(cmd/consumer/consumer.go:26-27,124-125) →internal/app/kafka_consumer/marketing_opt_in.go→internal/app/service/marketing_opt_in_service.go(ProcessOptInEvent: validate → Redis idempotency → update) →internal/app/repository/contact/update_marketing_opt_in.go(UpdateChatDataMarketingOptIn: positional$elemMatchupdate on composite keycontact_id + channel_id + account_uniq_id). Activity log viaCreateActivityLogForMarketingOptIn. - Event payload —
internal/app/payload/marketing_opt_in_event.go:MarketingOptInEvent{ channel_id, contact_id, marketing_opt_in, phone_number, qontak_customer_id, account_business_name, account_uniq_id, channel_target, timestamp, … }. - Meta channel constants —
internal/app/service/field_properties/default.go:20-23:whatsapp,facebook_messenger,instagram_dm,instagram_comment. → defines the "META services" filter set forchannel_target. - Current read serializer —
internal/app/repository/contact/create_serializer.go:177-181:WhatsappMarketingOptedInSerializer{ phone_number, marketing_opt_in, business_phone_name }, exposed asphone_marketing_opt_in[]on the contact detail response. ⚠️phone_numberis populated fromchat.AccountUniqID(create_serializer.go:310), not the real phone number (see G7). - Opt-in/out activity log (shipped Q2) —
internal/app/service/activity_log.go:793CreateActivityLogForMarketingOptIninserts anActivityLogon every opt-in/out event:action=opt-in/opt-out,category=campaign,associated_name= account business name, plus anassociated_changeschat_databefore/after snapshot frombuildChatDataChanges(thetosnapshot carriesphone_number,channel_target,marketing_opt_in,marketing_opt_in_timestamp). Title viaactivity_log_serializer.go:291-318(generateTitle). → The entry is created only when an event arrives, i.e. on a status change (matches the requirement).
Verdict: ✅ Buildable with no schema migration. The consent data is already captured at the right granularity; the work is (a) a BE read/serializer change (G1) and (b) a new read-only FE tab. No write path, no migration.
Fixes applied — all identified gaps closed in-spec (the working gap table was removed once fixes landed; full history in the repo git log):
- G1 — flat WhatsApp serializer → grouped, Meta-filtered read contract → §10 #1 + OPT-S06
- G7 — response
phone_numberwasaccount_uniq_id→ return the real phone → OPT-S06 AC-2 - G8 — serializer dropped
nullrecords → include as "-" (tri-state) → §10 #1 + OPT-S01/S02 - G5 — awkward activity-log title → consent-framed copy → §11 + OPT-S07 AC-3
- G2 — IG DM / Messenger have no data yet → render Meta grouping now (WhatsApp-populated) + explicit empty state → §17 decision (OQ-2 closed)
- G3 — WhatsApp-call has no backing field → out of scope (Non-Goal #1)
- G4 — no email-consent model → out of scope (Non-Goal #3)
- G6 — log
fromlacks the prior value → accepted (actionconveys the new state; write-path change out of scope, Non-Goal #5)
9.1 Grounding — "Request consent via broadcast" (OPT-S08)
Grounded against
hub-chatandqontak-customer-fe@master(2026-07-27). Evidence inpath:line. The recipient-list + broadcast backends live in the Qontak Core API (not in the FE repos); the FE calls verified below.
What already exists (reuse — do NOT rebuild):
- "Not yet asked" state —
contact-servicechat_data.marketing_opt_in *bool(base.go:130) isnullwhen the customer has never been asked → the "-" empty state (§11). This is the exact condition that gates the button. - Recipient-list create (single-step, inline contacts) —
POST /api/core/v1/contacts/contact_lists/direct_select, called from hub-chatfeatures/campaign/contacts/views/components/DialogSubmitSelect.vue:156with payload{ name, contact_ids: string[] }; a c360 variantdirect_select_c360(.../select-contact-modal-unified-360/components/DialogSubmitSelect.vue:160) can also carrymarketing_opt_in: { value, channel_ids }. Returns acontact_listobject (features/campaign/contacts/views/@types/contact.d.ts:2-19) withid,name,contacts_count,target_channel: "wa" | "email",progress. → A single-customer list ={ name, contact_ids: ["<contactId>"] }in one call. No separate "add contact" step. - WA broadcast create page — route
/campaign/whatsapp/create(hub-chat/pages/campaign/whatsapp/create.vue→features/campaign/whatsapp-campaign/views/CreatePage.vue), served by hub-chat = "Qontak Chat" atchat.qontak.com(configs/production.json:10) — a different app/host than CDP (customer.qontak.com). The broadcast references the list viacontact_list_id: recipient.id(CreatePage.vue:394) and the template viamessage_template_id: template.id(CreatePage.vue:393); recipient options come fromGET /contacts/contact_lists, templates fromGET /templates/whatsapp(useCreateCampaign.ts:60,95). - Template auto-fill is NOT in scope this phase (not possible yet) — the agent selects the opt-in template manually on the composer. (Future enhancement, for reference: the template list API already accepts
is_marketing_opt_in: boolean+order_by/order_direction(templates/whatsapp-new/@types/whatsapp.ts:39-55), templates carryis_marketing_opt_in?: boolean+created_at(whatsapp.ts:34,45), and there's an app-config gateapp_config.is_using_marketing_opt_in(useWhatsappListsFilter.ts:44-47) — so a later phase could auto-select the most recent marketing-opt-in template via?is_marketing_opt_in=true&order_by=created_at&order_direction=desc&limit=1. Deferred here — see §13 OQ-7.)* - Cross-app "open in new tab" pattern (already in CDP) —
qontak-customer-fe/features/customers/views/components/segment/DrawerSendCampaign.vue:98:window.open(${config.BASE_URL}/campaign/whatsapp/create?segment_id=${segmentId}, '_blank')whereconfig.BASE_URL = https://chat.qontak.com. → CDP already knows how to seed a list-launch and open the composer cross-app; OPT-S08 follows the same pattern withcontact_list_id.
Verdict: ✅ Buildable — CDP side is small (create list → open URL with the recipient pre-selected). The recipient-list API and the broadcast page both already exist. One net-new cross-squad gap (G9) blocks the recipient auto-fill.
- G9 (NEW — cross-squad, blocking for recipient auto-fill) — hub-chat's
CreatePage.vuedoes not readroute.queryat all (CreatePage.vue:304usesuseRouter()only; a repo-wide search forsegment_id/segmentIdconsumers returns zero). So thesegment_idCDP already appends is silently ignored today, and there is no query-param prefill support. → hub-chat must add logic to read the URL and preselect the Recipient list bycontact_list_id(CampaignSetup.vue,DropdownRecipient.vue). Owned by the Chat/Broadcast squad. Until G9 lands, CDP can create the list and open the page, but the recipient will not auto-fill → OPT-S08 AC-4 depends on G9. (Template preselect is out of scope — the agent picks it manually.) - G10 (confirm with Core team) —
contact_listcarries aprogressfield and the bulk/upload create paths can be "in progress";direct_selectreturns a synchronous "Recipient list created" toast. Confirm adirect_selectlist is immediately usable by a broadcast (not briefly processing) so the pre-selected list resolves on the composer. → §13 OQ-8.
10. API & Behavior
Behavioral contract in plain language; method/path/schema finalized in RFC.
| # | Behavior | Triggered by | Returns | Notes |
|---|---|---|---|---|
| 1 | Get communication consent for a customer | Customer Detail → Communication consent tab loads | Consent grouped by account-channel (account_uniq_id, account_business_name, channel_target), each with a list of { phone_number, marketing_opt_in, marketing_opt_in_timestamp } where marketing_opt_in is tri-state (true=opt-in · false=opt-out · null="-"); filtered to Meta channel_target values (whatsapp, instagram_dm, facebook_messenger). Includes accounts/numbers with null (rendered as "-"). | Read-only. Source: chat_data (§9). No WhatsApp-call, no email. |
| 2 | Filter by communication type | User picks a channel in the "All communication type" dropdown | Same shape, filtered to the chosen channel_target. | Default = all Meta channels. |
| 3 | List opt-in/out change history | Customer Detail → Activity log tab | The existing activity-log feed filtered to category = campaign, action ∈ {opt-in, opt-out}; each entry carries the chat_data change snapshot (phone, channel_target, status, timestamp) + title. | Already emitted per event (§9). FE renders/filters; corrected title per G5. |
| 4 | Create single-customer recipient list | User clicks Request consent via broadcast (empty state) | CDP calls POST /api/core/v1/contacts/contact_lists/direct_select with { name, contact_ids: [<this customer's contact_id>] }; response returns the contact_list { id, target_channel: "wa", … }. | Existing Core API (§9.1). name auto-generated (e.g. "Opt-in request — {customer} — {date}"). On non-2xx → inline error, no redirect (OPT-S08 AC-6). |
| 5 | Open WA broadcast composer with recipient pre-selected | Recipient list created successfully | CDP opens {CHAT_BASE_URL}/campaign/whatsapp/create?contact_list_id={id} in a new tab (chat.qontak.com). The composer auto-fills one field: Recipient list = the new single-customer list. The agent selects the message template manually. | Cross-app (§9.1). ⚠️ Recipient auto-fill requires hub-chat to read the param — net-new (G9). Template auto-fill is out of scope this phase (not possible yet). |
RFC to resolve: extend contact-detail response vs. dedicated
GET /contacts/{id}/communication-consent; pagination/expansion when an account has many numbers (Figma ">10 channels" state); exact Metachannel_targetallow-list; the exact query-param contract for the recipient pre-fill (contact_list_id) — coordinate with the Chat/Broadcast squad (G9); recipient-listnameconvention + whetherdirect_select_c360should carrymarketing_opt_incontext (OPT-S08).
11. Feature & UI — from Figma (draft)
The Figma is DRAFT and needs adjustment before FE build. Frames reviewed: Loading · Blank (not found) · Default <5 channels · Default >10 channels.
Consent status is tri-state (per phone number × account-channel) — grounded on chat_data.marketing_opt_in *bool (base.go:130):
marketing_opt_in | Meaning | Status shown |
|---|---|---|
null (no record) | Customer has not allowed/disallowed yet | "-" (empty) |
true | Customer allowed to receive broadcast | Marketing opt-in |
false | Customer not allowed to receive broadcast | Marketing opt-out |
⚠️ Today the serializer drops
nullrecords (create_serializer.go:308), so the "-" state is invisible — the new read contract (G1/G8) must include Meta accounts/numbers even whenmarketing_opt_inisnull.
Route: existing Customer Detail (/customers/{customer_id}) → new Communication consent tab (no new page/route). Opt-in/out change entries render in the existing Activity log tab.
Customer Detail → Communication consent tab → META services section: "List of META services the customers give communication consent." A table grouped by account-channel:
| Element | In Figma | This PRD |
|---|---|---|
| Channels column (account name + business phone, e.g. "Central Perk Promo · +6285817999478") | ✓ | ✓ Build (grouped by account-channel) |
| Marketing opt-in column ("2 phone numbers" → expandable list) | ✓ | ✓ Build — primary scope |
| WhatsApp call column ("1 phone number") | ✓ | ✗ Out of scope (Non-Goal #1) |
| "All communication type" filter | ✓ | ✓ Build (Meta channels) |
| Loading / Empty / Error (read fails) / scaling (>10) states | ✓ (loading/blank/scaling) | ✓ Build — incl. explicit error state |
| "Request consent via broadcast" CTA in the "no consent yet" empty state | ✗ (not in draft) | ✓ Build — new scope (OPT-S08) |
| Activity log tab — opt-in/out change entries (phone · status · channel · account · time) | ✓ (tab exists) | ✓ Build — new scope |
| Email section (marketing email) | ✓ | ✗ Out of scope this phase (Non-Goal #3) |
Figma adjustments needed (flag to Design):
- Remove/hide the WhatsApp-call column for this release (or mark clearly as a later phase).
- Clarify the expand interaction — clicking "2 phone numbers" should reveal each phone with its opt-in/out status + timestamp (the timestamp is available in data but not shown in the draft).
- IG DM / Messenger empty state (decided, G2) — show these Meta channels with an explicit "no consent data yet" state; release copy notes only WhatsApp has data today.
- Email section — split into its own future frame so it isn't implied as in-scope.
- Activity log — define the opt-in/out entry layout: title ("this customer allowed to receive broadcast (opt-in)" / "this customer not allowed to receive broadcast (opt-out)") + detail line phone · channel · status · timestamp.
- Tri-state — the "Marketing opt-in" column/expansion must represent all three states (opt-in / opt-out / "-"), not just opted-in counts.
- Empty-state CTA — in the "No WhatsApp communication consent yet" empty state (the state in the current build), add a "Request consent via broadcast" button (primary). Define its loading state (while the recipient list is being created) and its error state (list-create failed). Copy suggestion: button "Request consent via broadcast"; helper "Send this customer a WhatsApp broadcast asking them to opt in or out." → On the destination composer (WA Create campaign page), only the Recipient list field auto-fills (the new single-customer list); the agent selects the Message template (e.g.
marketing_opt_in) and fills campaign name / sender / send options, then clicks Send campaign. (Template auto-fill is out of scope this phase — not possible yet.)
Component tree (new components in bold):
CustomerDetailPage (existing)
└── CommunicationConsentTab (new)
├── CommunicationTypeFilter (new) — Meta channels
├── MetaServicesConsentTable (new)
│ └── AccountChannelRow (new) — business name + phone + opt-in summary
│ └── PhoneConsentList (new, expandable) — phone · tri-state status · timestamp
└── ConsentStateView (new) — loading / empty / error / success
└── RequestConsentButton (new) — in empty state; creates single-customer
recipient list (direct_select) → opens WA broadcast composer (new tab, cross-app)
ActivityLogTab (existing)
└── ConsentActivityEntry (new) — opt-in/out title + phone · channel · status · time
UI state diagram (consent tab):
stateDiagram-v2
[*] --> Loading
Loading --> Success: data resolved
Loading --> Empty: no Meta consent data
Loading --> Error: read API non-2xx
Error --> Loading: retry
Success --> Success: filter / expand row
12. System Flow + User Stories + ACs
12.1 System Flow — Agent views a customer's Meta marketing consent
Flow type: User Journey.
- Agent opens Customer Detail for a customer.
- Agent clicks the Communication consent tab.
- FE calls the consent read API (§10 #1).
- BE reads the customer's
chat_data, keeps Metachannel_targetelements, groups by account-channel, returns per-phone tri-state (opt-in / opt-out / "-") + timestamp. - FE renders the META services table (grouped), each account showing the count of opted-in numbers under Marketing opt-in.
- Agent clicks "N phone numbers" → expands to the phone list with opt-in/out + timestamp.
- Agent optionally filters by communication type.
Failure / edge branches:
- 3a. Consent read API returns non-2xx → FE shows an inline error state with retry (OPT-S04 AC-4);
consent_api_errorfires. - 4a. Customer has no Meta accounts → empty state (OPT-S04 AC-2), not an error.
- 6a. Account with 0 opted-in numbers → summary cell shows "-" (OPT-S01 AC-2).
12.1b System Flow — Request consent via broadcast (OPT-S08)
Flow type: User Journey (cross-app).
- Agent opens Customer Detail → Communication consent tab for a customer who has no consent yet (
marketing_opt_in = null→ "-"). - FE shows the "No WhatsApp communication consent yet" empty state with a "Request consent via broadcast" button.
- Agent clicks the button → button enters loading.
- CDP calls
POST /contacts/contact_lists/direct_selectwith{ name, contact_ids: [<customer contact_id>] }(§10 #4). - Core API returns the new
contact_list{ id, target_channel: "wa" }. - CDP opens a new browser tab at
{CHAT_BASE_URL}/campaign/whatsapp/create?contact_list_id={id}(§10 #5). - The Chat app's broadcast composer loads with the Recipient list pre-selected to the new single-customer list (requires G9). The agent selects the Message template manually (template auto-fill out of scope this phase) and fills the remaining fields (campaign name, sender, sending options).
- Agent reviews and sends the broadcast in the Chat app (CDP does not send — Non-Goal #8).
- Customer replies opt-in/out → the existing Q2 Kafka pipeline (§9) updates
chat_data→ the consent status later appears on the CDP tab + Activity log.
Failure / edge branches:
- 4a.
direct_selectreturns non-2xx → inline error on the button, no new tab opened (OPT-S08 AC-6);consent_request_errorfires. - 6a. Pop-up blocked by the browser → surface a fallback link to open the composer manually.
- 7a. G9 not yet shipped → the composer opens but the recipient is not auto-filled (agent selects it manually) — the flow still works, degraded (OPT-S08 AC-4 gated on G9).
- Gating. Button shown only when the user can create broadcasts / has the required permission (OPT-S08 AC-2) and only in the not-yet-asked ("-") state (AC-1).
12.2 User Stories
All ACs in strict Given/When/Then. Priority uses MoSCoW.
| User Story | Priority | Mockup | Technical Notes | Acceptance Criteria |
|---|---|---|---|---|
| [OPT-S01] View Meta marketing opt-in on Customer Detail As a marketer/agent, I want to see each customer's marketing opt-in per Meta account-channel, so I know who consented before targeting. | Must Have | Figma "Default <5" | Read-only; source chat_data (§9); grouped by account_uniq_id. | AC-1 (Section renders): Given a customer with ≥ 1 Meta chat account, When I open the Communication consent tab, Then I see a "META services" section listing each account-channel (business name + business phone) with a Marketing opt-in column. AC-2 (Account summary, tri-state): Given an account-channel where 2 numbers have marketing_opt_in = true and 1 is null,When the section renders, Then that account's Marketing opt-in cell shows "2 phone numbers"; And Given an account where no number has a true/false decision, Then the cell shows "-". AC-3 (Meta filter): Given the customer also has non-Meta chat accounts, When the section renders, Then only channel_target in {whatsapp, instagram_dm, facebook_messenger} is listed and non-Meta channels are excluded.AC-4 (Read-only): Given the consent section is displayed, When I inspect any row, Then no edit/toggle control is shown (consent is system-managed). AC-5 (Error path): Given the consent read API returns a non-2xx, When the tab loads, Then an inline error state with a retry action is shown (not a blank table) and consent_api_error fires. |
| [OPT-S02] Expand an account-channel to see phone numbers As a marketer/agent, I want to expand an account to see which numbers opted in/out and when. | Must Have | Figma "Default <5" | Per-phone {phone_number, marketing_opt_in, marketing_opt_in_timestamp}. | AC-1 (Expand): Given an account-channel row showing "N phone numbers", When I click that link, Then the row expands to list each phone number under that account-channel. AC-2 (Tri-state status + time): Given an expanded account-channel, When the list renders, Then each phone shows exactly one status — Marketing opt-in ( true), Marketing opt-out (false), or "-" (null) — and shows marketing_opt_in_timestamp when a decision exists.AC-3 (Null visible + distinct): Given a number with marketing_opt_in = null,When the list renders, Then it is shown as "-" (not hidden), and an opted-out number is shown as Marketing opt-out, visually distinct from opt-in. AC-4 (Error path): Given the phone list for an account fails to load, When I expand it, Then an inline error/retry state is shown for that account and consent_api_error fires. |
| [OPT-S03] Filter by communication type As a user, I want to filter the consent list by channel. | Should Have | Figma filter dropdown | Filter on channel_target. | AC-1 (Default): Given the consent tab is open, When it first renders, Then the "communication type" filter defaults to all Meta channels. AC-2 (Apply): Given the filter dropdown is open, When I select a specific channel (e.g. whatsapp),Then the table shows only account-channels with that channel_target.AC-3 (Persist): Given I have applied a filter, When I expand or collapse rows within the tab, Then the filter selection persists. AC-4 (Empty result): Given a filter that matches no account-channel, When it is applied, Then an empty state ("no Meta accounts for this channel") is shown, not an error. |
| [OPT-S04] Loading / empty / error / scaling states As a user, I want clear states when data is loading, absent, failing, or large. | Must Have | Figma "Loading", "Blank (not found)", ">10 channels" | State machine per §11. | AC-1 (Loading): Given the consent read is in flight, When the tab is opening, Then a loading state is shown until data resolves. AC-2 (Empty): Given the customer has no Meta consent data, When the tab loads, Then a blank/empty state is shown (not an error). AC-3 (Error): Given the consent read API returns a non-2xx, When the tab loads, Then an error state with a retry action is shown and consent_api_error fires.AC-4 (Scaling): Given a customer with > 10 account-channels, When the tab loads, Then the list stays usable via scroll/pagination and meets the render budget (§6, p95 ≤ 3s). |
| [OPT-S05] Permission gating As an admin, I want the consent view gated by customer-view permission. | Must Have | — | Reuse customers_customers_view. | AC-1 (No view permission): Given a user without customers_customers_view,When they open Customer Detail, Then the Communication consent tab and its data are not shown. AC-2 (Owned only): Given a user with customers_customers_view = OWNED ONLY,When they open a customer they do not own, Then consent data is not shown for that customer. AC-3 (All access): Given a user with customers_customers_view = ALL,When they open any customer, Then consent data is shown. AC-4 (Unauthorized API): Given a direct consent API call from a user without permission, When it is processed, Then it returns 403 and no consent data. |
| [OPT-S06] (BE) Grouped, Meta-filtered consent read contract As the FE, I need a read API returning consent grouped by account-channel, Meta-filtered, tri-state, with the real phone + timestamp. | Must Have | — | Closes gaps G1, G7, G8. No schema change. | AC-1 (Grouping): Given a customer with Meta chat_data,When the consent read API is called, Then the response groups by account_uniq_id and includes account_business_name + channel_target.AC-2 (Real phone — fixes G7): Given a chat_data element,When it is serialized, Then the response returns the real phone_number (not account_uniq_id), plus marketing_opt_in and marketing_opt_in_timestamp.AC-3 (Tri-state incl null — fixes G8): Given a chat_data element with marketing_opt_in = null,When it is serialized, Then it is included and represented as null ("-"), not dropped.AC-4 (Meta filter): Given non-Meta chat_data,When the API responds, Then only Meta channel_target values are returned; WhatsApp-call and email are excluded.AC-5 (Scope): Given a requester with OWNED-ONLY scope, When they request consent for a customer they do not own, Then the API denies or omits per scope. |
| [OPT-S07] Opt-in/out change history in Activity log As an agent/marketer, I want every marketing opt-in/opt-out change to appear in the customer's Activity log, so I can audit when and how consent changed per number & account. | Must Have | Figma "Activity log" tab | Log already emitted per event (§9); FE renders category = campaign + action = opt-in/opt-out. | AC-1 (Appears on change only): Given a marketing opt-in/out event is processed for the customer, When I open the Activity log tab, Then a new entry appears for that change; And Given no status change occurred, When I view the log, Then no new consent entry is created. AC-2 (Detail data): Given a consent activity-log entry, When it renders, Then it shows phone number, opt-in/out status, channel, account business name, and timestamp. AC-3 (Title copy): Given an opt-in entry, When it renders, Then the title reads "this customer allowed to receive broadcast (opt-in)"; And Given an opt-out entry, Then the title reads "this customer not allowed to receive broadcast (opt-out)". AC-4 (Read-only, chronological): Given the activity log, When entries render, Then they are read-only and ordered by timestamp (newest first). |
| [OPT-S08] Request consent via broadcast (empty state) As an agent/marketer, when a customer has not been asked for marketing consent yet, I want a one-click way to send them a WhatsApp opt-in request, so I can collect consent without leaving the profile and manually building a list. | Must Have | Figma empty-state (needs CTA added, §11 item 7) | Creates single-customer list via direct_select (§9.1/§10 #4) → opens WA composer new tab (§10 #5). Auto-fill needs G9 (hub-chat). | AC-1 (Button visibility — not-yet state): Given a customer whose Meta consent is "no consent yet" ( marketing_opt_in = null / "-"),When the Communication consent empty state renders, Then a "Request consent via broadcast" button is shown; And Given the customer already has an opt-in or opt-out decision on a number, Then the button is not the primary path for that number (the status is shown instead). AC-2 (Permission gate): Given a user without permission to create broadcasts (or without customers_customers_view for the customer),When the empty state renders, Then the button is hidden/disabled. AC-3 (Create single-customer list): Given I click the button, When CDP handles the click, Then it calls POST /contacts/contact_lists/direct_select with exactly one contact_id (this customer) and a generated list name, and shows a loading state until the call resolves.AC-4 (Open composer, auto-fill recipient list — new tab): Given the recipient list is created successfully, When CDP receives the contact_list id,Then it opens {CHAT_BASE_URL}/campaign/whatsapp/create?contact_list_id={id} in a new tab, and (given G9 is shipped) the composer's Recipient list field is pre-selected to that new single-customer list.AC-5 (Template selected manually — auto-fill out of scope): Given the composer opens, When it loads, Then the Message template field is not pre-filled by CDP this phase (template auto-fill is not possible yet); And the agent selects the opt-in template manually before sending. AC-6 (Create failure): Given direct_select returns a non-2xx,When the click is handled, Then an inline error is shown on the button, no new tab is opened, and consent_request_error fires.AC-7 (No auto-send): Given the composer is opened, When the tab loads, Then CDP has not sent any broadcast — the user must review and send in the Chat app (Non-Goal #8). |
Cross-squad requirement (tracked as G9, not a CDP story): hub-chat's
/campaign/whatsapp/createpage must read the URL and pre-populate one field — the Recipient list fromcontact_list_id. Without G9, OPT-S08 AC-4 degrades to "composer opens, agent selects the recipient manually" (§12.1b branch 7a). Message-template auto-fill is out of scope this phase (not possible yet); a future enhancement could auto-select the most recentis_marketing_opt_intemplate (§9.1, OQ-7). Owned by the Chat/Broadcast squad; must be sequenced with this PRD.
13. Open Questions
| # | Type | Question | Owner | Deadline |
|---|---|---|---|---|
| 1 | Open | Extend the existing contact-detail response, or add a dedicated GET .../communication-consent endpoint? | Eng (CDP) | RFC |
| 2 | Open | For IG DM / Messenger under "META services" — ship with an empty state now, or scope release copy to WhatsApp only until those channels emit opt-in events (G2)? Answered (v1.7): render Meta grouping now, WhatsApp-populated; IG/Messenger show "no consent data yet" empty state (§17 decision). | PM + Design | Closed |
| 3 | Open | Should opted-out numbers be listed (with an "opted out" state) or only opted-in ones? Answered (v1.3): tri-state — opt-in / opt-out / "-" all shown (§11). | PM + Design | Closed |
| 4 | Assumption | channel_target reliably holds one of the Meta constants for all Meta chat accounts. | Eng (CDP) | RFC |
| 5 | Open | Email marketing consent + WhatsApp-call consent — confirm both are separate future PRDs. | PM | Post-GA |
| 6 | Risk | G9 sequencing: hub-chat's broadcast create page ignores the URL today — it must add contact_list_id query-param pre-fill so OPT-S08 can preselect the recipient list. Mitigation: the flow degrades gracefully (composer opens, agent selects the recipient manually) if G9 lands later; coordinate sequencing with the Chat/Broadcast squad and gate OPT-S08 AC-4 on it. | PM + Chat/Broadcast | RFC |
| 7 | Deferred | Template auto-fill (future enhancement): not possible this phase, so the agent selects the opt-in template manually. When feasible, auto-select the most recent is_marketing_opt_in template (?is_marketing_opt_in=true&order_by=created_at&order_direction=desc, gated by is_using_marketing_opt_in — §9.1). Open for that future slice: hub-chat resolves it (recommended) vs. CDP resolves the id; and the fallback when no such template exists. | PM + Chat/Broadcast | Future |
| 8 | Open | direct_select immediacy (G10): is a list created via direct_select immediately usable by a broadcast, or can it be briefly "in progress" (contact_list.progress)? If the latter, does the composer handle a still-processing list gracefully? | Eng (CDP) + Core team | RFC |
| 9 | Open | Recipient-list naming convention for auto-created single-customer lists (e.g. "Opt-in request — {customer} — {date}") — and cleanup/retention of these one-off lists so they don't clutter the lists index. | PM + Chat/Broadcast | Pre-build |
14. Rollout
| Item | Detail |
|---|---|
| Feature flag | cdp_communication_consent_tab — default OFF (see §6 Constraints); flipped per rollout stage. |
| Rollout sequence | Stage 1 — Internal: enable for the CDP squad's own org only. → Stage 2 — GA: enable for all Growth & Enterprise CIDs. No Beta — read-only, additive, low-risk change. |
| Backward compatibility | Yes — fully backward compatible. Additive read-only tab; no change to existing Customer Detail behavior, no write path, no contract change for existing consumers of the contact-detail response. |
| Migration | None. Reads existing chat_data; no schema change and no data migration, so there is no old/new data transition window to manage. |
| Rollback | Flip cdp_communication_consent_tab → OFF to instantly hide the tab; no data side-effects (read-only). Semantic-regression rollback N/A (no AI/ML/ranking output). |
15. Observability
Events
| Event | Trigger | Properties |
|---|---|---|
consent_tab_viewed | User opens the Communication consent tab on Customer Detail | company_id, user_id, customer_id, meta_account_count |
consent_account_expanded | User expands an account-channel to reveal phone numbers | company_id, user_id, account_uniq_id, channel_target, phone_count |
consent_filter_applied | User changes the "communication type" filter | company_id, user_id, channel_target |
consent_api_error | The consent read API returns a non-2xx | company_id, customer_id, status_code, endpoint |
consent_request_clicked | User clicks "Request consent via broadcast" (OPT-S08) | company_id, user_id, customer_id |
consent_request_opened | Recipient list created + composer tab opened | company_id, user_id, customer_id, contact_list_id |
consent_request_error | direct_select list-create fails (no redirect) | company_id, customer_id, status_code |
Dashboard owner: CDP Squad (CDP analytics board).
Alerts:
consent_api_errorrate > 2% of consent-tab loads over a 5-minute window → page CDP on-call.- Consent-tab render p95 > 3s → CDP Slack alert.
Post-launch monitoring cadence: Review adoption (consent_tab_viewed) and error rate weekly for the first month post-GA, then monthly. Investigate if consent_api_error > 2% for 2 consecutive days OR render success < 99% (§7 metric).
16. Launch Plan & Stage Gates
| Stage | Audience | Duration | Success gate |
|---|---|---|---|
| Internal | CDP squad's own Qontak org (~10 users) | 2–3 days | No console/API errors; consent tab renders opt-in / opt-out / "-" correctly for test customers spanning ≥ 2 Meta accounts |
| GA | All Growth & Enterprise CIDs (flag ON 100%) | Rolling over ~1 week | ⭐ Consent-visibility ≥ 99% render success (§7 metric); consent_api_error < 2%; no open P1 bugs |
No Beta stage — read-only, additive, low-risk change (per rollout decision, §14 / §17).
17. Key Decisions + Alternatives Rejected
17a — Decisions Made
| Date | Decision | Rationale |
|---|---|---|
| 2026-07-24 | Read-only display in CDP (no manual toggle) | Consent is system-managed via the Kafka pipeline (base.go:126-127); a CDP write path would create a competing source of truth |
| 2026-07-24 | Tri-state status — null→"-", true→opt-in, false→opt-out | Matches the marketing_opt_in *bool model (base.go:130); lets users distinguish "never decided" from "opted out" |
| 2026-07-24 | Meta services only this phase (whatsapp / instagram_dm / facebook_messenger) | Focus the first slice; Meta channel constants already exist (field_properties/default.go:20-23) |
| 2026-07-24 | Reuse chat_data — no schema change/migration | All fields (phone, channel, status, timestamp) already persisted; work is a read contract + FE tab |
| 2026-07-24 | Ship Internal → GA, no Beta | Read-only additive change is low-risk; a Beta stage adds process cost without de-risking |
| 2026-07-24 | Render Meta grouping now, populated from WhatsApp data; IG DM / Messenger show a "no consent data yet" empty state | Only WhatsApp emits opt-in events today (G2); Meta grouping future-proofs IG/Messenger with no rework — closes OQ-2 |
| 2026-07-27 | Add a "Request consent via broadcast" action in the not-yet-asked empty state | Closes the "captured but can't acquire" gap — the profile can now collect consent, not just display it |
| 2026-07-27 | Reuse the existing Chat broadcast composer (open /campaign/whatsapp/create cross-app in a new tab) — do not build a composer in CDP | The composer, recipient-list API, and cross-app new-tab pattern (DrawerSendCampaign.vue:98) already exist; CDP only seeds the list + opens the URL |
| 2026-07-27 | Create a single-customer recipient list via direct_select (one contact_id), CDP does not auto-send | Matches the existing single-step list-create API (§9.1); keeps CDP read-only on the write/consent path (send + outcome stay in Chat/Kafka) |
| 2026-07-27 | Auto-fill only the Recipient list (the new single-customer list); the agent selects the template manually | Template auto-fill is not possible yet — descoped this phase; recipient pre-select is the achievable win. A future slice can add template auto-select (OQ-7) |
17b — Alternatives Rejected
| Alternative | Why Rejected | Date |
|---|---|---|
| Manual opt-in/out toggle in CDP | Breaks the single system-of-record; consent must flow from the Broadcast/Chat pipeline | 2026-07-24 |
| Show only opted-in numbers (hide opt-out & "-") | Loses the compliance signal — users must see who opted out and who never decided | 2026-07-24 |
| WhatsApp-only display (no Meta grouping) | Meta grouping future-proofs IG DM / Messenger with no rework when they emit events | 2026-07-24 |
| Include WhatsApp-call consent now | No backing field in chat_data (G3); needs a separate producer — deferred | 2026-07-24 |
| Build a broadcast composer inside CDP | Duplicates the Chat app's composer + recipient-list APIs; high cost, owned elsewhere | 2026-07-27 |
| Auto-send the opt-in broadcast from CDP | Removes human review of message/recipient; send stays a deliberate action in Chat | 2026-07-27 |
Pass raw contact_id to the composer (skip creating a list) | The composer takes a contact_list_id, not raw contacts (CreatePage.vue:394); a list must exist first | 2026-07-27 |
18. Appendix — Current Payloads (evidence / recall with engineers)
Grounded from
contact-service@master(2026-07-24). These are the actual current shapes — attach as recall/evidence in the RFC/engineering sync.
A. Contact-detail response — current marketing opt-in block (create_serializer.go:304-312)
"phone_marketing_opt_in": [
{
"phone_number": "628581799xxxx",
"marketing_opt_in": true,
"business_phone_name": "Central Perk Promo"
}
]
⚠️ G7:
phone_numberhere is actuallychat_data.account_uniq_id(create_serializer.go:310), not the customer's real phone. The block is also flat (not grouped by account) and omitschannel_target+marketing_opt_in_timestamp. → this is exactly what the new read contract (G1) must fix.
B. Inbound Kafka event — source of truth (payload/marketing_opt_in_event.go, topic send_marketing_opt_in)
{
"channel_id": "chn_123",
"contact_id": "ct_456",
"marketing_opt_in": false,
"phone_number": "628111102938",
"qontak_customer_id": "665f0a1b2c3d4e5f60718293",
"account_business_name": "Central Perk Promo",
"account_uniq_id": "628581799xxxx",
"channel_target": "whatsapp",
"timestamp": "2026-01-06T10:12:00Z",
"marketing_opt_in_timestamp": "2026-01-06T10:12:00Z",
"broadcast_id": "bc_789",
"email_sender": "agent@centralperk.id"
}
C. Resulting activity-log entry (activity_log.go:793 + activity_log_serializer.go)
{
"timestamp": "2026-01-06T10:12:00Z",
"action": "opt-out",
"category": "campaign",
"associated_type": "campaign",
"associated_name": "Central Perk Promo",
"title": "opted out to marketing",
"associated_changes": [
{
"field": "chat_data",
"from": [
{ "id": "ct_456", "channel_id": "chn_123", "account_uniq_id": "628581799xxxx", "channel_target": "whatsapp" }
],
"to": [
{ "id": "ct_456", "channel_id": "chn_123", "account_uniq_id": "628581799xxxx", "channel_target": "whatsapp",
"phone_number": "628111102938", "account_business_name": "Central Perk Promo",
"marketing_opt_in": false, "marketing_opt_in_timestamp": "2026-01-06T10:12:00Z" }
]
}
]
}
Notes:
titleis awkward (G5 → suggest "this customer not allowed to receive broadcast (opt-out)"). Thefromsnapshot has no priormarketing_opt_in(G6), so the entry shows the resulting state;actionconveys opt-in vs opt-out. Everything the UI needs (phone, channel, status, timestamp) is in thetosnapshot.
D. Request-consent — recipient-list create request (POST /api/core/v1/contacts/contact_lists/direct_select; grounded from hub-chat DialogSubmitSelect.vue:156-173)
{
"name": "Opt-in request — Eka Setiawan — 2026-01-06",
"contact_ids": ["665f0a1b2c3d4e5f60718293"]
}
Response is a
contact_listobject (contact.d.ts:2-19):{ "id": "cl_abc123", "name": "...", "contacts_count": 1, "target_channel": "wa", "progress": ... }. The c360 variant (direct_select_c360) can additionally carrymarketing_opt_in: { value, channel_ids }.
E. Request-consent — composer redirect URL (built by CDP; consumed by hub-chat CreatePage.vue — pre-fill is net-new, G9)
https://chat.qontak.com/campaign/whatsapp/create?contact_list_id=cl_abc123
Auto-fills one field: Recipient list =
cl_abc123(the new single-customer list). The agent selects the Message template manually (template auto-fill out of scope this phase — OQ-7). ⚠️ G9:CreatePage.vue:304usesuseRouter()only and never readsroute.query; a repo-wide search forsegment_idconsumers returns zero. So the param is ignored today — hub-chat must add recipient pre-fill logic toCampaignSetup.vue/DropdownRecipient.vue. The broadcast itself isPOST /api/core/v1/{org}/broadcasts/whatsappwithcontact_list_id+message_template_id(useCreateCampaign.ts:233,CreatePage.vue:393-394) — sent by the user in the Chat app, not by CDP.
PRD CHANGELOG
| Version | Date | By | Section | Type | Summary |
|---|---|---|---|---|---|
| 1.11 | 2026-07-27 | Claude | S5, S8, S9.1, S10, S11, S12, S13, S15, S17, S18 | MODIFIED | Removed the message-template auto-fill (not possible yet) — the composer now auto-fills only the Recipient list (?contact_list_id={id}); the agent selects the opt-in template manually. Template auto-select kept as a documented future enhancement (OQ-7, §9.1). Updated §5/§8 deps, G9 (recipient-only), §10 #5 URL, §11 item 7, §12.1b, OPT-S08 AC-4/AC-5, decisions, consent_request_opened event, Appendix E. |
| 1.10 | 2026-07-27 | Claude | S5, S8, S9.1, S10, S11, S12, S13, S17, S18 | MODIFIED | Sharpened the composer auto-fill to exactly 2 fields per the WA Create-campaign UI: (1) Recipient list = the new single-customer list; (2) Message template = the most recent is_marketing_opt_in template. Grounded the marketing-opt-in template as a queryable type — GET /templates/whatsapp?is_marketing_opt_in=true&order_by=created_at&order_direction=desc (whatsapp-new/@types/whatsapp.ts:39-55, useWhatsappListsFilter.ts:61-65,110), gated by app-config is_using_marketing_opt_in. Updated G9 (hub-chat resolves template; CDP passes prefill_template=marketing_opt_in), §10 #5 URL, OPT-S08 AC-4/AC-5, OQ-7 (now resolution-owner only), 2 decisions, Appendix E. |
| 1.9 | 2026-07-27 | Claude | S1, S4, S5, S8, S9.1, S10, S11, S12, S13, S15, S17, S18 | MODIFIED | Added scope: "Request consent via broadcast" for the not-yet-asked (null/"-") state — a CTA that creates a single-customer recipient list and opens the WA broadcast composer pre-filled. Grounded against hub-chat + qontak-customer-fe (§9.1): recipient-list create (direct_select), composer route (/campaign/whatsapp/create, cross-app chat.qontak.com), and the existing new-tab pattern (DrawerSendCampaign.vue:98) all exist. New grounded gap G9 — hub-chat's create page ignores route.query, so auto-fill (contact_list_id/message_template_id) is net-new (cross-squad). Added story OPT-S08 (7 ACs), flow §12.1b, API #4/#5, dependencies, decisions, OQ-6/7/8/9, events, and Appendix D/E. |
| 1.8 | 2026-07-24 | Claude | S9 | MODIFIED | Removed the now-redundant Gaps-to-close table (all fixes applied); replaced with a compact "fixes applied" list so the G1–G8 references elsewhere still resolve. Kept the grounding evidence + feasibility verdict. |
| 1.7 | 2026-07-24 | Claude | S9 (gaps), S13, S17 | MODIFIED | Applied/annotated the proposed fix for every gap (status tags G1–G8 in §9); G2 decided — render Meta grouping now, WhatsApp-populated, IG/Messenger empty state (added §17 decision, closed OQ-2, tightened §11 item 3). All gaps now closed in-spec. |
| 1.6 | 2026-07-24 | Claude | S2 | MODIFIED | Reframed "What Happens If We Don't Build" around the data-transparency theme: today opt-in/out is not shown on Customer Detail — users must leave the profile, go to the Customers index and filter/segment by marketing opt-in; added time/magnitude signals (ongoing per-campaign compliance + WhatsApp quality-rating risk, widening gap). |
| 1.5 | 2026-07-24 | Claude | S1, S4, §11, §12 | MODIFIED | Rewrote all 7 stories' ACs into strict Given/When/Then + added an error-path AC per story (clears Partial verdicts); added §12.1 flow type + failure branches; added §11 route, component tree, UI state diagram, explicit error state; added Constraints performance + plan tier; trimmed one-liner to ≤ 25 words. |
| 1.4 | 2026-07-24 | Claude | S14–S17 (new) | MODIFIED | Added the four mandatory NEW-PRD sections: Rollout (flag cdp_communication_consent_tab, Internal→GA, no Beta, backward-compatible, no migration), Observability (4 events + alerts + weekly cadence), Launch Plan & Stage Gates (Internal → GA), Key Decisions + Alternatives Rejected. Added feature-flag row to Constraints; renumbered Appendix to §18. |
| 1.3 | 2026-07-24 | Claude | S9, S10, S11, S12 | MODIFIED | Defined tri-state consent status (grounded on marketing_opt_in *bool, base.go:130): null→"-" (not yet allowed/allowed), true→Marketing opt-in, false→Marketing opt-out. Added gap G8 (serializer drops null today → "-" invisible; create_serializer.go:308) and applied all P0/P1 fixes into the spec (§10/§11/§12). Updated OPT-S01/S02/S06 ACs + §10 read contract to include null accounts; closed OQ-3. |
| 1.2 | 2026-07-24 | Claude | S9, S11, S12, S14 | MODIFIED | Updated activity-log title copy to the consent-framed pattern: opt-in → "this customer allowed to receive broadcast (opt-in)", opt-out → "this customer not allowed to receive broadcast (opt-out)" (G5, OPT-S07 AC-3). |
| 1.1 | 2026-07-24 | Claude | §9, §12, §18 | MODIFIED | Added opt-in/out Activity-log scope (entry per status change; shows phone · status · channel · account · timestamp). Grounded the activity log as already-emitted (activity_log.go:793); recorded gaps G5 (title copy), G6 (from-snapshot lacks prior value), G7 (phone_number=account_uniq_id). Added §18 Appendix with the 3 real payloads. |
| 1.0 | 2026-07-24 | Claude | All | CREATED | NEW PRD: display Meta-service marketing opt-in/out on CDP Customer Detail (read-only), building on the Q2 contact-service consent pipeline. Grounded against contact-service master (§9) — buildable with no schema migration; main gap is a grouped/Meta-filtered read contract (G1). WhatsApp-call (Non-Goal #1) and Email (Non-Goal #3) out of scope; Figma flagged as draft needing adjustment (§11). |