Skip to main content

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 — the local_id idempotency check must still be added (see RFC ADR-10 v2.8 corrected sample).


Summary

ChunkAreaTasks
1DB Migrations + Model Updates (hub_core)8 tasks
2Interactors + Services + Workers (hub_core)15 tasks
3Grape Endpoints (hub-service)8 tasks
4Webhook Handlers (hub-service)4 tasks

Implementation Status (verified 2026-07-14)

TaskFileStatus
1.1 — message_templates migration20260603000001_add_direct_send_columns_to_message_templates.rb✅ Done
1.2 — messages header/footer/indexpending❌ TODO
1.3 — Models::DirectSendRoomapp/core/domains/models/direct_send_room.rb✅ Done
1.4 — Models::Room TYPES + helpersdirect_send_room?, direct_send_locked? in room.rb✅ Done
2.1 — DirectSend::Services::Metricspending❌ TODO
2.2 — AgentSendsMessageapp/apps/direct_send/interactors/agent_sends_message.rb⚠️ Partial — local_id idempotency + advisory lock not implemented
2.3 — AgentListsContactsapp/apps/direct_send/interactors/agent_lists_contacts.rb✅ Done
2.4 — AdminGetsRestrictionStatusapp/apps/direct_send/interactors/admin_gets_restriction_status.rb✅ Done
2.5 — HandleTemplateCategoryMismatchapp/apps/direct_send/interactors/handle_template_category_mismatch.rb✅ Done
2.6 — HandleAccountRestrictionapp/apps/direct_send/interactors/handle_account_restriction.rb (called via ReceiveNotificationAccountUpdate)✅ Done
2.7 — SyncTemplateFromMetaapp/apps/direct_send/interactors/sync_template_from_meta.rb✅ Done
2.8 — WaCloud::Builders::DirectSendMessageapp/apps/wa_cloud/builders/direct_send_message.rb✅ Done
2.9 — WaCloud::Repositories::Messages::Send dispatchDirectSendRoom branch at line 27 of send.rb✅ Done
2.10 — AgentSendMessage locked-room guardLines 91-92 of wa_cloud/interactors/agent_send_message.rb✅ Done
2.12 — ReindexRoomWorkerpending❌ TODO
2.13 — ScrubPii repositorypending❌ TODO
2.14 — Room unlock on inbound replyunverified❓ Verify
Chunk 3 — Grape endpointsdirect_send/resources/{contacts,messages,restriction_status}.rb✅ Done
4.x — template_correct_category_detection webhookwaba.rbHandleTemplateCategoryMismatch✅ Done
4.x — SyncTemplateFromMeta in statuses branchpending — 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: 50 on source (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_id already exists (migration 20210202045334, no length constraint). This migration adds header, 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' to TYPES constant
  • Add helper methods:
    def direct_send_room?
    is_a?(Models::DirectSendRoom)
    end

    def 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):

MetricTags
direct_send.messages.totalstatus (success / failed: / idempotent_replay), contact_count_bucket (1/2/3/other), channel
direct_send.idempotency.hitsorganization_id
direct_send.template_sync.totalstatus (already_synced / non_direct_send_template / channel_not_found / meta_api_error / upsert_failed / synced)
direct_send.es_sync.failedstage (interactor / reindex_worker)
direct_send.pii_scrub.totalsource (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 with sender_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_id via assigned_agent_id: message.sender_id, fresh_send: false (IMP-005)
  • Validate channel (target_channel == 'wa_cloud', same org)
  • validate_not_restricted(channel) — reads channel.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.transaction wrapping all contacts; in-tx recheck of validate_contact_no_active_room per 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 (with header, footer columns AND raw_message JSONB — 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: emit es_sync.failed metric + enqueue ReindexRoomWorker (IMP-007)
  • Emit Rails.logger.info(event: 'direct_send.message_created', ...) with full CDG field set on success (IMP-004)
  • Emit direct_send.messages.total metric at every terminal branch (IMP-002)
  • Do NOT call deduct_balance — balance deduction is webhook-driven via WaDeductionWorker (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_enabled feature flag → Failure(:unauthorized) if off
  • Reads organization.settings['customer_360'] at runtime
    • customer_360 == true → call CentralizedContacts::Services::Apis (10s timeout); on failure rescue + Rollbar warning → fall back to internal ES query; add X-Contact-Source: internal-fallback header
    • customer_360 == false/nil → query internal Elasticsearch via Repositories::Contacts::ContactObjects::All
  • Returns Entities::ContactObject list 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_enabled flag → 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_id from Meta webhook
  • Scopes lookup to Models::MessageTemplate.where(organization_id:, is_direct_send: true) — non-Direct-Send templates with same Meta template_id are 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, expiration from account_update / ACCOUNT_RESTRICTION webhook
  • 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: trueSuccess(:already_synced) (no Meta call)
    • Existing row with is_direct_send: falseSuccess(:non_direct_send_template) (no mutation, no Meta call) — v2.6
  • 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 with is_direct_send: false, return Failure(:not_direct_send_template) rather than flipping
    • On Meta failure: log + emit template_sync.total{status:meta_api_error} + return Failure
  • Emit template_sync.total metric at every return point (IMP-002)
  • Always returns Success/Failure; caller MUST still return HTTP 200 to Meta even on Failure

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' } (no preview_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_seconds placed at top level when present

Error code handling in the send repository (not the builder):

  • 132015 (template paused) → status='failed' + system message
  • 139200 (account restricted) → status='failed' + system message + call HandleAccountRestriction

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: add where(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.footernil
  • raw_message JSONB 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.rb
  • hub_core/spec/apps/direct_send/interactors/agent_lists_contacts_spec.rb
  • hub_core/spec/apps/direct_send/interactors/admin_gets_restriction_status_spec.rb
  • hub_core/spec/apps/direct_send/interactors/handle_template_category_mismatch_spec.rb
  • hub_core/spec/apps/direct_send/interactors/handle_account_restriction_spec.rb
  • hub_core/spec/apps/direct_send/interactors/sync_template_from_meta_spec.rb
  • hub_core/spec/apps/wa_cloud/builders/direct_send_message_spec.rb
  • hub_core/spec/apps/direct_send/services/metrics_spec.rb
  • hub_core/spec/apps/direct_send/workers/reindex_room_worker_spec.rb
  • hub_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_id again → returns original rooms; no new rooms; no publisher calls
  • contact_ids: [] → validation failure
  • Any contact assignedFailure(: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_balance call anywhere; WhatsappPackage.balance unchanged immediately post-call
  • validate_not_restrictedFailure(:direct_send_restricted) when is_active: true and expiration in future or blank
  • validate_not_restrictedSuccess when 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.count unchanged (IMP-010)
  • validate_balance subtracts batch cost before comparing pools (v2.6)
  • Duplicate replay: agent_ids includes original sender_id; room status reflects current state (IMP-005)
  • Post-commit ES failure → emits es_sync.failed{stage:interactor} + enqueues ReindexRoomWorker; 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 → 422
  • header > 60 → 422
  • footer > 60 → 422
  • cta_button.label > 20 → 422
  • reply_buttons[].title > 20 → 422
  • local_id > 64 → 422
  • ttl_seconds < 30 → 422
  • ttl_seconds > 43200 → 422

IMP-009 error copy specs:

  • :contact_already_assigned response body contains "Contact is currently assigned to another agent. Resolve or reassign their existing conversation before sending."
  • :contact_has_active_room response 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_seconds present → top-level ttl_seconds in payload
  • WaCloud::Repositories::Messages::Send dispatches to DirectSendMessage when room.is_a?(Models::DirectSendRoom); NewMessage otherwise
  • Rooms created by AgentSendsMessage have type == 'Models::DirectSendRoom' in DB

SyncTemplateFromMeta:

  • Existing row is_direct_send: falseSuccess(: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, body max 1024; opt: header max 60, footer max 60, cta_button.label max 20, reply_buttons[].title max 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_with pattern; 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_assigned and :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 to me.id; supervisor/admin/owner/bot honored), status[] (opt), limit, offset, cursor params, start_date/end_date
  • Returns first outbound message per DirectSendRoomDISTINCT ON (room_id) ORDER BY room_id, created_at ASC, id ASC over 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.rb
  • hub-service/spec/services/api/core/v1/direct_send/resources/contacts_spec.rb
  • hub-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, data is array with 1 room object
  • contact_ids: [uuid1, uuid2] → 201, data is array with 2 rooms
  • Every room item has type: "Models::DirectSendRoom"
  • Every data.extra has is_locked: true; does NOT include is_direct_send
  • Room shape matches room_list_response.json: note: {text:""}, extra: {is_participant_online:false}, avatar with large/small/medium, last_message.review_star: 0, last_message.sender_type: "Models::User", last_message.participant_type: "agent"
  • meta.pagination.cursor.next is 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_assigned error message exact string match; :contact_has_active_room error message exact string match

GET /direct_send/contacts:

  • → 200; data is array; meta.pagination.cursor.next is base64 string
  • Contact shape matches contact_list_response.json: contact_handler_id: null, extra: {email:null, username:null}, avatar with large/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_detection event → HandleTemplateCategoryMismatch called; MessageTemplate.status = "FLAGGED"
  • account_update + ACCOUNT_RESTRICTIONHandleAccountRestriction called; channel_integration.settings['direct_send_restriction']['is_active'] = true
  • statuses type, template_id present + NOT in DB → SyncTemplateFromMeta called; row created with is_direct_send: true
  • statuses type, template_id already in DB → SyncTemplateFromMeta NOT called
  • Meta API failure in SyncTemplateFromMeta → logged to Rollbar; webhook still returns 200

Cross-Cutting Checklist (apply to all tasks)

  • # frozen_string_literal: true on every new .rb file
  • params[:organization_id] = me.organization_id in all Grape endpoints
  • Use AbstractIteractor (not AbstractInteractor) — the typo is baked into the base class
  • All ES queries include organization_id in filters (routing isolation)
  • All messages/participants queries include created_at range (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)

  1. Disable feature flag per org: Services::Preference.new.disable(:direct_send_enabled) — hides all endpoints (403) instantly, no deploy
  2. Roll back migrations: bundle exec rails db:rollback STEP=2 in hub_core
  3. Remove template_id sync block from waba.rb statuses? branch + redeploy hub-service
  4. Backfill room STI type: UPDATE rooms SET type='Models::CustomerServiceRoom' WHERE type='Models::DirectSendRoom'
  5. Remove direct_send_room.rb model file; remove is_a?(Models::DirectSendRoom) branch from WaCloud::Repositories::Messages::Send
  6. Clear stale locks: UPDATE rooms SET extra = extra || '{"is_locked":false}' WHERE type='Models::DirectSendRoom' AND extra->>'is_locked' = 'true'
  7. Clear stale restrictions: UPDATE channel_integrations SET settings = settings - 'direct_send_restriction' WHERE settings ? 'direct_send_restriction'

Open Items (non-blocking for Beta)

IDItemOwner
OQ-2.6-01Centralise "active room blocking statuses" as a Models::Room constantEng
OQ-2.6-04Add channel_integration_id filter to GET /direct_send/messages for multi-WABA orgsPM + Eng
OQ-2.6-07Benchmark DISTINCT ON on top-5 largest orgs; denormalize if p95 > 500msEng
OQ-2.7-01Wire ScrubPii into centralised contact-deletion interactor (right-to-delete auto-trigger)Eng
OQ-2.7-02Emit meta_api.latency_ms + mqtt.notifications.total from Sneakers consumerEng
qc-22448-GDrop messages.header/footer columns after ≥1 week stable in prod (ADR-15 cleanup)Eng + DBA