Production Run Reminders — Daily Discoverer
Sister flow to Partner WhatsApp — Production Run Flow. The transactional flow only fires on lifecycle events (assigned / cancelled / completed). This doc covers the scheduled daily reminder that nudges partners on production runs that have stalled.
Why this exists
The transactional flow handles the happy path: when a run is sent to a partner, they get a WhatsApp template, tap Accept, work it, and complete. In practice runs sit in three stuck states:
| Bucket | Condition | Why it stalls |
|---|---|---|
assignment_pending | status='sent_to_partner' AND accepted_at IS NULL AND created_at < now − 24h | Partner saw the message but never tapped Accept |
not_started | accepted_at IS NOT NULL AND started_at IS NULL AND accepted_at < now − 24h | Partner accepted but never tapped Start in the portal |
idle | status='in_progress' AND started_at < now − 72h | Run is in progress, no produced-quantity update for days |
Without an automated nudge, ops manually chase partners by phone. This flow does the chasing on a fixed weekday cadence and reuses the existing wildcard WhatsApp dispatcher to send the message.
System Overview
┌─────────────────────────────────────┐
│ Cron: 30 4 * * 1-5 (10:00 IST M-F) │
└─────────────────┬───── ──────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ NEW scheduled visual flow │
│ "Production Run Reminders — Daily Discoverer" │
│ │
│ read_active_runs read_data │
│ ↓ { status: { $in: [sent_to_partner, │
│ in_progress] } }, limit 500│
│ classify execute_code │
│ ↓ buckets rows into 3 reminder kinds │
│ drops rows missing partner_id │
│ dispatch bulk_trigger_workflow │
│ ↓ workflow_name = emit-production-run-reminder│
│ log_summary log │
└─────────────────┬────────────────────────────────────────────────┘
│ once per overdue run
▼
┌──────────────────────────────────────────────────────────────────┐
│ NEW Medusa workflow `emit-production-run-reminder` │
│ Single step: │
│ eventBus.emit({ │
│ name: "production_run.reminder_<kind>", │
│ data: { production_run_id, partner_id, design_id, │
│ reminder_kind } │
│ }) │
└─────────────────┬────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ EXISTING wildcard flow │
│ "Partner WhatsApp — Production Run (all events)" │
│ trigger_config.event_pattern: "production_run.*" │
│ │
│ read_run → read_partner → read_design │
│ → resolve_template (extended map adds the 3 reminder events) │
│ → has_template → send_whatsapp (template) │
│ → gen_link → send_image │
└─────────────────┬────────────────────────────────────────────────┘
▼
Partner's WhatsApp
The reminder flow does not send any messages itself. It only discovers stuck runs and emits events. The existing wildcard dispatcher does the actual WhatsApp send. Same send_whatsapp operation, same dedup story, same partner phone resolution, same deep-link generation — we just add three new event→template mappings to its existing transform node.
Key Components
| Role | File |
|---|---|
| Scheduled discoverer seed | src/scripts/seed-production-run-reminders-flow.ts |
| Per-run event emitter workflow | src/workflows/production-runs/emit-production-run-reminder.ts |
| Existing wildcard dispatcher (extended) | src/scripts/seed-partner-run-whatsapp-flow.ts |
| Event → flow subscriber (registers new event names) | src/subscribers/visual-flow-event-trigger.ts |
| Cron evaluator | src/jobs/run-scheduled-visual-flows.ts |
| Bulk dispatch operation | src/modules/visual_flows/operations/bulk-trigger-workflow.ts |
What Was Added This Iteration
1. New scheduled flow seed
src/scripts/seed-production-run-reminders-flow.ts creates an idempotent visual flow with trigger_type: "schedule" and trigger_config.cron: "30 4 * * 1-5". Four operations chained linearly:
read_active_runs(read_data) — single query with{ status: { $in: ["sent_to_partner", "in_progress"] } }, limit 500. Pulls the columns needed for bucketing:id, partner_id, design_id, status, accepted_at, started_at, finished_at, created_at, updated_at, produced_quantity, quantity.classify(execute_code) — pure JS that walks the rows, drops anything without apartner_id, and buckets each remaining row into one ofassignment_pending/not_started/idle. Returns{ items: [...], counts: {...}, total_inspected: N }.dispatch(bulk_trigger_workflow) — callsemit-production-run-reminderonce per item inclassify.items.max_items: 500(matches the read limit),continue_on_error: trueso a single bad row doesn't stop the rest.log_summary(log) — single info-level line with all the counts and dispatch result.
The flow is created with status: "draft" — operators flip it to active in the admin UI once the templates are approved (see Activation Gate below).
2. New event-emitter workflow
src/workflows/production-runs/emit-production-run-reminder.ts registers a Medusa workflow named "emit-production-run-reminder". Single step that maps reminder_kind → event name and emits via Modules.EVENT_BUS. No DB writes. No reads. No retries. The event payload deliberately mirrors the shape of production_run.sent_to_partner so the existing wildcard flow's read_run/read_partner/read_design filters work unchanged:
{
production_run_id: string,
partner_id: string,
design_id: string | null,
reminder_kind: "assignment_pending" | "not_started" | "idle",
}
3. Three new event→template mappings on the existing wildcard flow
src/scripts/seed-partner-run-whatsapp-flow.ts was extended in the RESOLVE_TEMPLATE_CODE node:
| Event | Template | Variables |
|---|---|---|
production_run.reminder_assignment_pending | jyt_production_run_reminder_pending_v1 | [partnerName, designName, runId, daysSinceAssignment] |
production_run.reminder_not_started | jyt_production_run_reminder_not_started_v1 | [partnerName, designName, runId, daysSinceAccepted] |
production_run.reminder_idle | jyt_production_run_reminder_idle_v1 | [partnerName, designName, runId, producedQty, quantity] |
Day-age helpers (daysSinceAssignment, daysSinceAccepted, daysSinceStarted) are derived inline from the run's timestamps so the message body reads "2 days ago" rather than a raw ISO string.
4. Per-day dedup context_id for reminder events
send_whatsapp dedups on (context_type, context_id) for 60 minutes by default. With the standard context_id = run_id, the second day's reminder would still fall inside that window only if it fired within an hour of the first — but since reminders fire at the same time of day, the more important property is that today's reminder must be allowed to land even though yesterday's reminder used the same run_id. The existing 60-minute window is fine for that. What we do need is to keep same-day retries (subscriber crash + Bus replay, etc.) deduplicated.
The fix: for reminder events only, resolve_template returns context_id = "<runId>:reminder:<YYYY-MM-DD>". Same-day retries dedup; next-day reminders carry a fresh suffix and are not blocked.
5. Separate run_id field on resolve_template
With context_id carrying a per-day suffix, downstream nodes that display the run id needed a clean copy:
gen_link.run_id→ usesresolve_template.run_id(the raw run id, not the suffixedcontext_id). The deep-link JWT must encode the actual run id or the partner portal won't resolve it.send_image.caption→ usesresolve_template.run_idso partners see "Run prod_run_…" rather than "Run prod_run_…:reminder:2026-04-25".
6. Three new event names registered with the visual-flow subscriber
src/subscribers/visual-flow-event-trigger.ts was extended to subscribe to production_run.reminder_assignment_pending, .reminder_not_started, .reminder_idle. Without this, the eventBus wouldn't deliver the new events to the visual-flow trigger machinery — even though the existing flow's event_pattern: "production_run.*" would otherwise match.
Language selection at send time
Reminders are written once, sent to partners across multiple WABAs in different languages. Selection happens at send time inside the send_whatsapp operation — it is not the responsibility of the reminder flow or the discoverer.
Resolution order (src/modules/visual_flows/operations/send-whatsapp.ts:277-282):
- Explicit
options.language_codeon the send node (the reminder flow does not set this). resolveLanguageFromConversation(messagingService, partnerId, to)— looks at the partner's WhatsApp conversation history for a previously-saved language preference.inferLanguageFromPhonePrefix(to)— heuristic:+91→hi, everything else →en.- Env
WHATSAPP_TEMPLATE_LANG. - Default
hi.
Per-platform policy for which languages get submitted to a WABA at template creation time is languagesForPlatform(platform) in src/scripts/whatsapp-templates/partner-run-templates.ts:251:
- Platforms with
+91inapi_config.country_codes→["en", "hi"] - Every other platform →
["en"] - Override via env
WHATSAPP_PLATFORM_LANGUAGES="AU=en;IN=en,hi;Europe=en,it"matched againstapi_config.label.
Implication for these three reminder templates: every IN-region WABA needs both en and hi approved before the flow is activated, otherwise partners with Hindi conversation history fall back to whatever language the resolver picks next, and a partner whose phone is +91 but whose conversation has no saved language gets hi — if hi isn't approved on that WABA, the send fails. Both variants are required.
Cron timing — read this before activating
The repo's cron evaluator (src/jobs/run-scheduled-visual-flows.ts:82-103) uses date.getMinutes() and date.getHours() — that is, the container's local time, not UTC. The seed script uses 30 4 * * 1-5, which is 10:00 IST Mon-Fri only if the container runs in UTC.
Before flipping the flow to active:
railway run --service medusa-server -- date
# Expect: Sat Apr 25 14:30:00 UTC 2026 (or similar UTC stamp)
If the container runs in IST, edit the flow in the admin UI and change the cron to 0 10 * * 1-5.
Activation Gate
-
Templates approved on every WABA. Three new templates, each with
en+hivariants for IN-region WABAs (six creates total per IN WABA, two per non-IN WABA):jyt_production_run_reminder_pending_v1(4 vars)jyt_production_run_reminder_not_started_v1(4 vars)jyt_production_run_reminder_idle_v1(5 vars)
Specs are in
src/scripts/whatsapp-templates/partner-run-templates.ts(the canonical source, used by both paths below).Path A — CLI fan-out (recommended for multi-WABA setups):
# Dry-run: show plan, no network calls
MODE=dry-run npx medusa exec ./src/scripts/manage-whatsapp-templates.ts
# Submit only the missing variants, on every configured WhatsApp platform
MODE=upsert npx medusa exec ./src/scripts/manage-whatsapp-templates.ts
# Restrict to specific platforms
PLATFORM_IDS=spfm_01ABC,spfm_01DEF \
MODE=upsert npx medusa exec ./src/scripts/manage-whatsapp-templates.tsPath B — Admin API (one POST per template variant):
The existing route
POST /admin/social-platforms/whatsapp/templates?platform_id=<id>accepts a{ name, category, language, components }body and forwards to Meta's Graph API. Six bodies follow — paste into Postman / curl / the admin client.Body 1 — `jyt_production_run_reminder_pending_v1` (en)
{
"name": "jyt_production_run_reminder_pending_v1",
"category": "UTILITY",
"language": "en",
"components": [
{
"type": "BODY",
"text": "Hi {{1}}, a quick reminder — production run {{3}} for design {{2}} has been waiting for your response.\n\n*Waiting since:* {{4}} day(s) ago\n\nPlease open the partner portal and tap Accept or Decline so we can plan the next steps. Reply here if you need help.",
"example": { "body_text": [["Rajesh", "Block Print Kurta", "prun_01ABC", "2"]] }
}
]
}Body 2 — `jyt_production_run_reminder_pending_v1` (hi)
{
"name": "jyt_production_run_reminder_pending_v1",
"category": "UTILITY",
"language": "hi",
"components": [
{
"type": "BODY",
"text": "नमस्ते {{1}}, याद दिला रहे हैं — डिज़ाइन {{2}} के लिए प्रोडक्शन रन {{3}} अभी भी आपके उत्तर की प्रतीक्षा में है।\n\n*प्रतीक्षा अवधि:* {{4}} दिन\n\nकृपया पार्टनर पोर्टल खोलें और स्वीकार करें या मना करें पर टैप करें ताकि हम अगले कदम तय कर सकें। मदद चाहिए तो यहीं उत्तर दें।",
"example": { "body_text": [["राजेश", "ब्लॉक प्रिंट कुर्ता", "prun_01ABC", "2"]] }
}
]
}Body 3 — `jyt_production_run_reminder_not_started_v1` (en)
{
"name": "jyt_production_run_reminder_not_started_v1",
"category": "UTILITY",
"language": "en",
"components": [
{
"type": "BODY",
"text": "Hi {{1}}, just checking in — you've accepted production run {{3}} for design {{2}}, but we haven't seen it start yet.\n\n*Days since acceptance:* {{4}}\n\nIf you've already begun, please tap Start in the partner portal so we can track progress. Reply here if you're blocked on anything and the team will help.",
"example": { "body_text": [["Rajesh", "Block Print Kurta", "prun_01ABC", "2"]] }
}
]
}Body 4 — `jyt_production_run_reminder_not_started_v1` (hi)
{
"name": "jyt_production_run_reminder_not_started_v1",
"category": "UTILITY",
"language": "hi",
"components": [
{
"type": "BODY",
"text": "नमस्ते {{1}}, बस संपर्क कर रहे हैं — आपने डिज़ाइन {{2}} के लिए प्रोडक्शन रन {{3}} स्वीकार किया है, लेकिन काम अभी शुरू नहीं हुआ है।\n\n*स्वीकृति के बाद के दिन:* {{4}}\n\nयदि आप पहले से शुरू कर चुके हैं, तो कृपया पार्टनर पोर्टल में Start (शुरू करें) पर टैप करें ताकि हम प्रगति ट्रैक कर सकें। कोई बाधा हो तो यहीं उत्तर दें, टीम मदद करेगी।",
"example": { "body_text": [["राजेश", "ब्लॉक प्रिंट कुर्ता", "prun_01ABC", "2"]] }
}
]
}Body 5 — `jyt_production_run_reminder_idle_v1` (en)
{
"name": "jyt_production_run_reminder_idle_v1",
"category": "UTILITY",
"language": "en",
"components": [
{
"type": "BODY",
"text": "Hi {{1}}, checking in on production run {{3}} for design {{2}} — it's been quiet for a few days.\n\n*Progress:* {{4}} of {{5}} pieces produced\n\nPlease log a fresh produced-quantity update in the partner portal so we know where things stand. Reply here if you're blocked and the team will help.",
"example": { "body_text": [["Rajesh", "Block Print Kurta", "prun_01ABC", "120", "250"]] }
}
]
}Body 6 — `jyt_production_run_reminder_idle_v1` (hi)
{
"name": "jyt_production_run_reminder_idle_v1",
"category": "UTILITY",
"language": "hi",
"components": [
{
"type": "BODY",
"text": "नमस्ते {{1}}, डिज़ाइन {{2}} के लिए प्रोडक्शन रन {{3}} पर अपडेट चाहिए — कुछ दिनों से कोई गतिविधि नहीं है।\n\n*प्रगति:* {{5}} में से {{4}} पीस पूरे\n\nकृपया पार्टनर पोर्टल में ताज़ा उत्पादित मात्रा अपडेट दर्ज करें ताकि हमें वर्तमान स्थिति का पता चले। कोई बाधा हो तो यहीं उत्तर दें, टीम मदद करेगी।",
"example": { "body_text": [["राजेश", "ब्लॉक प्रिंट कुर्ता", "prun_01ABC", "120", "250"]] }
}
]
}Curl pattern (Path B):
curl -X POST "$BACKEND/admin/social-platforms/whatsapp/templates?platform_id=$PLATFORM_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d @body-1.jsonRepeat with
platform_idswapped for each WABA you need approved on. Send thehibodies only against IN-region WABAs (those with+91incountry_codes); skiphifor non-IN WABAs (languagesForPlatformpolicy).Verifying approval status (either path):
MODE=dry-run npx medusa exec ./src/scripts/manage-whatsapp-templates.ts
# …or via the admin API:
curl -s "$BACKEND/admin/social-platforms/whatsapp/templates?platform_id=$PLATFORM_ID&status=APPROVED" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.templates[] | select(.name | startswith("jyt_production_run_reminder_"))'Meta typically returns
APPROVEDwithin minutes for UTILITY templates with no buttons; occasionally hours. Don't activate the flow until all six rows showAPPROVED. -
Existing wildcard flow re-seeded so the new template mappings are live in DB. The seed refuses to overwrite — rename the existing flow to
… [OLD]first (preserves execution history) and re-run:npx medusa exec ./src/scripts/seed-partner-run-whatsapp-flow.ts -
New scheduled flow created.
npx medusa exec ./src/scripts/seed-production-run-reminders-flow.ts -
Container TZ confirmed (see above).
-
Both flows flipped draft → active in the admin UI.
Production Test Playbook
Reminders chase real partners. Do not flip the new flow to active before testing. Run the steps below against the production DB with the flow still in draft and the dispatch wired up so you can observe end-to-end behavior without spamming partners.
Step 1 — Sanity-check the reads in isolation
Pick a known stuck run from prod. Then synthesize a run of just the read step using the admin replay endpoint, against a freshly seeded reminder flow in draft:
# Get the new flow id
curl -s "$BACKEND/admin/visual-flows?name=Production+Run+Reminders+%E2%80%94+Daily+Discoverer" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.flows[0].id'
# → vflow_…
# Manually execute it once (Run Now in the admin UI, or POST to /execute with empty body)
curl -X POST "$BACKEND/admin/visual-flows/<flow_id>/execute" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
Inspect the latest execution in the admin UI:
read_active_runs.countshould match the number of rows inSELECT count(*) FROM production_run
WHERE status IN ('sent_to_partner', 'in_progress');classify.countsshould sum to (or be less than) that count. The delta isskipped_no_partner + skipped_not_overdue.classify.itemsshould contain only rows older than the bucket thresholds. Spot-check 2-3 against the DB.
If these match, the discovery half works.
Step 2 — Dry-run dispatch
Because the new wildcard mappings might still be missing in DB (you haven't re-seeded yet), the events fire but the existing flow hits the no_template_for_event skip branch and sends nothing. Use this to your advantage:
- Make sure the existing wildcard flow has not yet been re-seeded with the reminder mappings.
- Re-run the scheduled flow as in Step 1.
- Confirm:
dispatch.triggeredequalsclassify.items.lengthdispatch.failedis 0- For each emitted event there's a matching execution on the existing wildcard flow (admin UI, filter by
triggered_by LIKE 'event:production_run.reminder_%') - Each of those wildcard executions terminates at the
log_skipnode with reasonno_template_for_event messaging_messagetable has no new rows from these executions
This proves: dispatch wires correctly, the events route to the existing flow, the flow correctly skips when no template is configured, and most importantly no WhatsApp messages were sent. You've now verified everything except the actual send.
Step 3 — Smoke-test one real send to an internal partner
Pick (or create) a partner record whose whatsapp_number belongs to someone on the engineering team — not a real customer. Make sure that partner has a stuck run in one bucket, e.g. status='sent_to_partner', accepted_at IS NULL, created_at = now() − 2 days.
Re-seed the existing wildcard flow with the new template mappings:
# In admin UI: rename "Partner WhatsApp — Production Run (all events)"
# to "… [OLD]" and set status=draft.
npx medusa exec ./src/scripts/seed-partner-run-whatsapp-flow.ts
# Flip the new flow to active in the admin UI.
Now manually run the scheduled discoverer once via the admin UI (Run Now, or POST /admin/visual-flows/<id>/execute). Watch:
- The internal partner should receive one template WhatsApp followed by the design image. The template body should reference the design name and the day-count.
- Tap the deep-link button. The partner portal should authenticate without a password (24h JWT verified at
/partners/wa-auth). - In
messaging_message, find the row withcontext_type='production_run',context_id='<run_id>:reminder:YYYY-MM-DD'. Confirm the template name on the row matches the bucket (e.g.jyt_production_run_reminder_pending_v1). - Re-run the discoverer immediately (within the 60-min dedup window). The internal partner should not receive a duplicate. The new wildcard execution should still log a
messaging_messagerow, but the WhatsApp dispatch should be skipped — confirm themeta_message_idis null and the row carries the dedup marker. - Roll the system clock forward by a day (or wait a day) and re-run. The dedup
context_idnow has a different date suffix. The internal partner should receive a fresh reminder. This is the per-day resend property — confirm it works before letting the cron own it.
Step 4 — Activate
If steps 1-3 pass:
- Confirm container TZ matches the cron (see "Cron timing" above).
- Verify all three templates show
APPROVEDon every target WABA (MODE=dry-run npx medusa exec ./src/scripts/manage-whatsapp-templates.ts). - Flip the scheduled discoverer to
activein the admin UI. - The first cron tick at the next scheduled time will fire on real partners. Watch Railway logs for the first execution:
You should see one log line per cron tick with the inspected/dispatched/failed counts.
railway logs --service medusa-server | grep "Reminder run —"
Step 5 — Monitor for 1-2 weeks
- Check
messaging_messagedaily for rows whosecontext_idmatches%:reminder:%. Count by template — anomalous spikes mean a bucket threshold is too aggressive. - Watch Railway logs for
[bulk_trigger_workflow]failure entries. These would mean the emit workflow itself is erroring (rare — only emits an event). - Track partner replies. If the same partner hits the reminder for the same run for ≥7 consecutive days, ops should escalate manually — the reminder system is not a substitute for human intervention on chronic stalls.
Rollback
If reminders cause partner complaints or send to the wrong people:
- Immediate: flip the scheduled flow to
draftin the admin UI. The cron won't fire it again. No code deploy needed. - If the wildcard flow itself is the problem: rename it back to
… [NEW]and reactivate the previous… [OLD]flow you renamed during Step 3 above. Reverts the template mappings without code changes. - Code rollback: revert the four-file commit. Redeploy. The
emit-production-run-reminderworkflow remains registered but is unused; that's harmless. The three new event names also remain in the subscriber list; harmless.
Known Gaps & Future Work
Per-partner aggregation
A partner with five stuck runs gets five WhatsApp messages in one cron tick. Meta business policy and partner UX both prefer one digest message ("You have 3 runs awaiting acceptance, 1 not started, 1 idle"). The current classify step emits one item per run; rewriting it to group by partner_id and emitting one event per (partner, bucket) (with a payload listing the runs) would let us add a "digest" template family.
Escalation cadence
Today every overdue run gets the same nudge every weekday until it moves on. A more humane policy would be day 1, day 3, day 7, then weekly. Implement by adding a last_reminder_at column on production_run (or a side table) and filtering rows whose previous reminder was less than the cadence threshold ago.
Bucket thresholds in config
The 24h / 24h / 72h thresholds are hardcoded in the classify execute_code. Promoting them to flow operation options (or to env vars) would let ops tune them without re-seeding.
Idle detection beyond started_at
The idle bucket only checks started_at < now − 72h. A partner who started two weeks ago and is reporting daily progress would still qualify. Better: check updated_at on the latest production-run-task or the latest produced_quantity change. Requires a join we don't currently make in the read step.
File Index
| Path | Purpose |
|---|---|
src/workflows/production-runs/emit-production-run-reminder.ts | New — single-step Medusa workflow that emits one of 3 reminder events |
src/scripts/seed-production-run-reminders-flow.ts | New — idempotent seed creating the scheduled discoverer flow |
src/scripts/seed-partner-run-whatsapp-flow.ts | Extended RESOLVE_TEMPLATE_CODE map with 3 reminder mappings; added per-day context_id; added separate run_id field for deep-links/captions |
src/subscribers/visual-flow-event-trigger.ts | Registered 3 new event names (production_run.reminder_*) |