Task Breakdown — WhatsApp Campaign: Send Campaign with Segmentation (BE Phase 1)
RFC: rfc-wa-campaign-segmentation.md · Review: rfc-wa-campaign-segmentation-review.md
Slicing mode: chunk-based (this RFC is 100% backend — FE is explicitly out of scope, covered by a separate RFC — so the RFC's own 8-chunk Agent Execution Plan is the natural task shape, not a horizontal UI/API split).
Repo verified against: hub_core (local checkout /Users/hilmi.damamekari.com/Documents/hub/hub_core, branch feature/QC-23034-adjust-interactor-send-campaign-with-segmentation).
Important — this reflects real repo state, not a from-scratch plan
Codebase reconnaissance found this RFC is already ~60% implemented. Chunks 1, 3, and 4 are committed (QC-23030, QC-23032, QC-23033); Chunk 5 (UserCreateBroadcast extension) is in progress with uncommitted local changes. Chunk 2 was superseded entirely — instead of building Services::Cdp::SegmentClient, the implementation reuses the pre-existing CentralizedContacts::Services::Apis#fetch_customers. This breakdown covers only what remains, grounded in the actual diff and files as of 2026-07-10.
Effort Summary
| Task | FE days | BE days | QA days | Total |
|---|---|---|---|---|
1 — Duplicate-campaign guard + audience source on message_broadcasts (Decisions 12 & 13) | — | 1.5 | 0.5 | 2.0 |
| 2 — Segment ownership / IDOR check (Decision 14) | — | 1.0 | 0.5 | 1.5 |
| 3 — Campaign Detail recipients endpoint (Chunk 6) | — | 1.5 | 0.5 | 2.0 |
| 4 — Feature flag registration (Chunk 7) | — | 0.5 | — | 0.5 |
| 5 — Full suite + lint + security gate (Chunk 8) | — | 0.5 | 0.5 | 1.0 |
| Grand total | — | 5.0 | 2.0 | 7.0 |
Confidence: medium. Chunks 1/3/4 are committed and Chunk 5's core is already coded — that de-risks most of the RFC. The two open items are: (1) Task 2 depends on the CDP team confirming an org-scoped segment lookup (Open Q #10) with no committed date — the contract wiring can be built now against a placeholder, but the real check can't land until that answer arrives; (2) Task 5's full-suite run is the first time the sizable uncommitted Chunk 5 diff gets exercised end-to-end, so it may surface regressions not yet visible task-by-task.
Already shipped (context, not actionable work)
| Chunk | What | Status |
|---|---|---|
| 1 | segment_id + segment_version on contact_lists | ✅ committed (QC-23030, 20260707120000_add_segment_id_to_contact_lists.rb) |
| 2 | CDP client | ✅ done, but not as the RFC specified — no Services::Cdp::SegmentClient was built; CreateFromSegmentProcess calls the pre-existing CentralizedContacts::Services::Apis#fetch_customers instead. No separate task needed. |
| 3 | CreateFromSegment repository | ✅ committed (QC-23032) |
| 4 | CreateRecipientFromSegmentWorker + CreateFromSegmentProcess | ✅ committed (QC-23033 + fix c00e8b0e). Decision 8 (identity resolution via batched Models::Contact lookup) is implemented. Idempotency is handled via a Redis last-imported-page bookmark (create_from_segment_last_imported_page::<id>), not the RFC's originally-planned partial unique indexes — a design deviation, not a gap; no action needed unless the team wants a DB-level constraint as defense-in-depth. |
| 5 (partial) | UserCreateBroadcast segment path — contract params, 1-hour rule, recurring block, feature-flag gate, transaction wrapping (Decision 10), balance validation (Decision 5, ValidateSegmentBroadcastQuota), Datadog metric | 🟡 uncommitted, in progress — this is what Tasks 1–2 below complete |
Task 1: [BE] Duplicate-campaign guard + audience source on message_broadcasts (Decisions 12 & 13)
A campaign creator is prevented from double-submitting the same segment+template combo, and every campaign (segment or recipient-list) now records its own audience source without a join to
contact_lists.
Status: ✅ Actionable
What to build
Add nullable source_type/segment_id columns to message_broadcasts (Decision 13), wire both creation paths in UserCreateBroadcast to set them, and add the Decision 12 contract rule that rejects a new segment campaign when a non-terminal one already exists for the same segment_id + message_template_id — this rule reads the very column the migration adds.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | database/core/db/migrate/<timestamp>_add_source_type_and_segment_id_to_message_broadcasts.rb | Nullable source_type:string, segment_id:string + partial index WHERE segment_id IS NOT NULL (mirrors the style of database/core/db/migrate/20260707120000_add_segment_id_to_contact_lists.rb) |
| modify | app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rb | Add rule(:segment_id, :message_template_id); in create_broadcast_from_segment, stop excluding segment_id from broadcast_params and merge source_type: 'segment'; in the non-segment branch of result, merge source_type: 'contacts' onto broadcast_params |
| modify | app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb | Add: duplicate segment+template → 422; assert broadcast.source_type/.segment_id on the segment happy path; assert source_type: 'contacts' on the existing recipient-list path |
File path rule: all paths above verified directly against the checked-out
hub_corerepo (not[unverified]).
Implementation steps
- Explore: re-open
app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rb(your own in-progress Decision 9/10/11 work) andapp/core/domains/models/message_broadcast.rb. Note there is noTERMINAL_STATUSESconstant — the RFC's contract-rule sketch assumed one that doesn't exist. The real non-terminal check must use the model's actualexecute_statusenum (done, todo, pacing_failed, in_progress, canceled, failed, ~L45–52) — treattodo/in_progressas "in progress" for the duplicate check. - Write failing tests (red): add the three new cases listed above to
user_create_broadcast_wa_cloud_spec.rb. Runbundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rband confirm they fail. - Migration: create the migration mirroring
20260707120000_add_segment_id_to_contact_lists.rb's plainadd_column+ partial-index style. Runbundle exec rake db:migrate. - Wire the contract rule: add
rule(:segment_id, :message_template_id)checkingModels::MessageBroadcast.where(organization_id:, segment_id:, message_template_id:).where(execute_status: %w[todo in_progress]).exists?. - Wire
source_type/segment_idpassthrough: increate_broadcast_from_segment, change thebroadcast_params.except(...)list to keepsegment_idand addsource_type: 'segment'; in the plainresultbranch's existingbroadcast_params = params.except(...)line, mergesource_type: 'contacts'.Repositories::Whatsapp::Broadcasts::Create#calldoesresource_org.message_broadcasts.new(@params...merge(...).as_json)(L29) — it mass-assigns whatever's in@params, so no change tocreate.rbitself is needed once the columns exist. - Go green:
bundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rbuntil all pass. - Quality gate:
bundle exec rubocop -A app/core/domains/interactors/whatsapp/broadcasts/ database/core/db/migrate/then--no-colorto confirm 0 offenses.
Acceptance criteria
- A second
POST /broadcastsfor the samesegment_id+message_template_id, while an earlier one istodo/in_progress, returns 422 "a campaign for this segment and template is already in progress" - A prior campaign in a terminal
execute_status(done/canceled/failed/pacing_failed) does not block a new one for the same segment+template -
message_broadcasts.source_type = 'segment'and.segment_idpopulated on every segment-audience campaign -
message_broadcasts.source_type = 'contacts'populated on the existing recipient-list path — no regression, existing rows unaffected (nullable, additive)
Test strategy
Extend the existing user_create_broadcast_wa_cloud_spec.rb "segment audience" context (already present from the in-progress work) with a duplicate-detection case: create one segment broadcast, then assert a second identical POST fails with the exact contract message and creates no second ContactList. Assert source_type/segment_id directly on the persisted Models::MessageBroadcast row — same DB-level assertion style already used by the existing segment specs in this file, no mocks.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: no existing
TERMINAL_STATUSESconstant — reusesexecute_statusenum values directly; migration is purely additive, no backfill.
Run to verify
bundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb && bundle exec rubocop --no-color
Depends on
- None — builds directly on the already-committed/in-progress Chunk 5 work.
Task 2: [BE] Verify segment ownership before creating a segment audience campaign (Decision 14 — IDOR)
A caller can no longer create a campaign against another organization's CDP segment by supplying a guessed/hardcoded
segment_id.
Status: ⚠️ Partially blocked — the exact CDP org-scoping mechanism is unconfirmed (Open Q #10: does CDP expose an org-scoped segment-detail lookup, or must hub-core just always pass organization_id and trust CDP's own tenant scoping?). The contract wiring can be built now against the RFC's placeholder call shape; swap in the real mechanism once CDP confirms. Note CentralizedContacts::Services::Apis#fetch_customers already sends company_sso_id: @org.unified_sso_id on every call (L310), so CDP is already told which org is asking during recipient generation — but that's an async-time check; Decision 14 needs a create-time fast-fail so a wrong-org segment_id never gets a ContactList created for it at all.
What to build
A pre-contract ownership check in the segment path of UserCreateBroadcast, called before any DB write, that fails with a generic 404-style message on a cross-org segment_id (never revealing cross-org existence).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| modify | app/apps/centralized_contacts/services/apis.rb | Add a segment-ownership/detail method once Open Q #10 resolves (method name/shape TBD by the CDP team's answer) |
| modify | app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rb | Add validate_segment_ownership(organization_id, segment_id) called via yield before validate_segment_feature_flag (or right after) in the segment branch of result |
| modify | app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb | Add case: segment_id belonging to a different org → 404/generic failure, no ContactList created |
Implementation steps
- Explore: re-open
app/apps/centralized_contacts/services/apis.rb(the CDP client actually used — not the RFC's hypotheticalServices::Cdp::SegmentClient) and confirm no segment-detail/ownership method exists yet (fetch_customersis the only segment-related method today). - Stub the check now: implement
validate_segment_ownershipinuser_create_broadcast.rbusing the RFC's placeholder shape — call whatever CDP method exists (or a new stubbed one) and treat a blank/403 result as "not this org's segment" →Failure('Contact list not found'). Mark clearly with a# TODO(Open Q #10)comment pointing at Decision 14 so the placeholder is easy to find and swap. - Write the spec case (red): add a wrong-org segment_id test expecting failure and zero
ContactListrows; confirm it fails against the not-yet-wired check. - Wire the
yieldinto the segment branch ofresult, beforevalidate_segment_broadcast_quota. - Go green:
bundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb. - Quality gate:
bundle exec rubocop --no-color. - Once Open Q #10 resolves: replace the stubbed check with the real CDP-confirmed mechanism in
apis.rb— no other file changes needed since the interactor already calls throughvalidate_segment_ownership.
Acceptance criteria
-
segment_idbelonging to a different organization → generic 404-style failure, noContactListorMessageBroadcastrow created -
segment_idbelonging to the caller's own organization → proceeds normally - (pending OQ-10) Real CDP-confirmed ownership mechanism replaces the placeholder before Chunk 5 is considered fully closed
Test strategy
One new spec context asserting a cross-org segment_id fails fast with no side effects (no ContactList, no worker enqueue) — mirrors the existing "flag disabled" test's assertion style in the same file.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: the real CDP mechanism, once confirmed, is a drop-in replacement inside
validate_segment_ownership/apis.rb— no interactor-level restructuring needed.
Run to verify
bundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb
Depends on
- [External: CDP team — Open Q #10, org-scoped segment lookup mechanism (pending)]
Task 3: [BE] Campaign Detail — list recipients for a segment-sourced contact list (Chunk 6)
A user opening the Campaign Detail drawer can see the actual customer list a segment-audience campaign will send to.
Status: ✅ Actionable
What to build
A new read-only repository + interactor returning contact_list_recipients rows for a given contact_list_id, sorted full_name ASC, paginated, org-scoped. Route registration (GET /contact_lists/:id/recipients) lives in hub-service, a separate repo/ticket — out of scope here.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | app/core/domains/repositories/contact_list_recipients/list.rb | New repo: Models::ContactList.find_by(id:, organization_id:) (404-style failure if nil, mirrors Repositories::ContactLists::Detail), then paginated Models::ContactListRecipient.where(contact_list_id:).order(full_name: :asc) |
| create | app/core/domains/repositories/contact_list_recipients/list_spec.rb | Co-located spec, matching this repo's observed convention |
| create | app/core/domains/interactors/whatsapp/contacts/user_list_contact_list_recipients.rb | Thin interactor mirroring app/core/domains/interactors/whatsapp/contacts/user_detail_contact_list.rb's shape (contract + yield result_of_validating_params + delegate to the repo) |
| create | app/core/domains/interactors/whatsapp/contacts/user_list_contact_list_recipients_spec.rb | Co-located spec |
Implementation steps
- Explore: open
app/core/domains/repositories/contact_lists/detail.rb(the exact org-scoped 404 pattern to clone) andapp/core/domains/interactors/whatsapp/contacts/user_detail_contact_list.rb(the thin-interactor pattern to clone). Also noteapp/core/domains/models/contact_list_recipient.rb's associations (belongs_to :contact_list,has_many :contact_extras) — no existingBuilders::ContactListRecipient, so the repo can return plain hashes/.as_jsonfor now, or a minimal inline builder; don't invent a heavyweight builder class for a 3-field response. - Write failing tests (red): create
list_spec.rbcovering happy path (sorted, paginated), wrongorganization_id→ failure, anduser_list_contact_list_recipients_spec.rbcovering the interactor's param validation (pagedefault 1,per_pagedefault 10, max 200). Runbundle exec rspec app/core/domains/repositories/contact_list_recipients/list_spec.rb app/core/domains/interactors/whatsapp/contacts/user_list_contact_list_recipients_spec.rband confirm failures. - Scaffold: create
Repositories::ContactListRecipients::ListmirroringRepositories::ContactLists::Detail'sinitialize/prepare!shape. - Wire query + pagination: implement
Models::ContactListRecipient.where(organization_id:, contact_list_id:).order(full_name: :asc).limit(per_page).offset((page - 1) * per_page), and a parallel.countfor thepagination.total/total_pagesresponse fields per the RFC's §2.4 response shape. - Scaffold the interactor:
Interactors::Whatsapp::Contacts::UserListContactListRecipients, contract withrequired(:organization_id),required(:id),optional(:page).filled(:integer).default(1),optional(:per_page).filled(:integer, lteq?: 200).default(10)— do not reuseInteractors::AbstractIteractor.pagination_schema(that's ES/cursor-shapedoffset/limit; this RFC's contract explicitly wantspage/per_pagewith a max-200 cap). - Go green: run both specs until passing.
- Quality gate:
bundle exec rubocop -A app/core/domains/repositories/contact_list_recipients/ app/core/domains/interactors/whatsapp/contacts/.
Acceptance criteria
- Recipients returned sorted by
full_name ASC -
page/per_pagepagination works,per_pagecapped at 200 - Wrong
organization_id→ failure (hub-service maps this to 404, no cross-org data leak) - Interactor param defaults match the RFC contract (
page: 1,per_page: 10)
Test strategy
list_spec.rb asserts row order and pagination math directly against seeded ContactListRecipient fixtures (no mocks — plain ActiveRecord assertions, matching create_direct_select_all_process_spec.rb's style); the interactor spec asserts contract validation and delegation to the repo with a stubbed repo call.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: no dedicated builder needed for the response shape (3 flat fields); hub-service route registration is tracked separately and not part of this task's Definition of Done.
Run to verify
bundle exec rspec app/core/domains/repositories/contact_list_recipients/list_spec.rb app/core/domains/interactors/whatsapp/contacts/user_list_contact_list_recipients_spec.rb && bundle exec rubocop --no-color
Depends on
- None (Chunks 1–4 already provide everything this reads)
- [External: hub-service — route registration is a separate ticket outside hub-core]
Task 4: [BE] Register the send_campaign_with_segment feature flag (Chunk 7)
Ops/PIC can toggle the segment-audience feature on per-org during staged rollout.
Status: ✅ Actionable
What to build
A rake task (or deploy-time migration comment, per existing repo convention) that registers the Flipper flag via Services::Preference.new.add(...), defaulting OFF.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | lib/tasks/send_campaign_with_segment_flag.rake (path TBD — confirm exact lib/tasks/ convention used by other one-off flag-registration tasks in this repo before naming) | Registers the flag: Services::Preference.new.add(:send_campaign_with_segment, title: 'Send Campaign with Segmentation', target: 'feature', author: 'hilmi.dama@mekari.com', expires_in: Date.today + 3.months) |
File path rule:
[unverified — check repo]— reconnaissance did not find an existing rake task registering a similar one-off flag to confirm the exact directory/naming convention; confirm against a sibling flag-registration task before creating this file.
Implementation steps
- Explore: search
lib/tasks/for an existing one-offServices::Preference.new.addregistration task to clone the exact rake-task boilerplate and invocation convention (rake <namespace>:<task_name>). - Write the task: register the flag with the exact params already used in the spec's
beforeblock (app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb's segment context already callsServices::Preference.new.add :send_campaign_with_segment, title: 'flag to enable segment audience for campaigns', extra: {}, target: 'feature', author: 'hilmi.dama@mekari.com'for test setup — mirror that exact title/author for production consistency). - Run it in a local/staging console to confirm the flag appears in Flipper/Preference storage, defaulting OFF (no
.enablecall in the task itself). - Quality gate:
bundle exec rubocop --no-color.
Acceptance criteria
- Flag
send_campaign_with_segmentexists in DB/Flipper after the task runs, default OFF - Enabling it per-org via
Services::Preference.new.enable(:send_campaign_with_segment)/set_organization_idsworks (already exercised by the test suite'sbeforeblock)
Test strategy
No new spec required — this is an ops task, not app logic; validated by running it once against a local/staging environment and confirming the flag record exists, plus the existing test suite already depends on the identical registration call succeeding.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions: pure ops/config task, no user-facing behavior to QA independently of Tasks 1–3.
Run to verify
bundle exec rubocop --no-color
Depends on
- None
Task 5: [BE] Full suite + lint + security gate (Chunk 8)
Confirms the entire segment-audience feature — Chunks 1–7 combined — is safe to merge.
Status: ✅ Actionable (run last, once Tasks 1–4 land)
What to build
No new files — run the repo's full pre-PR check and fix anything it surfaces.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| n/a | — | No new files; verification-only task |
Implementation steps
bundle exec rubocop --no-color— fix any offenses across all files touched by Tasks 1–4.bundle exec rspec— full suite; investigate and fix any failure, paying particular attention to the large uncommitted Chunk 5 diff getting its first full-suite run alongside Tasks 1–4's additions.bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q— confirm no new HIGH findings (the new migration + IDOR check are the most likely places for a new finding).- Per
AGENTS.md's documented safe-command set,bin/overcommit_runis the single command bundling steps 1–3 — prefer it if available locally.
Acceptance criteria
- Rubocop: 0 offenses
- RSpec: full suite green, including every new/modified spec from Tasks 1–4
- Brakeman: no new HIGH findings
Test strategy
No new assertions — this task's "test" is the full suite itself passing, confirming Tasks 1–4 didn't regress the already-shipped Chunks 1–5 work.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1.0 |
Assumptions: Tasks 1–4 are complete and individually green before this runs; this is a gate, not new development.
Run to verify
bundle exec rubocop --no-color && bundle exec rspec && bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q
Depends on
- [Task 1], [Task 2], [Task 3], [Task 4]
Ordering rationale
- Tasks 1 and 2 both touch
user_create_broadcast.rb, so they should be done back-to-back by the same person to avoid merge friction on the same file — but Task 1 has no external blocker while Task 2 is capped by an open question, so Task 1 should land first and Task 2 can start in parallel on its non-blocked wiring. - Task 3 is fully independent — it only reads data Chunks 1–4 already produce — and can run in parallel with Tasks 1–2 by a second engineer.
- Task 4 is trivial and independent — can be picked up any time, even by a different person, with zero risk of conflict.
- Task 5 is the critical-path tail — it cannot start meaningfully until Tasks 1–4 are individually green, since it's the integration gate for all of them plus the already-shipped Chunks 1–5.
- The team should push externally on Open Q #10 (CDP org-scoped segment lookup) now — it's the only item with no committed owner/date, and it's what keeps Task 2 from closing out completely.
Skipped stories
(Full-scope mode — every 🚫/⚠️ blocked item, with its unblocking condition)
| Story / Item | Reason |
|---|---|
| Task 2's real ownership check (vs. the placeholder wiring) | Blocked on Open Q #10 — CDP team hasn't confirmed whether an org-scoped segment lookup exists; placeholder call ships now, swapped once answered |
wa_cloud BroadcastSpecificWorker progress-check update (progress IN ('success','partially_completed')) | Out of hub-core scope entirely — owned by the wa_cloud team, no ticket/owner assigned yet (RFC §5 Dangling Decision #1). Until this ships, partially_completed campaigns silently never execute. Not a hub-core task, but a cross-team blocker to flag loudly. |
Open Q #5 — PII encryption (LOCKBOX) for contact_extras.extra | Non-blocking for development; Infosec sign-off required before 100% rollout, not before merging Tasks 1–5 |
| Open Q #9 — retention/TTL for segment-derived PII | Non-blocking for development; needed before 100% rollout sign-off |
| Open Q #2 — CDP p99 latency measurement | Non-blocking for development; a staging go/no-go gate, not a coding task |
| Faraday/Pigeon retry-backoff verification (RFC review Priority Action 1) | CentralizedContacts::Services::Apis#fetch_customers passes no explicit retry_statuses/backoff config to @pigeon.get — worth a quick confirmation of Pigeon::Client's default retry behavior before 100% rollout, but not a new task since no bespoke Faraday client was built (Chunk 2 was superseded) |