Skip to main content

RFC: Order Reminder Automation — channel-agnostic reminder engine for Desty marketplace channels

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance and is agent-execution-ready: §1 Design References (FE) + §1 PRD-to-Schema Derivation (BE), §2 Repo Reading Guide, mermaid diagrams, §2.G Cross-Layer Contract Verification, and §4 Agent Execution Plan + Verification & Rollback Recipe are all filled. The YAML frontmatter and the Metadata table agree on every shared field.

Repo grounding scope note: the three backend repos in this workspace (hub_service, hub_core, hub-worker) are grounded against real files (see §2.0 Source Verification). The frontend repo (hub-chat) is not present in this workspace; FE surfaces are specified at contract level from the PRD and Figma-pending, and every FE file path is tagged FE-repo-verify. This is called out in §2.I and §5.

Metadata

FieldValueNotes
StatusRFCIDEA / RFC / ABANDON / AGREED
OwnerCommunication SquadTeam owning the RFC
Author(s)Communication Squad BEPrimary author(s)
ReviewersComm BE Lead, Comm FE Lead, Commerce SquadTech reviewers across affected squads (FE + BE)
Approver(s)EM (Communication) + InfoSec approver [REQUIRED]Tech leaders + infosec approver
Submitted Date2026-07-10ISO-8601
Last Updated2026-07-10ISO-8601
Target Release2026-Q3Aligned with Desty→Qontak Q3 migration milestone (PRD §2)
Related DocumentsPRD page 51232080555 (v1.2)Single PRD driver
Discussion#comm-squad-alertsSlack

Type: full-stack Frontend sub-type: new-feature Backend sub-type: new-feature

Sections at a Glance

  1. Overview (incl. §1 Design References — FE half, and §1 PRD-to-Schema Derivation — BE half)
  2. Technical Design (Infrastructure Topology → Repo Reading Guide → end-to-end mermaid → DDL → APIs → cross-layer contract verification)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (incl. cross-layer rollout matrix, Agent Execution Plan, Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

Qontak Chat has zero automated order reminders; Desty Chat has 8. 844 CIDs are migrating off Desty and lose all automated buyer communication on arrival — a direct capability regression that stalls the Q3 migration milestone (PRD §1, §2). This RFC delivers a channel-agnostic order reminder engine: a webhook-driven backend on hub_service/hub_core/hub-worker that schedules, renders, sends, and cancels delay-based buyer messages across the four Desty marketplace channels (Shopee, Lazada, TikTok, Tokopedia), plus a per-channel admin configuration UI. The engine is designed to be reused by the future WA automation PRD but WA delivery wiring is out of scope here.

The system outcome: an order event from Desty (business_type != 0) is routed into a new OrderEvents::Receive interactor, which finds/creates the buyer's room, reads per-channel order_reminder_settings, and enqueues one Sidekiq job per configured delay. Jobs render a template and send via the existing Desty::Interactors::Messages::BotSend path. A status-change event cancels still-pending jobs by their Redis-stored Sidekiq JIDs.

Success Criteria

Measurable (from PRD §12, tightened to be assertable):

  • SC-1 Delivery reliability: order_reminder_sent / (order_reminder_sent + order_reminder_failed) >= 98% measured over any rolling 24h during beta.
  • SC-2 Latency: p95 of (order_reminder_scheduled timestamp + configured delay) → order_reminder_sent timestamp is <= 60s past the configured delay.
  • SC-3 Cancellation correctness: 0 reminders delivered for an order_id after a terminal status change (paid/cancelled) is received before the scheduled run_at. Verified by the cancellation integration spec + order_reminder_cancelled count.
  • SC-4 Coverage: all 8 reminder types fire correctly on the Shopee channel in staging (Alpha gate, PRD §10/§13).
  • SC-5 Adoption: order_reminder_settings.enabled = true for >= 40% of migrated Desty orgs by Week 8 post-GA.

Out of Scope

Per PRD §5, plus RFC-specific exclusions:

  • Direct marketplace API integration (all channels via Desty middleware).
  • Cross-channel template inheritance (each channel_integration_id has its own settings).
  • WA broadcast reminders / WA delivery wiring (separate PRD; engine is reuse-ready only).
  • Seller-facing order-management UI.
  • AI-generated templates.
  • DelayedShipmentCheckWorker (hourly cron) is deferred to Phase 1.5 — the existing get_order_list is customer-scoped, not shop-wide (see §2.0, Decision D8, §5 OQ-6). It is non-blocking per PRD §14.
  • PRD: https://jurnal.atlassian.net/wiki/spaces/QON/pages/51232080555 (v1.2, 2026-06-26). Reviewed — primary driver.
  • Repo evidence: hub_service, hub_core, hub-worker (see §2.0 Source Verification for exact paths/lines). Reviewed — source of truth for patterns.
  • Phase 1 WA automation PRD — reviewed, no material impact on this RFC beyond "engine must be reusable" (satisfied by the scheduler/worker being channel-parameterized).

Assumptions

  • A1 (Desty payload — BLOCKING, see §5 OQ-1/2/3): Desty pushes order-lifecycle events to POST /webhooks/desty with business_type != 0. The exact business_type value per status, the payload field names, and conversation_id presence per platform are unconfirmed. This RFC isolates every such value in one mapping constant Desty::OrderEvents::TYPE_MAP (§2.3, §2.4 Inbound) so that confirmation changes a constant, not the flow. The parser validates against the assumed schema and returns 200 on unknown shapes (§3.A).
  • A2: Buyer identity in the order payload maps to a room via external_id = conversation_id (matches Desty::Services::InboundMessage#create_room, inbound_message.rb:108).
  • A3: channel_integration.settings['shop_id'] and Lockbox-encrypted access_token are populated for every active Desty channel (existing invariant, channel_integration.rb).
  • A4: Reminders are bot messages, stored via Repositories::Messages::Creates::Bot (visible to agents), consistent with the existing bot pattern.

Dependencies

Both layers:

DependencyTypeOwnerStatusBlocks
Desty business_type values per status (OQ-1)BLOCKINGBE + Desty APIOpen — Sprint 1 discoveryWebhook router mapping constant
Desty order payload schema incl. conversation_id, cancelled_by (OQ-2/3/5)BLOCKINGBE + Desty APIOpen — Sprint 1 discoveryParser, room create, cancel actor filter
order_reminder_settings migration (hub_core core DB)BLOCKING (BE-internal)BENot startedConfig UI + all workers
Redis JID store services (schedule/cancel)BLOCKING (BE-internal)BENot startedCancellation (E2/E3)
order_reminder_automation_enabled Flipper flagBLOCKING (BE-internal)BE LeadNot startedGated rollout
Admin Config UI (hub-chat FE)Non-blocking for BEFENot startedSeller self-service (deploy after BE)
InfoSec review of bot-send-on-behalf-of-sellerProcessInfoSecNot startedGA gate

Design References (frontend half — required)

PRD-named surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
Reminder Settings Pagen/a — design pending@mekari/ds (version TBD)Design (PRD S16 Q7)Blocks FE chunks only; see §5 OQ-7. BE unblocked.
Reminder Toggle row (per type)n/a — design pendingTBDDesignBehavior specified in §2.A/2.C from PRD §7.1
Template Editor (variable chips)n/a — design pendingTBDDesignVariable set specified in §2.A

Figma frames are TBD (PRD S16 Q7). FE implementation chunks (§4.D chunks 8–10) are gated on these frames — do not build FE against imagined designs. BE chunks (1–7) and the FE contracts (§2.A/2.G) are fully specified and unblocked.

PRD-to-Schema Derivation (backend half — required)

PRD-described entity / attribute / rulePersisted as (table.column)Exposed via (endpoint / event)Enforced whereSource
Per-channel reminder config (8 types)order_reminder_settings.reminder_type (enum, CHECK)GET/POST /api/core/v1/desty/reminder_settingsDB CHECK + model enum + Grape paramsPRD §6, §7.2, §9.2
Config scoped per org + channel (not global)order_reminder_settings(organization_id, channel_integration_id) unique w/ typesameUnique partial index; repo scopingPRD §6, §15.1
Enable/disable per typeorder_reminder_settings.enabled boolean default falsePOST reminder_settingsworker checks at execution timePRD §7.1, E3
Up to 4 delay values per typeorder_reminder_settings.delays_minutes jsonb + CHECK len ≤ 4POST reminder_settingsDB CHECK + model validation → 422PRD §6 (Max delays)
Message template (≤300 mktplace / ≤500 WA)order_reminder_settings.message_template textPOST reminder_settingsmodel validation → 422PRD §6 (char limit)
WA dual-use follow_up templateorder_reminder_settings.message_template_id uuid (FK → message_templates, nullable)POST reminder_settingsFK; only set when type=follow_up (WA, future)PRD §15.1 (v1.2)
Reminder is a bot message stored in roommessages row via Repositories::Messages::Creates::BotPublishers::MessageSend eventBotSend interactorPRD §9.2, §15.2
Proactive room creation from conversation_idrooms.external_id = conversation_idn/a (internal)InboundMessage#get_room/create_roomPRD §6, E1
Scheduled job IDs for cancellationRedis order_reminder:{order_id}:{reminder_type} (TTL 72h)n/aOrderReminderScheduler / OrderReminderCancellerPRD §6 (Redis TTL), E2
Dedup on duplicate webhookRedis order_reminder_dedup:{order_id}:{reminder_type} (SET NX)n/ascheduler guardPRD E4

Every DDL row in §2.3 and every endpoint in §2.4 traces back to a row here or a Design Reference above (typically both).

Detail 1.A — PRD Traceability (cross-layer)

Forward:

PRD requirementFE section / componentBE section / endpoint
§7.1 Config UI (8 toggle rows, delays, template)ReminderSettingsPage, ReminderToggleRow, TemplateEditor (§2.A)GET/POST /api/core/v1/desty/reminder_settings (§2.4)
§7.2 Order event branch (business_type != 0)n/a — BE-onlydesty.rb new branch → OrderEvents::Receive (§2.1, §2.4 Inbound)
§7.2 Schedulern/aOrderReminderScheduler (§2.F)
§7.2 Cancellern/aOrderReminderCanceller (§2.E, §2.F)
§7.2 Worker (render+send, retry)n/aOrderReminderWorker (§2.F, §3.A)
§9.2 Payment reminder (unpaid)Toggle: expedite_paymentscheduler + worker + BotSend
§9.2 Confirm/In-delivery/DeliveredToggles: confirm_order / order_in_delivery / order_deliveredscheduler + worker
§9.2 Cancellation (seller/buyer actor filter)Toggles: seller_cancel / buyer_cancelOrderEvents::Receive actor filter (§3.A.1)
§12 Observability metricsanalytics on save (§3 Monitoring)5 tracking events (§3 Monitoring)
§10 Feature flag rolloutflag-gated settings visibilityServices::Preference order_reminder_automation_enabled

Reverse:

New FE component / BE endpoint / dependencyPRD need
order_reminder_settings tablePRD §6 "No existing OrderReminderSetting model"; per-channel config
OrderEvents::Receive interactorPRD §6 "business_type != 0 gap"
Redis JID store servicesPRD §6 "Sidekiq job cancellation complexity"
OrderReminderScheduler/Worker/CancellerPRD §7.2 engine components
GET/POST reminder_settingsPRD §8 rows 5–6
ReminderSettingsPage + childrenPRD §7.1 admin config

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired reads (BE)Required writes (BE)FE componentStatus surface
Reminder Settings Pageweb (admin)GET reminder_settingsPOST reminder_settingsReminderSettingsPageenabled, updated_at per row
Reminder Toggle (×8)web(from page load)POST reminder_settingsReminderToggleRowenabled
Template Editorweb(from page load)POST reminder_settingsTemplateEditorvalidation error state
Buyer reminder in roomweb/agent (read)existing room messages readn/a — written by BotSendexisting Room message listmessages.status (created/sent/failed)

Role Coverage

PRD roleAuthorization mechanismEndpoints permitted (BE)UI surface visibility (FE)Cross-tenant?Audit trail
Owneroauth2 :owner (OAuth2 bearer, me.organization_id)GET + POST reminder_settingsfull config pageno (own org)PaperTrail on settings write (§3)
Adminoauth2 :adminGET + POST reminder_settingsfull config pagenoPaperTrail
Supervisoroauth2 :supervisorGET reminder_settings (read-only)read-only pagenoread logged
Agentnot in oauth2 scope listnone → 403page hidden / 403non/a
System (bot)internal (SystemAccount / BOT_ID)n/a — sends via BotSendn/an/aorder_reminder_sent event

PRD contradiction resolved (D-role): PRD §6 says "Agent role: read-only" but PRD §9.2 ERR says "Agent … returns 403". Resolved to 403 for Agent (no read), aligning with the existing oauth2 :admin, :owner, :supervisor macro (channel_integration.rb:27). Supervisor holds read-only. Logged as OQ-11 for PM confirmation.

PRD Section Coverage

PRD §TitleWhere covered
1One-liner + Problem§1 Overview
2What if we don't build§1 Overview
3Strategic Context§1 (pricing → OQ-12)
4Target Users/Persona§1.A Role Coverage
5Non-Goals§1 Out of Scope
6Constraints§2.3 DDL, §2.F async spec, §3 Perf/Security, §4 Config
7New Features (7.1 UI / 7.2 engine)§2.A UI Contract / §2.1–2.F
8API & Webhook Behavior§2.4 APIs (Outbound + Inbound)
9System Flow + Stories + ACs§2.2 Sequence, §1.C Per-Story Change Map
10Rollout§4 Rollout Strategy + 4.A matrix
11Observability§3 Monitoring & Alerting
12Success Metrics§1 Success Criteria
13Launch Plan & Stage Gates§4 Rollout Strategy
14Dependencies§1 Dependencies
15Key Decisions + Alternatives§1.B + §2 Technical Decisions (ADRs)
16Open Questions§5

Detail 1.B — Decisions Closed (cross-layer)

DecisionChosen optionAlternatives rejectedWhy rejectedLayer
D1 StorageNew order_reminder_settings table, PostgreSQL core DB, per (org, channel, type)(a) global-per-org config; (b) store in channel_integration.settings jsonb(a) PRD §15.2 — channels differ; (b) settings jsonb is per-integration scalar config, not per-type rows — no query/index for 8 typesBE
D2 Send pathReuse Desty::Interactors::Messages::BotSendDesty::Services::SendMessage::Apis#send_message(a) BroadcastSendWorker; (b) IdleCustomers::SendMessageWorker; (c) new direct HTTP(a) routes to Interactors::Whatsapp::Broadcasts::* (WA-only interactors); (b) hardcoded SEND_INTERACTOR={'wa_cloud'=>…}.fetchKeyError for marketplace (send_message_worker.rb:8-12,50); (c) duplicates existing, validated Desty sendBE
D3 Sync vs asyncWebhook does sync parse+room+schedule, returns 200 immediately; delivery async via Sidekiq perform_atfully-sync send in webhookDesty API latency gates delivery; PRD requires always-200 (no Desty retry storms)BE
D4 CancellationStore Sidekiq JIDs in Redis keyed by order_id:reminder_type, cancel via Sidekiq::ScheduledSet#delete_by_jidscan ScheduledSet by args each timeO(n) scan of the whole set per cancel is expensive; JID-keyed delete is the existing idle_customers pattern (create.rb:51-61, delete.rb:12-17)BE
D5 DedupSET nx:true ex:72h guard key per (order, type) before schedulingDB unique constraint on a job-log tableRedis guard matches abstract_models.rb:52 pattern; no new table; TTL auto-cleansBE
D6 FlagServices::Preference Flipper flag order_reminder_automation_enabled, per-orgENV var; per-org columnFlipper+Redis org-list is the house mechanism (preference.rb:61-71); ENV can't target orgsBE + FE
D7 Feature-flag character-limit & delay-count validationmodel-level + DB CHECKFE-only validationFE validation is UX; server is source of truth (422)BE
D8 DelayedShipmentCheckWorkerDeferred to Phase 1.5ship in Phase 1Existing get_order_list is customer-scoped (get_order_list.rb:52-55); shop-wide poll has no confirmed Desty call shapeBE
D9 WA template dual-usemessage_template_id FK nullable; NULL for marketplace, set for WA follow_upcopy templatesMessageTemplate type enum {campaign, follow_up} exists (message_template.rb:19-22); reuse Meta-approved templateBE
D10 Room createReuse InboundMessage#get_room (fetch-or-create)new room-create pathExisting path keys on external_id=conversation_id, returns {room, participant, is_first_message}BE
D11 Retry/backoffretry: 3 + sidekiq_retry_in do |count| …end exponential (100/300/900ms base → capped)fixed 10-min retry (existing default)Payment reminders are time-sensitive; 10-min retry misses the window. No existing exponential — this is a justified deviation (uses current_retry_count via ExposeRetryCount middleware)BE
D12 API namespaceNew API::Core::V1::Desty::Resources::ReminderSetting at /api/core/v1/desty/reminder_settingsPRD's /api/v1/desty/...Corrects PRD path to the real mount (API::CoreAPI => '/api/core', desty/routes.rb:6-8)BE + FE
D-roleAgent → 403; Supervisor read-onlyAgent read-only (PRD §6)Resolves PRD internal contradiction; matches existing oauth2 macroBE + FE
D13 Per-status lifecycle (settings)Soft-delete via acts_as_paranoid (deleted_at); config never hard-deleted, only enabled=falsehard deletePRD §9.2 "Config cannot be deleted — only disabled"; matches AbstractParanoiaElastic conventionBE
D14 Inbound webhook ownershipComm Squad owns the business_type != 0 branch in hub_service desty.rbDesty teamWe own the Qontak-side handler; Desty owns event emissionboth

The decision table closes: per-status lifecycle (D13), soft vs hard delete (D13), cross-squad responsibility (D14, §2.F.1), inbound webhook shape (D14, §2.4 Inbound), branch/skip ownership (§3.A.1), and reuse-vs-new for every new endpoint (D2/D12, §2.4 Reuse? column). FE/BE alignment points (casing, error shape) are confirmed in §2.G.

Detail 1.C — Per-Story Change Map

Story #Story titleLayer scopeFE changesBE changesAcceptance criteria (verifiable)RFC anchors
ORR-S01Seller configures reminders per channelFE + BEReminderSettingsPage, ReminderToggleRow, TemplateEditor; data fetch via existing api client; analytics reminder_setting_savedorder_reminder_settings table+model; GET/POST reminder_settings; validation (delays ≤4, template ≤300)POST with delays [5,10,30]+valid template → 200 & row persisted; empty template → 422 MESSAGE_TEMPLATE_REQUIRED; 5 delays → 422 TOO_MANY_DELAYS; reminder_setting_saved count > 0§2.3 · §2.4 Outbound r1–2 · §2.A · §2.C · §4.D ch1,2,8 · §1 PRD-to-Schema r1–5
ORR-S02Buyer receives payment reminder until paidRuntime / behavior (BE)n/a — BE-onlydesty.rb branch; OrderEvents::Receive; OrderReminderScheduler; OrderReminderWorker; Redis JID store; OrderReminderCancellerunpaid event + expedite_payment enabled delays [5] → 1 job scheduled (order_reminder_scheduled); at T+5min with status still unpaid → BotSend called, message row status=created, order_reminder_sent; paid before T+5 → job cancelled, 0 sends, order_reminder_cancelled; cancellation spec passes§2.2 · §2.4 Inbound · §2.D · §2.E · §2.F · §4.D ch3–6
ORR-S03Buyer receives confirm/shipped/delivered noticesRuntime / behavior (BE)n/areminder_type ∈ {confirm_order, order_in_delivery, order_delivered}; template var rendering with blank substitutionstatus→confirmed + confirm_order delay 0 → immediate send; shipped + order_in_delivery delay 5 → send with {{courier_name}}/{{awb_number}}/{{tracking_link}}; missing {{awb_number}} → renders empty, still sends§2.2 · §2.F · §3.A · §4.D ch5
ORR-S04Buyer receives cancellation notice (actor-filtered)Runtime / behavior (BE)n/aactor filter on cancelled_by; seller_cancel (0-delay) vs buyer_cancel (delayed)cancelled_by=seller + seller_cancel enabled → seller template only; cancelled_by=buyer → buyer template only; cancelled_by absent → logged "unresolvable actor", no fire§2.1 branch/skip · §3.A.1 · §4.D ch7
ORR-S05 (NEG-1)No cross-channel template inheritanceBE(config page shows blank for unconfigured channel)separate row per channel_integration_id; no copyShopee template configured; Tokopedia page → no prefill (distinct rows)§2.3 unique index · §2.A
ORR-S06Delayed shipment reminder (stuck >24h)Runtime / behavior (BE)n/adeferred — Phase 1.5 (needs shop-wide Desty order-list; existing get_order_list customer-scoped — OQ-6)n/a — deferred§5 OQ-6 · §1 Out of Scope

2. Technical Design

Infrastructure Topology (start here — understand the runtime before the code)

flowchart TB
desty[[Desty API / Webhook<br/>external 3rd-party]]
buyer([Buyer in marketplace chat])
admin([Seller Admin browser])

subgraph edge[Ingress]
lb{{Load Balancer / Ingress}}
end

subgraph svc[hub_service pods · Grape/Rails]
webhook[/POST /webhooks/desty/]
coreapi[/API::CoreAPI /api/core/v1/desty/]
end

subgraph core[hub_core gem · domain logic]
interactors[Desty::Interactors::*]
repos[Repositories::*]
end

subgraph worker[hub-worker pods · Sidekiq]
sched[OrderReminderScheduler]
orworker[OrderReminderWorker]
cancel[OrderReminderCanceller]
end

subgraph data[Stateful infra]
pg[(PostgreSQL core DB<br/>primary + read replica)]
redisw[(Redis REDIS_W<br/>Sidekiq queue + JID store)]
redisr[(Redis REDIS_R<br/>reads/flag org-list)]
end

desty -->|order event business_type != 0| lb --> webhook
admin -->|GET/POST reminder_settings| lb --> coreapi
buyer -.receives reminder.- desty
webhook --> interactors
coreapi --> interactors
interactors --> repos --> pg
interactors -->|perform_at| redisw
sched --> redisw
redisw --> orworker
orworker --> interactors
interactors -->|POST /api/send_message| desty
cancel --> redisw
orworker --> pg

Per-service responsibility table:

ServiceUse cases (high-level)Internal calls (owning team)External / 3rd-party APIs
hub_service (Grape)Receive Desty webhook; serve admin config APIhub_core interactors (Comm)Desty webhook inbound
hub_core (gem)Parse order event; find/create room; read settings; render+send; cancelRepositories::*, Services::Preference (Comm)Desty POST /api/send_message (outbound)
hub-worker (Sidekiq)Execute scheduled reminders; cancel pendinghub_core interactors (Comm)none directly (via hub_core)
PostgreSQL core DBPersist order_reminder_settings, rooms, messages
Redis (REDIS_W/REDIS_R)Sidekiq queues; JID store; dedup guard; flag org-list

Technical Decisions (ADR-format — the engineering heart of the RFC)

Summary index in §1.B. Full ADR blocks below for the load-bearing decisions. Minimum-coverage checklist: Storage D1 · Sync/async D3 · Caching ADR-C · Third-party D2/ADR-B · Consistency ADR-D · Multi-tenancy ADR-E · Reuse-vs-new D2/D12/§2.4.

ADR-A — Storage: dedicated order_reminder_settings table (D1)

  • Context: PRD needs per-(org, channel, type) config with 8 types, each toggle + up to 4 delays + a template. No OrderReminderSetting exists (grep NOT FOUND across hub_core).
  • Options: (1) new table (chosen); (2) global-per-org; (3) pack into channel_integration.settings jsonb.
    • (1) pros: indexable per-type rows, unique constraint, soft-delete convention; cons: new migration.
    • (2) cons: PRD §15.2 rejects — channels differ; template collisions.
    • (3) cons: settings is a per-integration scalar store (store_accessor, channel_integration.rb:19-27); cannot express 8 typed rows or enforce uniqueness/CHECK.
  • Decision: new table in hub_core/database/core/db/migrate/ (PostgreSQL, ActiveRecord::Migration[6.1], id: :uuid), model Models::OrderReminderSetting < Models::AbstractModel with acts_as_paranoid.
  • Consequences: one migration; model + repository + validation. Backwards-compatible (additive, PRD §10).
  • Reversibility: high pre-GA (drop table, no reads elsewhere); medium post-adoption (config data exists — keep table, disable flag).

ADR-B — Send via existing BotSend, not broadcast workers (D2)

  • Context: must send to Shopee/Lazada/TikTok/Tokopedia via Desty.
  • Options: BotSend (chosen); BroadcastSendWorker; IdleCustomers::SendMessageWorker; new direct HTTP.
    • BroadcastSendWorker routes to Interactors::Whatsapp::Broadcasts::SystemSendBroadcast (broadcast_send_worker.rb:42-61) — WA-only interactors, not marketplace.
    • IdleCustomers::SendMessageWorker is genuinely hardcoded: SEND_INTERACTOR = {'wa_cloud'=>…} + .fetch(room.channel_integration.target_channel) (send_message_worker.rb:8-12,50) → KeyError for desty_*.
    • Desty::Interactors::Messages::BotSendDesty::Services::SendMessage::Apis#send_message (send_message/apis.rb:4-30) is the validated, all-4-channels Desty path.
  • Decision: OrderReminderWorker calls Desty::Interactors::Messages::BotSend (required params room_id, organization_id; text in text/content, not message).
  • Consequences: message stored as bot message (Repositories::Messages::Creates::Bot, status created), Publishers::MessageSend fired — agent-visible, consistent with existing bot flow.
  • Reversibility: high (single call site).
  • Correction to PRD: PRD §15.1 references Desty::Repositories::Messages::Send as the send path and a type='campaign' broadcast-template filter to "widen". Neither matches the repo: the send path is the SendMessage::Apis mixin; and no query filters MessageTemplate.type = 'campaign' (all 'campaign' matches are Room.status). D9 keeps the FK column for future WA reuse but there is no filter to change now.

ADR-C — Caching / Redis JID store & dedup (D4, D5)

  • Context: must cancel still-pending jobs on status change; must dedup duplicate Desty deliveries (PRD E2/E4).
  • Options: JID-keyed Redis store + delete_by_jid (chosen); full ScheduledSet arg-scan; DB job-log table.
  • Decision: on schedule, perform_at returns a JID; store JSON [{jid, run_at}] at order_reminder:{order_id}:{reminder_type} via REDIS_W.set(key, json, ex: 72.hours) (mirrors SetRoomJidTimestamp, set_room_jid_timestamp.rb:11-13). Dedup guard REDIS_W.set("order_reminder_dedup:{order_id}:{reminder_type}", 1, nx: true, ex: 72.hours) (mirrors abstract_models.rb:52). Cancel: read key → Sidekiq::ScheduledSet.new.delete_by_jid(run_at.to_f, jid) per entry → REDIS_W.del(key).
  • Consequences: cancellation is O(#delays) not O(set size). TTL auto-cleans (idempotent no-op after 72h, PRD §6).
  • Reversibility: high.
  • Stampede protection: not applicable (no read-through cache); dedup NX guard prevents double-scheduling under duplicate webhooks.

ADR-D — Consistency / transaction boundaries (D3)

  • Context: room create (DB), job schedule (Redis/Sidekiq), and JID store (Redis) are not one atomic unit; external send is separate.
  • Decision: ordered, compensating steps in OrderEvents::Receive: (1) get_room (DB, idempotent fetch-or-create); (2) NX dedup guard — if key exists, skip (duplicate); (3) perform_at per delay; (4) set JID store. If step 4 fails after step 3, immediately delete_by_jid the just-scheduled jobs and raise (webhook still returns 200 via top-level rescue). At execution, OrderReminderWorker re-checks enabled and current order status before send (PRD E2/E3) — eventual consistency is acceptable because the worker is the final gate.
  • Consequences: no partial "scheduled-but-uncancellable" state in the happy path; worst case a duplicate webhook that loses the NX race is caught by the worker's status re-check.
  • Reversibility: n/a (behavioral).

ADR-E — Multi-tenancy isolation

  • Decision: every settings query is scoped by organization_id (from me.organization_id on the API, from the event's resolved channel_integration.organization_id on the worker path). Enforced in the repository layer, mirroring validate_organization (get_order_list.rb). Unique partial index (organization_id, channel_integration_id, reminder_type) WHERE deleted_at IS NULL prevents cross-tenant/duplicate rows.
  • Reversibility: n/a.

Detail 2.0 — Repo Reading Guide

Repo Map (mermaid, both layers)

flowchart LR
subgraph fe["hub-chat (FE — NOT in workspace, contract-only)"]
page["ReminderSettingsPage"]
editor["TemplateEditor"]
apiclient["desty api client"]
end
subgraph hs["hub_service/app/services/api"]
wh["webhook/resources/desty.rb"]
rs["core/v1/desty/resources/reminder_setting.rb (NEW)"]
end
subgraph hc["hub_core/app/apps/desty & app/core"]
oe["interactors/order_events/receive.rb (NEW)"]
im["services/inbound_message.rb"]
bs["interactors/messages/bot_send.rb"]
sm["services/send_message/apis.rb"]
ors["models/order_reminder_setting.rb (NEW)"]
rjid["services/redis/order_reminder/* (NEW)"]
end
subgraph hw["hub_core/app/core/workers + hub-worker/config"]
scw["order_reminder_worker.rb (NEW)"]
cron["sidekiq_schedule.yml / sidekiq.yml"]
end
subgraph infra
pg[(postgres core)]
rd[(redis)]
end
apiclient --> rs --> oe
wh --> oe
oe --> im --> pg
oe --> ors --> pg
oe --> rjid --> rd
rd --> scw --> bs --> sm
scw --> pg

Existing Code Anchors

LayerPathWhy the agent reads itWhat pattern it teaches
BEhub_service/app/services/api/webhook/resources/desty.rbAdd the business_type != 0 branch (line 13)Grape resource, always-200, Rollbar rescue, interactor.parameters→new.result + Dry::Matcher
BEhub_service/app/services/api/core/v1/desty/resources/channel_integration.rbMirror for new reminder_setting.rb resourceoauth2 :admin,:owner,:supervisor, me.organization_id, then_raise_error! errors, 422, Grape params
BEhub_service/app/services/api/core/v1/desty/routes.rbMount the new resource (after line 8)resource mounting
BEhub_core/app/apps/desty/services/inbound_message.rbReuse get_room/create_room (l.56-114)fetch-or-create room by external_id=conversation_id
BEhub_core/app/apps/desty/interactors/messages/bot_send.rbCall to send reminderrequired room_id/organization_id; text via text/content
BEhub_core/app/apps/desty/services/send_message/apis.rbUnderstand the Desty send contract (l.4-30)POST /api/send_message, Bearer, returns {request_id, message_id}
BEhub_core/app/apps/desty/interactors/market_place/get_order_list.rbInteractor pattern templatecontract do params, Dry::Monads::Do.for(:result), def result monadic yield
BEhub_core/app/core/domains/services/idle_customers/create.rb + delete.rbBlueprint for schedule+JID-store+cancelperform_at→jid, SetRoomJidTimestamp, Sidekiq::ScheduledSet#delete_by_jid
BEhub_core/app/core/domains/services/redis/idle_customers/set_room_jid_timestamp.rbCopy for JID store (l.11-13)REDIS_W.set(key, val, ex:) TTL convention
BEhub_core/app/core/workers/idle_customers/send_message_worker.rbWorker+interactor+error-log shapeAbstractSidekiqWorker, sidekiq_options, CustomLogFormat.new(...).error
BEhub_core/database/core/db/migrate/20260624000001_create_direct_send_message_histories.rbMigration dialect templatecreate_table … id: :uuid, t.jsonb, add_index
BEhub_core/app/core/domains/services/preference.rbFlag registration+check (l.21,61-71,215)Services::Preference.new.enabled?(:flag, organization_id:)
FEhub-chat reminder settings page (FE-repo-verify)Contract only — repo not in workspacematches PRD §7.1 state machine

Existing Contracts to Reuse, Extend, or Replace (BE)

ContractStatusJustificationOwner
Desty::Interactors::Messages::BotSendreusevalidated all-channel Desty sendComm
Desty::Services::InboundMessage#get_room/create_roomreuseproactive room by conversation_idComm
Services::Preference flagreuseorg-scoped FlipperComm
Sidekiq::ScheduledSet#delete_by_jid + Redis JID servicesreuse (pattern)cancellation blueprintComm
POST /webhooks/desty handlerextendadd business_type != 0 branchComm
GET/POST /api/core/v1/desty/reminder_settingsnew-with-justificationno existing reminder-config endpoint (grep NOT FOUND)Comm
Desty::Interactors::OrderEvents::Receivenew-with-justificationno order-event handler exists (only business_type==0)Comm
OrderReminderScheduler/Worker/Cancellernew-with-justificationno reminder engine existsComm
order_reminder_settings tablenew-with-justificationmodel NOT FOUNDComm

Patterns to Follow (and where to find them)

LayerConcernPattern in repoReference fileDeviation?
FEState management(PRD §7.1 state machine)FE-repo-verify (hub-chat)FE repo not in workspace — verify
FEError / toast / retryinline validation + save-error toast (PRD §7.1)FE-repo-verifyverify
BEHTTP handler shapeGrape resource + oauth2 + Dry::Matcherchannel_integration.rbnone
BERepository / DB accessRepositories::AbstractRepository + dry-monadsrepositories/messages/creates/bot.rbnone
BEInteractorAbstractIteractor (note misspelling) + contract/resultget_order_list.rbnone
BEWorkerAbstractSidekiqWorker + sidekiq_optionsidle_customers/send_message_worker.rbexponential backoff via sidekiq_retry_in do |count| — no existing example (D11)
BERedis TTLREDIS_W.set(k,v, ex:) / nx:trueset_room_jid_timestamp.rb, abstract_models.rb:52none
CrossNaming (snake_case API → camelCase FE) + transformexisting FE api client transformsFE-repo-verifyverify (§2.G)

Reading Order for the Agent

  1. hub_service/.../webhook/resources/desty.rb — where the branch goes.
  2. hub_core/.../desty/services/inbound_message.rb — room create/fetch.
  3. hub_core/.../desty/interactors/messages/bot_send.rb — send contract.
  4. hub_core/.../desty/services/send_message/apis.rb — Desty send shape.
  5. hub_core/.../services/idle_customers/create.rb + delete.rb — schedule+cancel blueprint.
  6. hub_core/.../services/redis/idle_customers/set_room_jid_timestamp.rb — TTL store.
  7. hub_core/.../workers/idle_customers/send_message_worker.rb — worker shape.
  8. hub_core/database/core/db/migrate/20260624000001_create_direct_send_message_histories.rb — migration dialect.
  9. hub_service/.../core/v1/desty/resources/channel_integration.rb — API resource + auth.
  10. hub_core/.../services/preference.rb — flag check.

Source Verification (anti-hallucination — required)

LayerAnchor / patternVerified byEvidence
BEdesty webhook branchreadif attributes.business_type == 0 at desty.rb:13; no else branch
BEalways-200 + Rollbarreadpresent :status, 'success' desty.rb:21; Rollbar.error(e, …) rescue nil desty.rb:26
BEwebhook mountreadmount API::WebhookAPI => '/webhooks' config/routes.rb:49; mount API::Webhook::Resources::Desty webhook/routes.rb:26
BEcore API mount + authreadmount API::CoreAPI => '/api/core' config/routes.rb:47; oauth2 :admin, :owner, :supervisor + me.organization_id channel_integration.rb; mount …Desty::Resources::* desty/routes.rb:6-8
BEcreate_room/get_roomreaddef create_room(channel_integration) inbound_message.rb:103; external_id: params[:conversation_id] l.108; get_room returns {room, participant, is_first_message} l.56-101
BEDesty sendreadendpoint = "#{@api_url}/api/send_message" send_message/apis.rb:6; returns {request_id, message_id}; no explicit timeout
BEBotSend paramsreadrequired room_id,organization_id; optional text,content,order_id; no message param bot_send.rb:7-19
BEMessageTemplate enumreadenum type: {campaign:'campaign', follow_up:'follow_up'} message_template.rb:19-22; has_many :message_broadcasts
BENO type='campaign' template filtergrepno query filters MessageTemplate type; 'campaign' matches are Room.status (create_from_broadcast.rb:148,194)
BEchannel_integrationreadtarget_channel enum incl desty_shopee/lazada/tokopedia/tiktok/blibli l.51-57; no active column — soft-delete deleted_at; active checked via FindByWebhook(check_active:true)
BEwa_cloud couplingreadSEND_INTERACTOR={'wa_cloud'=>…}.fetch(...) send_message_worker.rb:8-12,50; BroadcastSendWorker routes to WA interactors broadcast_send_worker.rb:42-61 (not literally wa_cloud)
BERedis JID store + cancelreadREDIS_W.set("idle_customers:#{room_id}", "#{jid},#{ts}", ex:…) set_room_jid_timestamp.rb:11-13; Sidekiq::ScheduledSet.new.delete_by_jid(ts.to_f, jid) idle_customers/create.rb:57, delete.rb:12-17
BEworker base + retryreadAbstractSidekiqWorker abstract_sidekiq_worker.rb:3; sidekiq_options queue:…, retry:3; sidekiq_retry_in do …end fixed 10.min (no exponential exists); current_retry_count l.80-82
BEsidekiq-cronreadsidekiq-cron (1.9.1) Gemfile.lock:759; schedule hub-worker/config/sidekiq_schedule.yml (auto_resolve_retention_room hourly precedent)
BEmigration dialectreadActiveRecord::Migration[6.1], create_table …, id: :uuid, t.jsonb, add_index 20260624000001_…rb; core DB at hub_core/database/core/db/migrate/
BEfeature flagreadServices::Preference.new.enabled?(:feature, organization_id:) preference.rb:21,61-71; usage inbound_message.rb:37
BEinteractor patternreadAbstractIteractor < CleanArchitecture::UseCases::AbstractUseCase (misspelled) abstract_iteractor.rb:6; contract/Dry::Monads::Do.for(:result)/def result get_order_list.rb:3-48
BEtest envreadruby 2.6.3 (hub_core/hub-worker), 2.6.10 (hub_service); bundle exec rspec; needs CATCH_WITH_ROLLBAR=true, LOCKBOX_MASTER_KEY, LOCKBOX_BILLING_MASTER_KEY; CI Bitbucket Pipelines
BEOrderReminderSetting absentgrepNOT FOUND anywhere in hub_core
FEhub-chat surfacesnot verifiedFE-repo-verify — FE repo not in this workspace (§5 OQ-7)

Design ↔ Code Mapping (frontend half — required)

Figma frame / componentImplementing fileReuse vs newDesign tokensBacking API endpoint(s)Deviation
Reminder Settings Pagehub-chat/.../ReminderSettingsPage (FE-repo-verify)newn/a — design pendingGET/POST reminder_settingsdesign pending (OQ-7)
Reminder Toggle rowhub-chat/.../ReminderToggleRow (FE-repo-verify)newpendingPOST reminder_settingspending
Template Editorhub-chat/.../TemplateEditor (FE-repo-verify)newpendingPOST reminder_settingspending

FE chunks are gated on Figma frames (OQ-7). The API contracts the FE consumes are frozen in §2.4 and verified in §2.G — FE and BE can build against them in parallel.

Detail 2.1 — Architecture (mermaid)

End-to-end component diagram

flowchart TB
admin([Seller Admin]) --> page[ReminderSettingsPage FE]
page --> client[FE desty api client]
client --> rs[/API::Core::V1::Desty::Resources::ReminderSetting/]
rs --> upsert[Repositories::OrderReminderSettings::Upsert]
upsert --> db[(postgres.order_reminder_settings)]

desty[[Desty webhook]] --> wh[/webhook/resources/desty.rb/]
wh --> oe[Desty::Interactors::OrderEvents::Receive]
oe --> room[InboundMessage#get_room]
oe --> read[OrderReminderSettings::FindEnabled]
oe --> sched[OrderReminderScheduler]
sched --> redis[(redis JID store)]
sched --> sq[[Sidekiq queue order_reminder]]
sq --> worker[OrderReminderWorker]
worker --> bot[Desty::Interactors::Messages::BotSend]
bot --> send[[Desty POST /api/send_message]]
worker --> msg[(postgres.messages bot row)]
statuschg[[Desty status-change event]] --> wh
wh --> cancel[OrderReminderCanceller]
cancel --> redis

Data model (mermaid erDiagram)

erDiagram
ORGANIZATIONS ||--o{ ORDER_REMINDER_SETTINGS : owns
CHANNEL_INTEGRATIONS ||--o{ ORDER_REMINDER_SETTINGS : scopes
MESSAGE_TEMPLATES ||--o{ ORDER_REMINDER_SETTINGS : "optional FK (WA follow_up)"
CHANNEL_INTEGRATIONS ||--o{ ROOMS : has
ROOMS ||--o{ MESSAGES : has
ORDER_REMINDER_SETTINGS {
uuid id PK
uuid organization_id FK
uuid channel_integration_id FK
string reminder_type
boolean enabled
jsonb delays_minutes
text message_template
uuid message_template_id FK
timestamptz created_at
timestamptz updated_at
timestamptz deleted_at
}
MESSAGES {
uuid id PK
uuid room_id FK
string type
text text
string status
timestamptz created_at
}

State machine — reminder job lifecycle

stateDiagram-v2
[*] --> scheduled: OrderEvents::Receive enqueues perform_at
scheduled --> cancelled: status change → Canceller delete_by_jid
scheduled --> executing: delay elapses
executing --> skipped: setting disabled OR status already terminal (re-check)
executing --> sent: BotSend success
executing --> retrying: Desty error (retry ≤3, exp backoff)
retrying --> sent: success
retrying --> failed: retries exhausted → Rollbar + order_reminder_failed
sent --> [*]
cancelled --> [*]
skipped --> [*]
failed --> [*]

State machine — order_reminder_settings lifecycle

stateDiagram-v2
[*] --> disabled: row created (enabled=false default)
disabled --> enabled: admin saves enabled=true
enabled --> disabled: admin toggles off (enabled=false)
disabled --> soft_deleted: (never via UI; only ops) deleted_at set
enabled --> enabled: template/delays edited
soft_deleted --> [*]

Branch & skip flow — cancellation actor filter (seller vs buyer)

flowchart TD
ev([cancellation event]) --> has{cancelled_by present?}
has -- no --> unresolvable[log 'unresolvable actor'; no fire; return 200]
has -- yes --> who{cancelled_by == seller?}
who -- yes --> seller[fire seller_cancel if enabled]
who -- no --> buyer[fire buyer_cancel if enabled]
seller --> done([handler complete 200])
buyer --> done
unresolvable --> done

Detail 2.2 — Sequence (mermaid, end-to-end per scenario incl. failure paths)

Scenario 1 — payment reminder: schedule → cancel-on-paid vs fire

sequenceDiagram
actor Buyer
participant Desty
participant LB as LoadBalancer
participant WH as hub_service webhook
participant OE as OrderEvents::Receive (hub_core)
participant DB as Postgres (primary)
participant RD as Redis (REDIS_W)
participant SQ as Sidekiq
participant W as OrderReminderWorker
participant BS as BotSend → Desty send

Buyer->>Desty: place order (unpaid)
Desty->>LB: POST /webhooks/desty (business_type!=0, unpaid)
LB->>WH: forward
WH->>OE: interactor.parameters→new.result
OE->>DB: get_room (fetch-or-create by conversation_id)
OE->>RD: SET nx order_reminder_dedup:{order}:{type} (guard)
OE->>DB: FindEnabled(expedite_payment)
OE->>SQ: perform_at(+5m) → jid
OE->>RD: SET order_reminder:{order}:{type} = [{jid,run_at}] ex 72h
WH-->>Desty: 200
alt paid before T+5m
Desty->>WH: POST /webhooks/desty (status=paid)
WH->>OE: route to Canceller
OE->>RD: GET jids
OE->>SQ: ScheduledSet.delete_by_jid
OE->>RD: DEL key
Note over SQ: no message sent (order_reminder_cancelled)
else still unpaid at T+5m
SQ->>W: perform
W->>DB: re-check enabled + order status
W->>BS: BotSend(room_id, text)
BS->>Desty: POST /api/send_message
Desty-->>BS: {message_id}
W->>DB: store bot message (status=created)
Note over W: order_reminder_sent
end

Scenario 2 — Desty send failure (retry + DLQ-equivalent)

sequenceDiagram
participant SQ as Sidekiq
participant W as OrderReminderWorker
participant BS as BotSend
participant Desty
participant RB as Rollbar/CustomLogFormat
SQ->>W: perform (attempt 1)
W->>BS: BotSend
BS->>Desty: POST /api/send_message
Desty-->>BS: 5xx / timeout
BS-->>W: Failure
W->>W: retry_job(SqOrderReminderError) → raise
Note over SQ: Sidekiq retries (retry:3, exp backoff 100/300/900ms via sidekiq_retry_in)
SQ->>W: perform (attempt 4 exhausted)
W->>RB: CustomLogFormat.error + typed exception (order_reminder_failed)
Note over SQ: job to Sidekiq Dead set (retention 6mo default)

Detail 2.3 — Database Model (DDL)

Migration file: hub_core/database/core/db/migrate/<ts>_create_order_reminder_settings.rb (ActiveRecord::Migration[6.1]). Dialect matches 20260624000001_create_direct_send_message_histories.rb.

CREATE TABLE order_reminder_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
channel_integration_id uuid NOT NULL,
reminder_type varchar(40) NOT NULL,
enabled boolean NOT NULL DEFAULT false,
delays_minutes jsonb NOT NULL DEFAULT '[]'::jsonb, -- array of ints, e.g. [5,10,30]
message_template text, -- ≤300 (mktplace) / ≤500 (WA) enforced in model
message_template_id uuid, -- FK → message_templates.id; NULL for marketplace (D9)
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
deleted_at timestamptz, -- acts_as_paranoid soft-delete (D13)
CONSTRAINT chk_ors_reminder_type CHECK (reminder_type IN (
'confirm_order','expedite_payment','order_in_delivery','order_delivered',
'seller_cancel','buyer_cancel','failed_delivery','delayed_shipment')),
CONSTRAINT chk_ors_delays_len CHECK (jsonb_array_length(delays_minutes) <= 4)
);

-- one active config per (org, channel, type); supports FindEnabled(org, channel, type)
CREATE UNIQUE INDEX idx_ors_org_channel_type
ON order_reminder_settings (organization_id, channel_integration_id, reminder_type)
WHERE deleted_at IS NULL;

-- supports GET reminder_settings?channel_integration_id=... (list all types for a channel)
CREATE INDEX idx_ors_channel ON order_reminder_settings (channel_integration_id)
WHERE deleted_at IS NULL;
  • Volume: ≤ 8 rows × #channels × #orgs. For 844 migrating orgs × ~4 channels × 8 types ≈ 27k rows steady-state. Tiny; no partitioning.
  • PII: none in this table (message_template is seller-authored copy with {{variables}}, not resolved buyer data). Buyer PII only appears transiently at render time in the worker, never persisted here. messages.text (rendered) is in the existing, already-classified messages table (range-partitioned by created_at).
  • Retention: lifetime of the org's config; soft-deleted rows retained per existing paranoia policy.
  • FK note: message_template_id references message_templates(id); enforced at app layer (repo pattern uses belongs_to), matching how existing associations avoid hard cross-DB FKs.

Per-status lifecycle (settings.enabled + deleted_at):

StateVisibility (FE)RetentionRestoreTransitions
enabled=trueshown as green togglepermanentn/a→ disabled (toggle off), → edited
enabled=falseshown as grey togglepermanenttoggle on→ enabled
deleted_at sethiddensoft-deleted (paranoia)ops-only restoreterminal

Detail 2.4 — APIs

Outbound endpoints (consumers call us)

EndpointMethodAuthN/AuthZRequest schemaResponse schemaStatus codesIdempotencyVersioningReuse?
/api/core/v1/desty/reminder_settingsGEToauth2 :admin,:owner,:supervisor; me.organization_idquery: channel_integration_id:string(req){ data: [ { id, reminder_type, enabled:boolean, delays_minutes:int[], message_template:string|null, message_template_id:uuid|null, updated_at:iso8601 } ] } (all 8 types, defaults for unconfigured)200; 401 (no token); 403 (agent); 404 (channel not found / not in org)safe (GET)additive; v1 pathnew-with-justification
/api/core/v1/desty/reminder_settingsPOSToauth2 :admin,:owner; me.organization_id{ channel_integration_id:uuid(req), settings:[ { reminder_type:enum(req), enabled:boolean(req), delays_minutes:int[0..4], message_template:string(≤300) } ] }{ data: [ …same as GET row… ] }200 (upsert ok); 401; 403 (agent/supervisor); 422 (field errors)idempotent upsert on (org, channel, reminder_type) (natural key)additive; v1 pathnew-with-justification

Response example (GET):

{ "data": [
{ "id": "3f2…", "reminder_type": "expedite_payment", "enabled": true,
"delays_minutes": [5,10,30], "message_template": "Hi {{buyer_name}}, please pay order {{order_id}}",
"message_template_id": null, "updated_at": "2026-07-10T04:12:00Z" }
] }

Error taxonomy (POST): see §3.B. Shape: { "errors": { "<field>": ["<message>"] } } matching Grape then_raise_error! convention.

Inbound webhooks (other services call us)

EndpointMethodAuthN/AuthZSourceRequest schema (ASSUMED — OQ-1/2)ResponseStatusIdempotencyVersioning
/webhooks/destyPOSTDesty signature/token (existing webhook auth)Desty{ business_type:int(!=0 for order), event/status:string, order_id:string, conversation_id:string, account_uniq_id:string, name:string, cancelled_by:string?, courier_name?, awb_number?, tracking_link?, total_amount?, payment_deadline? }field names ASSUMED, isolated in Desty::OrderEvents::PAYLOAD_MAP{ status: 'success' }always 200 (even on internal error)dedup via Redis NX per (order_id, reminder_type)none — Desty-owned

The inbound schema is the single biggest unknown (OQ-1/2/3/5). Every assumed field is read through Desty::OrderEvents::PAYLOAD_MAP and every business_type→reminder_type mapping through Desty::OrderEvents::TYPE_MAP; confirming the real values is a constant edit, not a flow change. Unknown business_type/event values are logged and 200'd (§3.A).

Detail 2.A — UI Contract

ComponentProps (name: type, required, default)State (shape, owner)Events
ReminderSettingsPagechannelIntegrationId: string (req){ status: 'loading'|'empty'|'viewing'|'saving'|'saveError', settings: ReminderSetting[] } (page store)page_view
ReminderToggleRowreminderType: enum (req), enabled: boolean (req), delaysMinutes: number[] (req), messageTemplate: string (req, default '')local edit buffertoggle, edit
TemplateEditorvalue: string (req), maxLength: number (default 300), variables: string[] (default ['order_id','buyer_name','total_amount','courier_name','awb_number','tracking_link'])localchange, validationError
SaveButtonstate: 'idle'|'saving'|'success'|'error'click → POST

Types (FE, camelCase — transform layer per §2.G):

type ReminderSetting = {
id: string; reminderType: ReminderType; enabled: boolean;
delaysMinutes: number[]; messageTemplate: string | null;
messageTemplateId: string | null; updatedAt: string;
};
type ReminderType = 'confirm_order'|'expedite_payment'|'order_in_delivery'|'order_delivered'|'seller_cancel'|'buyer_cancel'|'failed_delivery'|'delayed_shipment';

Detail 2.B — Data-Fetching Strategy

  • Fetch: on page mount, GET /api/core/v1/desty/reminder_settings?channel_integration_id=… via existing hub-chat api client (FE-repo-verify for exact lib — SWR/axios). Cache key: ['reminder_settings', channelIntegrationId].
  • Refetch: on channel switch (key change) and after successful POST (invalidate key).
  • Stale-while-revalidate: show cached rows while refetching after save.
  • Optimistic update: none — save is a full upsert; wait for 200 then show "Settings saved" + updated_at (avoids showing unsaved state as saved).

Detail 2.C — UI State Matrix

ComponentLoadingEmptyErrorPartialSuccess
ReminderSettingsPagespinner"No channel connected" + CTA"Failed to load — retry"n/a (all-or-nothing load)8 toggle rows rendered
SaveButtondisabled + spinnern/a"Failed to save — try again" + retryn/a"Settings saved" + timestamp
TemplateEditorn/aplaceholder text"Message template is required" / "Unknown variable — will render as blank"n/avalid template shown

Detail 2.D — Data Integrity Matrix

Write PathTransaction ScopePartial FailureIdempotencyConsistencyDuplicate Handling
POST reminder_settings upsertsingle DB txn over N type rows (transaction do)all-or-nothing; 422 rolls backnatural key (org, channel, type) upsertstrong (DB)re-POST overwrites (idempotent)
OrderEvents schedulenot atomic (DB read + Redis + Sidekiq) — ordered+compensating (ADR-D)if JID store fails after schedule → delete_by_jid + raiseNX dedup guard per (order, type)eventual; worker re-checksduplicate webhook loses NX race → worker status re-check skips
Worker send + store bot msgBotSend stores message in its own path (Creates::Bot)Desty send fail → retry; msg only stored on successone send per (order, type, delay) jobeventualSidekiq at-least-once → status re-check + already-sent guard

Detail 2.E — Concurrency Collision Map

Shared resourceWritersCollision scenarioResolutionOn failed check
order_reminder:{order}:{type} Redis keyscheduler (write), canceller (del), worker (read)schedule vs cancel arriving near-simultaneouslycanceller delete_by_jid is idempotent; if worker already executing, worker's status re-check skips sendno-op (idempotent)
order_reminder_settings rowtwo admins saving same channellast-write-wins on (org, channel, type) upsert within a txnDB row-level lock during upsert txn422 only on validation, else serialized
Sidekiq job (order, type, delay)Sidekiq (at-least-once delivery)job runs twiceworker checks order status + enabled + not-already-sent guardskip + log

Detail 2.F — Async Job / Event Consumer Spec

Job / ConsumerTriggerInput shapeRetry policyDead / DLQConcurrency limitIdempotency keyTimeout
OrderReminderWorkerperform_at(delay) from scheduler(order_reminder_setting_id, room_id, organization_id, order_id, reminder_type, order_context:hash)retry: 3, exp backoff via sidekiq_retry_in do |c| (0.1/0.3/0.9s)Sidekiq Dead set (default 6mo) + order_reminder_failed eventqueue order_reminder weighted high in sidekiq.yml(order_id, reminder_type, delay) — worker checks Redis "sent" guardper-job: Desty send bounded by pigeon-http profile http_integration_desty (propose explicit 10s — OQ-8)
OrderReminderScheduler (service, sync)OrderEvents::Receiveresolved settings + roomn/a (sync)n/aNX dedup per (order, type)n/a
OrderReminderCanceller (service, sync)status-change eventorder_id (+ optional type)n/an/adelete_by_jid idempotentn/a
DelayedShipmentCheckWorkerdeferred — Phase 1.5n/an/an/an/an/a

Detail 2.F.1 — Responsibility Boundary Matrix

Step (execution order)Owning squad / serviceInbound triggerOutbound effectFailure handlerPRD anchor
1 Emit order eventDesty (external)order status changePOST /webhooks/destyDesty retries until 200§6, §14
2 Receive + routeComm / hub_service desty.rbwebhook POSTcall OrderEvents::Receivelog + 200 (Rollbar)§7.2, §8 r1
3 Parse + room + scheduleComm / hub_core OrderEventsinteractor callSidekiq jobs + Redis JIDsFailure→200; compensating cancel§7.2, §9.1
4 Execute + sendComm / hub-worker + hub_corescheduled jobDesty send_message + bot msgretry×3 → Dead + order_reminder_failed§8 r3, §11
5 Cancel on status changeComm / hub_core Cancellerstatus eventdelete_by_jididempotent no-op§8 r4, E2
6 ConfigureComm / FE + hub_service APIadmin saveupsert settings422§7.1, §8 r5-6

Detail 2.F.2 — State Surface Contract

EntityState field / eventDefaultUpdated byRead viaStale window
order_reminder_settingsenabled, updated_atfalse, now()POST upsertGET reminder_settingsnone (strong)
reminder joborder_reminder_scheduled/sent/cancelled/failed eventsscheduler/worker/cancelleranalytics pipelineevent-time
bot messagemessages.status (created→sent)createdBotSend + delivery pipelineroom message readdelivery-lag

Detail 2.G — Cross-Layer Contract Verification

EndpointBE response schemaFE expected schemaMatch?Gaps
GET reminder_settingssnake_case: reminder_type, delays_minutes, message_template, message_template_id, updated_atcamelCase: reminderType, delaysMinutes, messageTemplate, messageTemplateId, updatedAtyescasing handled by FE api-client transform layer (§2.0 Cross pattern) — explicit transform required; no nullability gaps (message_template/messageTemplateId nullable both sides)
POST reminder_settingsbody snake_case channel_integration_id, settings[].reminder_typeFE sends snake_case in body (API contract) → FE transforms camel→snake before POSTyesFE must serialize to snake_case; enum values identical strings both sides
POST 422 error{ errors: { "<field>": ["msg"] } }FE reads errors[field][0] → inline messageyeserror shape frozen (§3.B); FE error catalog (§3.C) maps codes to copy

All rows Match? = yes. The only transformation is snake_case↔camelCase, handled by the existing FE api-client transform (verify exact util in hub-chat, FE-repo-verify). No pagination (bounded ≤8 rows). Auth: bearer token via existing session (§3 Security).

Detail 2.H — End-to-End Data Flow

Save config: Admin edits toggle → ReminderSettingsPage local buffer → click Save → FE transforms camel→snake → POST /api/core/v1/desty/reminder_settingsAPI::Core::V1::Desty::Resources::ReminderSetting (oauth2 :admin,:owner, me.organization_id) → Repositories::OrderReminderSettings::Upsert (txn) → order_reminder_settings rows → {data} response → FE invalidates cache, refetch → "Settings saved". Side effects: reminder_setting_saved analytics; PaperTrail audit row.

Fire reminder: Desty event → POST /webhooks/destyOrderEvents::Receiveget_room (DB) → FindEnabled (DB) → OrderReminderScheduler.perform_at (Sidekiq) + Redis JID store → [delay] → OrderReminderWorker → re-check (DB) → BotSend → Desty send_message + messages bot row → (agent sees message). Side effects: order_reminder_scheduled then order_reminder_sent/failed; Publishers::MessageSend.

Step ownership: FE (browser) → hub_service (Grape) → hub_core (domain) → PostgreSQL/Redis → hub-worker (Sidekiq) → hub_core (send) → Desty.

Detail 2.I — Scope Boundaries

  • BE create: hub_core: models/order_reminder_setting.rb, apps/desty/interactors/order_events/receive.rb, apps/desty/services/order_events/{type_map,payload_map}.rb, core/domains/services/order_reminders/{scheduler,canceller}.rb, core/domains/services/redis/order_reminder/{set,get,del}_job_ids.rb, core/domains/repositories/order_reminder_settings/{upsert,find_enabled,list}.rb, core/workers/order_reminder_worker.rb; hub_service: app/services/api/core/v1/desty/resources/reminder_setting.rb; migration in hub_core/database/core/db/migrate/.
  • BE modify: hub_service/app/services/api/webhook/resources/desty.rb (add business_type != 0 branch — gate, don't touch the ==0 path); hub_service/app/services/api/core/v1/desty/routes.rb (mount new resource); hub-worker/config/sidekiq.yml (add order_reminder queue). NOT touched: existing CustomerSendMessage chat path; existing broadcast/idle workers.
  • FE create (FE-repo-verify, gated on Figma): ReminderSettingsPage, ReminderToggleRow, TemplateEditor, api-client method. NOT touched: existing channel settings pages beyond adding an entry point.
  • Shared modules touched + impact: desty.rb webhook — the new branch is additive and gated by business_type; regression risk limited to ensuring the ==0 chat path is unchanged (covered by existing desty_spec.rb). MessageTemplateno filter change (ADR-B correction), so no broadcast regression.

Detail 2.J — Asset Inventory (frontend half)

AssetTypeSourceFormat & sizesPath in repo
Reminder-type icons (×8)icon@mekari/ds if available; else new exportSVGFE-repo-verify — pending Figma (OQ-7)
Toggle / spinnericondesign systemSVGdesign-system (reuse)

Prefer design-system assets. Any new icon flagged for design review (OQ-7).


3. High-Availability & Security

Reminders are best-effort background jobs: a delayed or failed reminder degrades UX but never blocks order flow. The webhook path always returns 200, so Desty is never gated on our internal availability. Sidekiq retries absorb transient Desty outages; the Dead set preserves exhausted jobs for inspection.

Performance Requirement

  • Frontend: config page is a low-traffic admin view. LCP < 2.5s on throttled 3G; WCAG AA; browser support = hub-chat baseline (FE-repo-verify). Bundle delta target < 30KB gz (3 small components). No heavy assets.
  • Backend: webhook handler adds one DB fetch-or-create + N perform_at (N ≤ 8×4). Target handler p95 < 150ms (excludes async send). Worker→Desty send p95 gated by Desty API. Reminder latency SLA: send within 60s of configured delay (SC-2). Load: bounded by Desty order-event volume for 844 orgs — low relative to existing chat webhook traffic. Load test: replay N synthetic order events in staging, assert scheduled/sent counts.

Monitoring & Alerting

  • FE analytics: page_view (reminder settings), reminder_setting_saved { org_id, channel_integration_id, reminder_type, delays_count, enabled }.
  • BE metrics/events (PRD §11, emitted from scheduler/worker/canceller):
    • order_reminder_scheduled { order_id, reminder_type, delay_minutes, channel_type, org_id }
    • order_reminder_sent { order_id, reminder_type, channel_type, org_id, message_length }
    • order_reminder_cancelled { order_id, reminder_type, cancellation_reason: paid|disabled|setting_deleted }
    • order_reminder_failed { order_id, reminder_type, error_code, retry_count, channel_type }
    • reminder_setting_saved { … }
  • Alerts (PRD §11, aligned to existing broadcast baseline):
    • order_reminder_failed rate > 5% over 5-min window → Slack #comm-squad-alerts + PagerDuty BE on-call.
    • order_reminder_sent = 0 for > 10 min during 08:00–22:00 WIB → Slack.
    • order_reminder_scheduled = 0 for > 30 min business hours → Slack.
  • Cross-layer trace: propagate the webhook request_id/order_id from OrderEvents::Receive → scheduler job args → worker logs (CustomLogFormat fields) so on-call can follow a buyer-reported miss from event to send. FE emits org_id/channel_integration_id on save for correlation.
  • Dashboard owner: Communication Squad; new panels on the existing Sidekiq/queue dashboard.

Logging

  • FE: log field channel_integration_id, level info on save, error on 4xx/5xx (no PII).
  • BE: CustomLogFormat.new(error:, message:, class_name:, method_name:, args:).error (existing worker convention); centralized config.error_handlers captures job errors. Structured fields: order_id, reminder_type, org_id, channel_type, retry_count.
  • PII removal: rendered message.text contains buyer name/order data and lives in the already-classified messages table; do not log rendered text or buyer PII in worker logs — log IDs only. Desty access_token is Lockbox-encrypted and must never be logged.

Security Implications

  • AuthN/AuthZ: admin API guarded by oauth2 :admin,:owner (write) / :admin,:owner,:supervisor (read); tenant scoping via me.organization_id on every query (ownership: settings row organization_id must equal me.organization_id, enforced in repo). Webhook uses existing Desty webhook auth.
  • Input validation: reminder_type ∈ CHECK enum; delays_minutes array of ints length ≤ 4, each 0..1440; message_template length ≤ 300 (marketplace), charset text; channel_integration_id must belong to me.organization_id (404 otherwise). Grape params enforce types before the interactor.
  • Injection: all DB access via ActiveRecord/repository (parameterized); no raw SQL string interpolation in new code. Template rendering uses a whitelist of {{variables}} (§2.A) — unknown variables render blank, never eval. SSRF: outbound only to ENV['DESTY_API_URL'] (fixed host), not a user-supplied URL.
  • Secrets: Desty access_token via LOCKBOX.decrypt (existing); no new secrets. LOCKBOX_MASTER_KEY/LOCKBOX_BILLING_MASTER_KEY required in every env (test + prod).
  • Audit: settings writes recorded via PaperTrail (existing convention — verify enabled on new model; FE-repo-verify/BE-verify) with whodunnit = me.id.
  • Tenancy isolation: unique index includes organization_id; no cross-tenant reads possible via the scoped repo.
  • Bot-send-on-behalf-of-seller is the key InfoSec review item: reminders are sent as the seller's shop bot to real buyers — abuse/rate concerns covered by the per-type enable gate + 4-delay cap + worker status re-check.

Role × Endpoint Authorization Matrix

RoleEndpoint(s)Permitted methodsTenant scopeUI visibility (FE)Additional constraintAudit trail
Ownerreminder_settingsGET, POSTown orgfullPaperTrail
Adminreminder_settingsGET, POSTown orgfullPaperTrail
Supervisorreminder_settingsGETown orgread-onlyno save (403 on POST)read logged
Agentreminder_settingsnonehidden403 all methodsn/a
System (bot)n/a (BotSend)own orgn/asend only when enabled + status validorder_reminder_sent

Detail 3.A — Failure Mode Catalog (merged)

SurfaceFE behavior on failureBE response on failureCode-shape consistency
Load settings"Failed to load — retry"404 (channel not found) / 401yes (FE handles 401→login, 404→empty, 5xx→retry)
Save settingsinline field errors / "Failed to save" toast422 {errors:{field:[msg]}} / 401 / 403yes (§3.C maps codes)
Desty send (worker)n/a (background)retry×3 exp backoff → Dead set + order_reminder_failedyes
Missing conversation_id (worker/receive)n/alog Rollbar, no room, no send, return 200yes (PRD ERR-2)
Unknown business_type/eventn/alog, 200, no scheduleyes
Desty timeoutn/atreated as retryable Failureyes
Order already paid at executionn/aworker skips send, logs "already paid — skipped"yes (PRD AC-4)

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAudit trailUser-visible?
enabled = false at executionworker (re-check)skip send, loglog onlyno
order status terminal (paid/cancelled) at executionworker (re-check)skip sendlog + implicitno
cancelled_by absentOrderEvents::Receive"unresolvable actor", no firelogno
duplicate webhook (NX guard hit)schedulerskip re-schedulelogno
unknown business_type/eventreceive parser200, no schedulelogno
failed_delivery on TikTok/Tokopedia (unconfirmed by Desty)receive parserskip type (allowlist Shopee+Lazada) until OQ-4 confirmedlogno

Detail 3.B — Error Response Catalog (BE)

Shape: { "errors": { "<field>": ["<message>"] } } (Grape then_raise_error!).

Code (logical)HTTPFieldConditionUser-facing?
MESSAGE_TEMPLATE_REQUIRED422message_templateempty on enabled typeyes
TEMPLATE_TOO_LONG422message_template> 300 charsyes
TOO_MANY_DELAYS422delays_minutes> 4 valuesyes
INVALID_DELAY422delays_minutesnon-int / <0 / >1440yes
INVALID_REMINDER_TYPE422reminder_typenot in enumyes (dev)
CHANNEL_NOT_ACTIVE422channel_integration_idchannel soft-deleted/inactiveyes
CHANNEL_NOT_FOUND404channel not in orgyes
UNAUTHORIZED401no/invalid tokenyes
FORBIDDEN403agent, or supervisor on POSTyes

Detail 3.C — Error Message Catalog (FE)

CodeUser-facing messageShown whereUser-facing vs logged
MESSAGE_TEMPLATE_REQUIRED"Message template is required"inline under editoruser-facing
TEMPLATE_TOO_LONG"Template exceeds 300 characters"inlineuser-facing
TOO_MANY_DELAYS"Maximum 4 delays allowed"inlineuser-facing
unknown-variable (client-side)"Unknown variable — will render as blank"inline warning + confirmuser-facing (PRD ERR-2)
CHANNEL_NOT_ACTIVE"Channel is not active"toastuser-facing
401(redirect to login)logged
5xx / network"Failed to save — please try again"toast + retryuser-facing

Detail 3.D — Compliance & Data Governance

Triggered: buyer PII (name, order id, amount, courier/AWB) is rendered into messages.

  • Classification: message_template = seller copy (no PII). Rendered messages.text = PII (buyer name/order) — governed by the existing messages table policy.
  • Legal basis: contractual necessity (order fulfillment communication the buyer initiated by ordering).
  • Retention: rendered messages follow existing room/message retention; settings have no PII.
  • Right-to-delete: buyer deletion handled by existing messages/room deletion path — no new PII store introduced.
  • Encryption: in transit (HTTPS to Desty); Desty token at rest via Lockbox. No new at-rest PII column.
  • Access/audit: settings writes audited (PaperTrail); message access via existing agent-visibility controls.
  • Cross-border: no new data residency change; Desty already processes these events.

Detail 3.E — Accessibility

  • Keyboard: all toggles/inputs tabbable; Save reachable via keyboard; focus returns to edited row after save.
  • ARIA: toggle rows role="switch" with aria-checked; template errors aria-live="polite".
  • Contrast: enabled/disabled toggle states meet WCAG AA.
  • (Detailed a11y QA against Figma once frames land — OQ-7.)

4. Backwards Compatibility and Rollout Plan

Compatibility

  • BE: additive only — new table, new endpoints, new webhook branch gated on business_type != 0. Existing business_type == 0 chat path untouched (desty_spec.rb guards it). No API versioning break.
  • FE: new page; no saved-state migration. Feature-flag-gated visibility (org without flag never sees the settings entry point).
  • Cross-layer: GET/POST contracts are new; nothing consumes them today. Enum strings are shared verbatim (no transform beyond casing).

Rollout Strategy

  • Deploy order: BE first, then FE. Reason: the engine (webhook branch + workers) must exist and be flag-gated before the UI can save configs that do anything; FE with no BE would let admins "save" into nothing. BE ships behind order_reminder_automation_enabled = default OFF, so BE-first is inert until an org is flagged on.
  • Feature-flag coordination — single flag order_reminder_automation_enabled (Services::Preference), per-org. It gates both the BE scheduling path (webhook branch checks the flag before scheduling) and FE settings visibility (GET returns 403/empty + FE hides entry point when off). One flag, toggled per org by BE — no independent FE/BE flags to desync.
  • Rollback per layer + sequence: FE first (revert PR / hide entry point), then BE (flag off → scheduling stops; existing scheduled jobs either fire harmlessly if configs still enabled, or are drained). Never roll back BE while FE still exposes the page for a flagged org (would 5xx saves) — hence FE-before-BE rollback.
  • Stop conditions: order_reminder_failed > 5% (5-min) → halt rollout, flag off affected orgs; any P1; reminder sent to a paid order (cancellation bug) → immediate flag off.
  • Stages (PRD §10/§13):
StageAudienceGatePICTimeline
Alphainternal / stagingall 8 types fire on Shopee in staging; OQ-1/2/3 resolvedBESprint 1–2
Beta5–10 pilot Desty orgsflag on per org; conversation_id confirmed; proactive room create + cancellation verifiedBE + PMSprint 3–4
GAall Commerce orgs (844 migrating first)0 P1 in 2-wk beta; delivery success > 98%; config UI shippedPMSprint 5+

Detail 4.A — Cross-Layer Rollout Compatibility Matrix

ScenarioFEBEWorks?Mitigation
Pre-deployOldOldyesbaseline
Backend firstOldNewyesBE inert while flag OFF; no FE dependency; webhook branch gated
Frontend firstNewOldnoavoided by deploy order (BE first); if it happened, GET 404 → FE shows "unavailable" and hides save
Both deployedNewNewyestarget state
Backend rollbackNewOld (rolled back)noroll back FE first (sequence rule); or flag OFF so FE hides page before BE revert
Frontend rollbackOld (rolled back)NewyesBE stays flag-gated; no orphaned FE state

Detail 4.B — Configuration Contract

LayerEnv var / flagTypeDefaultRequiredProvisionerSecret?
BEorder_reminder_automation_enabled (Flipper, per-org)feature flagOFFyesBE Lead via Services::Preference.new.add/enableno
BEDESTY_API_URLstring (existing)existingyesinfrano
BELOCKBOX_MASTER_KEY, LOCKBOX_BILLING_MASTER_KEYsecret (existing)yesinfra/vaultyes
BECATCH_WITH_ROLLBAR (test/prod)bool (existing)env-dependentyes (tests)infrano
BESidekiq queue order_reminderqueue configyeshub-worker/config/sidekiq.ymlno
BEpigeon profile timeout http_integration_desty (propose 10s)int (proposed)gem defaultnoinfra (OQ-8)no
FEorder_reminder_automation_enabled (read via BE)flag (derived)OFFyesBEno

Detail 4.C — Test Plan (commands the agent will run)

Sources: hub_core/.rspec, hub_service/.rspec, hub-worker/.rspec; CI */bitbucket-pipelines.yml; env from hub_core/spec/rails_helper.rb:18 (CATCH_WITH_ROLLBAR) + lockbox.rb.

LayerCommand (source)What it must prove
BE unit (hub_core, ruby 2.6.3)CATCH_WITH_ROLLBAR=true LOCKBOX_MASTER_KEY=… LOCKBOX_BILLING_MASTER_KEY=… bundle exec rspec spec/apps/desty/interactors/order_events/receive_spec.rb spec/core/workers/order_reminder_worker_spec.rb spec/core/domains/services/order_reminders (.rspec)scheduler enqueues per delay; worker re-checks + sends; canceller deletes by jid
BE unit (models/repos)… bundle exec rspec spec/core/domains/models/order_reminder_setting_spec.rb spec/core/domains/repositories/order_reminder_settingsenum CHECK, delays≤4, template≤300, upsert idempotent
BE integration (hub_service, ruby 2.6.10)RAILS_ENV=test bundle exec rspec spec/services/api/webhook/resources/desty_spec.rb spec/services/api/core/v1/desty/resources/reminder_setting_spec.rb (bitbucket-pipelines.yml)business_type!=0 routes to OrderEvents; ==0 path unchanged; GET/POST auth + 422
BE integration (real DB, hub-worker)RAILS_ENV=test bundle exec rspec app (hub-worker/bitbucket-pipelines.yml:83-98)worker runs against DB + Redis; cancellation flow
BE lint/securitybundle exec rubocop ; brakeman (hub-worker CI)style + security gates
Cross-layer integrationscenario "unpaid→schedule→paid→cancel→0 sends"; "unpaid→T+delay→1 send" (BE integration specs above)end-to-end schedule/cancel/send
FE unit / E2EFE-repo-verify — hub-chat not in workspace; FE test commands defined in FE RFCconfig page states; save flow

Detail 4.D — Agent Execution Plan

OrderLayerChunkFiles to modify/createCommandsAcceptance criteria
1BEMigration + modelhub_core/database/core/db/migrate/<ts>_create_order_reminder_settings.rb; hub_core/app/core/domains/models/order_reminder_setting.rbRAILS_ENV=test bundle exec rails app:db:migrate; … rspec spec/core/domains/models/order_reminder_setting_spec.rbtable exists w/ CHECK+unique index; model enum + validations (delays≤4, template≤300) pass
2BESettings repos + API resource…/repositories/order_reminder_settings/{upsert,find_enabled,list}.rb; hub_service/…/core/v1/desty/resources/reminder_setting.rb; mount in …/desty/routes.rb… rspec spec/services/api/core/v1/desty/resources/reminder_setting_spec.rbGET returns 8 rows; POST upsert idempotent; 422 on empty template / 5 delays; 403 agent
3BERedis JID services…/services/redis/order_reminder/{set,get,del}_job_ids.rb… rspec spec/core/domains/services/redis/order_reminderset/get/del round-trip with 72h TTL; NX dedup guard
4BEScheduler + Canceller…/services/order_reminders/{scheduler,canceller}.rb… rspec spec/core/domains/services/order_remindersscheduler perform_at per delay, stores jids, NX dedup; canceller delete_by_jid all pending; idempotent
5BEWorker (render+send+retry)hub_core/app/core/workers/order_reminder_worker.rb; hub-worker/config/sidekiq.yml (+queue)RAILS_ENV=test bundle exec rspec app (hub-worker)re-checks enabled+status; renders whitelisted vars (blank sub for missing); BotSend called; retry×3 then failed event
6BEOrderEvents::Receive + payload/type mapshub_core/app/apps/desty/interactors/order_events/receive.rb; …/services/order_events/{type_map,payload_map}.rb… rspec spec/apps/desty/interactors/order_events/receive_spec.rbmaps business_type→type; get_room; schedules; unknown type → log+skip; missing conversation_id → Rollbar+skip
7BEWebhook branchmodify hub_service/app/services/api/webhook/resources/desty.rb (add business_type != 0 branch, flag-gated)RAILS_ENV=test bundle exec rspec spec/services/api/webhook/resources/desty_spec.rb!=0→OrderEvents; ==0 path unchanged; always 200; flag OFF → no schedule
8FEConfig page + api client (gated on OQ-7)hub-chat ReminderSettingsPage, api client (FE-repo-verify)FE test cmd (FE RFC)loads 8 rows; save→200→"Settings saved"
9FEToggle + Template editor (gated)ReminderToggleRow, TemplateEditor (FE-repo-verify)FE test cmdinline validation; unknown-var warning; empty→error
10FEFlag gating + entry point (gated)channel settings entry point (FE-repo-verify)FE test cmdpage hidden when flag OFF

Detail 4.E — Verification & Rollback Recipe

  • Pre-merge verification (in order):
    • BE:
      1. CATCH_WITH_ROLLBAR=true LOCKBOX_MASTER_KEY=… LOCKBOX_BILLING_MASTER_KEY=… bundle exec rspec (hub_core, changed specs)
      2. RAILS_ENV=test bundle exec rspec spec/services/api/webhook/resources/desty_spec.rb spec/services/api/core/v1/desty (hub_service)
      3. RAILS_ENV=test bundle exec rspec app (hub-worker)
      4. bundle exec rubocop ; brakeman
    • FE (FE-repo-verify, per FE RFC):
      1. FE lint + typecheck
      2. FE unit + E2E for the settings page
  • Post-deploy verification signals:
    • order_reminder_scheduled count > 0 within minutes of flagging a pilot org (dashboard: Comm queue panel).
    • order_reminder_sent / (sent+failed) >= 0.98 over first 24h.
    • order_reminder_cancelled fires on a test paid order; 0 order_reminder_sent after cancel for that order_id.
    • Sidekiq Dead set not growing abnormally.
  • Rollback recipe (deploy-order-aware):
    1. Flip order_reminder_automation_enabled OFF for affected org(s) via Services::Preference (stops new scheduling immediately).
    2. Revert FE PR / hide entry point (so no saves hit a reverting BE).
    3. If needed, drain/clear the order_reminder Sidekiq queue and ScheduledSet for affected orders.
    4. Revert BE PRs (webhook branch, workers). Migration is additive — leave the table (no destructive down needed); only run migrate:down if fully abandoning.
    5. Confirm order_reminder_scheduled drops to 0 and no order_reminder_sent for rolled-back orgs.

Detail 4.F — Resource & Cost Notes (advisory)

  • New Sidekiq queue order_reminder on existing hub-worker pods — no new pods expected at 844-org scale (order-event volume ≪ chat volume). Redis: +2 small keys per active order (JID store + dedup), 72h TTL — negligible memory. DB: ~27k tiny rows steady-state. No new infra components. Route to infra planning if pilot shows higher-than-expected order-event QPS.

5. Concern, Questions, or Known Limitations

Open Questions (blocking marked):

  • OQ-1 (BLOCKING): Exact Desty business_type value per order status (unpaid/paid/cancelled/shipped/delivered). Resolution: isolated in Desty::OrderEvents::TYPE_MAP; confirm with Desty API team (PRD S16 Q1, target Jul 4). Until then, staging uses assumed values.
  • OQ-2 (BLOCKING): Order-event payload field names (order_id, conversation_id, cancelled_by, status). Isolated in PAYLOAD_MAP (PRD S16 Q2/Q3/Q5).
  • OQ-3 (BLOCKING): Is conversation_id present for all 4 platforms? Room creation depends on it; missing → Rollbar + skip (PRD ERR-2). (PRD S16 Q3.)
  • OQ-4: Failed Delivery available for TikTok/Tokopedia? Current design allowlists Shopee+Lazada for failed_delivery (§3.A.1) until confirmed (PRD S16 Q4).
  • OQ-5: cancelled_by exact field name for actor filter (PRD S16 Q5).
  • OQ-6: Shop-wide order-list call for DelayedShipmentCheckWorker — existing get_order_list is customer-scoped; a new Desty call shape is needed → deferred to Phase 1.5.
  • OQ-7: Figma frames for the config UI (PRD S16 Q7) — gates FE chunks 8–10 only.
  • OQ-8: Explicit Desty HTTP timeout — no timeout is set in app code today (pigeon-http profile http_integration_desty); propose configuring 10s. Confirm with infra.
  • OQ-9: Confirm PaperTrail is enabled on the new model (audit requirement).
  • OQ-11: Confirm role model (Agent 403 vs read-only) — resolved to 403 (D-role); PM to confirm (PRD S16).
  • OQ-12: Pricing/packaging (PRD S16 Q12) — non-technical, does not block engineering.

Known limitations:

  • Reminders are at-least-once (Sidekiq); the worker's status/enabled/sent re-check is the correctness gate against duplicates and races.
  • A message already delivered to Desty cannot be unsent (PRD) — cancellation only affects scheduled-but-unsent jobs.
  • FE is contract-verified but not repo-grounded (hub-chat absent from workspace).

6. Comment logs

DateComment(s) FromAction Item(s)
2026-07-10RFC authorInitial draft from PRD v1.2 + repo grounding

7. Ready for agent execution

  • yes — for the backend (chunks 1–7), conditional on OQ-1/2/3 (Desty payload) being confirmed before Beta. The mapping-constant design lets Alpha proceed against assumed values in staging.
  • no — for the frontend (chunks 8–10) until Figma frames land (OQ-7). FE contracts are ready; FE implementation is design-gated.

Execution-readiness gate status:

  • §1 Design References (FE) — partial: surfaces named, Figma pending (OQ-7) → FE chunks gated.
  • §1 PRD-to-Schema Derivation (BE) — yes: every entity/rule mapped to table.column + endpoint + enforcement.
  • Detail 1.C Per-Story Change Map — yes: 6 stories, each with layer scope + FE/BE changes + verifiable AC (S06 deferred with reason).
  • Repo Reading Guide (2.0) — yes for BE (anchors + Source Verification with line-level evidence); FE anchors FE-repo-verify.
  • Source Verification — yes (BE); FE rows honestly marked unverifiable.
  • Design ↔ Code Mapping — partial (Figma pending).
  • Asset Inventory — partial (pending Figma).
  • Mermaid diagrams — yes: topology, component, ER, 2 state machines, 2 sequences (incl. failure), branch/skip.
  • DDL + per-status lifecycle — yes; every row traces to a PRD-to-Schema row.
  • APIs (outbound + inbound) — yes; every new endpoint tagged; inbound webhook schema assumed + isolated.
  • Cross-Layer Contract Verification — yes: all rows Match? = yes (casing transform noted).
  • End-to-End Data Flow — yes (both save + fire).
  • UI State Matrix / Failure Catalog / Error catalogs — yes; aligned.
  • Cross-Layer Rollout Matrix + deploy order — yes (BE-first).
  • Configuration Contract — yes (per layer); single flag coordination explicit.
  • Agent Execution Plan — yes: every chunk has layer + files + commands + acceptance criteria.
  • Verification & Rollback Recipe — yes: runnable per layer; signals named; deploy-order-aware rollback.

Handed to rfc-reviewer for a second-pass score.