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.
| Field | Value | Notes |
|---|
| Status | RFC | IDEA / RFC / ABANDON / AGREED |
| Owner | Communication Squad | Team owning the RFC |
| Author(s) | Communication Squad BE | Primary author(s) |
| Reviewers | Comm BE Lead, Comm FE Lead, Commerce Squad | Tech reviewers across affected squads (FE + BE) |
| Approver(s) | EM (Communication) + InfoSec approver [REQUIRED] | Tech leaders + infosec approver |
| Submitted Date | 2026-07-10 | ISO-8601 |
| Last Updated | 2026-07-10 | ISO-8601 |
| Target Release | 2026-Q3 | Aligned with Desty→Qontak Q3 migration milestone (PRD §2) |
| Related Documents | PRD page 51232080555 (v1.2) | Single PRD driver |
| Discussion | #comm-squad-alerts | Slack |
Type: full-stack
Frontend sub-type: new-feature
Backend sub-type: new-feature
Sections at a Glance
- Overview (incl. §1 Design References — FE half, and §1 PRD-to-Schema Derivation — BE half)
- Technical Design (Infrastructure Topology → Repo Reading Guide → end-to-end mermaid → DDL → APIs → cross-layer contract verification)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (incl. cross-layer rollout matrix, Agent Execution Plan, Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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:
| Dependency | Type | Owner | Status | Blocks |
|---|
Desty business_type values per status (OQ-1) | BLOCKING | BE + Desty API | Open — Sprint 1 discovery | Webhook router mapping constant |
Desty order payload schema incl. conversation_id, cancelled_by (OQ-2/3/5) | BLOCKING | BE + Desty API | Open — Sprint 1 discovery | Parser, room create, cancel actor filter |
order_reminder_settings migration (hub_core core DB) | BLOCKING (BE-internal) | BE | Not started | Config UI + all workers |
| Redis JID store services (schedule/cancel) | BLOCKING (BE-internal) | BE | Not started | Cancellation (E2/E3) |
order_reminder_automation_enabled Flipper flag | BLOCKING (BE-internal) | BE Lead | Not started | Gated rollout |
| Admin Config UI (hub-chat FE) | Non-blocking for BE | FE | Not started | Seller self-service (deploy after BE) |
| InfoSec review of bot-send-on-behalf-of-seller | Process | InfoSec | Not started | GA gate |
Design References (frontend half — required)
| PRD-named surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|
| Reminder Settings Page | n/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 pending | — | TBD | Design | Behavior specified in §2.A/2.C from PRD §7.1 |
| Template Editor (variable chips) | n/a — design pending | — | TBD | Design | Variable 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 / rule | Persisted as (table.column) | Exposed via (endpoint / event) | Enforced where | Source |
|---|
| Per-channel reminder config (8 types) | order_reminder_settings.reminder_type (enum, CHECK) | GET/POST /api/core/v1/desty/reminder_settings | DB CHECK + model enum + Grape params | PRD §6, §7.2, §9.2 |
| Config scoped per org + channel (not global) | order_reminder_settings(organization_id, channel_integration_id) unique w/ type | same | Unique partial index; repo scoping | PRD §6, §15.1 |
| Enable/disable per type | order_reminder_settings.enabled boolean default false | POST reminder_settings | worker checks at execution time | PRD §7.1, E3 |
| Up to 4 delay values per type | order_reminder_settings.delays_minutes jsonb + CHECK len ≤ 4 | POST reminder_settings | DB CHECK + model validation → 422 | PRD §6 (Max delays) |
| Message template (≤300 mktplace / ≤500 WA) | order_reminder_settings.message_template text | POST reminder_settings | model validation → 422 | PRD §6 (char limit) |
| WA dual-use follow_up template | order_reminder_settings.message_template_id uuid (FK → message_templates, nullable) | POST reminder_settings | FK; only set when type=follow_up (WA, future) | PRD §15.1 (v1.2) |
| Reminder is a bot message stored in room | messages row via Repositories::Messages::Creates::Bot | Publishers::MessageSend event | BotSend interactor | PRD §9.2, §15.2 |
| Proactive room creation from conversation_id | rooms.external_id = conversation_id | n/a (internal) | InboundMessage#get_room/create_room | PRD §6, E1 |
| Scheduled job IDs for cancellation | Redis order_reminder:{order_id}:{reminder_type} (TTL 72h) | n/a | OrderReminderScheduler / OrderReminderCanceller | PRD §6 (Redis TTL), E2 |
| Dedup on duplicate webhook | Redis order_reminder_dedup:{order_id}:{reminder_type} (SET NX) | n/a | scheduler guard | PRD 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 requirement | FE section / component | BE 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-only | desty.rb new branch → OrderEvents::Receive (§2.1, §2.4 Inbound) |
| §7.2 Scheduler | n/a | OrderReminderScheduler (§2.F) |
| §7.2 Canceller | n/a | OrderReminderCanceller (§2.E, §2.F) |
| §7.2 Worker (render+send, retry) | n/a | OrderReminderWorker (§2.F, §3.A) |
| §9.2 Payment reminder (unpaid) | Toggle: expedite_payment | scheduler + worker + BotSend |
| §9.2 Confirm/In-delivery/Delivered | Toggles: confirm_order / order_in_delivery / order_delivered | scheduler + worker |
| §9.2 Cancellation (seller/buyer actor filter) | Toggles: seller_cancel / buyer_cancel | OrderEvents::Receive actor filter (§3.A.1) |
| §12 Observability metrics | analytics on save (§3 Monitoring) | 5 tracking events (§3 Monitoring) |
| §10 Feature flag rollout | flag-gated settings visibility | Services::Preference order_reminder_automation_enabled |
Reverse:
| New FE component / BE endpoint / dependency | PRD need |
|---|
order_reminder_settings table | PRD §6 "No existing OrderReminderSetting model"; per-channel config |
OrderEvents::Receive interactor | PRD §6 "business_type != 0 gap" |
| Redis JID store services | PRD §6 "Sidekiq job cancellation complexity" |
OrderReminderScheduler/Worker/Canceller | PRD §7.2 engine components |
| GET/POST reminder_settings | PRD §8 rows 5–6 |
ReminderSettingsPage + children | PRD §7.1 admin config |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads (BE) | Required writes (BE) | FE component | Status surface |
|---|
| Reminder Settings Page | web (admin) | GET reminder_settings | POST reminder_settings | ReminderSettingsPage | enabled, updated_at per row |
| Reminder Toggle (×8) | web | (from page load) | POST reminder_settings | ReminderToggleRow | enabled |
| Template Editor | web | (from page load) | POST reminder_settings | TemplateEditor | validation error state |
| Buyer reminder in room | web/agent (read) | existing room messages read | n/a — written by BotSend | existing Room message list | messages.status (created/sent/failed) |
Role Coverage
| PRD role | Authorization mechanism | Endpoints permitted (BE) | UI surface visibility (FE) | Cross-tenant? | Audit trail |
|---|
| Owner | oauth2 :owner (OAuth2 bearer, me.organization_id) | GET + POST reminder_settings | full config page | no (own org) | PaperTrail on settings write (§3) |
| Admin | oauth2 :admin | GET + POST reminder_settings | full config page | no | PaperTrail |
| Supervisor | oauth2 :supervisor | GET reminder_settings (read-only) | read-only page | no | read logged |
| Agent | not in oauth2 scope list | none → 403 | page hidden / 403 | no | n/a |
| System (bot) | internal (SystemAccount / BOT_ID) | n/a — sends via BotSend | n/a | n/a | order_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 § | Title | Where covered |
|---|
| 1 | One-liner + Problem | §1 Overview |
| 2 | What if we don't build | §1 Overview |
| 3 | Strategic Context | §1 (pricing → OQ-12) |
| 4 | Target Users/Persona | §1.A Role Coverage |
| 5 | Non-Goals | §1 Out of Scope |
| 6 | Constraints | §2.3 DDL, §2.F async spec, §3 Perf/Security, §4 Config |
| 7 | New Features (7.1 UI / 7.2 engine) | §2.A UI Contract / §2.1–2.F |
| 8 | API & Webhook Behavior | §2.4 APIs (Outbound + Inbound) |
| 9 | System Flow + Stories + ACs | §2.2 Sequence, §1.C Per-Story Change Map |
| 10 | Rollout | §4 Rollout Strategy + 4.A matrix |
| 11 | Observability | §3 Monitoring & Alerting |
| 12 | Success Metrics | §1 Success Criteria |
| 13 | Launch Plan & Stage Gates | §4 Rollout Strategy |
| 14 | Dependencies | §1 Dependencies |
| 15 | Key Decisions + Alternatives | §1.B + §2 Technical Decisions (ADRs) |
| 16 | Open Questions | §5 |
Detail 1.B — Decisions Closed (cross-layer)
| Decision | Chosen option | Alternatives rejected | Why rejected | Layer |
|---|
| D1 Storage | New 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 types | BE |
| D2 Send path | Reuse Desty::Interactors::Messages::BotSend → Desty::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'=>…}.fetch → KeyError for marketplace (send_message_worker.rb:8-12,50); (c) duplicates existing, validated Desty send | BE |
| D3 Sync vs async | Webhook does sync parse+room+schedule, returns 200 immediately; delivery async via Sidekiq perform_at | fully-sync send in webhook | Desty API latency gates delivery; PRD requires always-200 (no Desty retry storms) | BE |
| D4 Cancellation | Store Sidekiq JIDs in Redis keyed by order_id:reminder_type, cancel via Sidekiq::ScheduledSet#delete_by_jid | scan ScheduledSet by args each time | O(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 Dedup | SET nx:true ex:72h guard key per (order, type) before scheduling | DB unique constraint on a job-log table | Redis guard matches abstract_models.rb:52 pattern; no new table; TTL auto-cleans | BE |
| D6 Flag | Services::Preference Flipper flag order_reminder_automation_enabled, per-org | ENV var; per-org column | Flipper+Redis org-list is the house mechanism (preference.rb:61-71); ENV can't target orgs | BE + FE |
| D7 Feature-flag character-limit & delay-count validation | model-level + DB CHECK | FE-only validation | FE validation is UX; server is source of truth (422) | BE |
| D8 DelayedShipmentCheckWorker | Deferred to Phase 1.5 | ship in Phase 1 | Existing get_order_list is customer-scoped (get_order_list.rb:52-55); shop-wide poll has no confirmed Desty call shape | BE |
| D9 WA template dual-use | message_template_id FK nullable; NULL for marketplace, set for WA follow_up | copy templates | MessageTemplate type enum {campaign, follow_up} exists (message_template.rb:19-22); reuse Meta-approved template | BE |
| D10 Room create | Reuse InboundMessage#get_room (fetch-or-create) | new room-create path | Existing path keys on external_id=conversation_id, returns {room, participant, is_first_message} | BE |
| D11 Retry/backoff | retry: 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 namespace | New API::Core::V1::Desty::Resources::ReminderSetting at /api/core/v1/desty/reminder_settings | PRD's /api/v1/desty/... | Corrects PRD path to the real mount (API::CoreAPI => '/api/core', desty/routes.rb:6-8) | BE + FE |
| D-role | Agent → 403; Supervisor read-only | Agent read-only (PRD §6) | Resolves PRD internal contradiction; matches existing oauth2 macro | BE + FE |
| D13 Per-status lifecycle (settings) | Soft-delete via acts_as_paranoid (deleted_at); config never hard-deleted, only enabled=false | hard delete | PRD §9.2 "Config cannot be deleted — only disabled"; matches AbstractParanoiaElastic convention | BE |
| D14 Inbound webhook ownership | Comm Squad owns the business_type != 0 branch in hub_service desty.rb | Desty team | We own the Qontak-side handler; Desty owns event emission | both |
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 title | Layer scope | FE changes | BE changes | Acceptance criteria (verifiable) | RFC anchors |
|---|
| ORR-S01 | Seller configures reminders per channel | FE + BE | ReminderSettingsPage, ReminderToggleRow, TemplateEditor; data fetch via existing api client; analytics reminder_setting_saved | order_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-S02 | Buyer receives payment reminder until paid | Runtime / behavior (BE) | n/a — BE-only | desty.rb branch; OrderEvents::Receive; OrderReminderScheduler; OrderReminderWorker; Redis JID store; OrderReminderCanceller | unpaid 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-S03 | Buyer receives confirm/shipped/delivered notices | Runtime / behavior (BE) | n/a | reminder_type ∈ {confirm_order, order_in_delivery, order_delivered}; template var rendering with blank substitution | status→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-S04 | Buyer receives cancellation notice (actor-filtered) | Runtime / behavior (BE) | n/a | actor 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 inheritance | BE | (config page shows blank for unconfigured channel) | separate row per channel_integration_id; no copy | Shopee template configured; Tokopedia page → no prefill (distinct rows) | §2.3 unique index · §2.A |
| ORR-S06 | Delayed shipment reminder (stuck >24h) | Runtime / behavior (BE) | n/a | deferred — 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:
| Service | Use cases (high-level) | Internal calls (owning team) | External / 3rd-party APIs |
|---|
hub_service (Grape) | Receive Desty webhook; serve admin config API | → hub_core interactors (Comm) | Desty webhook inbound |
hub_core (gem) | Parse order event; find/create room; read settings; render+send; cancel | → Repositories::*, Services::Preference (Comm) | Desty POST /api/send_message (outbound) |
hub-worker (Sidekiq) | Execute scheduled reminders; cancel pending | → hub_core interactors (Comm) | none directly (via hub_core) |
| PostgreSQL core DB | Persist order_reminder_settings, rooms, messages | — | — |
Redis (REDIS_W/REDIS_R) | Sidekiq queues; JID store; dedup guard; flag org-list | — | — |
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::BotSend → Desty::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
| Layer | Path | Why the agent reads it | What pattern it teaches |
|---|
| BE | hub_service/app/services/api/webhook/resources/desty.rb | Add the business_type != 0 branch (line 13) | Grape resource, always-200, Rollbar rescue, interactor.parameters→new.result + Dry::Matcher |
| BE | hub_service/app/services/api/core/v1/desty/resources/channel_integration.rb | Mirror for new reminder_setting.rb resource | oauth2 :admin,:owner,:supervisor, me.organization_id, then_raise_error! errors, 422, Grape params |
| BE | hub_service/app/services/api/core/v1/desty/routes.rb | Mount the new resource (after line 8) | resource mounting |
| BE | hub_core/app/apps/desty/services/inbound_message.rb | Reuse get_room/create_room (l.56-114) | fetch-or-create room by external_id=conversation_id |
| BE | hub_core/app/apps/desty/interactors/messages/bot_send.rb | Call to send reminder | required room_id/organization_id; text via text/content |
| BE | hub_core/app/apps/desty/services/send_message/apis.rb | Understand the Desty send contract (l.4-30) | POST /api/send_message, Bearer, returns {request_id, message_id} |
| BE | hub_core/app/apps/desty/interactors/market_place/get_order_list.rb | Interactor pattern template | contract do params, Dry::Monads::Do.for(:result), def result monadic yield |
| BE | hub_core/app/core/domains/services/idle_customers/create.rb + delete.rb | Blueprint for schedule+JID-store+cancel | perform_at→jid, SetRoomJidTimestamp, Sidekiq::ScheduledSet#delete_by_jid |
| BE | hub_core/app/core/domains/services/redis/idle_customers/set_room_jid_timestamp.rb | Copy for JID store (l.11-13) | REDIS_W.set(key, val, ex:) TTL convention |
| BE | hub_core/app/core/workers/idle_customers/send_message_worker.rb | Worker+interactor+error-log shape | AbstractSidekiqWorker, sidekiq_options, CustomLogFormat.new(...).error |
| BE | hub_core/database/core/db/migrate/20260624000001_create_direct_send_message_histories.rb | Migration dialect template | create_table … id: :uuid, t.jsonb, add_index |
| BE | hub_core/app/core/domains/services/preference.rb | Flag registration+check (l.21,61-71,215) | Services::Preference.new.enabled?(:flag, organization_id:) |
| FE | hub-chat reminder settings page (FE-repo-verify) | Contract only — repo not in workspace | matches PRD §7.1 state machine |
Existing Contracts to Reuse, Extend, or Replace (BE)
| Contract | Status | Justification | Owner |
|---|
Desty::Interactors::Messages::BotSend | reuse | validated all-channel Desty send | Comm |
Desty::Services::InboundMessage#get_room/create_room | reuse | proactive room by conversation_id | Comm |
Services::Preference flag | reuse | org-scoped Flipper | Comm |
Sidekiq::ScheduledSet#delete_by_jid + Redis JID services | reuse (pattern) | cancellation blueprint | Comm |
POST /webhooks/desty handler | extend | add business_type != 0 branch | Comm |
GET/POST /api/core/v1/desty/reminder_settings | new-with-justification | no existing reminder-config endpoint (grep NOT FOUND) | Comm |
Desty::Interactors::OrderEvents::Receive | new-with-justification | no order-event handler exists (only business_type==0) | Comm |
OrderReminderScheduler/Worker/Canceller | new-with-justification | no reminder engine exists | Comm |
order_reminder_settings table | new-with-justification | model NOT FOUND | Comm |
Patterns to Follow (and where to find them)
| Layer | Concern | Pattern in repo | Reference file | Deviation? |
|---|
| FE | State management | (PRD §7.1 state machine) | FE-repo-verify (hub-chat) | FE repo not in workspace — verify |
| FE | Error / toast / retry | inline validation + save-error toast (PRD §7.1) | FE-repo-verify | verify |
| BE | HTTP handler shape | Grape resource + oauth2 + Dry::Matcher | channel_integration.rb | none |
| BE | Repository / DB access | Repositories::AbstractRepository + dry-monads | repositories/messages/creates/bot.rb | none |
| BE | Interactor | AbstractIteractor (note misspelling) + contract/result | get_order_list.rb | none |
| BE | Worker | AbstractSidekiqWorker + sidekiq_options | idle_customers/send_message_worker.rb | exponential backoff via sidekiq_retry_in do |count| — no existing example (D11) |
| BE | Redis TTL | REDIS_W.set(k,v, ex:) / nx:true | set_room_jid_timestamp.rb, abstract_models.rb:52 | none |
| Cross | Naming (snake_case API → camelCase FE) + transform | existing FE api client transforms | FE-repo-verify | verify (§2.G) |
Reading Order for the Agent
hub_service/.../webhook/resources/desty.rb — where the branch goes.
hub_core/.../desty/services/inbound_message.rb — room create/fetch.
hub_core/.../desty/interactors/messages/bot_send.rb — send contract.
hub_core/.../desty/services/send_message/apis.rb — Desty send shape.
hub_core/.../services/idle_customers/create.rb + delete.rb — schedule+cancel blueprint.
hub_core/.../services/redis/idle_customers/set_room_jid_timestamp.rb — TTL store.
hub_core/.../workers/idle_customers/send_message_worker.rb — worker shape.
hub_core/database/core/db/migrate/20260624000001_create_direct_send_message_histories.rb — migration dialect.
hub_service/.../core/v1/desty/resources/channel_integration.rb — API resource + auth.
hub_core/.../services/preference.rb — flag check.
Source Verification (anti-hallucination — required)
| Layer | Anchor / pattern | Verified by | Evidence |
|---|
| BE | desty webhook branch | read | if attributes.business_type == 0 at desty.rb:13; no else branch |
| BE | always-200 + Rollbar | read | present :status, 'success' desty.rb:21; Rollbar.error(e, …) rescue nil desty.rb:26 |
| BE | webhook mount | read | mount API::WebhookAPI => '/webhooks' config/routes.rb:49; mount API::Webhook::Resources::Desty webhook/routes.rb:26 |
| BE | core API mount + auth | read | mount 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 |
| BE | create_room/get_room | read | def 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 |
| BE | Desty send | read | endpoint = "#{@api_url}/api/send_message" send_message/apis.rb:6; returns {request_id, message_id}; no explicit timeout |
| BE | BotSend params | read | required room_id,organization_id; optional text,content,order_id; no message param bot_send.rb:7-19 |
| BE | MessageTemplate enum | read | enum type: {campaign:'campaign', follow_up:'follow_up'} message_template.rb:19-22; has_many :message_broadcasts |
| BE | NO type='campaign' template filter | grep | no query filters MessageTemplate type; 'campaign' matches are Room.status (create_from_broadcast.rb:148,194) |
| BE | channel_integration | read | target_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) |
| BE | wa_cloud coupling | read | SEND_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) |
| BE | Redis JID store + cancel | read | REDIS_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 |
| BE | worker base + retry | read | AbstractSidekiqWorker 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 |
| BE | sidekiq-cron | read | sidekiq-cron (1.9.1) Gemfile.lock:759; schedule hub-worker/config/sidekiq_schedule.yml (auto_resolve_retention_room hourly precedent) |
| BE | migration dialect | read | ActiveRecord::Migration[6.1], create_table …, id: :uuid, t.jsonb, add_index 20260624000001_…rb; core DB at hub_core/database/core/db/migrate/ |
| BE | feature flag | read | Services::Preference.new.enabled?(:feature, organization_id:) preference.rb:21,61-71; usage inbound_message.rb:37 |
| BE | interactor pattern | read | AbstractIteractor < CleanArchitecture::UseCases::AbstractUseCase (misspelled) abstract_iteractor.rb:6; contract/Dry::Monads::Do.for(:result)/def result get_order_list.rb:3-48 |
| BE | test env | read | ruby 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 |
| BE | OrderReminderSetting absent | grep | NOT FOUND anywhere in hub_core |
| FE | hub-chat surfaces | not verified | FE-repo-verify — FE repo not in this workspace (§5 OQ-7) |
Design ↔ Code Mapping (frontend half — required)
| Figma frame / component | Implementing file | Reuse vs new | Design tokens | Backing API endpoint(s) | Deviation |
|---|
| Reminder Settings Page | hub-chat/.../ReminderSettingsPage (FE-repo-verify) | new | n/a — design pending | GET/POST reminder_settings | design pending (OQ-7) |
| Reminder Toggle row | hub-chat/.../ReminderToggleRow (FE-repo-verify) | new | pending | POST reminder_settings | pending |
| Template Editor | hub-chat/.../TemplateEditor (FE-repo-verify) | new | pending | POST reminder_settings | pending |
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,
message_template text,
message_template_id uuid,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
deleted_at timestamptz,
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)
);
CREATE UNIQUE INDEX idx_ors_org_channel_type
ON order_reminder_settings (organization_id, channel_integration_id, reminder_type)
WHERE deleted_at IS NULL;
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):
| State | Visibility (FE) | Retention | Restore | Transitions |
|---|
| enabled=true | shown as green toggle | permanent | n/a | → disabled (toggle off), → edited |
| enabled=false | shown as grey toggle | permanent | toggle on | → enabled |
| deleted_at set | hidden | soft-deleted (paranoia) | ops-only restore | terminal |
Detail 2.4 — APIs
Outbound endpoints (consumers call us)
| Endpoint | Method | AuthN/AuthZ | Request schema | Response schema | Status codes | Idempotency | Versioning | Reuse? |
|---|
/api/core/v1/desty/reminder_settings | GET | oauth2 :admin,:owner,:supervisor; me.organization_id | query: 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 path | new-with-justification |
/api/core/v1/desty/reminder_settings | POST | oauth2 :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 path | new-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)
| Endpoint | Method | AuthN/AuthZ | Source | Request schema (ASSUMED — OQ-1/2) | Response | Status | Idempotency | Versioning |
|---|
/webhooks/desty | POST | Desty 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
| Component | Props (name: type, required, default) | State (shape, owner) | Events |
|---|
ReminderSettingsPage | channelIntegrationId: string (req) | { status: 'loading'|'empty'|'viewing'|'saving'|'saveError', settings: ReminderSetting[] } (page store) | page_view |
ReminderToggleRow | reminderType: enum (req), enabled: boolean (req), delaysMinutes: number[] (req), messageTemplate: string (req, default '') | local edit buffer | toggle, edit |
TemplateEditor | value: string (req), maxLength: number (default 300), variables: string[] (default ['order_id','buyer_name','total_amount','courier_name','awb_number','tracking_link']) | local | change, validationError |
SaveButton | state: '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
| Component | Loading | Empty | Error | Partial | Success |
|---|
| ReminderSettingsPage | spinner | "No channel connected" + CTA | "Failed to load — retry" | n/a (all-or-nothing load) | 8 toggle rows rendered |
| SaveButton | disabled + spinner | n/a | "Failed to save — try again" + retry | n/a | "Settings saved" + timestamp |
| TemplateEditor | n/a | placeholder text | "Message template is required" / "Unknown variable — will render as blank" | n/a | valid template shown |
Detail 2.D — Data Integrity Matrix
| Write Path | Transaction Scope | Partial Failure | Idempotency | Consistency | Duplicate Handling |
|---|
| POST reminder_settings upsert | single DB txn over N type rows (transaction do) | all-or-nothing; 422 rolls back | natural key (org, channel, type) upsert | strong (DB) | re-POST overwrites (idempotent) |
| OrderEvents schedule | not atomic (DB read + Redis + Sidekiq) — ordered+compensating (ADR-D) | if JID store fails after schedule → delete_by_jid + raise | NX dedup guard per (order, type) | eventual; worker re-checks | duplicate webhook loses NX race → worker status re-check skips |
| Worker send + store bot msg | BotSend stores message in its own path (Creates::Bot) | Desty send fail → retry; msg only stored on success | one send per (order, type, delay) job | eventual | Sidekiq at-least-once → status re-check + already-sent guard |
Detail 2.E — Concurrency Collision Map
| Shared resource | Writers | Collision scenario | Resolution | On failed check |
|---|
order_reminder:{order}:{type} Redis key | scheduler (write), canceller (del), worker (read) | schedule vs cancel arriving near-simultaneously | canceller delete_by_jid is idempotent; if worker already executing, worker's status re-check skips send | no-op (idempotent) |
order_reminder_settings row | two admins saving same channel | last-write-wins on (org, channel, type) upsert within a txn | DB row-level lock during upsert txn | 422 only on validation, else serialized |
| Sidekiq job (order, type, delay) | Sidekiq (at-least-once delivery) | job runs twice | worker checks order status + enabled + not-already-sent guard | skip + log |
Detail 2.F — Async Job / Event Consumer Spec
| Job / Consumer | Trigger | Input shape | Retry policy | Dead / DLQ | Concurrency limit | Idempotency key | Timeout |
|---|
OrderReminderWorker | perform_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 event | queue order_reminder weighted high in sidekiq.yml | (order_id, reminder_type, delay) — worker checks Redis "sent" guard | per-job: Desty send bounded by pigeon-http profile http_integration_desty (propose explicit 10s — OQ-8) |
OrderReminderScheduler (service, sync) | OrderEvents::Receive | resolved settings + room | n/a (sync) | n/a | NX dedup per (order, type) | n/a | |
OrderReminderCanceller (service, sync) | status-change event | order_id (+ optional type) | n/a | n/a | delete_by_jid idempotent | n/a | |
DelayedShipmentCheckWorker | deferred — Phase 1.5 | n/a | n/a | n/a | n/a | n/a | |
Detail 2.F.1 — Responsibility Boundary Matrix
| Step (execution order) | Owning squad / service | Inbound trigger | Outbound effect | Failure handler | PRD anchor |
|---|
| 1 Emit order event | Desty (external) | order status change | POST /webhooks/desty | Desty retries until 200 | §6, §14 |
| 2 Receive + route | Comm / hub_service desty.rb | webhook POST | call OrderEvents::Receive | log + 200 (Rollbar) | §7.2, §8 r1 |
| 3 Parse + room + schedule | Comm / hub_core OrderEvents | interactor call | Sidekiq jobs + Redis JIDs | Failure→200; compensating cancel | §7.2, §9.1 |
| 4 Execute + send | Comm / hub-worker + hub_core | scheduled job | Desty send_message + bot msg | retry×3 → Dead + order_reminder_failed | §8 r3, §11 |
| 5 Cancel on status change | Comm / hub_core Canceller | status event | delete_by_jid | idempotent no-op | §8 r4, E2 |
| 6 Configure | Comm / FE + hub_service API | admin save | upsert settings | 422 | §7.1, §8 r5-6 |
Detail 2.F.2 — State Surface Contract
| Entity | State field / event | Default | Updated by | Read via | Stale window |
|---|
order_reminder_settings | enabled, updated_at | false, now() | POST upsert | GET reminder_settings | none (strong) |
| reminder job | order_reminder_scheduled/sent/cancelled/failed events | — | scheduler/worker/canceller | analytics pipeline | event-time |
| bot message | messages.status (created→sent) | created | BotSend + delivery pipeline | room message read | delivery-lag |
Detail 2.G — Cross-Layer Contract Verification
| Endpoint | BE response schema | FE expected schema | Match? | Gaps |
|---|
| GET reminder_settings | snake_case: reminder_type, delays_minutes, message_template, message_template_id, updated_at | camelCase: reminderType, delaysMinutes, messageTemplate, messageTemplateId, updatedAt | yes | casing 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_settings | body snake_case channel_integration_id, settings[].reminder_type | FE sends snake_case in body (API contract) → FE transforms camel→snake before POST | yes | FE must serialize to snake_case; enum values identical strings both sides |
| POST 422 error | { errors: { "<field>": ["msg"] } } | FE reads errors[field][0] → inline message | yes | error 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_settings → API::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/desty → OrderEvents::Receive → get_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). MessageTemplate — no filter change (ADR-B correction), so no broadcast regression.
Detail 2.J — Asset Inventory (frontend half)
| Asset | Type | Source | Format & sizes | Path in repo |
|---|
| Reminder-type icons (×8) | icon | @mekari/ds if available; else new export | SVG | FE-repo-verify — pending Figma (OQ-7) |
| Toggle / spinner | icon | design system | SVG | design-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.
- 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
| Role | Endpoint(s) | Permitted methods | Tenant scope | UI visibility (FE) | Additional constraint | Audit trail |
|---|
| Owner | reminder_settings | GET, POST | own org | full | — | PaperTrail |
| Admin | reminder_settings | GET, POST | own org | full | — | PaperTrail |
| Supervisor | reminder_settings | GET | own org | read-only | no save (403 on POST) | read logged |
| Agent | reminder_settings | none | — | hidden | 403 all methods | n/a |
| System (bot) | n/a (BotSend) | — | own org | n/a | send only when enabled + status valid | order_reminder_sent |
Detail 3.A — Failure Mode Catalog (merged)
| Surface | FE behavior on failure | BE response on failure | Code-shape consistency |
|---|
| Load settings | "Failed to load — retry" | 404 (channel not found) / 401 | yes (FE handles 401→login, 404→empty, 5xx→retry) |
| Save settings | inline field errors / "Failed to save" toast | 422 {errors:{field:[msg]}} / 401 / 403 | yes (§3.C maps codes) |
| Desty send (worker) | n/a (background) | retry×3 exp backoff → Dead set + order_reminder_failed | yes |
Missing conversation_id (worker/receive) | n/a | log Rollbar, no room, no send, return 200 | yes (PRD ERR-2) |
Unknown business_type/event | n/a | log, 200, no schedule | yes |
| Desty timeout | n/a | treated as retryable Failure | yes |
| Order already paid at execution | n/a | worker skips send, logs "already paid — skipped" | yes (PRD AC-4) |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit trail | User-visible? |
|---|
enabled = false at execution | worker (re-check) | skip send, log | log only | no |
| order status terminal (paid/cancelled) at execution | worker (re-check) | skip send | log + implicit | no |
cancelled_by absent | OrderEvents::Receive | "unresolvable actor", no fire | log | no |
| duplicate webhook (NX guard hit) | scheduler | skip re-schedule | log | no |
| unknown business_type/event | receive parser | 200, no schedule | log | no |
failed_delivery on TikTok/Tokopedia (unconfirmed by Desty) | receive parser | skip type (allowlist Shopee+Lazada) until OQ-4 confirmed | log | no |
Detail 3.B — Error Response Catalog (BE)
Shape: { "errors": { "<field>": ["<message>"] } } (Grape then_raise_error!).
| Code (logical) | HTTP | Field | Condition | User-facing? |
|---|
MESSAGE_TEMPLATE_REQUIRED | 422 | message_template | empty on enabled type | yes |
TEMPLATE_TOO_LONG | 422 | message_template | > 300 chars | yes |
TOO_MANY_DELAYS | 422 | delays_minutes | > 4 values | yes |
INVALID_DELAY | 422 | delays_minutes | non-int / <0 / >1440 | yes |
INVALID_REMINDER_TYPE | 422 | reminder_type | not in enum | yes (dev) |
CHANNEL_NOT_ACTIVE | 422 | channel_integration_id | channel soft-deleted/inactive | yes |
CHANNEL_NOT_FOUND | 404 | — | channel not in org | yes |
UNAUTHORIZED | 401 | — | no/invalid token | yes |
FORBIDDEN | 403 | — | agent, or supervisor on POST | yes |
Detail 3.C — Error Message Catalog (FE)
| Code | User-facing message | Shown where | User-facing vs logged |
|---|
MESSAGE_TEMPLATE_REQUIRED | "Message template is required" | inline under editor | user-facing |
TEMPLATE_TOO_LONG | "Template exceeds 300 characters" | inline | user-facing |
TOO_MANY_DELAYS | "Maximum 4 delays allowed" | inline | user-facing |
| unknown-variable (client-side) | "Unknown variable — will render as blank" | inline warning + confirm | user-facing (PRD ERR-2) |
CHANNEL_NOT_ACTIVE | "Channel is not active" | toast | user-facing |
| 401 | (redirect to login) | — | logged |
| 5xx / network | "Failed to save — please try again" | toast + retry | user-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):
| Stage | Audience | Gate | PIC | Timeline |
|---|
| Alpha | internal / staging | all 8 types fire on Shopee in staging; OQ-1/2/3 resolved | BE | Sprint 1–2 |
| Beta | 5–10 pilot Desty orgs | flag on per org; conversation_id confirmed; proactive room create + cancellation verified | BE + PM | Sprint 3–4 |
| GA | all Commerce orgs (844 migrating first) | 0 P1 in 2-wk beta; delivery success > 98%; config UI shipped | PM | Sprint 5+ |
Detail 4.A — Cross-Layer Rollout Compatibility Matrix
| Scenario | FE | BE | Works? | Mitigation |
|---|
| Pre-deploy | Old | Old | yes | baseline |
| Backend first | Old | New | yes | BE inert while flag OFF; no FE dependency; webhook branch gated |
| Frontend first | New | Old | no | avoided by deploy order (BE first); if it happened, GET 404 → FE shows "unavailable" and hides save |
| Both deployed | New | New | yes | target state |
| Backend rollback | New | Old (rolled back) | no | roll back FE first (sequence rule); or flag OFF so FE hides page before BE revert |
| Frontend rollback | Old (rolled back) | New | yes | BE stays flag-gated; no orphaned FE state |
Detail 4.B — Configuration Contract
| Layer | Env var / flag | Type | Default | Required | Provisioner | Secret? |
|---|
| BE | order_reminder_automation_enabled (Flipper, per-org) | feature flag | OFF | yes | BE Lead via Services::Preference.new.add/enable | no |
| BE | DESTY_API_URL | string (existing) | existing | yes | infra | no |
| BE | LOCKBOX_MASTER_KEY, LOCKBOX_BILLING_MASTER_KEY | secret (existing) | — | yes | infra/vault | yes |
| BE | CATCH_WITH_ROLLBAR (test/prod) | bool (existing) | env-dependent | yes (tests) | infra | no |
| BE | Sidekiq queue order_reminder | queue config | — | yes | hub-worker/config/sidekiq.yml | no |
| BE | pigeon profile timeout http_integration_desty (propose 10s) | int (proposed) | gem default | no | infra (OQ-8) | no |
| FE | order_reminder_automation_enabled (read via BE) | flag (derived) | OFF | yes | BE | no |
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.
| Layer | Command (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_settings | enum 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/security | bundle exec rubocop ; brakeman (hub-worker CI) | style + security gates |
| Cross-layer integration | scenario "unpaid→schedule→paid→cancel→0 sends"; "unpaid→T+delay→1 send" (BE integration specs above) | end-to-end schedule/cancel/send |
| FE unit / E2E | FE-repo-verify — hub-chat not in workspace; FE test commands defined in FE RFC | config page states; save flow |
Detail 4.D — Agent Execution Plan
| Order | Layer | Chunk | Files to modify/create | Commands | Acceptance criteria |
|---|
| 1 | BE | Migration + model | hub_core/database/core/db/migrate/<ts>_create_order_reminder_settings.rb; hub_core/app/core/domains/models/order_reminder_setting.rb | RAILS_ENV=test bundle exec rails app:db:migrate; … rspec spec/core/domains/models/order_reminder_setting_spec.rb | table exists w/ CHECK+unique index; model enum + validations (delays≤4, template≤300) pass |
| 2 | BE | Settings 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.rb | GET returns 8 rows; POST upsert idempotent; 422 on empty template / 5 delays; 403 agent |
| 3 | BE | Redis JID services | …/services/redis/order_reminder/{set,get,del}_job_ids.rb | … rspec spec/core/domains/services/redis/order_reminder | set/get/del round-trip with 72h TTL; NX dedup guard |
| 4 | BE | Scheduler + Canceller | …/services/order_reminders/{scheduler,canceller}.rb | … rspec spec/core/domains/services/order_reminders | scheduler perform_at per delay, stores jids, NX dedup; canceller delete_by_jid all pending; idempotent |
| 5 | BE | Worker (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 |
| 6 | BE | OrderEvents::Receive + payload/type maps | hub_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.rb | maps business_type→type; get_room; schedules; unknown type → log+skip; missing conversation_id → Rollbar+skip |
| 7 | BE | Webhook branch | modify 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 |
| 8 | FE | Config 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" |
| 9 | FE | Toggle + Template editor (gated) | ReminderToggleRow, TemplateEditor (FE-repo-verify) | FE test cmd | inline validation; unknown-var warning; empty→error |
| 10 | FE | Flag gating + entry point (gated) | channel settings entry point (FE-repo-verify) | FE test cmd | page hidden when flag OFF |
Detail 4.E — Verification & Rollback Recipe
- Pre-merge verification (in order):
- BE:
CATCH_WITH_ROLLBAR=true LOCKBOX_MASTER_KEY=… LOCKBOX_BILLING_MASTER_KEY=… bundle exec rspec (hub_core, changed specs)
RAILS_ENV=test bundle exec rspec spec/services/api/webhook/resources/desty_spec.rb spec/services/api/core/v1/desty (hub_service)
RAILS_ENV=test bundle exec rspec app (hub-worker)
bundle exec rubocop ; brakeman
- FE (
FE-repo-verify, per FE RFC):
- FE lint + typecheck
- 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):
- Flip
order_reminder_automation_enabled OFF for affected org(s) via Services::Preference (stops new scheduling immediately).
- Revert FE PR / hide entry point (so no saves hit a reverting BE).
- If needed, drain/clear the
order_reminder Sidekiq queue and ScheduledSet for affected orders.
- Revert BE PRs (webhook branch, workers). Migration is additive — leave the table (no destructive down needed); only run
migrate:down if fully abandoning.
- 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).
| Date | Comment(s) From | Action Item(s) |
|---|
| 2026-07-10 | RFC author | Initial 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.