Skip to main content

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

TaskFE daysBE daysQA daysTotal
1 — Duplicate-campaign guard + audience source on message_broadcasts (Decisions 12 & 13)1.50.52.0
2 — Segment ownership / IDOR check (Decision 14)1.00.51.5
3 — Campaign Detail recipients endpoint (Chunk 6)1.50.52.0
4 — Feature flag registration (Chunk 7)0.50.5
5 — Full suite + lint + security gate (Chunk 8)0.50.51.0
Grand total5.02.07.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)

ChunkWhatStatus
1segment_id + segment_version on contact_lists✅ committed (QC-23030, 20260707120000_add_segment_id_to_contact_lists.rb)
2CDP 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.
3CreateFromSegment repository✅ committed (QC-23032)
4CreateRecipientFromSegmentWorker + 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

ActionFileWhat changes
createdatabase/core/db/migrate/<timestamp>_add_source_type_and_segment_id_to_message_broadcasts.rbNullable 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)
modifyapp/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rbAdd 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
modifyapp/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rbAdd: 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_core repo (not [unverified]).

Implementation steps

  1. Explore: re-open app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rb (your own in-progress Decision 9/10/11 work) and app/core/domains/models/message_broadcast.rb. Note there is no TERMINAL_STATUSES constant — the RFC's contract-rule sketch assumed one that doesn't exist. The real non-terminal check must use the model's actual execute_status enum (done, todo, pacing_failed, in_progress, canceled, failed, ~L45–52) — treat todo/in_progress as "in progress" for the duplicate check.
  2. Write failing tests (red): add the three new cases listed above to user_create_broadcast_wa_cloud_spec.rb. Run bundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb and confirm they fail.
  3. Migration: create the migration mirroring 20260707120000_add_segment_id_to_contact_lists.rb's plain add_column + partial-index style. Run bundle exec rake db:migrate.
  4. Wire the contract rule: add rule(:segment_id, :message_template_id) checking Models::MessageBroadcast.where(organization_id:, segment_id:, message_template_id:).where(execute_status: %w[todo in_progress]).exists?.
  5. Wire source_type/segment_id passthrough: in create_broadcast_from_segment, change the broadcast_params .except(...) list to keep segment_id and add source_type: 'segment'; in the plain result branch's existing broadcast_params = params.except(...) line, merge source_type: 'contacts'. Repositories::Whatsapp::Broadcasts::Create#call does resource_org.message_broadcasts.new(@params...merge(...).as_json) (L29) — it mass-assigns whatever's in @params, so no change to create.rb itself is needed once the columns exist.
  6. Go green: bundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb until all pass.
  7. Quality gate: bundle exec rubocop -A app/core/domains/interactors/whatsapp/broadcasts/ database/core/db/migrate/ then --no-color to confirm 0 offenses.

Acceptance criteria

  • A second POST /broadcasts for the same segment_id + message_template_id, while an earlier one is todo/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_id populated 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

DisciplineDays
Frontend
Backend1.5
QA0.5
Total2.0

Assumptions: no existing TERMINAL_STATUSES constant — reuses execute_status enum 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

ActionFileWhat changes
modifyapp/apps/centralized_contacts/services/apis.rbAdd a segment-ownership/detail method once Open Q #10 resolves (method name/shape TBD by the CDP team's answer)
modifyapp/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast.rbAdd validate_segment_ownership(organization_id, segment_id) called via yield before validate_segment_feature_flag (or right after) in the segment branch of result
modifyapp/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rbAdd case: segment_id belonging to a different org → 404/generic failure, no ContactList created

Implementation steps

  1. Explore: re-open app/apps/centralized_contacts/services/apis.rb (the CDP client actually used — not the RFC's hypothetical Services::Cdp::SegmentClient) and confirm no segment-detail/ownership method exists yet (fetch_customers is the only segment-related method today).
  2. Stub the check now: implement validate_segment_ownership in user_create_broadcast.rb using 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.
  3. Write the spec case (red): add a wrong-org segment_id test expecting failure and zero ContactList rows; confirm it fails against the not-yet-wired check.
  4. Wire the yield into the segment branch of result, before validate_segment_broadcast_quota.
  5. Go green: bundle exec rspec app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb.
  6. Quality gate: bundle exec rubocop --no-color.
  7. 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 through validate_segment_ownership.

Acceptance criteria

  • segment_id belonging to a different organization → generic 404-style failure, no ContactList or MessageBroadcast row created
  • segment_id belonging 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

DisciplineDays
Frontend
Backend1.0
QA0.5
Total1.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

ActionFileWhat changes
createapp/core/domains/repositories/contact_list_recipients/list.rbNew 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)
createapp/core/domains/repositories/contact_list_recipients/list_spec.rbCo-located spec, matching this repo's observed convention
createapp/core/domains/interactors/whatsapp/contacts/user_list_contact_list_recipients.rbThin 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)
createapp/core/domains/interactors/whatsapp/contacts/user_list_contact_list_recipients_spec.rbCo-located spec

Implementation steps

  1. Explore: open app/core/domains/repositories/contact_lists/detail.rb (the exact org-scoped 404 pattern to clone) and app/core/domains/interactors/whatsapp/contacts/user_detail_contact_list.rb (the thin-interactor pattern to clone). Also note app/core/domains/models/contact_list_recipient.rb's associations (belongs_to :contact_list, has_many :contact_extras) — no existing Builders::ContactListRecipient, so the repo can return plain hashes/.as_json for now, or a minimal inline builder; don't invent a heavyweight builder class for a 3-field response.
  2. Write failing tests (red): create list_spec.rb covering happy path (sorted, paginated), wrong organization_id → failure, and user_list_contact_list_recipients_spec.rb covering the interactor's param validation (page default 1, per_page default 10, max 200). Run 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 and confirm failures.
  3. Scaffold: create Repositories::ContactListRecipients::List mirroring Repositories::ContactLists::Detail's initialize/prepare! shape.
  4. 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 .count for the pagination.total/total_pages response fields per the RFC's §2.4 response shape.
  5. Scaffold the interactor: Interactors::Whatsapp::Contacts::UserListContactListRecipients, contract with required(:organization_id), required(:id), optional(:page).filled(:integer).default(1), optional(:per_page).filled(:integer, lteq?: 200).default(10) — do not reuse Interactors::AbstractIteractor.pagination_schema (that's ES/cursor-shaped offset/limit; this RFC's contract explicitly wants page/per_page with a max-200 cap).
  6. Go green: run both specs until passing.
  7. 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_page pagination works, per_page capped 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

DisciplineDays
Frontend
Backend1.5
QA0.5
Total2.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

ActionFileWhat changes
createlib/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

  1. Explore: search lib/tasks/ for an existing one-off Services::Preference.new.add registration task to clone the exact rake-task boilerplate and invocation convention (rake <namespace>:<task_name>).
  2. Write the task: register the flag with the exact params already used in the spec's before block (app/core/domains/interactors/whatsapp/broadcasts/user_create_broadcast_wa_cloud_spec.rb's segment context already calls Services::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).
  3. Run it in a local/staging console to confirm the flag appears in Flipper/Preference storage, defaulting OFF (no .enable call in the task itself).
  4. Quality gate: bundle exec rubocop --no-color.

Acceptance criteria

  • Flag send_campaign_with_segment exists in DB/Flipper after the task runs, default OFF
  • Enabling it per-org via Services::Preference.new.enable(:send_campaign_with_segment) / set_organization_ids works (already exercised by the test suite's before block)

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

DisciplineDays
Frontend
Backend0.5
QA
Total0.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

ActionFileWhat changes
n/aNo new files; verification-only task

Implementation steps

  1. bundle exec rubocop --no-color — fix any offenses across all files touched by Tasks 1–4.
  2. 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.
  3. 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).
  4. Per AGENTS.md's documented safe-command set, bin/overcommit_run is 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

DisciplineDays
Frontend
Backend0.5
QA0.5
Total1.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 / ItemReason
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.extraNon-blocking for development; Infosec sign-off required before 100% rollout, not before merging Tasks 1–5
Open Q #9 — retention/TTL for segment-derived PIINon-blocking for development; needed before 100% rollout sign-off
Open Q #2 — CDP p99 latency measurementNon-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)