mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Final feature PR before GA cleanup. Closes the gap between "flow
runner works" (PR 2) and "users have everything they need to build
real automation" — three new node types, plain-text sends actually
sending, three starter templates, a run-history page for debugging.
What landed:
1. engineSendText helper (src/lib/flows/meta-send.ts) — wraps
sendTextMessage with the phone-variant retry + DB persistence
pattern. Unblocks send_message + collect_input nodes that
previously auto-advanced without sending.
2. Three new node types in the engine:
- collect_input: sends a prompt, suspends, captures the next
text reply into flow_runs.vars[var_key], advances. Supports
{{vars.X}} interpolation in downstream prompts + handoff
notes.
- condition: branches on subject (var / tag / contact_field)
with operators (equals / contains / present / absent).
Auto-advancing, no Meta call. Pure predicate logic exported
as evaluateConditionPredicate with 7 new unit tests.
- set_tag: upsert/delete on contact_tags then advance. Non-
fatal on write failure — logged + advances anyway so a tag
glitch doesn't strand the customer.
3. Real send_message — calls engineSendText, logs the result,
handles errors by ending the run with status='failed'. Same
{{vars.X}} interpolation.
4. Three starter templates (src/lib/flows/templates.ts):
- Welcome menu (the owner's brief example)
- FAQ bot (keyword → list → answers → end)
- Lead capture (first_inbound → collect_input chain → handoff
with vars in the note)
POST /api/flows now accepts {template_slug} to clone a
template into a fresh draft. GET /api/flows/templates exposes
the gallery shape (no nodes payload — fetched on actual clone).
5. Builder UI extensions:
- NODE_META + config sub-forms for collect_input, condition,
set_tag.
- Template gallery on the /flows New-flow dialog (loads from
/api/flows/templates, falls back gracefully if absent).
- "Runs" button in the editor header → /flows/[id]/runs.
6. Run-history viewer at /flows/[id]/runs — newest-first list of
the 50 most recent runs, with expandable per-run event
timeline (started, node_entered, message_sent,
reply_received, fallback_fired, handoff, timeout, error,
completed). Useful for debugging "why didn't my flow advance?"
from the brief's use case.
7. Validator updates: new error rules for each new node type
(prompt_text required, var_key alphanumeric, operator valid,
true_next/false_next exist, etc.). outgoingEdges helper now
covers collect_input/condition/set_tag for reachability.
All gated behind isFlowsEnabled(profile). Non-beta accounts see no
change. Run-history API is also 404 for non-beta.
Validation: 173/173 tests pass (added 7 new for the predicate);
typecheck + lint + build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1023 lines
33 KiB
TypeScript
1023 lines
33 KiB
TypeScript
/**
|
|
* 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<string, unknown> },
|
|
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<typeof supabaseAdmin>;
|
|
|
|
async function loadActiveRunForContact(
|
|
db: AdminClient,
|
|
userId: string,
|
|
contactId: string,
|
|
): Promise<FlowRunRow | null> {
|
|
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<FlowRow | null> {
|
|
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<FlowNodeRow | null> {
|
|
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<string, unknown> = {},
|
|
): Promise<void> {
|
|
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<boolean> {
|
|
// 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<FlowRow | null> {
|
|
// 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<void> {
|
|
const cfg = node.config as { assign_to?: string; note?: string };
|
|
const convUpdate: Record<string, unknown> = {
|
|
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<boolean> {
|
|
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<string, unknown> | 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, unknown>): 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<void> {
|
|
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<boolean> {
|
|
// 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<DispatchInboundResult> {
|
|
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<DispatchInboundResult> {
|
|
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<DispatchInboundResult> {
|
|
// 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,
|
|
};
|
|
}
|