Direct Send API — Task Breakdown
RFC: RFC-2026-001 v2.8
Source: direct-send-api.md
Services: hub_core · hub-service
Execute chunks in order. Within a chunk, tasks can run in parallel unless noted.
v2.8 implementation status: Code-verified 2026-07-14. Tasks marked ✅ are already implemented in the codebase. Remaining tasks are unmarked. The advisory lock in Task 2.2 was NOT implemented in the current
agent_sends_message.rb— thelocal_ididempotency check must still be added (see RFC ADR-10 v2.8 corrected sample).
Summary
| Chunk | Area | Tasks |
|---|---|---|
| 1 | DB Migrations + Model Updates (hub_core) | 8 tasks |
| 2 | Interactors + Services + Workers (hub_core) | 15 tasks |
| 3 | Grape Endpoints (hub-service) | 8 tasks |
| 4 | Webhook Handlers (hub-service) | 4 tasks |
Implementation Status (verified 2026-07-14)
| Task | File | Status |
|---|---|---|
1.1 — message_templates migration | 20260603000001_add_direct_send_columns_to_message_templates.rb | ✅ Done |
1.2 — messages header/footer/index | pending | ❌ TODO |
1.3 — Models::DirectSendRoom | app/core/domains/models/direct_send_room.rb | ✅ Done |
1.4 — Models::Room TYPES + helpers | direct_send_room?, direct_send_locked? in room.rb | ✅ Done |
2.1 — DirectSend::Services::Metrics | pending | ❌ TODO |
2.2 — AgentSendsMessage | app/apps/direct_send/interactors/agent_sends_message.rb | ⚠️ Partial — local_id idempotency + advisory lock not implemented |
2.3 — AgentListsContacts | app/apps/direct_send/interactors/agent_lists_contacts.rb | ✅ Done |
2.4 — AdminGetsRestrictionStatus | app/apps/direct_send/interactors/admin_gets_restriction_status.rb | ✅ Done |
2.5 — HandleTemplateCategoryMismatch | app/apps/direct_send/interactors/handle_template_category_mismatch.rb | ✅ Done |
2.6 — HandleAccountRestriction | app/apps/direct_send/interactors/handle_account_restriction.rb (called via ReceiveNotificationAccountUpdate) | ✅ Done |
2.7 — SyncTemplateFromMeta | app/apps/direct_send/interactors/sync_template_from_meta.rb | ✅ Done |
2.8 — WaCloud::Builders::DirectSendMessage | app/apps/wa_cloud/builders/direct_send_message.rb | ✅ Done |
2.9 — WaCloud::Repositories::Messages::Send dispatch | DirectSendRoom branch at line 27 of send.rb | ✅ Done |
2.10 — AgentSendMessage locked-room guard | Lines 91-92 of wa_cloud/interactors/agent_send_message.rb | ✅ Done |
2.12 — ReindexRoomWorker | pending | ❌ TODO |
2.13 — ScrubPii repository | pending | ❌ TODO |
| 2.14 — Room unlock on inbound reply | unverified | ❓ Verify |
| Chunk 3 — Grape endpoints | direct_send/resources/{contacts,messages,restriction_status}.rb | ✅ Done |
4.x — template_correct_category_detection webhook | waba.rb → HandleTemplateCategoryMismatch | ✅ Done |
4.x — SyncTemplateFromMeta in statuses branch | pending — not in waba.rb yet | ❌ TODO |
Chunk 1 — DB Migrations + Model Updates (hub_core)
Gate:
bundle exec rails db:migrate && bundle exec rspec spec/apps/ && bundle exec rubocop --no-color app/core/domains/models/
✅ Task 1.1 — Migration: add Direct Send columns to message_templates — DONE
File: hub_core/database/core/db/migrate/20260603000001_add_direct_send_columns_to_message_templates.rb
Status: Migration already ran. is_direct_send (boolean), source (varchar 50), and idx_message_templates_is_direct_send partial index all exist.
Actual migration uses
limit: 50onsource(not 255). The RFC § 4.1 DDL and § 7 migration code were corrected in v2.8.
Acceptance (already passing): Models::MessageTemplate.column_names.include?('is_direct_send') → true; Models::MessageTemplate.column_names.include?('source') → true
Task 1.2 — Migration: add header/footer to messages + local_id index
File: hub_core/database/core/db/migrate/<timestamp>_add_direct_send_columns_to_messages.rb
Action: Create new migration
Note (v2.8):
messages.local_idalready exists (migration20210202045334, no length constraint). This migration addsheader,footer, and the partition-pruning index only.
# frozen_string_literal: true
class AddDirectSendColumnsToMessages < ActiveRecord::Migration[6.1]
def change
add_column :messages, :header, :string, limit: 60 unless column_exists?(:messages, :header)
add_column :messages, :footer, :string, limit: 60 unless column_exists?(:messages, :footer)
unless index_exists?(:messages, [:local_id, :organization_id], name: 'idx_messages_local_id_org')
add_index :messages, [:local_id, :organization_id],
where: 'local_id IS NOT NULL',
name: 'idx_messages_local_id_org'
end
end
end
Note: messages is range-partitioned by created_at — the local_id index must be partial (WHERE local_id IS NOT NULL).
Acceptance: Models::Message.column_names.include?('header') → true; ActiveRecord::Base.connection.indexes(:messages).any? { |i| i.name == 'idx_messages_local_id_org' } → true
Task 1.3 — Create Models::DirectSendRoom STI class
File: hub_core/app/core/domains/models/direct_send_room.rb
Action: Create new file
# frozen_string_literal: true
class Models::DirectSendRoom < Models::CustomerServiceRoom
# index_name [Rails.env[0..3], 'models_rooms'].join('_')
end
Note: No new migration — reuses existing type STI column on rooms.
Acceptance: Models::DirectSendRoom.superclass == Models::CustomerServiceRoom → true; creating via .create! sets type='Models::DirectSendRoom' in DB
Task 1.4 — Update Models::Room — extend TYPES + add helper methods
File: hub_core/app/core/domains/models/room.rb
Action: Edit
- Add
'Models::DirectSendRoom'toTYPESconstant - Add helper methods:
def direct_send_room?is_a?(Models::DirectSendRoom)enddef direct_send_locked?direct_send_room? && extra&.dig('is_locked')end
Acceptance: Models::Room::TYPES.include?('Models::DirectSendRoom') → true; room.direct_send_locked? returns true for a newly locked Direct Send room
Task 1.5 — Update Models::MessageTemplate — add named scopes
File: hub_core/app/core/domains/models/message_template.rb
Action: Edit — add scopes
scope :direct_send, -> { where(is_direct_send: true) }
scope :manual, -> { where(is_direct_send: false) }
Acceptance: Models::MessageTemplate.respond_to?(:direct_send) → true
Task 1.6 — Update Models::Message — add accessors for new columns
File: hub_core/app/core/domains/models/message.rb
Action: Edit — ensure header, footer, local_id are accessible (add attr_accessor or confirm column mapping picks them up automatically after migration)
Task 1.7 — Update Models::ChannelIntegration — add store_accessor
File: hub_core/app/core/domains/models/channel_integration.rb
Action: Edit — add to existing store_accessor :settings block:
store_accessor :settings, :direct_send_restriction
Task 1.8 — Update Models::Organization — add direct_send_enabled settings accessor
File: hub_core/app/core/domains/models/organization.rb
Action: Edit — add to existing store_accessor :settings:
store_accessor :settings, :direct_send_enabled
Note: customer_360 already exists via current settings access pattern — do NOT add it.
Chunk 2 — New Interactors + Services + Workers (hub_core)
Gate:
bundle exec rspec spec/apps/direct_send/ && bundle exec rubocop --no-color app/apps/direct_send/ && bundle exec brakeman --no-exit-on-warn --no-exit-on-error
Prerequisite: Chunk 1 complete.
Task 2.1 — Create DirectSend::Services::Metrics (ADR-17 / IMP-002)
File: hub_core/app/apps/direct_send/services/metrics.rb
Action: Create new file — centralised Datadog counter emitter
Key metrics to emit (degrade gracefully when no statsd client):
| Metric | Tags |
|---|---|
direct_send.messages.total | status (success / failed:contact_count_bucket (1/2/3/other), channel |
direct_send.idempotency.hits | organization_id |
direct_send.template_sync.total | status (already_synced / non_direct_send_template / channel_not_found / meta_api_error / upsert_failed / synced) |
direct_send.es_sync.failed | stage (interactor / reindex_worker) |
direct_send.pii_scrub.total | source (contact / explicit_ids), count_bucket |
Acceptance: DirectSend::Services::Metrics.increment(metric, tags: {...}) does not raise when statsd is not configured; correct calls verified in specs via argument-shape assertions
Task 2.2 — Create DirectSend::Interactors::AgentSendsMessage
File: hub_core/app/apps/direct_send/interactors/agent_sends_message.rb
Action: Create — main send interactor
Key requirements (v2.6 + v2.7):
- Contract attributes:
organization_id,channel_integration_id,contact_ids(Array[UUID], min 1 max 3),sender_id,local_id(optional, max 64),message(Hash) - Field-length constants exported:
BODY_MAX_LEN = 1024,HEADER_MAX_LEN = 60,FOOTER_MAX_LEN = 60,CTA_LABEL_MAX_LEN = 20,REPLY_TITLE_MAX_LEN = 20(IMP-001) Rollbar.scope!called after param validation withsender_id,organization_id,channel_integration_id,direct_send: true(IMP-004)- Batch-level idempotency check with
pg_advisory_xact_lock+ 7-day partition window (ADR-10 / v2.6):existing_messages = Models::Message.where(local_id:, organization_id:, created_at: 7.days.ago..Time.zone.now)- Duplicate response carries original
sender_idviaassigned_agent_id: message.sender_id, fresh_send: false(IMP-005)
- Duplicate response carries original
- Validate channel (
target_channel == 'wa_cloud', same org) validate_not_restricted(channel)— readschannel.settings['direct_send_restriction']; honors lapsed expiration (v2.6 ADR-07)- Validate contacts (exist, same org, not blocked)
validate_balance(org, channel, count)— read-only, symbol failures (:account_frozen,:package_inactive,:insufficient_balance), subtracts batch cost from each pool before checking (v2.6 ADR-13)- Single
ApplicationRecord.transactionwrapping all contacts; in-tx recheck ofvalidate_contact_no_active_roomper contact AFTER advisory lock (v2.6); surface specific failure symbol from losing contact (IMP-010) - Per-contact:
INSERT rooms(type=Models::DirectSendRoom,extra: {is_locked: true}),INSERT participants,INSERT messages(withheader,footercolumns ANDraw_messageJSONB — dual storage per ADR-15) - Post-commit:
Publishers::MessageSend.publish(id: message.id)per room - Post-commit ES ops:
es_index_document,SetAttributes(status: assigned, agent_ids: [sender_id]),SetLastMessage; on ES failure: emites_sync.failedmetric + enqueueReindexRoomWorker(IMP-007) - Emit
Rails.logger.info(event: 'direct_send.message_created', ...)with full CDG field set on success (IMP-004) - Emit
direct_send.messages.totalmetric at every terminal branch (IMP-002) - Do NOT call
deduct_balance— balance deduction is webhook-driven viaWaDeductionWorker(ADR-13)
Task 2.3 — Create DirectSend::Interactors::AgentListsContacts
File: hub_core/app/apps/direct_send/interactors/agent_lists_contacts.rb
Action: Create
- Checks
direct_send_enabledfeature flag →Failure(:unauthorized)if off - Reads
organization.settings['customer_360']at runtimecustomer_360 == true→ callCentralizedContacts::Services::Apis(10s timeout); on failure rescue + Rollbar warning → fall back to internal ES query; addX-Contact-Source: internal-fallbackheadercustomer_360 == false/nil→ query internal Elasticsearch viaRepositories::Contacts::ContactObjects::All
- Returns
Entities::ContactObjectlist with cursor pagination (base64-encoded cursors)
Task 2.4 — Create DirectSend::Interactors::AdminGetsRestrictionStatus
File: hub_core/app/apps/direct_send/interactors/admin_gets_restriction_status.rb
Action: Create
- Checks
direct_send_enabledflag →Failure(:unauthorized)if off - Loads channel integration by
channel_integration_id+organization_id(scoped) - Reads
channel.settings['direct_send_restriction'] - Returns
{ is_restricted:, violation_type:, restriction_type:, expires_at: }
Task 2.5 — Create DirectSend::Interactors::HandleTemplateCategoryMismatch
File: hub_core/app/apps/direct_send/interactors/handle_template_category_mismatch.rb
Action: Create
- Receives
template_idfrom Meta webhook - Scopes lookup to
Models::MessageTemplate.where(organization_id:, is_direct_send: true)— non-Direct-Send templates with same Metatemplate_idare deliberately ignored (v2.6 ADR-07) - On match: sets
status = 'FLAGGED' - On no match:
Success(:template_not_found)(no-op)
Task 2.6 — Create DirectSend::Interactors::HandleAccountRestriction
File: hub_core/app/apps/direct_send/interactors/handle_account_restriction.rb
Action: Create
- Receives
violation_type,restriction_type,expirationfromaccount_update / ACCOUNT_RESTRICTIONwebhook - Writes to
channel_integration.settings['direct_send_restriction']:{ 'violation_type' => ..., 'restriction_type' => ..., 'expiration' => ..., 'is_active' => true } - On unban webhook: merges
is_active: false
Task 2.7 — Create DirectSend::Interactors::SyncTemplateFromMeta
File: hub_core/app/apps/direct_send/interactors/sync_template_from_meta.rb
Action: Create
- Pre-Meta-API short-circuit:
- Existing row with
is_direct_send: true→Success(:already_synced)(no Meta call) - Existing row with
is_direct_send: false→Success(:non_direct_send_template)(no mutation, no Meta call) — v2.6
- Existing row with
- If no row: call
GET /<WABA_ID>/message_templates/{template_id}(10s timeout, no retry)- On Meta success: upsert with
is_direct_send: true, source: 'AUTO_GENERATED'; defense-in-depth: if concurrent path created row withis_direct_send: false, returnFailure(:not_direct_send_template)rather than flipping - On Meta failure: log + emit
template_sync.total{status:meta_api_error}+ returnFailure
- On Meta success: upsert with
- Emit
template_sync.totalmetric at every return point (IMP-002) - Always returns
Success/Failure; caller MUST still return HTTP 200 to Meta even onFailure
Task 2.8 — Create WaCloud::Builders::DirectSendMessage (ADR-14)
File: hub_core/app/apps/wa_cloud/builders/direct_send_message.rb
Action: Create
Constructor signature: new(source, phone) / self.build(source, phone:) (IMP-006)
Supported types → Meta payload:
text→{ type: 'text', text: { body: }, category: 'utility' }(nopreview_url)interactive_cta_url→{ type: 'interactive', interactive: { type: 'cta_url', body:, action: { name: 'cta_url', parameters: { display_text:, url: } }, header?: { type:'text', text: }, footer?: { text: } }, category: 'utility' }interactive_reply_button→{ type: 'interactive', interactive: { type: 'button', body:, action: { buttons: [{type:'reply', reply:{id:,title:}}] }, header?:, footer?: }, category: 'utility' }ttl_secondsplaced at top level when present
Error code handling in the send repository (not the builder):
132015(template paused) →status='failed'+ system message139200(account restricted) →status='failed'+ system message + callHandleAccountRestriction
Task 2.9 — Edit WaCloud::Repositories::Messages::Send — add DirectSendRoom dispatch
File: hub_core/app/apps/wa_cloud/repositories/messages/send.rb
Action: Edit — add 2-branch dispatch before calling service.send_message
payload = if @room.is_a?(Models::DirectSendRoom)
WaCloud::Builders::DirectSendMessage.build(@message, phone: @room.contact.phone.to_phone)
else
WaCloud::Builders::NewMessage.build(@message,
recipient_identifier: recipient_identifier,
is_wa_group: @is_wa_group
)
end
Note: WaCloud::Builders::NewMessage must remain untouched.
Task 2.10 — Edit WaCloud::Interactors::AgentSendMessage — add locked-room guard
File: hub_core/app/apps/wa_cloud/interactors/agent_send_message.rb
Action: Edit — add early return inside result method (before send logic)
if room.is_a?(Models::DirectSendRoom) && room.extra&.dig('is_locked')
return Failure(:direct_send_room_locked)
end
Task 2.11 — Edit Interactors::Whatsapp::Template::UserListLocalTemplate — add filter
File: hub_core/app/core/domains/interactors/whatsapp/templates/user_list_local_template.rb
Action: Edit
- Accept new optional param
is_direct_send_template: boolean - When
true: addwhere(is_direct_send: true)to query; enforce admin/owner guard →Failure(:unauthorized)for other roles - When absent or
false: existing behavior unchanged
Task 2.12 — Create DirectSend::Workers::ReindexRoomWorker (IMP-007)
File: hub_core/app/apps/direct_send/workers/reindex_room_worker.rb
Action: Create — Sidekiq worker for ES self-heal after post-commit failure
- Accepts
message_id,sender_id - Loads message + room from DB
- Re-runs:
es_index_document,Services::Elasticsearch::Rooms::SetAttributes(status: 'assigned', agent_ids: [sender_id]),SetLastMessage - Idempotent — safe to run multiple times
- On failure: emit
direct_send.es_sync.failed{stage:reindex_worker}metric
Task 2.13 — Create DirectSend::Repositories::Messages::ScrubPii (IMP-003 / OQ-14)
File: hub_core/app/apps/direct_send/repositories/messages/scrub_pii.rb
Action: Create
DirectSend::Repositories::Messages::ScrubPii.new(
organization_id: organization_id,
contact_id: contact_id, # OR: message_ids: [uuid, ...]
lookback: 90.days # partition-prune window; default
).call # => Success(scrubbed: N) | Failure(:scrub_failed)
Scrubs (for Models::DirectSendRoom rows only, org-isolated):
messages.text→'[REDACTED]'messages.header,messages.footer→nilraw_messageJSONB keys:header,footer,cta_button,reply_buttons→ removed- Preserves:
ttl_seconds, Meta status envelope keys (non-PII)
Emit direct_send.pii_scrub.total metric on completion (IMP-002)
Task 2.14 — Add room unlock on first inbound customer message
File: Inbound message subscriber (locate via existing CustomerSendMessage flow in wa_cloud)
Action: Edit — add unlock block after room load
if room.is_a?(Models::DirectSendRoom) && room.extra&.dig('is_locked')
room.update!(extra: (room.extra || {}).merge('is_locked' => false))
Services::Elasticsearch::Rooms::SetAttributes.new(
id: room.id, organization_id: room.organization_id,
extra: room.reload.extra
).call
end
Task 2.15 — Write specs for all Chunk 2 files
Files:
hub_core/spec/apps/direct_send/interactors/agent_sends_message_spec.rbhub_core/spec/apps/direct_send/interactors/agent_lists_contacts_spec.rbhub_core/spec/apps/direct_send/interactors/admin_gets_restriction_status_spec.rbhub_core/spec/apps/direct_send/interactors/handle_template_category_mismatch_spec.rbhub_core/spec/apps/direct_send/interactors/handle_account_restriction_spec.rbhub_core/spec/apps/direct_send/interactors/sync_template_from_meta_spec.rbhub_core/spec/apps/wa_cloud/builders/direct_send_message_spec.rbhub_core/spec/apps/direct_send/services/metrics_spec.rbhub_core/spec/apps/direct_send/workers/reindex_room_worker_spec.rbhub_core/spec/apps/direct_send/repositories/messages/scrub_pii_spec.rb
Key spec cases (from § 7 Chunk 2 acceptance criteria):
AgentSendsMessage:
contact_ids: [uuid1, uuid2]→Success([room1, room2]); publisher called once per contact- Same
local_idagain → returns original rooms; no new rooms; no publisher calls contact_ids: []→ validation failure- Any contact
assigned→Failure(:contact_already_assigned); NO rooms created (full rollback) - Any contact has active room →
Failure(:contact_has_active_room); NO rooms created - Insufficient balance →
Failure(:insufficient_balance); NO rooms created; NO billing DB writes - No
deduct_balancecall anywhere;WhatsappPackage.balanceunchanged immediately post-call validate_not_restricted→Failure(:direct_send_restricted)whenis_active: trueand expiration in future or blankvalidate_not_restricted→Successwhen expiration is in the past (lapsed)- Idempotency lookup includes
created_at: 7.days.ago..partition window - Concurrent batches with different
local_ids targeting same contact → only 1 room created - Per-contact failure inside tx surfaces specific symbol, not generic
:transaction_failed - 1-of-3 contacts has active room → 422;
Models::Room.countunchanged (IMP-010) validate_balancesubtracts batch cost before comparing pools (v2.6)- Duplicate replay:
agent_idsincludes originalsender_id; roomstatusreflects current state (IMP-005) - Post-commit ES failure → emits
es_sync.failed{stage:interactor}+ enqueuesReindexRoomWorker; HTTP 201 still completes (IMP-007) - Success emits
Rails.logger.info(event: 'direct_send.message_created', ...)with CDG fields (IMP-004) Rollbar.scope!called after validation; rescue path carries context tags (IMP-004)
IMP-001 field-length specs (8 cases in interactor + 8 in Grape request spec):
message.body > 1024→ 422header > 60→ 422footer > 60→ 422cta_button.label > 20→ 422reply_buttons[].title > 20→ 422local_id > 64→ 422ttl_seconds < 30→ 422ttl_seconds > 43200→ 422
IMP-009 error copy specs:
:contact_already_assignedresponse body contains"Contact is currently assigned to another agent. Resolve or reassign their existing conversation before sending.":contact_has_active_roomresponse body contains"Contact has an open conversation. Resolve or close it before sending a new Direct Send."
WaCloud::Builders::DirectSendMessage:
- Text message →
{ messaging_product:"whatsapp", recipient_type:"individual", to:<phone>, type:"text", text:{body:...}, category:"utility" } - CTA URL →
interactive.type == "cta_url"+category:"utility" - Reply buttons →
interactive.type == "button"+category:"utility" ttl_secondspresent → top-levelttl_secondsin payloadWaCloud::Repositories::Messages::Senddispatches toDirectSendMessagewhenroom.is_a?(Models::DirectSendRoom);NewMessageotherwise- Rooms created by
AgentSendsMessagehavetype == 'Models::DirectSendRoom'in DB
SyncTemplateFromMeta:
- Existing row
is_direct_send: false→Success(:non_direct_send_template); no Meta API call; no mutation
HandleTemplateCategoryMismatch:
- Webhook for regular (non-direct-send) broadcast template → no-op with
:template_not_found
Chunk 3 — Grape Endpoints (hub-service)
Gate:
bundle exec rspec spec/services/api/core/v1/direct_send/ && bundle exec rubocop --no-color app/services/api/core/v1/direct_send/
Prerequisite: Chunk 2 complete.
Task 3.1 — Create API::Core::V1::DirectSend::Routes
File: hub-service/app/services/api/core/v1/direct_send/routes.rb
Action: Create — mounts the 4 Direct Send resources
Task 3.2 — Create Resources::Messages — POST + GET /direct_send/messages
File: hub-service/app/services/api/core/v1/direct_send/resources/messages.rb
Action: Create
POST (send):
- Scopes:
:admin, :owner, :supervisor, :agent, :has_broadcast_access - Feature flag guard:
Services::Preference.enabled?(:direct_send_enabled, organization_id:)→ 403 if off - Params:
channel_integration_id(req),contact_ids(req, array, min 1 max 3),message(req:type,bodymax 1024; opt:headermax 60,footermax 60,cta_button.labelmax 20,reply_buttons[].titlemax 20),ttl_seconds(opt, 30–43200),local_id(opt, max 64) - Sets
params[:organization_id] = me.organization_id,params[:sender_id] = me.id - Uses
interact_withpattern; on success:status 201, returns array of rooms - Pagination cursor: integer ms timestamps (NOT base64) —
rooms.last&.last_activity_at&.to_i&.*(1000) - Error copy map (
DIRECT_SEND_FAILURE_COPY) for:contact_already_assignedand:contact_has_active_room(IMP-009)
GET (send history — ADR-16, v2.6):
- Scopes:
:admin, :owner, :supervisor, :agent, :member, :bot - Feature flag guard: 403 if off
- Params:
query(opt),sender_id(opt — agent role forces tome.id; supervisor/admin/owner/bot honored),status[](opt),limit,offset, cursor params,start_date/end_date - Returns first outbound message per
DirectSendRoom—DISTINCT ON (room_id) ORDER BY room_id, created_at ASC, id ASCover 90-day partition window - Default time window: 90 days
Task 3.3 — Create Resources::Contacts — GET /direct_send/contacts
File: hub-service/app/services/api/core/v1/direct_send/resources/contacts.rb
Action: Create
- Scopes:
:admin, :owner, :supervisor, :agent, :has_broadcast_access, :bot - Feature flag guard: 403 if off
- Params:
query,limit(default 25),offset,cursor,cursor_direction,order_by,order_direction,channels[],channel_integration_ids[],authoritys[],is_contact,active_room(optional boolean — nil = no filter),is_counted,time_offsets,start_date,end_date - Sets
params[:organization_id] = me.organization_id - Response:
data: [ContactObject], meta: { pagination: { cursor: { next: <base64>, prev: <base64>, pit: null }, ... } } - Note: cursor format is base64 (NOT ms-integer like POST messages)
Task 3.4 — Create Resources::RestrictionStatus — GET /direct_send/restriction_status
File: hub-service/app/services/api/core/v1/direct_send/resources/restriction_status.rb
Action: Create
- Scopes:
:admin, :owner, :supervisor, :agent, :member, :bot - Feature flag guard: 403 if off
- Requires:
channel_integration_id - Sets
params[:organization_id] = me.organization_id - Response:
{ is_restricted:, violation_type:, restriction_type:, expires_at: } - Error: 422 if channel not found (
channel_not_found)
Task 3.5 — Edit API::Core::V1::CoreAPI — mount DirectSend routes
File: hub-service/app/services/api/core_api.rb
Action: Edit — add mount for API::Core::V1::DirectSend::Routes
Task 3.6 — Edit templates/resources/templates.rb — add is_direct_send_template param
File: hub-service/app/services/api/core/v1/templates/resources/templates.rb
Action: Edit — add optional param to existing GET /api/core/v1/templates/whatsapp
optional :is_direct_send_template, type: Boolean, documentation: { param_type: 'query' }
- Pass through to
Interactors::Whatsapp::Template::UserListLocalTemplate - Admin/owner guard enforced in the interactor (Task 2.11); Grape layer does not need to duplicate the guard
Task 3.7 — Write specs for all Chunk 3 endpoints
Files:
hub-service/spec/services/api/core/v1/direct_send/resources/messages_spec.rbhub-service/spec/services/api/core/v1/direct_send/resources/contacts_spec.rbhub-service/spec/services/api/core/v1/direct_send/resources/restriction_status_spec.rb
All specs use stub_auth_deprecation + stub_interactor pattern.
Key spec cases (from § 7 Chunk 3 acceptance criteria):
POST /direct_send/messages:
contact_ids: [uuid]→ 201,datais array with 1 room objectcontact_ids: [uuid1, uuid2]→ 201,datais array with 2 rooms- Every room item has
type: "Models::DirectSendRoom" - Every
data.extrahasis_locked: true; does NOT includeis_direct_send - Room shape matches
room_list_response.json:note: {text:""},extra: {is_participant_online:false},avatarwithlarge/small/medium,last_message.review_star: 0,last_message.sender_type: "Models::User",last_message.participant_type: "agent" meta.pagination.cursor.nextis integer (ms), not string:contact_has_active_room→ 422:contact_already_assigned→ 422- Missing
contact_ids→ 422 - Empty
contact_ids: []→ 422 - IMP-001:
body > 1024,header > 60,footer > 60, etc. → 422 (8 cases) - IMP-009:
:contact_already_assignederror message exact string match;:contact_has_active_roomerror message exact string match
GET /direct_send/contacts:
- → 200;
datais array;meta.pagination.cursor.nextis base64 string - Contact shape matches
contact_list_response.json:contact_handler_id: null,extra: {email:null, username:null},avatarwithlarge/small/medium,qontak_customer_id: "",childs: []
Chunk 4 — Webhook Handlers (hub-service: extend waba.rb)
Gate:
bundle exec rspec spec/services/api/webhook/resources/waba_spec.rb && bundle exec rubocop --no-color app/services/api/webhook/resources/waba.rb
Prerequisite: Chunk 2 complete (interactors must exist).
Task 4.1 — Edit waba.rb — add template_correct_category_detection handler
File: hub-service/app/services/api/webhook/resources/waba.rb
Action: Edit — add new when branch
when 'template_correct_category_detection'
params[:organization_id] = waba_organization_id
interact_with(DirectSend::Interactors::HandleTemplateCategoryMismatch, error_code: 200)
Note: Webhook handlers always return 200 to Meta (error_code: 200).
Task 4.2 — Edit waba.rb — add account_update / ACCOUNT_RESTRICTION handler
File: hub-service/app/services/api/webhook/resources/waba.rb
Action: Edit — add new when branch (or conditional inside existing account_update handler if present)
when 'account_update'
if params.dig(:value, :account_restriction_event)
params[:organization_id] = waba_organization_id
interact_with(DirectSend::Interactors::HandleAccountRestriction, error_code: 200)
end
Task 4.3 — Edit waba.rb — add inline template sync in statuses? branch
File: hub-service/app/services/api/webhook/resources/waba.rb
Action: Edit — extend existing statuses? branch
elsif message_type.statuses?
interact_with(WaCloud::Interactors::SystemMessageStatusNotification, error_code: 200)
template_id = params.dig(:value, :statuses, 0, :template_id)
if template_id.present? && !Models::MessageTemplate.exists?(message_template_id: template_id)
params[:template_id] = template_id
params[:organization_id] = waba_organization_id
interact_with(DirectSend::Interactors::SyncTemplateFromMeta, error_code: 200)
end
Note: SyncTemplateFromMeta is only called when template_id is present AND the row does not yet exist in DB.
Task 4.4 — Write specs for new webhook scenarios
File: hub-service/spec/services/api/webhook/resources/waba_spec.rb (edit existing)
Key spec cases (from § 7 Chunk 4 acceptance criteria):
template_correct_category_detectionevent →HandleTemplateCategoryMismatchcalled;MessageTemplate.status = "FLAGGED"account_update+ACCOUNT_RESTRICTION→HandleAccountRestrictioncalled;channel_integration.settings['direct_send_restriction']['is_active'] = truestatusestype,template_idpresent + NOT in DB →SyncTemplateFromMetacalled; row created withis_direct_send: truestatusestype,template_idalready in DB →SyncTemplateFromMetaNOT called- Meta API failure in
SyncTemplateFromMeta→ logged to Rollbar; webhook still returns 200
Cross-Cutting Checklist (apply to all tasks)
-
# frozen_string_literal: trueon every new.rbfile -
params[:organization_id] = me.organization_idin all Grape endpoints - Use
AbstractIteractor(notAbstractInteractor) — the typo is baked into the base class - All ES queries include
organization_idin filters (routing isolation) - All
messages/participantsqueries includecreated_atrange (partition pruning) - Builders return plain entities (not monads); repositories wrap in
Success(entity) - No callbacks on models — put orchestration in interactors
Pre-Merge Verification Commands
# hub_core
cd hub_core
bundle exec rspec spec/apps/direct_send/
bundle exec rspec spec/apps/wa_cloud/interactors/agent_send_message_spec.rb # baseline must stay green
bundle exec rubocop --no-color app/apps/direct_send/ app/core/domains/models/
bundle exec brakeman --no-exit-on-warn --no-exit-on-error
# hub-service
cd hub-service
bundle exec rspec spec/services/api/core/v1/direct_send/
bundle exec rspec spec/services/api/webhook/
bundle exec rubocop --no-color app/services/api/core/v1/direct_send/ app/services/api/webhook/resources/waba.rb
Rollback Steps (if needed)
- Disable feature flag per org:
Services::Preference.new.disable(:direct_send_enabled)— hides all endpoints (403) instantly, no deploy - Roll back migrations:
bundle exec rails db:rollback STEP=2in hub_core - Remove
template_idsync block fromwaba.rbstatuses?branch + redeploy hub-service - Backfill room STI type:
UPDATE rooms SET type='Models::CustomerServiceRoom' WHERE type='Models::DirectSendRoom' - Remove
direct_send_room.rbmodel file; removeis_a?(Models::DirectSendRoom)branch fromWaCloud::Repositories::Messages::Send - Clear stale locks:
UPDATE rooms SET extra = extra || '{"is_locked":false}' WHERE type='Models::DirectSendRoom' AND extra->>'is_locked' = 'true' - Clear stale restrictions:
UPDATE channel_integrations SET settings = settings - 'direct_send_restriction' WHERE settings ? 'direct_send_restriction'
Open Items (non-blocking for Beta)
| ID | Item | Owner |
|---|---|---|
| OQ-2.6-01 | Centralise "active room blocking statuses" as a Models::Room constant | Eng |
| OQ-2.6-04 | Add channel_integration_id filter to GET /direct_send/messages for multi-WABA orgs | PM + Eng |
| OQ-2.6-07 | Benchmark DISTINCT ON on top-5 largest orgs; denormalize if p95 > 500ms | Eng |
| OQ-2.7-01 | Wire ScrubPii into centralised contact-deletion interactor (right-to-delete auto-trigger) | Eng |
| OQ-2.7-02 | Emit meta_api.latency_ms + mqtt.notifications.total from Sneakers consumer | Eng |
| qc-22448-G | Drop messages.header/footer columns after ≥1 week stable in prod (ADR-15 cleanup) | Eng + DBA |