Skip to main content

Task Breakdown — Register Campaign Type to CAA (Chatbot Autoreply), Backend

Vertical slicing, backend-only (hub_core). Derived from the RFC's §4.C Agent Execution Plan (rev5) + Detail 1.C. Design references: n/a — backend-only RFC (no UI). Repo: hub_core (local: /Users/isna.rahmatulmekari.com/Projects/qontak/chat/hub_core). Test command (AGENTS.md): cd hub_core && CATCH_WITH_ROLLBAR=true bundle exec rspec <path> (prereqs: .env.test present, RabbitMQ off; local ruby is 2.6.10 → RBENV_VERSION=2.6.10). Spec-location note: paths below follow the RFC §4.B (spec/apps/…, spec/core/domains/…). The AGENTS.md testing table also mentions co-located app/apps/**/*_spec.rb; confirm the squad's current convention before creating spec files — [confirm spec location].

Definition of Done (from §1 Success Criteria)

  • Case 1 (interactive-button reply) → campaign_context.trigger == "interactive_button", exactly one broadcasts entry (incl. replied_button).
  • Case 2 (non-interactive reply) → campaign_context.trigger == "room_broadcasts", all in-window campaigns (newest-first, cap 20).
  • No context / flag OFF → empty envelope / key absent (byte-compatible with today); last_campaign unchanged.
  • No migration; reads partition-pruned; case-2 batched (Bullet-clean); rubocop/full rspec/brakeman green.

Effort Summary

TaskFE daysBE daysQA daysTotal
T1 — Seed feature flag0.50.5
T2 — InteractiveBroadcastReply (case-1 detect/resolve)10.51.5
T3 — CampaignContentSerializer (content → text)1.50.52
T4 — CampaignMessageTypeResolver + CampaignContext entity/builder20.52.5
T5 — Rooms::CampaignContexts repository (cases 1/2)2.50.53
T6 — Extend CustomAgentAllocation + observability20.52.5
T7 — Full verification / regression0.50.51
Grand total10313

Confidence: medium. Biggest movers: Open Q#4 (serializer header/footer format — could reshape T3's spec), Open Q#2 (case-2 supporting index — could add a small migration to T5 if none exists), and Open Q#1 (exact case-2 eligible set). All are external confirmations, not architectural unknowns; the design is settled (rfc-reviewer rev5: 9.0 / Agentic-Ready).


Task 1: [BE] Seed feature flag register_campaign_type_to_caa (chunk 1)

A per-org kill-switch exists (default OFF) so the enrichment can ship dark and roll out per org.

Status: ✅ Actionable

What to build

Register the preference flag (default OFF) via Services::Preference, per AGENTS.md "Register a flag".

Implementation Plan

ActionFileWhat changes
addflag seeder (rake/admin task — [confirm seeder path with Infra])Services::Preference.new.add(:register_campaign_type_to_caa, title: 'Register campaign type to CAA', target: 'feature', author: 'isna.rahmatul@mekari.com') (do not enable)

Implementation steps

  1. Read AGENTS.md "Feature flags → Register a flag" and find where existing feature flags are seeded (migrations/admin tasks, not app code).
  2. Add the add(...) call for :register_campaign_type_to_caa; leave it disabled.
  3. Verify in the dummy app (see Run to verify).

Acceptance criteria

  • Services::Preference.new.enabled?(:register_campaign_type_to_caa) returns false after seeding.
  • The flag is org-scopable (enabled?(:..., organization_id: id)).

Test strategy

No unit spec (config). Verified by the runner check below; downstream tasks stub the flag in specs.

Effort estimate

DisciplineDays
Backend0.5
QA
Total0.5

Assumptions: reuses the standard Services::Preference mechanism; no new infra.

Run to verify

cd hub_core/spec/dummy && RBENV_VERSION=2.6.10 CATCH_WITH_ROLLBAR=true bundle exec rails runner \
"Services::Preference.new.add(:register_campaign_type_to_caa, title: 'Register campaign type to CAA', target: 'feature', author: 'isna.rahmatul@mekari.com'); puts Services::Preference.new.enabled?(:register_campaign_type_to_caa)"

Depends on

  • None.

Task 2: [BE] InteractiveBroadcastReply — case-1 detect + resolve (chunk 2)

Detects that an inbound message is a reply to a specific interactive (quick-reply) broadcast and returns which broadcast + which button was tapped.

Status: ✅ Actionable

What to build

A stateless service that mirrors the existing interactive-log condition: given the inbound message, return { broadcast_id, replied_button } when it is a quick-reply-button reply to a resolvable broadcast, else nil.

Implementation Plan

ActionFileWhat changes
createhub_core/app/apps/whatsapp/services/interactive_broadcast_reply.rbresolve(message, reference_at:){broadcast_id, replied_button} or nil; guard message.buttons&.first&.dig('type') == 'BUTTON'; parent lookup reply_id → Models::Message (pruned by created_at) → raw_message['message_broadcast_id']; replied_button = message.buttons.first['text']
createspec/apps/whatsapp/services/interactive_broadcast_reply_spec.rb [confirm spec location]truth-table below

Implementation steps

  1. Explore: read hub_core/app/core/workers/message_broadcast_interactive_log_worker.rb:10-33 (the resolution to mirror) and customer.rb:60-63 (the guard). Note raw_message['message_broadcast_id'] on the parent outbound.
  2. Red: write the spec truth-table (see ACs); confirm it fails.
  3. Implement: guard on buttons.first['type'] == 'BUTTON' + reply_id/context; resolve the parent with a partition-pruned lookup (created_at: (reference_at - 30.days)..reference_at) — improves on the worker's unpruned find_by(id:); return nil if parent missing or raw_message['message_broadcast_id'] blank.
  4. Green + quality gate: rspec passes; rubocop clean.

Acceptance criteria

  • quick-reply reply (type:'BUTTON' + reply_id + parent has message_broadcast_id) → { broadcast_id, replied_button: <text> }.
  • order-message type:'BUTTON' without a broadcast parent → nil (falls to case 2).
  • interactive-list type:'BUTTON_REPLY'nil.
  • non-button reply / no reply_idnil.
  • parent lookup includes a created_at range (partition-pruned) — assert the query scope.

Test strategy

Real Models::Message factories (inbound reply + parent outbound with raw_message); assert the returned tuple / nil per row. Stub nothing external (pure DB read).

Effort estimate

DisciplineDays
Backend1
QA0.5
Total1.5

Assumptions: reuses the worker's resolution logic; no new model; buttons/reply_id already on the message row.

Run to verify

cd hub_core && RBENV_VERSION=2.6.10 CATCH_WITH_ROLLBAR=true bundle exec rspec spec/apps/whatsapp/services/interactive_broadcast_reply_spec.rb && RBENV_VERSION=2.6.10 bundle exec rubocop --no-color app/apps/whatsapp/services/interactive_broadcast_reply.rb

Depends on

  • None (independent service).

Task 3: [BE] CampaignContentSerializer — content → text (chunk 3)

Renders a campaign message's elements into the message_content_text string the chatbot reads.

Status: ⚠️ Partially blocked — the header/footer output format is contingent on Open Q#4 (PRD table says "not available" vs CAA-S03 AC "{header}: {body} — {footer}"). Build against the AC now; a one-line PM confirmation locks it. Everything else (body/media/buttons/carousel/missing-field) is actionable.

What to build

A stateless serializer modeled on extract_content (create.rb:154-164) + the header-format branching in user_send_hsm.rb:155-177.

Implementation Plan

ActionFileWhat changes
createhub_core/app/apps/whatsapp/services/campaign_content_serializer.rbcall(template:, message:) → text: body as-is; header+footer → "{header}: {body} — {footer}" (AC form); media (header[:format] ∈ {IMAGE,VIDEO,DOCUMENT}) → append "[template contains image media]"; buttons ignored; carousel (message.raw_message['carousel']['cards']) → "[Products: 1. \"{name}\" — {price} — \"{description}\" / …]"; missing field → ""
createspec/apps/whatsapp/services/campaign_content_serializer_spec.rb [confirm spec location]truth-table

Implementation steps

  1. Explore: read create.rb:154-164 (HEADER/BODY/FOOTER/BUTTONS parse) and user_send_hsm.rb:155-177 (media format), plus builders/message_list/message.rb:95-98 (carousel read).
  2. Red: spec truth-table for every element rule.
  3. Implement: pure function; key media off header[:format]; read carousel cards from raw_message['carousel']; render missing product fields as "".
  4. Green + quality gate.

Acceptance criteria

  • body only → body as-is.
  • header+footer present → "{header}: {body} — {footer}" (pending Open Q#4 confirmation).
  • media header → output contains "[template contains image media]".
  • quick-reply/CTA buttons → not included in the text.
  • carousel → "[Products: 1. \"…\" — … — \"…\" / …]"; missing name/price/description → "" (rendered, not omitted).

Test strategy

Template factories with each shape (text/header+footer/media/buttons/carousel); assert exact strings.

Effort estimate

DisciplineDays
Backend1.5
QA0.5
Total2

Assumptions: no existing serializer to extend (verified); carousel branch is exercised only when the case-2 set includes a carousel (kept for forward-compat). Open Q#4 may flip the header/footer branch — small spec change.

Run to verify

cd hub_core && RBENV_VERSION=2.6.10 CATCH_WITH_ROLLBAR=true bundle exec rspec spec/apps/whatsapp/services/campaign_content_serializer_spec.rb

Depends on

  • Open Q#4 (PM confirms header/footer authority) — for the header/footer branch only.

Task 4: [BE] CampaignMessageTypeResolver + CampaignContext entity/builder (chunk 4)

Composes message_type/message_category from template flags and produces the canonical 10-field per-broadcast object.

Status: ✅ Actionable

What to build

(a) a resolver that maps template flags → message_type + message_category; (b) the Dry::Struct entity (10 fields) and its builder that maps (message, broadcast, template) → entity.

Implementation Plan

ActionFileWhat changes
createhub_core/app/apps/whatsapp/services/campaign_message_type_resolver.rbmessage_type(template) (campaign/follow_up/marketing_opt_in/phone_number_request/carousel from type,message_type,is_marketing_opt_in,is_request_phone_number); message_category(template) (utility/marketing, downcased)
createhub_core/app/core/domains/entities/webhooks/campaign_context.rbEntities::Webhooks::CampaignContext < Entities::AbstractEntity; 10 attrs: broadcast_id, broadcast_name, template_id, template_name, message_type, message_category, campaign_sent_at, message_body, message_content_text, replied_button
createhub_core/app/core/domains/builders/webhooks/campaign_context.rbmaps (message, broadcast, template, replied_button:) → entity; campaign_sent_at = message.created_at; `message_body = message.text.presence
createspec/apps/whatsapp/services/campaign_message_type_resolver_spec.rb + spec/core/domains/builders/webhooks/campaign_context_spec.rb [confirm spec location]truth-tables

Implementation steps

  1. Explore: message_template.rb:19-136 (type/message_type/category/flags + .name); message_broadcast.rb:12 (.name, template join); an existing Dry::Struct entity + builder pair (e.g. entities/message_by_webhook.rb, builders/message_by_webhook.rb) for the pattern.
  2. Red: resolver truth-table + builder spec (10 fields; campaign_sent_at == message.created_at).
  3. Implement: resolver first; then entity (Entities::AbstractEntity < Dry::Struct) + builder (returns a plain entity, wraps the resolver + CampaignContentSerializer).
  4. Green + quality gate.

Acceptance criteria

  • resolver: each template shape → correct message_type + message_category (lowercase).
  • entity has all 10 fields; optional fields nullable (broadcast_name/template_id/template_name/replied_button).
  • builder: campaign_sent_at == message.created_at; message_body falls back to template.body when message.text blank; replied_button passed through (nil for case 2).

Test strategy

Resolver: table of templates → expected enums. Builder: given message+broadcast+template → assert every field; builder returns entity (not a monad).

Effort estimate

DisciplineDays
Backend2
QA0.5
Total2.5

Assumptions: entity/builder follow the existing message_by_webhook pattern; resolver is pure.

Run to verify

cd hub_core && RBENV_VERSION=2.6.10 CATCH_WITH_ROLLBAR=true bundle exec rspec spec/apps/whatsapp/services/campaign_message_type_resolver_spec.rb spec/core/domains/builders/webhooks/campaign_context_spec.rb

Depends on

  • Task 3 (builder calls CampaignContentSerializer).

Task 5: [BE] Rooms::CampaignContexts repository — cases 1 & 2 (chunk 5)

The orchestrator: decides case 1 vs case 2, resolves broadcasts (batched), and returns the { trigger, broadcasts } envelope data.

Status: ✅ Actionable

What to build

Repositories::Rooms::CampaignContexts#call(message, room)Success(entity_set) where the set has trigger + broadcasts (array of CampaignContext entities). Case 1 via InteractiveBroadcastReply (single); else case 2 via the room window + batched broadcast→template resolution (newest-first, cap 20); empty envelope + log on error.

Implementation Plan

ActionFileWhat changes
createhub_core/app/core/domains/repositories/rooms/campaign_contexts.rbcase 1: InteractiveBroadcastReply.resolve → 1 broadcast → builder; case 2: Models::Message.where(organization_id:, room_id:, is_campaign: true, status: %w[created sent delivered read]).where(created_at: (ref-30.days)..ref).order(created_at: :desc).limit(20) → collect raw_message['message_broadcast_id']one Models::MessageBroadcast.where(id: ids).includes(:message_template) → builder per message; rescue → empty envelope + CustomLogFormat.error
createspec/core/domains/repositories/rooms/campaign_contexts_spec.rb [confirm spec location]full spec (below)

Implementation steps

  1. Explore: mark_replied_worker.rb:115-148 (window) + conversation_sessions.rb:15 (list-all shape); abstract_repository.rb:3,59 (success/failure, switch_replica_db). Confirm the batched includes(:message_template) avoids N+1 (Bullet).
  2. Red: spec covering case 1, case 2, empty, UC4 backdated, boundary, org/room scoping, status filter, cap 20 + truncation, Bullet-clean, error → empty.
  3. Implement: branch on InteractiveBroadcastReply; build entities via Task 4's builder; anchor the window on message.created_at; scope by organization_id (+ room_id in case 2).
  4. Partition-pruning check: run EXPLAIN on the case-2 query; confirm pruning + room_id use. If no suitable composite index exists (Open Q#2), add a migration under hub_core/database/core/db/migrate/ (unless index_exists? guard) — small add to this task.
  5. Green + quality gate (rspec + rubocop + Bullet-clean).

Acceptance criteria

  • Case 1 → trigger: "interactive_button", broadcasts size 1 (with replied_button).
  • Case 2 → trigger: "room_broadcasts", all in-window campaigns, newest-first, cap 20 (truncated logged beyond 20).
  • No prior campaign / not a reply → trigger: null, broadcasts: [].
  • UC4 backdated (campaign created_at < room.created_at) resolved; exactly-30-days included; >30 days excluded.
  • wrong org / wrong room / failed/deleted status excluded.
  • case-2 broadcast→template resolution is a single batched query (Bullet-clean).
  • any resolve/serialize error → empty envelope (never raises).
  • EXPLAIN shows partition pruning + room_id on the case-2 read.

Test strategy

Real factories (room + inbound reply + campaign outbounds with raw_message); assert envelope shape per case; a Bullet assertion (or n_plus_one guard) on case 2; an EXPLAIN check in a dedicated example or the runner.

Effort estimate

DisciplineDays
Backend2.5
QA0.5
Total3

Assumptions: reuses Tasks 2–4; +0.5 BE contingency folded in if Open Q#2 needs a small index migration.

Run to verify

cd hub_core && RBENV_VERSION=2.6.10 CATCH_WITH_ROLLBAR=true bundle exec rspec spec/core/domains/repositories/rooms/campaign_contexts_spec.rb

Depends on

  • Tasks 2, 3, 4. · Open Q#2 (index) resolved within this task's EXPLAIN step.

Task 6: [BE] Extend CustomAgentAllocation + observability (chunks 6 & 7)

Merges the campaign_context envelope into the CAA payload (flag-gated), leaves last_campaign untouched, and emits the enrichment metrics/logs.

Status: ✅ Actionable

What to build

In CustomAgentAllocation#build_message: when register_campaign_type_to_caa is ON, call Rooms::CampaignContexts, merge campaign_context: { trigger:, broadcasts: [...] } into the payload; when OFF, omit the key (byte-compatible with today). Emit caa_campaign_context_enriched / caa_campaign_context_skipped metrics + [caa campaign enrichment] log (no raw body/content text).

Implementation Plan

ActionFileWhat changes
extendhub_core/app/core/domains/services/webhooks/custom_agent_allocation.rbin build_message (~:44-47): flag guard → merge campaign_context envelope from the repo; leave get_last_campaign/last_campaign unchanged; wrap in the fail-safe rescue; add Services::Datadog::CaptureCustomMetric calls + CustomLogFormat line (tags/fields only, no body text)
extendspec/core/domains/services/webhooks/custom_agent_allocation_spec.rb [confirm spec location]envelope present (both cases + empty) when flag ON; absent when OFF (deep-equal today); last_campaign identical in all cases; error → today's payload; metrics/log assertions

Implementation steps

  1. Explore: custom_agent_allocation.rb:44-47 (build_message), :56 (enqueue), :23 (existing CaptureCustomMetric usage), :72-94 (get_last_campaign — leave as-is).
  2. Red: extend the existing spec: flag ON case 1/case 2/empty; flag OFF deep-equal to today; last_campaign unchanged; rescue path; metric/log emitted.
  3. Implement: flag check via Services::Preference; merge the envelope; ensure CaaWorker payload (:56) carries it; add metrics/log; fail-open to OFF if the flag lookup raises (Open Q#5).
  4. Green + quality gate.

Acceptance criteria

  • flag ON + case 1 → payload has campaign_context.trigger == "interactive_button" (1 broadcast).
  • flag ON + case 2 → ... == "room_broadcasts" (N broadcasts).
  • flag ON + no context → campaign_context: { trigger: null, broadcasts: [] }.
  • flag OFF → campaign_context absent; payload deep-equals today.
  • last_campaign byte-identical in every case.
  • enrichment error → today's payload still delivered (rescued).
  • caa_campaign_context_enriched{trigger,count,truncated} on hit; caa_campaign_context_skipped{reason} on skip; log excludes body/content.

Test strategy

Extend the existing CAA spec; stub Services::Preference; use factories driving case 1/2/empty; assert payload hash deep-equality for the flag-OFF path; assert metric/log calls.

Effort estimate

DisciplineDays
Backend2
QA0.5
Total2.5

Assumptions: CaaWorker unchanged (sends the enriched body as-is); observability reuses CaptureCustomMetric/CustomLogFormat.

Run to verify

cd hub_core && RBENV_VERSION=2.6.10 CATCH_WITH_ROLLBAR=true bundle exec rspec spec/core/domains/services/webhooks/custom_agent_allocation_spec.rb

Depends on

  • Task 1 (flag) · Task 5 (repo) · Open Q#5 (Preference fail-open) confirmed here.

Task 7: [BE] Full verification / regression (chunk 8)

Confirms the whole feature is green and non-regressive before the PR is opened.

Status: ✅ Actionable (after T1–T6)

What to build

No new code — the pre-merge gate.

Implementation steps

  1. Lint changed files, run the feature specs, then the full suite, then the security scan (commands below).
  2. Confirm no coverage drop / no Bullet failures / no new brakeman warnings.
  3. Capture the case-2 EXPLAIN output into the RFC §2.3 (closes Open Q#2 evidence).

Acceptance criteria

  • rubocop --no-color exits 0 on changed files.
  • All six feature specs green; full bundle exec rspec green (no regression).
  • brakeman — no new warnings.
  • EXPLAIN output for the case-2 query recorded in the RFC.

Effort estimate

DisciplineDays
Backend0.5
QA0.5
Total1

Assumptions: no unrelated pre-existing failures beyond the known ES/Kafka set (compare against a clean baseline).

Run to verify

cd hub_core && RBENV_VERSION=2.6.10 bundle exec rubocop --no-color \
&& RBENV_VERSION=2.6.10 CATCH_WITH_ROLLBAR=true bundle exec rspec \
&& RBENV_VERSION=2.6.10 bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q

Depends on

  • Tasks 1–6.

Ordering rationale

  • Critical path: T2 + T3 + T4 → T5 (repo, the heaviest, 3d) → T6 (integration) → T7. T1 (flag) is independent and can be done first/in parallel.
  • T2, T3, T4 parallelize across up to three devs (independent files); T4 depends on T3 (builder calls the serializer), so pair those if split.
  • Push externally now to keep the path clear: Open Q#4 (header/footer — unblocks T3's spec), Open Q#2 (index — verified inside T5, may add a small migration), Open Q#1 (case-2 eligible set — confirm before T5's spec is finalized).
  • T6 is the only place the payload contract meets the wire — do the flag-OFF deep-equality assertion here to guarantee zero regression.
  • T7 is a gate, not feature work — but keep it a task so the EXPLAIN evidence + full-suite run are owned, not assumed.

Skipped stories

Story / taskReason
CAA-S05 (chatbot consumption)Out of scope — owned by the Chatbot/Automation squad; this RFC only guarantees the campaign_context payload.
last_campaign removalDeferred (Open Q#10) — kept + deprecated here; removed in a post-GA follow-up once consumers migrate.

No task is fully blocked. T3 is ⚠️ partially blocked on Open Q#4 (header/footer format) — buildable now against the AC form.