/** * Flow runner. * * The single entry point `dispatchInboundToFlows` is called by the * WhatsApp webhook on every inbound message *for an account that has * opted into the Flows beta*. It decides whether the message belongs * to an active conversation flow (advance it) or matches the entry * trigger of an active flow (start a new run) — and reports back to * the webhook so the webhook knows whether to also fire automations. * * Architecture in a sentence: the runner walks the customer through * a DB-stored node graph, suspending only at nodes that need * customer input. Each tap or text reply wakes it back up. * * What lives here vs elsewhere: * - Pure decision logic (which button matched, where to advance to, * when to fallback) — here. * - DB shape (table reads/writes) — here. * - Meta API calls — `meta-send.ts` (engineSendInteractive*). * - Policy resolution (reprompt vs handoff vs end) — `fallback.ts`. * - Type definitions — `types.ts`. * * Concurrency model: * - Idempotency on `meta_message_id`: the runner refuses to advance * an active run twice for the same Meta message — protects against * Meta's retries. * - Optimistic UPDATE with `current_node_key` precondition: two * simultaneous taps for the same run collide at the DB layer; the * second is a no-op. * - Partial unique index `idx_one_active_run_per_contact`: two * simultaneous starts for the same contact collide; the second * INSERT raises 23505 and the runner catches & exits. */ import { supabaseAdmin } from "./admin-client"; import { engineSendInteractiveButtons, engineSendInteractiveList, engineSendText, } from "./meta-send"; import { decideFallback, resolveFallbackPolicy } from "./fallback"; import { type CollectInputNodeConfig, type ConditionNodeConfig, type DispatchInboundInput, type DispatchInboundResult, type FlowNodeRow, type FlowRow, type FlowRunRow, type ParsedInbound, type SendButtonsNodeConfig, type SendListNodeConfig, type SendMessageNodeConfig, type SetTagNodeConfig, type StartNodeConfig, type KeywordTriggerConfig, } from "./types"; // ============================================================ // Pure helpers — extracted so engine.test.ts can exercise them // without a Supabase / Meta mock. // ============================================================ /** * Given a node + the customer's reply_id, return the next_node_key * to advance to, or `null` if no option matches. */ export function matchReplyId( node: { node_type: string; config: Record }, reply_id: string, ): string | null { if (node.node_type === "send_buttons") { const cfg = node.config as unknown as SendButtonsNodeConfig; const hit = cfg.buttons?.find((b) => b.reply_id === reply_id); return hit?.next_node_key ?? null; } if (node.node_type === "send_list") { const cfg = node.config as unknown as SendListNodeConfig; for (const section of cfg.sections ?? []) { const hit = section.rows?.find((r) => r.reply_id === reply_id); if (hit) return hit.next_node_key; } return null; } return null; } /** * Case-insensitive contains/exact match against a list of keywords. * Used by the trigger evaluator. Stable enough that the v3 builder * UI can preview matches by passing canned strings. */ export function matchesKeywordTrigger( text: string, cfg: KeywordTriggerConfig, ): boolean { if (!text || !cfg.keywords?.length) return false; const matchType = cfg.match_type ?? "contains"; const haystack = cfg.case_sensitive ? text : text.toLowerCase(); for (const raw of cfg.keywords) { if (!raw) continue; const needle = cfg.case_sensitive ? raw : raw.toLowerCase(); if (matchType === "exact" ? haystack === needle : haystack.includes(needle)) { return true; } } return false; } /** Nodes that advance to a next_node_key without waiting for input. */ export function isAutoAdvancing(node_type: string): boolean { return ( node_type === "start" || node_type === "send_message" || node_type === "condition" || node_type === "set_tag" ); } /** Nodes that send a prompt and suspend awaiting a customer reply. */ export function isSuspending(node_type: string): boolean { return ( node_type === "send_buttons" || node_type === "send_list" || node_type === "collect_input" ); } /** Nodes that end the run. */ export function isTerminal(node_type: string): boolean { return node_type === "handoff" || node_type === "end"; } /** * Evaluate a `condition` node's predicate against the current run * state. Exported pure for unit testing — the engine wraps it with a * DB lookup for `tag` / `contact_field` subjects. */ export function evaluateConditionPredicate(args: { operator: ConditionNodeConfig["operator"]; /** * Resolved value of the subject. `undefined` means the subject is * absent (no var with that key / no such tag / contact field is * null). Pure function: caller does the DB lookup. */ subjectValue: string | undefined; /** The configured comparison value, when applicable. */ configValue: string | undefined; }): boolean { switch (args.operator) { case "present": return args.subjectValue !== undefined && args.subjectValue !== ""; case "absent": return args.subjectValue === undefined || args.subjectValue === ""; case "equals": if (args.subjectValue === undefined) return false; return args.subjectValue === (args.configValue ?? ""); case "contains": if (args.subjectValue === undefined) return false; return args.subjectValue.includes(args.configValue ?? ""); } } // ============================================================ // DB I/O — wrapped in tiny helpers so the dispatch flow stays // readable. Errors surface as thrown — the entry point catches. // ============================================================ type AdminClient = ReturnType; async function loadActiveRunForContact( db: AdminClient, userId: string, contactId: string, ): Promise { const { data, error } = await db .from("flow_runs") .select("*") .eq("user_id", userId) .eq("contact_id", contactId) .eq("status", "active") .maybeSingle(); if (error) { console.error("[flows] loadActiveRunForContact error:", error.message); return null; } return (data as FlowRunRow | null) ?? null; } async function loadFlow( db: AdminClient, flowId: string, ): Promise { const { data, error } = await db .from("flows") .select("*") .eq("id", flowId) .maybeSingle(); if (error) { console.error("[flows] loadFlow error:", error.message); return null; } return (data as FlowRow | null) ?? null; } async function loadNode( db: AdminClient, flowId: string, nodeKey: string, ): Promise { const { data, error } = await db .from("flow_nodes") .select("*") .eq("flow_id", flowId) .eq("node_key", nodeKey) .maybeSingle(); if (error) { console.error("[flows] loadNode error:", error.message); return null; } return (data as FlowNodeRow | null) ?? null; } async function logEvent( db: AdminClient, flowRunId: string, event_type: | "started" | "node_entered" | "message_sent" | "reply_received" | "fallback_fired" | "handoff" | "timeout" | "error" | "completed", node_key: string | null, payload: Record = {}, ): Promise { const { error } = await db.from("flow_run_events").insert({ flow_run_id: flowRunId, event_type, node_key, payload, }); if (error) { // Logging failure is non-fatal — surface but don't throw. console.error("[flows] logEvent error:", error.message); } } /** * Idempotency check — has a `reply_received` event with this Meta * message_id already been recorded for any of the contact's flow * runs? If yes, the inbound is a duplicate (Meta retry) and we * exit without re-advancing. * * Implementation note: scoped to runs belonging to this user/contact * so the lookup is cheap (the index on flow_run_events(flow_run_id, * event_type) plus the small set of runs per contact). */ async function isDuplicateInbound( db: AdminClient, userId: string, contactId: string, metaMessageId: string, ): Promise { // Fetch ALL run ids for this contact (active + historical). Bounded // by how many flows the customer has been through — small. const { data: runs } = await db .from("flow_runs") .select("id") .eq("user_id", userId) .eq("contact_id", contactId); if (!runs?.length) return false; const runIds = runs.map((r) => (r as { id: string }).id); const { count } = await db .from("flow_run_events") .select("id", { count: "exact", head: true }) .in("flow_run_id", runIds) .eq("event_type", "reply_received") .filter("payload->>meta_message_id", "eq", metaMessageId); return (count ?? 0) > 0; } async function findEntryFlow( db: AdminClient, userId: string, message: ParsedInbound, isFirstInbound: boolean, ): Promise { // Only text messages can match an entry trigger. Interactive replies // are responses to existing prompts; they never start a new flow. if (message.kind !== "text") return null; // Pull all active flows for this user. Active set is bounded (the // builder discourages double-trigger overlap; partial index makes // the lookup index-supported). const { data: flows, error } = await db .from("flows") .select("*") .eq("user_id", userId) .eq("status", "active") .order("created_at", { ascending: true }); if (error || !flows) return null; const typed = flows as FlowRow[]; for (const flow of typed) { if (flow.trigger_type === "keyword") { if (matchesKeywordTrigger( message.text, flow.trigger_config as KeywordTriggerConfig, )) { return flow; } } else if (flow.trigger_type === "first_inbound_message" && isFirstInbound) { return flow; } // 'manual' triggers do not auto-start from inbound messages. } return null; } // ============================================================ // Node executors — each handles ONE node type. send_buttons and // send_list also persist `last_prompt_message_id` so the inbox // thread can quote the prompt the customer is replying to. // ============================================================ async function sendButtonsAndSuspend( db: AdminClient, run: FlowRunRow, node: FlowNodeRow, ): Promise<{ outcome: "advanced"; node_key: string }> { const cfg = node.config as unknown as SendButtonsNodeConfig; const { whatsapp_message_id } = await engineSendInteractiveButtons({ userId: run.user_id, conversationId: run.conversation_id!, contactId: run.contact_id!, bodyText: cfg.text, headerText: cfg.header_text, footerText: cfg.footer_text, buttons: cfg.buttons.map((b) => ({ id: b.reply_id, title: b.title })), }); await logEvent(db, run.id, "message_sent", node.node_key, { node_type: "send_buttons", whatsapp_message_id, }); // Look up our internal message id so we can stash it on the run. // Cheap — indexed on `messages.message_id`. const { data: msg } = await db .from("messages") .select("id") .eq("message_id", whatsapp_message_id) .maybeSingle(); await db .from("flow_runs") .update({ last_prompt_message_id: (msg as { id: string } | null)?.id ?? null, }) .eq("id", run.id); return { outcome: "advanced", node_key: node.node_key }; } async function sendListAndSuspend( db: AdminClient, run: FlowRunRow, node: FlowNodeRow, ): Promise<{ outcome: "advanced"; node_key: string }> { const cfg = node.config as unknown as SendListNodeConfig; const { whatsapp_message_id } = await engineSendInteractiveList({ userId: run.user_id, conversationId: run.conversation_id!, contactId: run.contact_id!, bodyText: cfg.text, buttonLabel: cfg.button_label, headerText: cfg.header_text, footerText: cfg.footer_text, sections: cfg.sections.map((s) => ({ title: s.title, rows: s.rows.map((r) => ({ id: r.reply_id, title: r.title, description: r.description, })), })), }); await logEvent(db, run.id, "message_sent", node.node_key, { node_type: "send_list", whatsapp_message_id, }); const { data: msg } = await db .from("messages") .select("id") .eq("message_id", whatsapp_message_id) .maybeSingle(); await db .from("flow_runs") .update({ last_prompt_message_id: (msg as { id: string } | null)?.id ?? null, }) .eq("id", run.id); return { outcome: "advanced", node_key: node.node_key }; } async function executeHandoff( db: AdminClient, run: FlowRunRow, node: FlowNodeRow, ): Promise { const cfg = node.config as { assign_to?: string; note?: string }; const convUpdate: Record = { status: "pending", updated_at: new Date().toISOString(), }; if (cfg.assign_to) convUpdate.assigned_agent_id = cfg.assign_to; if (run.conversation_id) { await db .from("conversations") .update(convUpdate) .eq("id", run.conversation_id); } await logEvent(db, run.id, "handoff", node.node_key, { note: cfg.note ?? null, assigned_to: cfg.assign_to ?? null, }); await endRun(db, run.id, "handed_off", "handoff_node"); } /** * Resolve a condition node's subject value from DB / run state, then * call the pure `evaluateConditionPredicate`. Splits out so the * predicate itself stays unit-testable without a Supabase mock. * * Subject sources: * - `var` → `flow_runs.vars[subject_key]` (captured by collect_input * or http_fetch in v2). * - `tag` → present iff `contact_tags(contact_id, tag_id)` exists. * `subject_key` IS the tag UUID; the SELECT returns 1 row or 0. * - `contact_field` → one of name/email/phone/company on `contacts`. */ async function evaluateConditionNode( db: AdminClient, run: FlowRunRow, cfg: ConditionNodeConfig, ): Promise { let subjectValue: string | undefined; if (cfg.subject === "var") { const v = run.vars[cfg.subject_key]; subjectValue = typeof v === "string" ? v : v === undefined ? undefined : String(v); } else if (cfg.subject === "tag") { const { count } = await db .from("contact_tags") .select("contact_id", { count: "exact", head: true }) .eq("contact_id", run.contact_id!) .eq("tag_id", cfg.subject_key); // For tags, "present" really is the only meaningful test — the // `present`/`absent` operators are the natural fit. equals/contains // against a tag UUID would still work mechanically (compare its // existence to the value). subjectValue = (count ?? 0) > 0 ? cfg.subject_key : undefined; } else { const ALLOWED = ["name", "email", "phone", "company"] as const; type AllowedField = (typeof ALLOWED)[number]; if (!ALLOWED.includes(cfg.subject_key as AllowedField)) { throw new Error(`unsupported contact_field: ${cfg.subject_key}`); } const { data } = await db .from("contacts") .select(cfg.subject_key) .eq("id", run.contact_id!) .maybeSingle(); const raw = (data as Record | null)?.[cfg.subject_key]; subjectValue = typeof raw === "string" && raw.length > 0 ? raw : undefined; } return evaluateConditionPredicate({ operator: cfg.operator, subjectValue, configValue: cfg.value, }); } /** * Tiny `{{vars.foo}}` interpolation. Used by send_message + collect_input * prompt text so a captured `name` can show up in the next prompt * ("Thanks {{vars.name}}, what's your email?"). Missing vars render as * empty string — the same behavior as the automations engine. */ function interpolateVars(template: string, vars: Record): string { if (!template) return ""; return template.replace(/\{\{vars\.([a-zA-Z0-9_]+)\}\}/g, (_, key) => { const v = vars[key]; return v === undefined || v === null ? "" : String(v); }); } async function endRun( db: AdminClient, runId: string, status: "completed" | "handed_off" | "timed_out" | "failed", reason: string, ): Promise { await db .from("flow_runs") .update({ status, ended_at: new Date().toISOString(), end_reason: reason, }) .eq("id", runId); } // ============================================================ // The synchronous advance loop. Walks through auto-advance nodes // until it hits one that suspends (send_buttons/send_list) or // terminates (handoff/end). Each suspending node persists the // new current_node_key before returning. // ============================================================ async function advanceFromNodeKey( db: AdminClient, run: FlowRunRow, startNodeKey: string, ): Promise<{ outcome: "advanced" | "completed" | "handed_off" }> { let currentKey: string | null = startNodeKey; // Defensive cap — if a flow has a cycle (which the validator // SHOULD catch but doesn't yet in v1), we bail rather than loop. for (let safety = 0; safety < 64; safety += 1) { if (!currentKey) { await logEvent(db, run.id, "error", null, { reason: "next_node_key was null mid-advance", }); await endRun(db, run.id, "failed", "missing_next_node"); return { outcome: "completed" }; } const node = await loadNode(db, run.flow_id, currentKey); if (!node) { await logEvent(db, run.id, "error", currentKey, { reason: "node_not_found", }); await endRun(db, run.id, "failed", "node_not_found"); return { outcome: "completed" }; } await logEvent(db, run.id, "node_entered", node.node_key, { node_type: node.node_type, }); if (node.node_type === "start") { currentKey = (node.config as unknown as StartNodeConfig).next_node_key; continue; } if (node.node_type === "send_message") { const cfg = node.config as unknown as SendMessageNodeConfig; try { const { whatsapp_message_id } = await engineSendText({ userId: run.user_id, conversationId: run.conversation_id!, contactId: run.contact_id!, text: interpolateVars(cfg.text, run.vars), }); await logEvent(db, run.id, "message_sent", node.node_key, { node_type: "send_message", whatsapp_message_id, }); } catch (err) { await logEvent(db, run.id, "error", node.node_key, { reason: "send_text_failed", detail: err instanceof Error ? err.message : String(err), }); await endRun(db, run.id, "failed", "send_text_failed"); return { outcome: "completed" }; } currentKey = cfg.next_node_key; continue; } if (node.node_type === "collect_input") { // Send the prompt and suspend. Customer's next TEXT reply will // wake us up via handleReplyForActiveRun's collect_input branch. const cfg = node.config as unknown as CollectInputNodeConfig; try { const { whatsapp_message_id } = await engineSendText({ userId: run.user_id, conversationId: run.conversation_id!, contactId: run.contact_id!, text: interpolateVars(cfg.prompt_text, run.vars), }); await logEvent(db, run.id, "message_sent", node.node_key, { node_type: "collect_input", whatsapp_message_id, }); const { data: msg } = await db .from("messages") .select("id") .eq("message_id", whatsapp_message_id) .maybeSingle(); await db .from("flow_runs") .update({ last_prompt_message_id: (msg as { id: string } | null)?.id ?? null, }) .eq("id", run.id); } catch (err) { await logEvent(db, run.id, "error", node.node_key, { reason: "collect_input_prompt_failed", detail: err instanceof Error ? err.message : String(err), }); await endRun(db, run.id, "failed", "collect_input_prompt_failed"); return { outcome: "completed" }; } const advanced = await advanceCurrentNodeKey( db, run.id, run.current_node_key, node.node_key, ); if (!advanced) { await logEvent(db, run.id, "error", node.node_key, { reason: "lost_race_during_advance", }); } return { outcome: "advanced" }; } if (node.node_type === "condition") { const cfg = node.config as unknown as ConditionNodeConfig; let branch: "true" | "false"; try { branch = (await evaluateConditionNode(db, run, cfg)) ? "true" : "false"; } catch (err) { await logEvent(db, run.id, "error", node.node_key, { reason: "condition_evaluation_failed", detail: err instanceof Error ? err.message : String(err), }); await endRun(db, run.id, "failed", "condition_evaluation_failed"); return { outcome: "completed" }; } currentKey = branch === "true" ? cfg.true_next : cfg.false_next; await logEvent(db, run.id, "node_entered", node.node_key, { condition_result: branch, advancing_to: currentKey, }); continue; } if (node.node_type === "set_tag") { const cfg = node.config as unknown as SetTagNodeConfig; try { if (cfg.mode === "add") { await db .from("contact_tags") .upsert( { contact_id: run.contact_id!, tag_id: cfg.tag_id }, { onConflict: "contact_id,tag_id" }, ); } else { await db .from("contact_tags") .delete() .eq("contact_id", run.contact_id!) .eq("tag_id", cfg.tag_id); } } catch (err) { // Non-fatal — log + advance. A tag-write failure shouldn't // strand the customer mid-flow. await logEvent(db, run.id, "error", node.node_key, { reason: "set_tag_failed", detail: err instanceof Error ? err.message : String(err), }); } currentKey = cfg.next_node_key; continue; } if (node.node_type === "send_buttons") { await sendButtonsAndSuspend(db, run, node); // Persist the new current_node_key via optimistic UPDATE. const advanced = await advanceCurrentNodeKey( db, run.id, run.current_node_key, node.node_key, ); if (!advanced) { await logEvent(db, run.id, "error", node.node_key, { reason: "lost_race_during_advance", }); } return { outcome: "advanced" }; } if (node.node_type === "send_list") { await sendListAndSuspend(db, run, node); const advanced = await advanceCurrentNodeKey( db, run.id, run.current_node_key, node.node_key, ); if (!advanced) { await logEvent(db, run.id, "error", node.node_key, { reason: "lost_race_during_advance", }); } return { outcome: "advanced" }; } if (node.node_type === "handoff") { await executeHandoff(db, run, node); return { outcome: "handed_off" }; } if (node.node_type === "end") { await logEvent(db, run.id, "completed", node.node_key); await endRun(db, run.id, "completed", "end_node"); return { outcome: "completed" }; } // Unknown node type — shouldn't happen given the CHECK constraint. await logEvent(db, run.id, "error", node.node_key, { reason: `unknown_node_type:${node.node_type}`, }); await endRun(db, run.id, "failed", "unknown_node_type"); return { outcome: "completed" }; } // Safety break — log + fail. await logEvent(db, run.id, "error", currentKey, { reason: "advance_loop_safety_break", }); await endRun(db, run.id, "failed", "advance_loop_overflow"); return { outcome: "completed" }; } /** * Optimistic UPDATE — only advance current_node_key when it matches * the value we read at the top of dispatch. If another webhook beat * us, the row's pointer has already moved and our UPDATE returns * zero rows; we treat that as a no-op and let the other run continue. */ async function advanceCurrentNodeKey( db: AdminClient, runId: string, expectedOldKey: string | null, newKey: string, ): Promise { // PostgREST: when expectedOldKey is null we can't `.eq` (would match // any row); use `.is('current_node_key', null)` instead. let q = db .from("flow_runs") .update({ current_node_key: newKey, last_advanced_at: new Date().toISOString(), }) .eq("id", runId) .eq("status", "active"); if (expectedOldKey === null) { q = q.is("current_node_key", null); } else { q = q.eq("current_node_key", expectedOldKey); } const { data, error } = await q.select("id"); if (error) { console.error("[flows] advanceCurrentNodeKey error:", error.message); return false; } return Array.isArray(data) && data.length > 0; } // ============================================================ // Public entry point — the webhook calls this on every inbound. // ============================================================ export async function dispatchInboundToFlows( input: DispatchInboundInput & { isFirstInboundMessage: boolean }, ): Promise { const db = supabaseAdmin(); try { const activeRun = await loadActiveRunForContact( db, input.userId, input.contactId, ); // Idempotency — only matters if there's already a run for this // contact. For new runs, the partial unique index catches duplicate // starts at INSERT time. if (activeRun) { const dupe = await isDuplicateInbound( db, input.userId, input.contactId, input.message.meta_message_id, ); if (dupe) { return { consumed: true, flow_run_id: activeRun.id, outcome: "duplicate_inbound_ignored", }; } return handleReplyForActiveRun(db, activeRun, input.message); } // No active run → look for a flow whose entry trigger matches. const flow = await findEntryFlow( db, input.userId, input.message, input.isFirstInboundMessage, ); if (!flow || !flow.entry_node_id) { return { consumed: false, outcome: "no_match" }; } return startNewRun(db, flow, input); } catch (err) { console.error( "[flows] dispatchInboundToFlows threw:", err instanceof Error ? err.message : err, ); return { consumed: false, outcome: "no_match" }; } } async function handleReplyForActiveRun( db: AdminClient, run: FlowRunRow, message: ParsedInbound, ): Promise { await logEvent(db, run.id, "reply_received", run.current_node_key, { meta_message_id: message.meta_message_id, reply_kind: message.kind, reply_id: message.kind === "interactive_reply" ? message.reply_id : null, text: message.kind === "text" ? message.text : null, }); if (!run.current_node_key) { // Defensive — a run with status='active' but no current node is // malformed. Fail the run rather than spin. await endRun(db, run.id, "failed", "active_run_missing_current_node"); return { consumed: true, flow_run_id: run.id, outcome: "no_match", }; } const currentNode = await loadNode(db, run.flow_id, run.current_node_key); if (!currentNode) { await endRun(db, run.id, "failed", "current_node_not_found"); return { consumed: true, flow_run_id: run.id, outcome: "no_match" }; } // Two ways a reply can advance: // 1. Interactive button/list tap on a send_buttons/send_list node. // 2. Text reply on a collect_input node — capture into vars. // // Everything else falls through to the fallback policy below. let matched: string | null = null; if ( message.kind === "interactive_reply" && (currentNode.node_type === "send_buttons" || currentNode.node_type === "send_list") ) { matched = matchReplyId(currentNode, message.reply_id); } else if ( message.kind === "text" && currentNode.node_type === "collect_input" ) { const cfg = currentNode.config as unknown as CollectInputNodeConfig; const captured = message.text.trim(); if (captured.length > 0 && cfg.var_key) { // Persist captured value + reset reprompt count atomically. const { error: capErr } = await db .from("flow_runs") .update({ vars: { ...run.vars, [cfg.var_key]: captured }, reprompt_count: 0, }) .eq("id", run.id); if (!capErr) { await logEvent(db, run.id, "node_entered", currentNode.node_key, { captured_key: cfg.var_key, captured_length: captured.length, }); matched = cfg.next_node_key; } } } if (matched) { // Reset reprompt count on a successful match. await db .from("flow_runs") .update({ reprompt_count: 0 }) .eq("id", run.id); // Re-read so the advance loop sees the fresh reprompt_count. const fresh = await db .from("flow_runs") .select("*") .eq("id", run.id) .maybeSingle(); const next = (fresh.data as FlowRunRow | null) ?? run; const outcome = await advanceFromNodeKey(db, next, matched); return { consumed: true, flow_run_id: run.id, outcome: outcome.outcome, }; } // No match → fallback. Apply the policy. const policy = resolveFallbackPolicy( (await loadFlow(db, run.flow_id))?.fallback_policy, ); const newReprompts = run.reprompt_count + 1; await db .from("flow_runs") .update({ reprompt_count: newReprompts }) .eq("id", run.id); const action = decideFallback({ policy, reprompt_count: newReprompts }); await logEvent(db, run.id, "fallback_fired", run.current_node_key, { action: action.type, reprompt_count: newReprompts, }); if (action.type === "ignore") { // Don't consume — let automations have a shot at it. return { consumed: false, flow_run_id: run.id, outcome: "no_match" }; } if (action.type === "reprompt") { // Re-send the same prompt. Same node, no current_node_key change. if (currentNode.node_type === "send_buttons") { await sendButtonsAndSuspend(db, run, currentNode); } else if (currentNode.node_type === "send_list") { await sendListAndSuspend(db, run, currentNode); } else if (currentNode.node_type === "collect_input") { // Customer typed something we couldn't accept (empty after trim, // or var_key missing — rare). Re-send the prompt so they try again. const cfg = currentNode.config as unknown as CollectInputNodeConfig; try { await engineSendText({ userId: run.user_id, conversationId: run.conversation_id!, contactId: run.contact_id!, text: interpolateVars(cfg.prompt_text, run.vars), }); } catch (err) { await logEvent(db, run.id, "error", currentNode.node_key, { reason: "reprompt_send_failed", detail: err instanceof Error ? err.message : String(err), }); } } return { consumed: true, flow_run_id: run.id, outcome: "fallback_fired" }; } if (action.type === "handoff") { if (run.conversation_id) { await db .from("conversations") .update({ status: "pending", updated_at: new Date().toISOString() }) .eq("id", run.conversation_id); } await logEvent(db, run.id, "handoff", run.current_node_key, { reason: "fallback_exhausted", }); await endRun(db, run.id, "handed_off", "fallback_exhausted"); return { consumed: true, flow_run_id: run.id, outcome: "handed_off" }; } // action.type === 'end' await endRun(db, run.id, "completed", "fallback_exhausted_end"); return { consumed: true, flow_run_id: run.id, outcome: "completed" }; } async function startNewRun( db: AdminClient, flow: FlowRow, input: DispatchInboundInput, ): Promise { // INSERT — partial unique index `idx_one_active_run_per_contact` // catches concurrent inserts with 23505. We catch and return as // consumed:true (the parallel webhook handles it). const { data: inserted, error: insErr } = await db .from("flow_runs") .insert({ flow_id: flow.id, user_id: flow.user_id, contact_id: input.contactId, conversation_id: input.conversationId, status: "active", current_node_key: flow.entry_node_id, }) .select("*") .maybeSingle(); if (insErr) { // 23505 = unique_violation → another webhook is starting the run. const msg = insErr.message ?? ""; if (msg.includes("23505") || msg.includes("duplicate key")) { return { consumed: true, outcome: "duplicate_inbound_ignored" }; } console.error("[flows] startNewRun insert error:", insErr.message); return { consumed: false, outcome: "no_match" }; } const run = inserted as FlowRunRow; await logEvent(db, run.id, "started", flow.entry_node_id, { flow_id: flow.id, trigger_type: flow.trigger_type, meta_message_id: input.message.meta_message_id, }); // Bump the flow's execution counter — used by the builder UI to // surface "X runs since activation" on the flow card. await db .from("flows") .update({ execution_count: flow.execution_count + 1, last_executed_at: new Date().toISOString(), }) .eq("id", flow.id); // Run the advance loop starting from the entry node. const outcome = await advanceFromNodeKey(db, run, flow.entry_node_id!); return { consumed: true, flow_run_id: run.id, outcome: outcome.outcome === "advanced" ? "started" : outcome.outcome, }; }