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.testpresent, 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-locatedapp/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 onebroadcastsentry (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_campaignunchanged. - No migration; reads partition-pruned; case-2 batched (Bullet-clean);
rubocop/fullrspec/brakemangreen.
Effort Summary
| Task | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| T1 — Seed feature flag | — | 0.5 | — | 0.5 |
T2 — InteractiveBroadcastReply (case-1 detect/resolve) | — | 1 | 0.5 | 1.5 |
T3 — CampaignContentSerializer (content → text) | — | 1.5 | 0.5 | 2 |
T4 — CampaignMessageTypeResolver + CampaignContext entity/builder | — | 2 | 0.5 | 2.5 |
T5 — Rooms::CampaignContexts repository (cases 1/2) | — | 2.5 | 0.5 | 3 |
T6 — Extend CustomAgentAllocation + observability | — | 2 | 0.5 | 2.5 |
| T7 — Full verification / regression | — | 0.5 | 0.5 | 1 |
| Grand total | — | 10 | 3 | 13 |
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
| Action | File | What changes |
|---|---|---|
| add | flag 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
- Read AGENTS.md "Feature flags → Register a flag" and find where existing feature flags are seeded (migrations/admin tasks, not app code).
- Add the
add(...)call for:register_campaign_type_to_caa; leave it disabled. - Verify in the dummy app (see Run to verify).
Acceptance criteria
-
Services::Preference.new.enabled?(:register_campaign_type_to_caa)returnsfalseafter 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
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions: reuses the standard
Services::Preferencemechanism; 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/apps/whatsapp/services/interactive_broadcast_reply.rb | resolve(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'] |
| create | spec/apps/whatsapp/services/interactive_broadcast_reply_spec.rb [confirm spec location] | truth-table below |
Implementation steps
- Explore: read
hub_core/app/core/workers/message_broadcast_interactive_log_worker.rb:10-33(the resolution to mirror) andcustomer.rb:60-63(the guard). Noteraw_message['message_broadcast_id']on the parent outbound. - Red: write the spec truth-table (see ACs); confirm it fails.
- 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 unprunedfind_by(id:); returnnilif parent missing orraw_message['message_broadcast_id']blank. - Green + quality gate: rspec passes;
rubocopclean.
Acceptance criteria
- quick-reply reply (
type:'BUTTON'+ reply_id + parent hasmessage_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_id→nil. - parent lookup includes a
created_atrange (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
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | 0.5 |
| Total | 1.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_textstring 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/apps/whatsapp/services/campaign_content_serializer.rb | call(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 → "" |
| create | spec/apps/whatsapp/services/campaign_content_serializer_spec.rb [confirm spec location] | truth-table |
Implementation steps
- Explore: read
create.rb:154-164(HEADER/BODY/FOOTER/BUTTONS parse) anduser_send_hsm.rb:155-177(media format), plusbuilders/message_list/message.rb:95-98(carousel read). - Red: spec truth-table for every element rule.
- Implement: pure function; key media off
header[:format]; read carousel cards fromraw_message['carousel']; render missing product fields as"". - 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
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2 |
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_categoryfrom 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/apps/whatsapp/services/campaign_message_type_resolver.rb | message_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) |
| create | hub_core/app/core/domains/entities/webhooks/campaign_context.rb | Entities::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 |
| create | hub_core/app/core/domains/builders/webhooks/campaign_context.rb | maps (message, broadcast, template, replied_button:) → entity; campaign_sent_at = message.created_at; `message_body = message.text.presence |
| create | spec/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
- Explore:
message_template.rb:19-136(type/message_type/category/flags +.name);message_broadcast.rb:12(.name, template join); an existingDry::Structentity + builder pair (e.g.entities/message_by_webhook.rb,builders/message_by_webhook.rb) for the pattern. - Red: resolver truth-table + builder spec (10 fields;
campaign_sent_at == message.created_at). - Implement: resolver first; then entity (
Entities::AbstractEntity < Dry::Struct) + builder (returns a plain entity, wraps the resolver +CampaignContentSerializer). - 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_bodyfalls back totemplate.bodywhenmessage.textblank;replied_buttonpassed 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
| Discipline | Days |
|---|---|
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: entity/builder follow the existing
message_by_webhookpattern; 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/core/domains/repositories/rooms/campaign_contexts.rb | case 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 |
| create | spec/core/domains/repositories/rooms/campaign_contexts_spec.rb [confirm spec location] | full spec (below) |
Implementation steps
- 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 batchedincludes(:message_template)avoids N+1 (Bullet). - Red: spec covering case 1, case 2, empty, UC4 backdated, boundary, org/room scoping, status filter, cap 20 + truncation, Bullet-clean, error → empty.
- Implement: branch on
InteractiveBroadcastReply; build entities via Task 4's builder; anchor the window onmessage.created_at; scope byorganization_id(+room_idin case 2). - Partition-pruning check: run EXPLAIN on the case-2 query; confirm pruning +
room_iduse. If no suitable composite index exists (Open Q#2), add a migration underhub_core/database/core/db/migrate/(unless index_exists?guard) — small add to this task. - Green + quality gate (rspec + rubocop + Bullet-clean).
Acceptance criteria
- Case 1 →
trigger: "interactive_button",broadcastssize 1 (withreplied_button). - Case 2 →
trigger: "room_broadcasts", all in-window campaigns, newest-first, cap 20 (truncatedlogged 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/deletedstatus 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_idon 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
| Discipline | Days |
|---|---|
| Backend | 2.5 |
| QA | 0.5 |
| Total | 3 |
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_contextenvelope into the CAA payload (flag-gated), leaveslast_campaignuntouched, 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
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/services/webhooks/custom_agent_allocation.rb | in 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) |
| extend | spec/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
- Explore:
custom_agent_allocation.rb:44-47(build_message),:56(enqueue),:23(existingCaptureCustomMetricusage),:72-94(get_last_campaign— leave as-is). - Red: extend the existing spec: flag ON case 1/case 2/empty; flag OFF deep-equal to today;
last_campaignunchanged; rescue path; metric/log emitted. - Implement: flag check via
Services::Preference; merge the envelope; ensureCaaWorkerpayload (:56) carries it; add metrics/log; fail-open to OFF if the flag lookup raises (Open Q#5). - 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_contextabsent; payload deep-equals today. -
last_campaignbyte-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
| Discipline | Days |
|---|---|
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions:
CaaWorkerunchanged (sends the enriched body as-is); observability reusesCaptureCustomMetric/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
- Lint changed files, run the feature specs, then the full suite, then the security scan (commands below).
- Confirm no coverage drop / no Bullet failures / no new brakeman warnings.
- Capture the case-2 EXPLAIN output into the RFC §2.3 (closes Open Q#2 evidence).
Acceptance criteria
-
rubocop --no-colorexits 0 on changed files. - All six feature specs green; full
bundle exec rspecgreen (no regression). -
brakeman— no new warnings. - EXPLAIN output for the case-2 query recorded in the RFC.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1 |
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 / task | Reason |
|---|---|
| CAA-S05 (chatbot consumption) | Out of scope — owned by the Chatbot/Automation squad; this RFC only guarantees the campaign_context payload. |
last_campaign removal | Deferred (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.