Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
// Lazy, shared service-role client for the Flows engine.
// Mirrors src/lib/automations/admin-client.ts — same shape so anyone
// reading either file picks up the convention immediately.
let _adminClient: SupabaseClient | null = null
export function supabaseAdmin(): SupabaseClient {
if (!_adminClient) {
_adminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
}
return _adminClient
}

View File

@@ -0,0 +1,591 @@
import { describe, it, expect } from "vitest";
import {
applyEdgeConnection,
deriveCanvasEdges,
outgoingSlots,
unlinkNodeReferences,
} from "./edges";
import type { BuilderNode } from "@/components/flows/shared";
function nodes(...ns: BuilderNode[]): BuilderNode[] {
return ns;
}
describe("deriveCanvasEdges — single-outgoing node types", () => {
it("derives a `next` edge from send_message", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "b" },
},
{ node_key: "b", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(1);
expect(edges[0]).toMatchObject({
source: "a",
target: "b",
sourceHandle: "next",
});
});
it("derives a `next` edge from send_media, set_tag, collect_input, start", () => {
const edges = deriveCanvasEdges(
nodes(
{ node_key: "s", node_type: "start", config: { next_node_key: "m" } },
{
node_key: "m",
node_type: "send_media",
config: {
media_type: "image",
media_url: "https://x/y.png",
next_node_key: "t",
},
},
{
node_key: "t",
node_type: "set_tag",
config: { mode: "add", tag_id: "u", next_node_key: "ci" },
},
{
node_key: "ci",
node_type: "collect_input",
config: {
prompt_text: "p",
var_key: "v",
next_node_key: "e",
},
},
{ node_key: "e", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(4);
expect(edges.map((e) => `${e.source}->${e.target}`)).toEqual([
"s->m",
"m->t",
"t->ci",
"ci->e",
]);
});
it("skips dangling edges (next_node_key pointing nowhere)", () => {
const edges = deriveCanvasEdges(
nodes({
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "ghost" },
}),
);
expect(edges).toEqual([]);
});
it("skips empty next_node_key (fresh node)", () => {
const edges = deriveCanvasEdges(
nodes({
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "" },
}),
);
expect(edges).toEqual([]);
});
});
describe("deriveCanvasEdges — condition (true/false branches)", () => {
it("produces a labeled edge for each branch", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "c",
node_type: "condition",
config: {
subject: "var",
subject_key: "x",
operator: "equals",
value: "y",
true_next: "t",
false_next: "f",
},
},
{ node_key: "t", node_type: "end", config: {} },
{ node_key: "f", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(2);
expect(edges.find((e) => e.sourceHandle === "true")).toMatchObject({
target: "t",
label: "true",
});
expect(edges.find((e) => e.sourceHandle === "false")).toMatchObject({
target: "f",
label: "false",
});
});
it("emits whichever branches are set when one points nowhere", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "c",
node_type: "condition",
config: {
subject: "var",
subject_key: "x",
operator: "present",
true_next: "t",
false_next: "",
},
},
{ node_key: "t", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(1);
expect(edges[0].sourceHandle).toBe("true");
});
});
describe("deriveCanvasEdges — send_buttons (per-button)", () => {
it("emits one edge per button, labeled with the button title", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "menu",
node_type: "send_buttons",
config: {
text: "Pick",
buttons: [
{ reply_id: "yes", title: "Yes", next_node_key: "ok" },
{ reply_id: "no", title: "No", next_node_key: "bye" },
],
},
},
{ node_key: "ok", node_type: "handoff", config: {} },
{ node_key: "bye", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(2);
expect(edges[0]).toMatchObject({
source: "menu",
target: "ok",
sourceHandle: "button:yes",
label: "Yes",
});
expect(edges[1]).toMatchObject({
source: "menu",
target: "bye",
sourceHandle: "button:no",
label: "No",
});
});
it("falls back to reply_id when title is missing", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [{ reply_id: "raw", next_node_key: "e" }],
},
},
{ node_key: "e", node_type: "end", config: {} },
),
);
expect(edges[0].label).toBe("raw");
});
it("skips buttons whose target doesn't exist", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [
{ reply_id: "good", title: "G", next_node_key: "real" },
{ reply_id: "bad", title: "B", next_node_key: "ghost" },
],
},
},
{ node_key: "real", node_type: "end", config: {} },
),
);
expect(edges).toHaveLength(1);
expect(edges[0].sourceHandle).toBe("button:good");
});
});
describe("deriveCanvasEdges — send_list (per-row across sections)", () => {
it("emits one edge per row, with `row:<reply_id>` handles", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "list",
node_type: "send_list",
config: {
text: "Pick",
button_label: "View",
sections: [
{
title: "Recent",
rows: [
{ reply_id: "o1", title: "Order 1", next_node_key: "a" },
],
},
{
title: "Older",
rows: [
{ reply_id: "o2", title: "Order 2", next_node_key: "b" },
],
},
],
},
},
{ node_key: "a", node_type: "handoff", config: {} },
{ node_key: "b", node_type: "handoff", config: {} },
),
);
expect(edges).toHaveLength(2);
expect(edges[0].sourceHandle).toBe("row:o1");
expect(edges[0].label).toBe("Order 1");
expect(edges[1].sourceHandle).toBe("row:o2");
});
});
describe("deriveCanvasEdges — terminal nodes", () => {
it("emits no outgoing edges from handoff / end", () => {
const edges = deriveCanvasEdges(
nodes(
{ node_key: "h", node_type: "handoff", config: { note: "x" } },
{ node_key: "e", node_type: "end", config: {} },
),
);
expect(edges).toEqual([]);
});
});
describe("deriveCanvasEdges — id stability", () => {
it("produces unique, deterministic ids per (source, slot, target)", () => {
const edges = deriveCanvasEdges(
nodes(
{
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [
{ reply_id: "a", title: "A", next_node_key: "x" },
{ reply_id: "b", title: "B", next_node_key: "x" },
],
},
},
{ node_key: "x", node_type: "end", config: {} },
),
);
const ids = edges.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("outgoingSlots", () => {
it("returns a single 'next' slot for the auto-advancing types", () => {
const each = (node: BuilderNode) =>
outgoingSlots(node).map((s) => s.id);
expect(
each({ node_key: "x", node_type: "start", config: { next_node_key: "y" } }),
).toEqual(["next"]);
expect(
each({ node_key: "x", node_type: "send_message", config: {} }),
).toEqual(["next"]);
expect(
each({ node_key: "x", node_type: "send_media", config: {} }),
).toEqual(["next"]);
expect(
each({ node_key: "x", node_type: "collect_input", config: {} }),
).toEqual(["next"]);
expect(each({ node_key: "x", node_type: "set_tag", config: {} })).toEqual([
"next",
]);
});
it("returns true/false slots for condition", () => {
const slots = outgoingSlots({
node_key: "c",
node_type: "condition",
config: {},
});
expect(slots.map((s) => s.id)).toEqual(["true", "false"]);
expect(slots.map((s) => s.label)).toEqual(["true", "false"]);
});
it("returns one slot per button, labelled with the title", () => {
const slots = outgoingSlots({
node_key: "m",
node_type: "send_buttons",
config: {
text: "Pick",
buttons: [
{ reply_id: "yes", title: "Yes", next_node_key: "" },
{ reply_id: "no", title: "No", next_node_key: "" },
],
},
});
expect(slots).toEqual([
{ id: "button:yes", label: "Yes" },
{ id: "button:no", label: "No" },
]);
});
it("falls back to reply_id for buttons with no title", () => {
const slots = outgoingSlots({
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [{ reply_id: "raw", next_node_key: "" }],
},
});
expect(slots[0].label).toBe("raw");
});
it("flattens list rows across all sections", () => {
const slots = outgoingSlots({
node_key: "l",
node_type: "send_list",
config: {
text: "Pick",
button_label: "View",
sections: [
{ rows: [{ reply_id: "o1", title: "Order 1", next_node_key: "" }] },
{ rows: [{ reply_id: "o2", title: "Order 2", next_node_key: "" }] },
],
},
});
expect(slots.map((s) => s.id)).toEqual(["row:o1", "row:o2"]);
});
it("terminal nodes (handoff / end) have no outgoing slots", () => {
expect(
outgoingSlots({ node_key: "h", node_type: "handoff", config: {} }),
).toEqual([]);
expect(
outgoingSlots({ node_key: "e", node_type: "end", config: {} }),
).toEqual([]);
});
});
describe("applyEdgeConnection", () => {
it("patches next_node_key for single-outgoing nodes", () => {
const node: BuilderNode = {
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "" },
};
expect(applyEdgeConnection(node, "next", "b")).toEqual({
next_node_key: "b",
});
});
it("returns null when the source handle isn't recognised on the type", () => {
const node: BuilderNode = {
node_key: "a",
node_type: "send_message",
config: {},
};
expect(applyEdgeConnection(node, "true", "b")).toBeNull();
expect(applyEdgeConnection(node, "button:x", "b")).toBeNull();
});
it("patches the right branch on a condition", () => {
const node: BuilderNode = {
node_key: "c",
node_type: "condition",
config: {
subject: "var",
subject_key: "x",
operator: "equals",
value: "y",
true_next: "",
false_next: "",
},
};
expect(applyEdgeConnection(node, "true", "t")).toEqual({ true_next: "t" });
expect(applyEdgeConnection(node, "false", "f")).toEqual({
false_next: "f",
});
});
it("patches only the matching button row on send_buttons", () => {
const node: BuilderNode = {
node_key: "m",
node_type: "send_buttons",
config: {
text: "Pick",
buttons: [
{ reply_id: "yes", title: "Yes", next_node_key: "" },
{ reply_id: "no", title: "No", next_node_key: "" },
],
},
};
const patch = applyEdgeConnection(node, "button:yes", "ok");
expect(patch).toEqual({
buttons: [
{ reply_id: "yes", title: "Yes", next_node_key: "ok" },
{ reply_id: "no", title: "No", next_node_key: "" },
],
});
});
it("returns null when the button reply_id doesn't exist on the node", () => {
const node: BuilderNode = {
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [{ reply_id: "a", title: "A", next_node_key: "" }],
},
};
expect(applyEdgeConnection(node, "button:ghost", "z")).toBeNull();
});
it("patches the matching list row across sections", () => {
const node: BuilderNode = {
node_key: "l",
node_type: "send_list",
config: {
text: "x",
button_label: "View",
sections: [
{ rows: [{ reply_id: "o1", title: "O1", next_node_key: "" }] },
{ rows: [{ reply_id: "o2", title: "O2", next_node_key: "" }] },
],
},
};
const patch = applyEdgeConnection(node, "row:o2", "tgt") as {
sections: Array<{ rows: Array<{ next_node_key: string }> }>;
};
expect(patch.sections[0].rows[0].next_node_key).toBe("");
expect(patch.sections[1].rows[0].next_node_key).toBe("tgt");
});
it("returns null for terminal nodes (no outgoing)", () => {
expect(
applyEdgeConnection(
{ node_key: "h", node_type: "handoff", config: {} },
"next",
"x",
),
).toBeNull();
expect(
applyEdgeConnection(
{ node_key: "e", node_type: "end", config: {} },
"next",
"x",
),
).toBeNull();
});
});
describe("unlinkNodeReferences", () => {
it("clears next_node_key when it points at the deleted node", () => {
const before: BuilderNode[] = [
{
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "victim" },
},
{ node_key: "victim", node_type: "end", config: {} },
];
const after = unlinkNodeReferences(before, "victim");
expect(
(after[0].config as { next_node_key: string }).next_node_key,
).toBe("");
});
it("clears both true_next and false_next when condition points at the deleted node", () => {
const before: BuilderNode[] = [
{
node_key: "c",
node_type: "condition",
config: {
true_next: "victim",
false_next: "victim",
},
},
];
const after = unlinkNodeReferences(before, "victim");
const cfg = after[0].config as {
true_next: string;
false_next: string;
};
expect(cfg.true_next).toBe("");
expect(cfg.false_next).toBe("");
});
it("clears only the buttons that point at the deleted node", () => {
const before: BuilderNode[] = [
{
node_key: "m",
node_type: "send_buttons",
config: {
text: "x",
buttons: [
{ reply_id: "a", title: "A", next_node_key: "victim" },
{ reply_id: "b", title: "B", next_node_key: "safe" },
],
},
},
];
const after = unlinkNodeReferences(before, "victim");
const buttons = (after[0].config as {
buttons: Array<{ reply_id: string; next_node_key: string }>;
}).buttons;
expect(buttons[0].next_node_key).toBe("");
expect(buttons[1].next_node_key).toBe("safe");
});
it("clears only the list rows that point at the deleted node", () => {
const before: BuilderNode[] = [
{
node_key: "l",
node_type: "send_list",
config: {
sections: [
{
rows: [
{ reply_id: "r1", next_node_key: "victim" },
{ reply_id: "r2", next_node_key: "safe" },
],
},
],
},
},
];
const after = unlinkNodeReferences(before, "victim");
const rows = (after[0].config as {
sections: Array<{ rows: Array<{ next_node_key: string }> }>;
}).sections[0].rows;
expect(rows[0].next_node_key).toBe("");
expect(rows[1].next_node_key).toBe("safe");
});
it("returns the input nodes by identity when none reference the deleted key (no-op path)", () => {
const nodes: BuilderNode[] = [
{
node_key: "a",
node_type: "send_message",
config: { text: "hi", next_node_key: "b" },
},
{ node_key: "b", node_type: "end", config: {} },
];
const after = unlinkNodeReferences(nodes, "ghost");
// Same array length, each entry === input (no clone).
expect(after).toHaveLength(2);
expect(after[0]).toBe(nodes[0]);
expect(after[1]).toBe(nodes[1]);
});
});

View File

@@ -0,0 +1,412 @@
/**
* Derive canvas edges from the flow's node list.
*
* Edges live INSIDE each node's `config` JSONB (each button row /
* list row / condition branch carries its own `next_node_key`). The
* canvas needs them as a separate `{ source, target, label,
* sourceHandle }` list to render arrows, and the labels need to be
* meaningful — a `send_buttons` node with three buttons isn't useful
* on the canvas if the three outgoing arrows are unlabeled.
*
* Why this lives in lib/flows (not next to flow-canvas.tsx): the
* derivation is pure data manipulation with no React-Flow types in
* it, which makes it (a) trivially unit-testable and (b) reusable by
* the editable canvas (PR 2) without dragging in client-only deps.
*
* `sourceHandle` ids are stable strings the canvas wires up to its
* per-node renderer's outgoing connection points. They match the
* scheme PR 2's drag-to-connect handler will read:
* - `next` for single-outgoing nodes
* - `button:<reply_id>` for send_buttons rows
* - `row:<reply_id>` for send_list rows
* - `true` / `false` for condition branches
*/
import type { BuilderNode } from "@/components/flows/shared";
export interface CanvasEdge {
/** Stable per-edge id — required by React-Flow. */
id: string;
/** node_key of the source node. */
source: string;
/** node_key of the target node. */
target: string;
/** Identifies which outgoing slot on the source node this edge belongs to. */
sourceHandle: string;
/** Human-readable label rendered on the canvas (e.g. "Yes button"). */
label?: string;
}
export function deriveCanvasEdges(nodes: BuilderNode[]): CanvasEdge[] {
const knownKeys = new Set(nodes.map((n) => n.node_key));
const edges: CanvasEdge[] = [];
for (const node of nodes) {
const cfg = node.config;
switch (node.node_type) {
case "start":
case "send_message":
case "send_media":
case "collect_input":
case "set_tag": {
const next = (cfg as { next_node_key?: string }).next_node_key;
if (next && knownKeys.has(next)) {
edges.push({
id: `${node.node_key}--next--${next}`,
source: node.node_key,
target: next,
sourceHandle: "next",
});
}
break;
}
case "condition": {
const trueNext = (cfg as { true_next?: string }).true_next;
const falseNext = (cfg as { false_next?: string }).false_next;
if (trueNext && knownKeys.has(trueNext)) {
edges.push({
id: `${node.node_key}--true--${trueNext}`,
source: node.node_key,
target: trueNext,
sourceHandle: "true",
label: "true",
});
}
if (falseNext && knownKeys.has(falseNext)) {
edges.push({
id: `${node.node_key}--false--${falseNext}`,
source: node.node_key,
target: falseNext,
sourceHandle: "false",
label: "false",
});
}
break;
}
case "send_buttons": {
const buttons = Array.isArray(
(cfg as { buttons?: unknown }).buttons,
)
? ((cfg as { buttons: Array<Record<string, unknown>> }).buttons)
: [];
for (const btn of buttons) {
const replyId =
typeof btn.reply_id === "string" ? btn.reply_id : null;
const next =
typeof btn.next_node_key === "string" ? btn.next_node_key : null;
const title = typeof btn.title === "string" ? btn.title : null;
if (!replyId || !next || !knownKeys.has(next)) continue;
edges.push({
id: `${node.node_key}--button:${replyId}--${next}`,
source: node.node_key,
target: next,
sourceHandle: `button:${replyId}`,
label: title ?? replyId,
});
}
break;
}
case "send_list": {
const sections = Array.isArray(
(cfg as { sections?: unknown }).sections,
)
? ((cfg as { sections: Array<Record<string, unknown>> }).sections)
: [];
for (const section of sections) {
const rows = Array.isArray(section.rows)
? (section.rows as Array<Record<string, unknown>>)
: [];
for (const row of rows) {
const replyId =
typeof row.reply_id === "string" ? row.reply_id : null;
const next =
typeof row.next_node_key === "string" ? row.next_node_key : null;
const title = typeof row.title === "string" ? row.title : null;
if (!replyId || !next || !knownKeys.has(next)) continue;
edges.push({
id: `${node.node_key}--row:${replyId}--${next}`,
source: node.node_key,
target: next,
sourceHandle: `row:${replyId}`,
label: title ?? replyId,
});
}
}
break;
}
case "handoff":
case "end":
// Terminal nodes — no outgoing edges.
break;
}
}
return edges;
}
// ============================================================
// Inverse operations — used by the canvas's drag-to-connect and
// delete-with-cleanup handlers (PR 2b). Kept in lib/flows so the
// canvas component stays free of edge-bookkeeping logic.
// ============================================================
/**
* Outgoing-slot list for a node — used by the canvas to render one
* source-side Handle per slot, labelled with the slot's user-facing
* name. Order follows the order the slots appear in the node's
* config so visual layout matches the form layout.
*
* Terminal nodes (handoff / end) return an empty list — they have
* no outgoing edges and no source handles.
*/
export interface OutgoingSlot {
/** Stable id matching the `sourceHandle` scheme used in
* CanvasEdge. */
id: string;
/** Visible label rendered next to the handle. */
label: string;
}
export function outgoingSlots(node: BuilderNode): OutgoingSlot[] {
const cfg = node.config;
switch (node.node_type) {
case "start":
case "send_message":
case "send_media":
case "collect_input":
case "set_tag":
return [{ id: "next", label: "Next" }];
case "condition":
return [
{ id: "true", label: "true" },
{ id: "false", label: "false" },
];
case "send_buttons": {
const buttons = Array.isArray((cfg as { buttons?: unknown }).buttons)
? ((cfg as { buttons: Array<Record<string, unknown>> }).buttons)
: [];
return buttons
.filter((b) => typeof b.reply_id === "string" && b.reply_id)
.map((b) => {
const replyId = b.reply_id as string;
const title = typeof b.title === "string" ? b.title : null;
return {
id: `button:${replyId}`,
label: title ?? replyId,
};
});
}
case "send_list": {
const sections = Array.isArray((cfg as { sections?: unknown }).sections)
? ((cfg as { sections: Array<Record<string, unknown>> }).sections)
: [];
const slots: OutgoingSlot[] = [];
for (const section of sections) {
const rows = Array.isArray(section.rows)
? (section.rows as Array<Record<string, unknown>>)
: [];
for (const row of rows) {
const replyId =
typeof row.reply_id === "string" ? row.reply_id : null;
if (!replyId) continue;
const title = typeof row.title === "string" ? row.title : null;
slots.push({
id: `row:${replyId}`,
label: title ?? replyId,
});
}
}
return slots;
}
case "handoff":
case "end":
return [];
}
}
/**
* Compute the config patch to apply when the user drags an edge from
* `sourceHandle` on a node to `targetKey`. Returns `null` when the
* handle isn't recognised on the node type (defensive — React-Flow
* would have to misroute for this to fire).
*
* For `send_buttons` and `send_list`, only the button/row with the
* matching reply_id is patched; the rest of the array passes through
* unchanged.
*/
export function applyEdgeConnection(
node: BuilderNode,
sourceHandle: string,
targetKey: string,
): Record<string, unknown> | null {
switch (node.node_type) {
case "start":
case "send_message":
case "send_media":
case "collect_input":
case "set_tag":
if (sourceHandle === "next") return { next_node_key: targetKey };
return null;
case "condition":
if (sourceHandle === "true") return { true_next: targetKey };
if (sourceHandle === "false") return { false_next: targetKey };
return null;
case "send_buttons": {
if (!sourceHandle.startsWith("button:")) return null;
const replyId = sourceHandle.slice("button:".length);
const buttons = Array.isArray(
(node.config as { buttons?: unknown }).buttons,
)
? (node.config as {
buttons: Array<Record<string, unknown>>;
}).buttons
: [];
// No matching button → no-op (caller should have surfaced a
// missing slot before letting the user drag).
if (!buttons.some((b) => b.reply_id === replyId)) return null;
return {
buttons: buttons.map((b) =>
b.reply_id === replyId ? { ...b, next_node_key: targetKey } : b,
),
};
}
case "send_list": {
if (!sourceHandle.startsWith("row:")) return null;
const replyId = sourceHandle.slice("row:".length);
const sections = Array.isArray(
(node.config as { sections?: unknown }).sections,
)
? (node.config as {
sections: Array<Record<string, unknown>>;
}).sections
: [];
let matched = false;
const next = sections.map((s) => {
const rows = Array.isArray(s.rows)
? (s.rows as Array<Record<string, unknown>>)
: [];
return {
...s,
rows: rows.map((r) => {
if (r.reply_id === replyId) {
matched = true;
return { ...r, next_node_key: targetKey };
}
return r;
}),
};
});
return matched ? { sections: next } : null;
}
case "handoff":
case "end":
return null;
}
}
/**
* Walk every node and clear any `next_node_key` / `true_next` /
* `false_next` / `button.next_node_key` / `row.next_node_key`
* reference to `deletedKey`. Cleared refs become the empty string —
* the same "no target picked" sentinel the builder forms use.
*
* Returns a new array; original nodes are left untouched. Nodes
* without any matching reference pass through by identity to avoid
* needless re-renders downstream.
*/
export function unlinkNodeReferences(
nodes: BuilderNode[],
deletedKey: string,
): BuilderNode[] {
return nodes.map((n) => {
const patched = patchedConfigWithoutKey(n, deletedKey);
return patched ? { ...n, config: patched } : n;
});
}
function patchedConfigWithoutKey(
node: BuilderNode,
deletedKey: string,
): Record<string, unknown> | null {
const cfg = node.config;
switch (node.node_type) {
case "start":
case "send_message":
case "send_media":
case "collect_input":
case "set_tag": {
const next = (cfg as { next_node_key?: string }).next_node_key;
if (next !== deletedKey) return null;
return { ...cfg, next_node_key: "" };
}
case "condition": {
const c = cfg as { true_next?: string; false_next?: string };
const trueMatch = c.true_next === deletedKey;
const falseMatch = c.false_next === deletedKey;
if (!trueMatch && !falseMatch) return null;
return {
...cfg,
...(trueMatch ? { true_next: "" } : {}),
...(falseMatch ? { false_next: "" } : {}),
};
}
case "send_buttons": {
const buttons = Array.isArray((cfg as { buttons?: unknown }).buttons)
? (cfg as {
buttons: Array<Record<string, unknown>>;
}).buttons
: [];
if (!buttons.some((b) => b.next_node_key === deletedKey)) return null;
return {
...cfg,
buttons: buttons.map((b) =>
b.next_node_key === deletedKey ? { ...b, next_node_key: "" } : b,
),
};
}
case "send_list": {
const sections = Array.isArray((cfg as { sections?: unknown }).sections)
? (cfg as {
sections: Array<Record<string, unknown>>;
}).sections
: [];
let dirty = false;
const next = sections.map((s) => {
const rows = Array.isArray(s.rows)
? (s.rows as Array<Record<string, unknown>>)
: [];
return {
...s,
rows: rows.map((r) => {
if (r.next_node_key === deletedKey) {
dirty = true;
return { ...r, next_node_key: "" };
}
return r;
}),
};
});
return dirty ? { ...cfg, sections: next } : null;
}
case "handoff":
case "end":
return null;
}
}

View File

@@ -0,0 +1,299 @@
import { describe, it, expect } from "vitest";
import {
matchReplyId,
matchesKeywordTrigger,
isAutoAdvancing,
isSuspending,
isTerminal,
evaluateConditionPredicate,
} from "./engine";
describe("matchReplyId", () => {
it("returns null for nodes without options", () => {
expect(
matchReplyId({ node_type: "start", config: { next_node_key: "x" } }, "y"),
).toBeNull();
expect(
matchReplyId({ node_type: "send_message", config: {} }, "y"),
).toBeNull();
expect(matchReplyId({ node_type: "end", config: {} }, "y")).toBeNull();
});
it("matches the buttons array on a send_buttons node", () => {
const node = {
node_type: "send_buttons",
config: {
text: "Pick one",
buttons: [
{ reply_id: "yes", title: "Yes", next_node_key: "confirmed" },
{ reply_id: "no", title: "No", next_node_key: "declined" },
],
},
};
expect(matchReplyId(node, "yes")).toBe("confirmed");
expect(matchReplyId(node, "no")).toBe("declined");
});
it("returns null when no button reply_id matches", () => {
const node = {
node_type: "send_buttons",
config: {
text: "Pick",
buttons: [
{ reply_id: "a", title: "A", next_node_key: "to_a" },
{ reply_id: "b", title: "B", next_node_key: "to_b" },
],
},
};
expect(matchReplyId(node, "c")).toBeNull();
expect(matchReplyId(node, "")).toBeNull();
});
it("searches across all sections in a send_list node", () => {
const node = {
node_type: "send_list",
config: {
text: "Pick an order",
button_label: "View",
sections: [
{
title: "Recent",
rows: [
{ reply_id: "o1", title: "Order 1", next_node_key: "ord_1" },
],
},
{
title: "Older",
rows: [
{ reply_id: "o2", title: "Order 2", next_node_key: "ord_2" },
{ reply_id: "o3", title: "Order 3", next_node_key: "ord_3" },
],
},
],
},
};
expect(matchReplyId(node, "o1")).toBe("ord_1");
expect(matchReplyId(node, "o2")).toBe("ord_2");
expect(matchReplyId(node, "o3")).toBe("ord_3");
expect(matchReplyId(node, "o99")).toBeNull();
});
it("returns null when send_list has no sections / empty sections", () => {
expect(
matchReplyId(
{ node_type: "send_list", config: { text: "x", sections: [] } },
"x",
),
).toBeNull();
expect(
matchReplyId(
{
node_type: "send_list",
config: { text: "x", sections: [{ rows: [] }] },
},
"x",
),
).toBeNull();
});
});
describe("matchesKeywordTrigger", () => {
it("returns false for empty text", () => {
expect(matchesKeywordTrigger("", { keywords: ["hi"] })).toBe(false);
});
it("returns false when keywords array is empty", () => {
expect(matchesKeywordTrigger("anything", { keywords: [] })).toBe(false);
});
it("default match_type='contains' does case-insensitive substring", () => {
const cfg = { keywords: ["support"] };
expect(matchesKeywordTrigger("I need SUPPORT please", cfg)).toBe(true);
expect(matchesKeywordTrigger("Support is great", cfg)).toBe(true);
expect(matchesKeywordTrigger("Help me", cfg)).toBe(false);
});
it("match_type='exact' compares the whole string case-insensitively", () => {
const cfg = { keywords: ["help"], match_type: "exact" as const };
expect(matchesKeywordTrigger("help", cfg)).toBe(true);
expect(matchesKeywordTrigger("HELP", cfg)).toBe(true);
expect(matchesKeywordTrigger("help me", cfg)).toBe(false);
});
it("case_sensitive=true preserves case", () => {
const cfg = {
keywords: ["Support"],
case_sensitive: true,
};
expect(matchesKeywordTrigger("I need Support", cfg)).toBe(true);
expect(matchesKeywordTrigger("I need support", cfg)).toBe(false);
});
it("matches any one of multiple keywords", () => {
const cfg = { keywords: ["help", "support", "issue"] };
expect(matchesKeywordTrigger("I have an issue", cfg)).toBe(true);
expect(matchesKeywordTrigger("I need Help!", cfg)).toBe(true);
expect(matchesKeywordTrigger("nothing to see here", cfg)).toBe(false);
});
it("skips empty strings in the keywords array", () => {
const cfg = { keywords: ["", "support", ""] };
expect(matchesKeywordTrigger("support center", cfg)).toBe(true);
expect(matchesKeywordTrigger("nope", cfg)).toBe(false);
});
});
describe("node classification helpers", () => {
it("isAutoAdvancing covers start + send_message + send_media + condition + set_tag", () => {
expect(isAutoAdvancing("start")).toBe(true);
expect(isAutoAdvancing("send_message")).toBe(true);
expect(isAutoAdvancing("send_media")).toBe(true);
expect(isAutoAdvancing("condition")).toBe(true);
expect(isAutoAdvancing("set_tag")).toBe(true);
expect(isAutoAdvancing("send_buttons")).toBe(false);
expect(isAutoAdvancing("send_list")).toBe(false);
expect(isAutoAdvancing("collect_input")).toBe(false);
expect(isAutoAdvancing("handoff")).toBe(false);
expect(isAutoAdvancing("end")).toBe(false);
});
it("isSuspending covers the input-requiring nodes", () => {
expect(isSuspending("send_buttons")).toBe(true);
expect(isSuspending("send_list")).toBe(true);
expect(isSuspending("collect_input")).toBe(true);
expect(isSuspending("start")).toBe(false);
expect(isSuspending("send_message")).toBe(false);
expect(isSuspending("condition")).toBe(false);
expect(isSuspending("set_tag")).toBe(false);
expect(isSuspending("handoff")).toBe(false);
expect(isSuspending("end")).toBe(false);
});
it("isTerminal covers handoff + end", () => {
expect(isTerminal("handoff")).toBe(true);
expect(isTerminal("end")).toBe(true);
expect(isTerminal("start")).toBe(false);
expect(isTerminal("send_buttons")).toBe(false);
expect(isTerminal("condition")).toBe(false);
});
it("the three classifications are mutually exclusive for known node types", () => {
const types = [
"start",
"send_message",
"send_buttons",
"send_list",
"send_media",
"collect_input",
"condition",
"set_tag",
"handoff",
"end",
];
for (const t of types) {
const flags = [isAutoAdvancing(t), isSuspending(t), isTerminal(t)];
// Exactly one of the three should be true for every known node.
expect(flags.filter(Boolean).length).toBe(1);
}
});
});
describe("evaluateConditionPredicate", () => {
it("present: true when subject has a value", () => {
expect(
evaluateConditionPredicate({
operator: "present",
subjectValue: "alice@example.com",
configValue: undefined,
}),
).toBe(true);
});
it("present: false when subject is undefined or empty", () => {
expect(
evaluateConditionPredicate({
operator: "present",
subjectValue: undefined,
configValue: undefined,
}),
).toBe(false);
expect(
evaluateConditionPredicate({
operator: "present",
subjectValue: "",
configValue: undefined,
}),
).toBe(false);
});
it("absent: inverse of present", () => {
expect(
evaluateConditionPredicate({
operator: "absent",
subjectValue: undefined,
configValue: undefined,
}),
).toBe(true);
expect(
evaluateConditionPredicate({
operator: "absent",
subjectValue: "x",
configValue: undefined,
}),
).toBe(false);
});
it("equals: exact string comparison; case-sensitive", () => {
expect(
evaluateConditionPredicate({
operator: "equals",
subjectValue: "VIP",
configValue: "VIP",
}),
).toBe(true);
expect(
evaluateConditionPredicate({
operator: "equals",
subjectValue: "vip",
configValue: "VIP",
}),
).toBe(false);
});
it("equals: undefined subject never matches (even against empty)", () => {
expect(
evaluateConditionPredicate({
operator: "equals",
subjectValue: undefined,
configValue: "",
}),
).toBe(false);
});
it("contains: substring match", () => {
expect(
evaluateConditionPredicate({
operator: "contains",
subjectValue: "support@example.com",
configValue: "@example.com",
}),
).toBe(true);
expect(
evaluateConditionPredicate({
operator: "contains",
subjectValue: "support@other.com",
configValue: "@example.com",
}),
).toBe(false);
});
it("contains: undefined subject never matches", () => {
expect(
evaluateConditionPredicate({
operator: "contains",
subjectValue: undefined,
configValue: "anything",
}),
).toBe(false);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import {
decideFallback,
resolveFallbackPolicy,
} from "./fallback";
import { DEFAULT_FALLBACK_POLICY, type FlowFallbackPolicy } from "./types";
describe("resolveFallbackPolicy", () => {
it("returns defaults for null / undefined / non-object", () => {
expect(resolveFallbackPolicy(null)).toEqual(DEFAULT_FALLBACK_POLICY);
expect(resolveFallbackPolicy(undefined)).toEqual(DEFAULT_FALLBACK_POLICY);
expect(resolveFallbackPolicy("not-an-object")).toEqual(
DEFAULT_FALLBACK_POLICY,
);
expect(resolveFallbackPolicy(42)).toEqual(DEFAULT_FALLBACK_POLICY);
});
it("returns defaults for an empty object", () => {
expect(resolveFallbackPolicy({})).toEqual(DEFAULT_FALLBACK_POLICY);
});
it("preserves valid fields, defaults the rest", () => {
expect(
resolveFallbackPolicy({ max_reprompts: 5, on_exhaust: "end" }),
).toEqual({
...DEFAULT_FALLBACK_POLICY,
max_reprompts: 5,
on_exhaust: "end",
});
});
it("rejects invalid on_unknown_reply values", () => {
expect(
resolveFallbackPolicy({ on_unknown_reply: "nonsense" as unknown }),
).toEqual(DEFAULT_FALLBACK_POLICY);
});
it("rejects negative or NaN max_reprompts", () => {
expect(resolveFallbackPolicy({ max_reprompts: -1 })).toEqual(
DEFAULT_FALLBACK_POLICY,
);
expect(resolveFallbackPolicy({ max_reprompts: Number.NaN })).toEqual(
DEFAULT_FALLBACK_POLICY,
);
});
it("floors non-integer max_reprompts to be safe", () => {
expect(resolveFallbackPolicy({ max_reprompts: 2.7 }).max_reprompts).toBe(2);
});
it("rejects non-positive on_timeout_hours", () => {
expect(resolveFallbackPolicy({ on_timeout_hours: 0 })).toEqual(
DEFAULT_FALLBACK_POLICY,
);
expect(resolveFallbackPolicy({ on_timeout_hours: -5 })).toEqual(
DEFAULT_FALLBACK_POLICY,
);
});
});
const POLICY_REPROMPT_2_HANDOFF: FlowFallbackPolicy = {
on_unknown_reply: "reprompt",
max_reprompts: 2,
on_timeout_hours: 24,
on_exhaust: "handoff",
};
describe("decideFallback", () => {
it("returns ignore when on_unknown_reply is 'ignore'", () => {
expect(
decideFallback({
policy: { ...POLICY_REPROMPT_2_HANDOFF, on_unknown_reply: "ignore" },
reprompt_count: 1,
}),
).toEqual({ type: "ignore" });
});
it("returns handoff immediately when on_unknown_reply is 'handoff'", () => {
expect(
decideFallback({
policy: { ...POLICY_REPROMPT_2_HANDOFF, on_unknown_reply: "handoff" },
reprompt_count: 1,
}),
).toEqual({ type: "handoff" });
});
it("reprompts up to max_reprompts", () => {
// count=1 (first reprompt) and count=2 (second) still re-prompt
expect(
decideFallback({ policy: POLICY_REPROMPT_2_HANDOFF, reprompt_count: 1 }),
).toEqual({ type: "reprompt" });
expect(
decideFallback({ policy: POLICY_REPROMPT_2_HANDOFF, reprompt_count: 2 }),
).toEqual({ type: "reprompt" });
});
it("escalates to handoff once max_reprompts is exceeded", () => {
// count=3 with max=2 → exhaust → handoff
expect(
decideFallback({ policy: POLICY_REPROMPT_2_HANDOFF, reprompt_count: 3 }),
).toEqual({ type: "handoff" });
});
it("respects on_exhaust='end' when max is exhausted", () => {
const policy: FlowFallbackPolicy = {
...POLICY_REPROMPT_2_HANDOFF,
on_exhaust: "end",
};
expect(decideFallback({ policy, reprompt_count: 5 })).toEqual({
type: "end",
});
});
it("with max_reprompts=0, the first unknown reply exhausts", () => {
const policy: FlowFallbackPolicy = {
...POLICY_REPROMPT_2_HANDOFF,
max_reprompts: 0,
};
// count=1 already > max=0 → exhaust
expect(decideFallback({ policy, reprompt_count: 1 })).toEqual({
type: "handoff",
});
});
});

View File

@@ -0,0 +1,91 @@
/**
* Fallback-policy resolver.
*
* Pure logic that decides what the engine does when a customer reply
* doesn't match any option on the current `send_buttons` / `send_list`
* node. Lifted out of `engine.ts` so it can be unit-tested without a
* Supabase / Meta mock.
*
* The policy lives on `flows.fallback_policy` (JSONB) and is loaded
* with the run; defaults filled in by `resolveFallbackPolicy` so an
* older flow row (or a partial JSONB blob) doesn't crash the runner.
*/
import {
DEFAULT_FALLBACK_POLICY,
type FlowFallbackPolicy,
} from "./types";
export type FallbackAction =
/** Re-send the same prompt and wait again. */
| { type: "reprompt" }
/** End the run with status='handed_off', flip conversation to pending. */
| { type: "handoff" }
/** End the run with status='completed' (the `end` exhaust option). */
| { type: "end" }
/** Do nothing — the message wasn't for us. */
| { type: "ignore" };
/**
* Merge a partial / null fallback_policy from the DB with the v1
* defaults. The DB column defaults the *whole* JSONB to the right
* shape, but rows authored before this default landed, or rows
* manually edited to a subset, would otherwise crash the runner.
*/
export function resolveFallbackPolicy(
raw: unknown,
): FlowFallbackPolicy {
if (!raw || typeof raw !== "object") return DEFAULT_FALLBACK_POLICY;
const r = raw as Partial<FlowFallbackPolicy>;
return {
on_unknown_reply:
r.on_unknown_reply === "handoff" ||
r.on_unknown_reply === "ignore" ||
r.on_unknown_reply === "reprompt"
? r.on_unknown_reply
: DEFAULT_FALLBACK_POLICY.on_unknown_reply,
max_reprompts:
typeof r.max_reprompts === "number" && r.max_reprompts >= 0
? Math.floor(r.max_reprompts)
: DEFAULT_FALLBACK_POLICY.max_reprompts,
on_timeout_hours:
typeof r.on_timeout_hours === "number" && r.on_timeout_hours > 0
? r.on_timeout_hours
: DEFAULT_FALLBACK_POLICY.on_timeout_hours,
on_exhaust:
r.on_exhaust === "handoff" || r.on_exhaust === "end"
? r.on_exhaust
: DEFAULT_FALLBACK_POLICY.on_exhaust,
};
}
/**
* Decide the action when the customer's reply doesn't match a button
* id on the current node. The engine increments `reprompt_count` and
* persists, then calls this with the NEW count.
*
* - `on_unknown_reply: 'ignore'` → always ignore. Useful for a flow
* that should keep running even if the customer types something
* off-script in between taps (rare; default is reprompt).
* - `on_unknown_reply: 'handoff'` → immediately escalate. No retries.
* - `on_unknown_reply: 'reprompt'` → re-send the prompt up to
* `max_reprompts` times, then apply `on_exhaust`.
*/
export function decideFallback(args: {
policy: FlowFallbackPolicy;
/** Reprompt count AFTER incrementing (so 1 = first reprompt). */
reprompt_count: number;
}): FallbackAction {
const { policy, reprompt_count } = args;
if (policy.on_unknown_reply === "ignore") return { type: "ignore" };
if (policy.on_unknown_reply === "handoff") return { type: "handoff" };
// 'reprompt' — guarded by max_reprompts.
if (reprompt_count <= policy.max_reprompts) {
return { type: "reprompt" };
}
return policy.on_exhaust === "end"
? { type: "end" }
: { type: "handoff" };
}

View File

@@ -0,0 +1,128 @@
import { describe, it, expect } from "vitest";
import { autoLayout, shouldAutoLayout } from "./layout";
describe("shouldAutoLayout", () => {
it("returns false for an empty list", () => {
expect(shouldAutoLayout([])).toBe(false);
});
it("returns true when every node sits at 0,0", () => {
expect(
shouldAutoLayout([
{ position_x: 0, position_y: 0 },
{ position_x: 0, position_y: 0 },
]),
).toBe(true);
});
it("treats null / undefined positions as 0,0", () => {
expect(
shouldAutoLayout([
{ position_x: null, position_y: null },
{},
]),
).toBe(true);
});
it("returns false if any node has a non-zero position (mid-edit guard)", () => {
expect(
shouldAutoLayout([
{ position_x: 0, position_y: 0 },
{ position_x: 200, position_y: 50 },
]),
).toBe(false);
});
});
describe("autoLayout", () => {
it("returns a position for every input node", () => {
const positions = autoLayout(
[
{ id: "a" },
{ id: "b" },
{ id: "c" },
],
[
{ source: "a", target: "b" },
{ source: "b", target: "c" },
],
);
expect(positions.size).toBe(3);
expect(positions.has("a")).toBe(true);
expect(positions.has("b")).toBe(true);
expect(positions.has("c")).toBe(true);
});
it("lays a linear chain top-to-bottom by default", () => {
const positions = autoLayout(
[
{ id: "a" },
{ id: "b" },
{ id: "c" },
],
[
{ source: "a", target: "b" },
{ source: "b", target: "c" },
],
);
const a = positions.get("a")!;
const b = positions.get("b")!;
const c = positions.get("c")!;
// TB direction => y increases down the chain.
expect(a.y).toBeLessThan(b.y);
expect(b.y).toBeLessThan(c.y);
});
it("spreads branch targets horizontally on the same rank", () => {
const positions = autoLayout(
[
{ id: "root" },
{ id: "left" },
{ id: "right" },
],
[
{ source: "root", target: "left" },
{ source: "root", target: "right" },
],
);
const left = positions.get("left")!;
const right = positions.get("right")!;
// Same rank => same y; different positions horizontally.
expect(left.y).toBe(right.y);
expect(left.x).not.toBe(right.x);
});
it("ignores edges whose endpoints aren't in the node list", () => {
// Defensive — the canvas filters dangling edges but the helper
// shouldn't blow up if a stale edge slips through.
const positions = autoLayout(
[{ id: "only" }],
[
{ source: "only", target: "ghost" },
{ source: "phantom", target: "only" },
],
);
expect(positions.size).toBe(1);
expect(positions.get("only")).toBeDefined();
});
it("respects custom node widths when computing positions", () => {
const narrow = autoLayout(
[
{ id: "a", width: 100, height: 50 },
{ id: "b", width: 100, height: 50 },
],
[{ source: "a", target: "b" }],
);
const wide = autoLayout(
[
{ id: "a", width: 400, height: 50 },
{ id: "b", width: 400, height: 50 },
],
[{ source: "a", target: "b" }],
);
// Wider nodes don't shift vertical spacing on a single chain
// (rank gap is fixed) but they DO offset x to keep nodes centered.
expect(narrow.get("a")!.y).toBe(wide.get("a")!.y);
});
});

View File

@@ -0,0 +1,131 @@
/**
* Dagre-based auto-layout for the flow canvas.
*
* The canvas reads `flow_nodes.position_x` / `position_y` (added in
* migration 010 as `INTEGER NOT NULL DEFAULT 0` — reserved precisely
* for this view). Brand-new flows and every flow authored before the
* canvas shipped have all-zero positions, which would render as a
* single overlapping pile at the origin. This module computes
* reasonable starting positions in those cases.
*
* Why dagre over a hand-rolled BFS layout: branches with multiple
* outgoing edges (send_buttons, condition, send_list) need horizontal
* spread to be readable, and dagre's `rank`+`order` pass handles edge
* crossings far better than anything we'd write by hand. ~30 KB gz
* for the standalone wrapper, but the canvas already pulls in
* @xyflow/react so this is incremental.
*
* What we do NOT do here: re-layout on every edit. The canvas
* persists the user's drag positions, and we only ever auto-layout
* once when `shouldAutoLayout()` returns true. Otherwise a user who
* carefully arranged a flow would have their work overwritten on
* reload.
*/
import Dagre from "@dagrejs/dagre";
export interface LayoutNode {
id: string;
/** Optional measured size — falls back to defaults if not provided. */
width?: number;
height?: number;
}
export interface LayoutEdge {
source: string;
target: string;
}
export interface LayoutPosition {
x: number;
y: number;
}
export interface LayoutOptions {
/** Top-to-bottom is the natural reading order for conversation flows. */
direction?: "TB" | "LR";
/** Gap between rows (TB) / columns (LR). */
rankSep?: number;
/** Gap between sibling nodes within the same rank. */
nodeSep?: number;
/** Default node width when a node's width isn't measured yet. */
defaultWidth?: number;
/** Default node height when a node's height isn't measured yet. */
defaultHeight?: number;
}
const DEFAULTS: Required<LayoutOptions> = {
direction: "TB",
rankSep: 80,
nodeSep: 60,
defaultWidth: 240,
defaultHeight: 90,
};
/**
* True iff every node sits at the origin — the signal that no human
* has positioned this flow yet and auto-layout is safe to run.
*
* Why `every`, not `some`: a partially-laid-out flow (some nodes at
* 0,0, others positioned) is almost certainly mid-edit. Re-running
* dagre would shuffle the positioned ones the user already chose.
* Better to leave the new nodes at 0,0 and let the user drag them.
*/
export function shouldAutoLayout(
nodes: Array<{ position_x?: number | null; position_y?: number | null }>,
): boolean {
if (nodes.length === 0) return false;
return nodes.every(
(n) => (n.position_x ?? 0) === 0 && (n.position_y ?? 0) === 0,
);
}
/**
* Compute positions for every node id. Returns a map keyed by node
* id; consumers merge it into their React-Flow nodes array. The
* returned coordinates are the TOP-LEFT corner (matches React-Flow's
* coordinate space — dagre internally tracks centers, we translate).
*/
export function autoLayout(
nodes: LayoutNode[],
edges: LayoutEdge[],
options: LayoutOptions = {},
): Map<string, LayoutPosition> {
const opts = { ...DEFAULTS, ...options };
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: opts.direction,
ranksep: opts.rankSep,
nodesep: opts.nodeSep,
});
for (const n of nodes) {
g.setNode(n.id, {
width: n.width ?? opts.defaultWidth,
height: n.height ?? opts.defaultHeight,
});
}
for (const e of edges) {
// Dagre tolerates edges to/from non-existent nodes by inserting
// them as zero-size — that would silently warp the layout. Skip
// dangling edges instead; the canvas's edge derivation already
// filters them but defending here keeps this helper standalone.
if (g.node(e.source) && g.node(e.target)) {
g.setEdge(e.source, e.target);
}
}
Dagre.layout(g);
const positions = new Map<string, LayoutPosition>();
for (const n of nodes) {
const laid = g.node(n.id);
if (!laid) continue;
// Dagre returns the center; React-Flow wants the top-left.
positions.set(n.id, {
x: laid.x - (n.width ?? opts.defaultWidth) / 2,
y: laid.y - (n.height ?? opts.defaultHeight) / 2,
});
}
return positions;
}

View File

@@ -0,0 +1,434 @@
import {
sendInteractiveButtons,
sendInteractiveList,
sendMediaMessage,
sendTextMessage,
type InteractiveButton,
type InteractiveListSection,
type MediaKind,
} from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption'
import {
sanitizePhoneForMeta,
isValidE164,
phoneVariants,
isRecipientNotAllowedError,
} from '@/lib/whatsapp/phone-utils'
import { supabaseAdmin } from './admin-client'
// ------------------------------------------------------------
// Flows-side Meta sender (interactive variants).
//
// Mirrors src/lib/automations/meta-send.ts (engineSendText /
// engineSendTemplate) but emits interactive button + list messages.
// Kept separate from the automations file so the two engines don't
// fight over each other's shape — once both stabilize, the
// phone-variant retry + DB persistence are obvious extraction
// candidates into a shared base.
//
// PR #1 ships this in isolation: callers don't exist yet. PR #2
// brings the flow runner online and wires it up. Shipping it now
// keeps the foundation PR self-contained and unit-testable.
// ------------------------------------------------------------
interface SendTextEngineArgs {
/** Account-level tenancy key. Drives contact + whatsapp_config
* lookups so a flow authored by user A still sends through the
* WhatsApp number user B saved on the same account. */
accountId: string
/** Original author of the flow — used for INSERT audit columns
* and for resolving the agent's identity in logs. Not consulted
* for tenancy. */
userId: string
conversationId: string
contactId: string
text: string
}
/**
* Send a plain-text WhatsApp message from the Flows engine.
*
* Used by the runner's `send_message` and `collect_input` nodes —
* both prompt the customer with text and either auto-advance (the
* send_message case) or suspend awaiting a text reply (collect_input).
*
* Wraps the same phone-variant retry + DB persistence pattern as the
* interactive senders; the duplication will be DRY'd into a shared
* `engineSendBase` once the v2 features (templates with variables,
* media sends) settle.
*/
export async function engineSendText(
args: SendTextEngineArgs,
): Promise<{ whatsapp_message_id: string }> {
const db = supabaseAdmin()
const { data: contact, error: contactErr } = await db
.from('contacts')
.select('id, phone')
.eq('id', args.contactId)
.eq('account_id', args.accountId)
.maybeSingle()
if (contactErr || !contact?.phone) {
throw new Error('contact not found for this account')
}
const sanitized = sanitizePhoneForMeta(contact.phone)
if (!isValidE164(sanitized)) {
throw new Error(`contact phone invalid: ${contact.phone}`)
}
const { data: config, error: configErr } = await db
.from('whatsapp_config')
.select('*')
.eq('account_id', args.accountId)
.single()
if (configErr || !config) {
throw new Error('WhatsApp not configured for this account')
}
const accessToken = decrypt(config.access_token)
const attempt = async (phone: string): Promise<string> => {
const r = await sendTextMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
text: args.text,
})
return r.messageId
}
const variants = phoneVariants(sanitized)
let workingPhone = sanitized
let waMessageId = ''
let lastError: unknown = null
for (const v of variants) {
try {
waMessageId = await attempt(v)
workingPhone = v
lastError = null
break
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isRecipientNotAllowedError(msg)) throw err
lastError = err
}
}
if (lastError) throw lastError
if (workingPhone !== sanitized) {
await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id)
}
const { error: msgErr } = await db.from('messages').insert({
conversation_id: args.conversationId,
sender_type: 'bot',
content_type: 'text',
content_text: args.text,
message_id: waMessageId,
status: 'sent',
})
if (msgErr) {
throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`)
}
await db
.from('conversations')
.update({
last_message_text: args.text,
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', args.conversationId)
return { whatsapp_message_id: waMessageId }
}
interface SendMediaEngineArgs {
accountId: string
userId: string
conversationId: string
contactId: string
kind: MediaKind
/** Public URL Meta fetches at send time. */
link: string
caption?: string
/** Document-only; ignored by Meta for image/video. */
filename?: string
}
/**
* Send an image / video / document from the Flows engine.
*
* Used by the runner's `send_media` node. Auto-advances after the
* send lands (same suspend semantics as send_message). Same
* phone-variant retry + DB persistence as the text/interactive
* senders; persists the outgoing message with `content_type` matching
* the media kind so the inbox renders the right preview.
*/
export async function engineSendMedia(
args: SendMediaEngineArgs,
): Promise<{ whatsapp_message_id: string }> {
const db = supabaseAdmin()
const { data: contact, error: contactErr } = await db
.from('contacts')
.select('id, phone')
.eq('id', args.contactId)
.eq('account_id', args.accountId)
.maybeSingle()
if (contactErr || !contact?.phone) {
throw new Error('contact not found for this account')
}
const sanitized = sanitizePhoneForMeta(contact.phone)
if (!isValidE164(sanitized)) {
throw new Error(`contact phone invalid: ${contact.phone}`)
}
const { data: config, error: configErr } = await db
.from('whatsapp_config')
.select('*')
.eq('account_id', args.accountId)
.single()
if (configErr || !config) {
throw new Error('WhatsApp not configured for this account')
}
const accessToken = decrypt(config.access_token)
const attempt = async (phone: string): Promise<string> => {
const r = await sendMediaMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
kind: args.kind,
link: args.link,
caption: args.caption,
filename: args.filename,
})
return r.messageId
}
const variants = phoneVariants(sanitized)
let workingPhone = sanitized
let waMessageId = ''
let lastError: unknown = null
for (const v of variants) {
try {
waMessageId = await attempt(v)
workingPhone = v
lastError = null
break
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isRecipientNotAllowedError(msg)) throw err
lastError = err
}
}
if (lastError) throw lastError
if (workingPhone !== sanitized) {
await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id)
}
// content_type='image'|'video'|'document' — these are already in the
// messages_content_type_check constraint (migration 001 + 010).
// content_text carries the caption (or empty) so the conversation
// list preview shows something meaningful when the user glances at it.
const preview = args.caption?.trim() || `[${args.kind}]`
const { error: msgErr } = await db.from('messages').insert({
conversation_id: args.conversationId,
sender_type: 'bot',
content_type: args.kind,
content_text: args.caption ?? null,
message_id: waMessageId,
status: 'sent',
})
if (msgErr) {
throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`)
}
await db
.from('conversations')
.update({
last_message_text: preview,
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', args.conversationId)
return { whatsapp_message_id: waMessageId }
}
interface SendInteractiveButtonsEngineArgs {
accountId: string
userId: string
conversationId: string
contactId: string
bodyText: string
buttons: InteractiveButton[]
headerText?: string
footerText?: string
}
interface SendInteractiveListEngineArgs {
accountId: string
userId: string
conversationId: string
contactId: string
bodyText: string
buttonLabel: string
sections: InteractiveListSection[]
headerText?: string
footerText?: string
}
/**
* Send an interactive-button WhatsApp message from the Flows engine.
*
* Persists the outgoing message to `messages` with
* `content_type='interactive'` and `sender_type='bot'` so the inbox
* surfaces it with the "Button reply" affordance and the conversation
* thread reflects the bot's prompt.
*
* Returns the Meta message id so the caller (engine) can stash it on
* the `flow_runs.last_prompt_message_id` field for later reference.
*/
export async function engineSendInteractiveButtons(
args: SendInteractiveButtonsEngineArgs,
): Promise<{ whatsapp_message_id: string }> {
return sendInteractiveViaMeta({ ...args, kind: 'buttons' })
}
/**
* Send an interactive-list WhatsApp message from the Flows engine.
* Used when the flow needs more than 3 options (Meta's button cap).
*/
export async function engineSendInteractiveList(
args: SendInteractiveListEngineArgs,
): Promise<{ whatsapp_message_id: string }> {
return sendInteractiveViaMeta({ ...args, kind: 'list' })
}
type SendInput =
| (SendInteractiveButtonsEngineArgs & { kind: 'buttons' })
| (SendInteractiveListEngineArgs & { kind: 'list' })
async function sendInteractiveViaMeta(
input: SendInput,
): Promise<{ whatsapp_message_id: string }> {
const db = supabaseAdmin()
// Scope the contact + whatsapp_config lookups by account_id —
// same defense-in-depth rationale as automations/meta-send.ts.
// Migration 017 moved both tables to account-scoped tenancy.
const { data: contact, error: contactErr } = await db
.from('contacts')
.select('id, phone')
.eq('id', input.contactId)
.eq('account_id', input.accountId)
.maybeSingle()
if (contactErr || !contact?.phone) {
throw new Error('contact not found for this account')
}
const sanitized = sanitizePhoneForMeta(contact.phone)
if (!isValidE164(sanitized)) {
throw new Error(`contact phone invalid: ${contact.phone}`)
}
const { data: config, error: configErr } = await db
.from('whatsapp_config')
.select('*')
.eq('account_id', input.accountId)
.single()
if (configErr || !config) {
throw new Error('WhatsApp not configured for this account')
}
const accessToken = decrypt(config.access_token)
const attempt = async (phone: string): Promise<string> => {
if (input.kind === 'buttons') {
const r = await sendInteractiveButtons({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
bodyText: input.bodyText,
buttons: input.buttons,
headerText: input.headerText,
footerText: input.footerText,
})
return r.messageId
}
const r = await sendInteractiveList({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
bodyText: input.bodyText,
buttonLabel: input.buttonLabel,
sections: input.sections,
headerText: input.headerText,
footerText: input.footerText,
})
return r.messageId
}
// Same phone-variant retry as automations/meta-send.ts. Numbers
// registered with/without a trunk 0 + Meta's sandbox quirks all
// need this to reliably land a message.
const variants = phoneVariants(sanitized)
let workingPhone = sanitized
let waMessageId = ''
let lastError: unknown = null
for (const v of variants) {
try {
waMessageId = await attempt(v)
workingPhone = v
lastError = null
break
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isRecipientNotAllowedError(msg)) throw err
lastError = err
}
}
if (lastError) throw lastError
if (workingPhone !== sanitized) {
await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id)
}
// Persist the bot's prompt to the messages table so it appears in
// the inbox. content_type='interactive' is supported as of
// migration 010; sender_type='bot' distinguishes flow sends from
// manual agent sends (the conversation list preview will pick up
// last_message_text as a sensible summary).
//
// We do NOT set interactive_reply_id here — that column is reserved
// for the customer's tap on this message, populated by the webhook
// when their reply arrives.
const { error: msgErr } = await db.from('messages').insert({
conversation_id: input.conversationId,
sender_type: 'bot',
content_type: 'interactive',
content_text: input.bodyText,
message_id: waMessageId,
status: 'sent',
})
if (msgErr) {
throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`)
}
await db
.from('conversations')
.update({
last_message_text: input.bodyText,
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', input.conversationId)
return { whatsapp_message_id: waMessageId }
}

View File

@@ -0,0 +1,304 @@
/**
* Starter flow templates.
*
* Three pre-canned flows users can clone with one click instead of
* building from scratch. Each template is a plain JS object describing
* the same shape `/api/flows` PUT accepts — name, trigger config,
* entry_node_id, fallback_policy, nodes[] — keyed by a stable
* `slug`.
*
* The clone path (`/api/flows` POST with `template_slug`) creates a
* NEW flow_row + flow_nodes rows for the user. `node_key`s are kept
* verbatim (they're stable strings, not UUIDs, so cloning never
* needs to rewrite edge references).
*
* Choosing a single static module over a DB-backed gallery for v1
* because: (a) the set is small and changes with code releases, not
* data; (b) keeps templates portable across self-hosted instances
* without migrations; (c) editing in source is the lowest-friction
* way to add the next template.
*/
import type {
CollectInputNodeConfig,
ConditionNodeConfig,
HandoffNodeConfig,
KeywordTriggerConfig,
SendButtonsNodeConfig,
SendListNodeConfig,
SendMessageNodeConfig,
StartNodeConfig,
} from "./types";
export type FlowTemplateNodeType =
| "start"
| "send_message"
| "send_buttons"
| "send_list"
| "collect_input"
| "condition"
| "set_tag"
| "handoff"
| "end";
export interface FlowTemplateNode {
node_key: string;
node_type: FlowTemplateNodeType;
config:
| StartNodeConfig
| SendMessageNodeConfig
| SendButtonsNodeConfig
| SendListNodeConfig
| CollectInputNodeConfig
| ConditionNodeConfig
| HandoffNodeConfig
| Record<string, unknown>;
}
export interface FlowTemplate {
slug: string;
name: string;
description: string;
/** Used by the gallery to surface a relevant icon. lucide-react name. */
icon: "MessageSquare" | "HelpCircle" | "UserPlus";
trigger_type: "keyword" | "first_inbound_message" | "manual";
trigger_config: KeywordTriggerConfig | Record<string, unknown>;
entry_node_id: string;
nodes: FlowTemplateNode[];
}
// ============================================================
// 1. Welcome menu — the example from the owner's brief
// ============================================================
const WELCOME_MENU: FlowTemplate = {
slug: "welcome_menu",
name: "Welcome menu",
description:
"Greet customers who type a keyword and route them to the right agent based on whether they're new or existing.",
icon: "MessageSquare",
trigger_type: "keyword",
trigger_config: { keywords: ["support", "help", "hi"], match_type: "contains" },
entry_node_id: "start",
nodes: [
{
node_key: "start",
node_type: "start",
config: { next_node_key: "welcome" },
},
{
node_key: "welcome",
node_type: "send_buttons",
config: {
text: "Hi! 👋 Welcome to support. Are you an existing customer or new here?",
footer_text: "Tap a button below to continue.",
buttons: [
{
reply_id: "existing",
title: "Existing customer",
next_node_key: "existing_handoff",
},
{
reply_id: "new",
title: "New customer",
next_node_key: "new_handoff",
},
],
} as SendButtonsNodeConfig,
},
{
node_key: "existing_handoff",
node_type: "handoff",
config: {
note: "Existing customer needs assistance — please check account history before replying.",
} as HandoffNodeConfig,
},
{
node_key: "new_handoff",
node_type: "handoff",
config: {
note: "New customer — share pricing + onboarding link.",
} as HandoffNodeConfig,
},
],
};
// ============================================================
// 2. FAQ bot — list-message answers, fully automated
// ============================================================
const FAQ_BOT: FlowTemplate = {
slug: "faq_bot",
name: "FAQ bot",
description:
"Answer common questions automatically. Customer picks a topic from a list; the bot replies with the answer and ends.",
icon: "HelpCircle",
trigger_type: "keyword",
trigger_config: {
keywords: ["faq", "question", "info"],
match_type: "contains",
},
entry_node_id: "start",
nodes: [
{
node_key: "start",
node_type: "start",
config: { next_node_key: "topics" },
},
{
node_key: "topics",
node_type: "send_list",
config: {
text: "What can I help you with?",
button_label: "View topics",
sections: [
{
title: "Common questions",
rows: [
{
reply_id: "hours",
title: "Opening hours",
next_node_key: "answer_hours",
},
{
reply_id: "pricing",
title: "Pricing",
next_node_key: "answer_pricing",
},
{
reply_id: "refunds",
title: "Refund policy",
next_node_key: "answer_refunds",
},
],
},
{
title: "Other",
rows: [
{
reply_id: "human",
title: "Talk to a human",
next_node_key: "human_handoff",
},
],
},
],
} as SendListNodeConfig,
},
{
node_key: "answer_hours",
node_type: "send_message",
config: {
text: "We're open MonFri, 9am6pm local time. Weekend support is limited to urgent issues.",
next_node_key: "end",
} as SendMessageNodeConfig,
},
{
node_key: "answer_pricing",
node_type: "send_message",
config: {
text: "Our pricing starts at $9/mo. Visit https://example.com/pricing for the full breakdown.",
next_node_key: "end",
} as SendMessageNodeConfig,
},
{
node_key: "answer_refunds",
node_type: "send_message",
config: {
text: "Refunds are honored within 30 days of purchase. Reply with your order number and we'll process it.",
next_node_key: "end",
} as SendMessageNodeConfig,
},
{
node_key: "human_handoff",
node_type: "handoff",
config: {
note: "Customer asked to talk to a human from the FAQ bot.",
} as HandoffNodeConfig,
},
{
node_key: "end",
node_type: "end",
config: {},
},
],
};
// ============================================================
// 3. Lead capture — collect_input chain, ends in a handoff
// ============================================================
const LEAD_CAPTURE: FlowTemplate = {
slug: "lead_capture",
name: "Lead capture",
description:
"Greet first-time inbounds, capture name + email + company, then hand off to sales with the answers in the note.",
icon: "UserPlus",
trigger_type: "first_inbound_message",
trigger_config: {},
entry_node_id: "start",
nodes: [
{
node_key: "start",
node_type: "start",
config: { next_node_key: "intro" },
},
{
node_key: "intro",
node_type: "send_message",
config: {
text: "Welcome! 👋 I'll ask a few quick questions so we can get you to the right person.",
next_node_key: "ask_name",
} as SendMessageNodeConfig,
},
{
node_key: "ask_name",
node_type: "collect_input",
config: {
prompt_text: "What's your name?",
var_key: "name",
next_node_key: "ask_email",
} as CollectInputNodeConfig,
},
{
node_key: "ask_email",
node_type: "collect_input",
config: {
prompt_text: "Thanks {{vars.name}}! What's your work email?",
var_key: "email",
next_node_key: "ask_company",
} as CollectInputNodeConfig,
},
{
node_key: "ask_company",
node_type: "collect_input",
config: {
prompt_text: "Almost done — what's your company name?",
var_key: "company",
next_node_key: "handoff",
} as CollectInputNodeConfig,
},
{
node_key: "handoff",
node_type: "handoff",
config: {
note: "New lead — name={{vars.name}}, email={{vars.email}}, company={{vars.company}}.",
} as HandoffNodeConfig,
},
],
};
// ============================================================
// Registry
// ============================================================
const TEMPLATES: Record<string, FlowTemplate> = {
welcome_menu: WELCOME_MENU,
faq_bot: FAQ_BOT,
lead_capture: LEAD_CAPTURE,
};
export function getFlowTemplate(slug: string): FlowTemplate | null {
return TEMPLATES[slug] ?? null;
}
export function listFlowTemplates(): FlowTemplate[] {
return Object.values(TEMPLATES);
}

View File

@@ -0,0 +1,374 @@
/**
* Type definitions for the Flows runtime.
*
* These mirror the Supabase schema added in migration 010 (`flows`,
* `flow_nodes`, `flow_runs`, `flow_run_events`) plus the discriminated
* unions the engine uses to typecheck node configs.
*
* Schema invariants enforced here that the DB CHECK constraints don't:
* - Each node_type maps to one config shape — adding a new node_type
* requires adding the matching config interface AND extending
* `FlowNodeConfig` so the engine's exhaustiveness checks light up.
* - Edges live INSIDE the config (each button row / list row carries
* `next_node_key`). The DB schema doesn't model this — the
* validator (PR #3) catches missing or orphan edges at save time.
*
* `next_node_key` is the stable string id stored in `flow_nodes.node_key`,
* not a UUID, so flows can be cloned / templated without rewriting
* references in JSONB.
*/
// ============================================================
// Node configs (discriminated union by node_type)
// ============================================================
export interface StartNodeConfig {
/** Stable node_key of the first real node to advance to. */
next_node_key: string;
}
export interface SendMessageNodeConfig {
/** Plain text sent to the customer; can interpolate {{vars.X}}. */
text: string;
/** Auto-advance target after the message lands at Meta. */
next_node_key: string;
}
export interface SendButtonsNodeConfig {
text: string;
/** Optional header / footer lines around the buttons. */
header_text?: string;
footer_text?: string;
/** 1-3 buttons; Meta cap enforced in meta-api validation. */
buttons: Array<{
/** Stable id sent back by Meta when this button is tapped. */
reply_id: string;
/** Visible label (≤ 20 chars per Meta). */
title: string;
/** node_key the runner advances to when this button is tapped. */
next_node_key: string;
}>;
}
export interface SendListNodeConfig {
text: string;
/** Label of the tap-to-expand button on the message bubble. */
button_label: string;
header_text?: string;
footer_text?: string;
/** 1-10 rows TOTAL across sections; cap enforced in meta-api. */
sections: Array<{
title?: string;
rows: Array<{
reply_id: string;
title: string;
description?: string;
next_node_key: string;
}>;
}>;
}
/**
* Sends a single image / video / document via WhatsApp, then
* auto-advances. The media file is uploaded to the `flow-media`
* Supabase Storage bucket by the builder; `media_url` is the public
* URL Meta fetches at send time.
*
* Why one node with a `media_type` discriminator (rather than three
* separate node types): Meta's send-side payload differs only in the
* top-level key (`image` / `video` / `document`) and the
* filename-on-document quirk. Modeling three node types would triple
* the builder forms, engine cases, and add-menu entries for no
* meaningful behavioural difference.
*/
export interface SendMediaNodeConfig {
media_type: "image" | "video" | "document";
/** Public URL Meta will fetch. Uploaded via the builder's file picker. */
media_url: string;
/** Optional caption shown under the media (Meta caps at 1024 chars). */
caption?: string;
/**
* Filename shown in the recipient's chat. Documents only — Meta
* ignores it for image/video. Defaults to the file's original name
* at upload time; the user can edit it.
*/
filename?: string;
/** Auto-advance target after the send lands at Meta. */
next_node_key: string;
}
export interface HandoffNodeConfig {
/** Optional internal note written to flow_run_events.payload.note. */
note?: string;
/**
* Optional agent user_id to assign on the conversation when this
* node fires. Leave unset to flip the status without assignment.
*/
assign_to?: string;
}
/**
* Captures the customer's next free-text reply into
* `flow_runs.vars[var_key]`, then advances.
*
* v1.5 ships without runtime validation (`validation` is accepted on
* the config for forward compat but ignored by the runner); the
* builder still surfaces the field so users can author flows that
* v2 will start enforcing.
*/
export interface CollectInputNodeConfig {
/** Prompt text sent to the customer before they reply. */
prompt_text: string;
/**
* Key under which to store the captured text in
* `flow_runs.vars`. Stable identifier — used by downstream
* `condition` nodes and `handoff` notes via interpolation.
*/
var_key: string;
/**
* Reserved for v2. Accepted on the config but ignored by the v1.5
* runner — captures any non-empty text.
*/
validation?: "any" | "email" | "phone" | "regex";
/** Used only when `validation === 'regex'`. */
regex?: string;
/** Node to advance to after capture. */
next_node_key: string;
}
export type ConditionOperator =
| "equals"
| "contains"
| "present"
| "absent";
export type ConditionSubject = "var" | "tag" | "contact_field";
/**
* Routes the run based on a predicate over the contact's tags,
* profile fields, or stored vars. Always auto-advances — no Meta
* call, no customer-side input.
*/
export interface ConditionNodeConfig {
subject: ConditionSubject;
/**
* For `var`: the key in flow_runs.vars.
* For `tag`: the tag UUID (matched against contact_tags).
* For `contact_field`: one of 'name' | 'email' | 'phone' | 'company'.
*/
subject_key: string;
operator: ConditionOperator;
/** Compared against `subject` for `equals`/`contains`. Ignored for `present`/`absent`. */
value?: string;
/** Node to advance to when the predicate evaluates true. */
true_next: string;
/** Node to advance to when it evaluates false. */
false_next: string;
}
export interface SetTagNodeConfig {
mode: "add" | "remove";
/** Tag UUID. The builder picks from the user's existing tags. */
tag_id: string;
next_node_key: string;
}
// Terminal nodes carry no config — they just stop the run.
export type EndNodeConfig = Record<string, never>;
/**
* Total union — every concrete node_type the v1 engine understands.
* Add new node types here and the engine's switch will flag missing
* cases via TypeScript's exhaustiveness check.
*
* v1.5+ additions (collect_input, condition, set_tag, http_fetch) will
* extend this union — out-of-scope for the v1 engine PR.
*/
export type FlowNodeConfig =
| { node_type: "start"; config: StartNodeConfig }
| { node_type: "send_message"; config: SendMessageNodeConfig }
| { node_type: "send_buttons"; config: SendButtonsNodeConfig }
| { node_type: "send_list"; config: SendListNodeConfig }
| { node_type: "send_media"; config: SendMediaNodeConfig }
| { node_type: "collect_input"; config: CollectInputNodeConfig }
| { node_type: "condition"; config: ConditionNodeConfig }
| { node_type: "set_tag"; config: SetTagNodeConfig }
| { node_type: "handoff"; config: HandoffNodeConfig }
| { node_type: "end"; config: EndNodeConfig };
export type FlowNodeType = FlowNodeConfig["node_type"];
// ============================================================
// Triggers (matches `flows.trigger_type` + `trigger_config`)
// ============================================================
export interface KeywordTriggerConfig {
/** One or more keywords. Match is case-insensitive by default. */
keywords: string[];
match_type?: "exact" | "contains";
case_sensitive?: boolean;
}
// No knobs in v1 — the trigger has a single semantic. Kept as a type
// alias (not an empty interface) for forward compat without tripping
// the no-empty-object-type lint rule.
export type FirstInboundTriggerConfig = Record<string, never>;
export type FlowTriggerConfig =
| { trigger_type: "keyword"; config: KeywordTriggerConfig }
| { trigger_type: "first_inbound_message"; config: FirstInboundTriggerConfig }
| { trigger_type: "manual"; config: Record<string, never> };
// ============================================================
// DB-row shapes (read by the engine via supabaseAdmin)
// ============================================================
export interface FlowRow {
id: string;
/** Account tenancy (NOT NULL post-017). The engine looks up active
* flows for inbound dispatch using this field. */
account_id: string;
/** Author. Used as a default sender-of-record on engine sends and
* preserved on flow_runs for log/audit display. */
user_id: string;
name: string;
description: string | null;
status: "draft" | "active" | "archived";
trigger_type: "keyword" | "first_inbound_message" | "manual";
trigger_config: KeywordTriggerConfig | FirstInboundTriggerConfig | Record<string, unknown>;
entry_node_id: string | null;
fallback_policy: FlowFallbackPolicy;
execution_count: number;
last_executed_at: string | null;
created_at: string;
updated_at: string;
}
export interface FlowNodeRow {
id: string;
flow_id: string;
node_key: string;
node_type: FlowNodeType;
config: Record<string, unknown>;
position_x: number;
position_y: number;
created_at: string;
}
export interface FlowRunRow {
id: string;
flow_id: string;
/** Tenancy. Matches flows.account_id; NOT NULL post-017. */
account_id: string;
/** Audit. Matches the parent flow.user_id. */
user_id: string;
contact_id: string | null;
conversation_id: string | null;
status:
| "active"
| "completed"
| "handed_off"
| "timed_out"
| "paused_by_agent"
| "failed";
current_node_key: string | null;
last_prompt_message_id: string | null;
vars: Record<string, unknown>;
reprompt_count: number;
started_at: string;
last_advanced_at: string;
ended_at: string | null;
end_reason: string | null;
}
// ============================================================
// Fallback policy (matches flows.fallback_policy JSONB)
// ============================================================
export interface FlowFallbackPolicy {
/** What to do when the customer reply doesn't match any option. */
on_unknown_reply: "reprompt" | "handoff" | "ignore";
/** Max reprompts before applying `on_exhaust`. */
max_reprompts: number;
/** Stale-run sweep cutoff. */
on_timeout_hours: number;
/** What to do once max_reprompts has been hit. */
on_exhaust: "handoff" | "end";
}
export const DEFAULT_FALLBACK_POLICY: FlowFallbackPolicy = {
on_unknown_reply: "reprompt",
max_reprompts: 2,
on_timeout_hours: 24,
on_exhaust: "handoff",
};
// ============================================================
// Engine input — what `dispatchInboundToFlows` accepts
// ============================================================
/**
* Normalised view of an inbound message that the runner needs. The
* webhook lifts this out of the raw Meta payload before invoking the
* runner; keeps the runner free of any WhatsApp-API specifics.
*/
export type ParsedInbound =
| {
kind: "text";
/** The user's typed message body. */
text: string;
/** Meta's `messages[0].id` — used for idempotency. */
meta_message_id: string;
}
| {
kind: "interactive_reply";
/** The reply_id of the tapped button or list row. */
reply_id: string;
/** The visible title of the tapped option (for logging). */
reply_title: string;
meta_message_id: string;
};
export interface DispatchInboundInput {
/** Account tenancy key. Drives the lookup of active flows and the
* idempotency check for previously-seen inbound message_ids. */
accountId: string;
/** Sender-of-record for the bot's outbound prompts on engine
* sends. Set by the webhook to the WhatsApp config owner. */
userId: string;
contactId: string;
conversationId: string;
message: ParsedInbound;
}
export interface DispatchInboundResult {
/**
* True iff the runner handled the message — it either advanced an
* existing run or started a new one matching a flow trigger.
* Webhook uses this to decide whether to also fire automations.
*/
consumed: boolean;
/** For diagnostics / logging — null when not consumed. */
flow_run_id?: string;
/** For diagnostics. */
outcome?:
| "advanced"
| "started"
| "completed"
| "handed_off"
| "fallback_fired"
| "duplicate_inbound_ignored"
| "no_match";
}
// ============================================================
// Helpers — exhaustiveness assertions
// ============================================================
/**
* Throws a typed compile-time error if the switch over a discriminated
* union forgets a case. Used in the engine's node-type switch.
*/
export function assertNever(x: never): never {
throw new Error(`Unhandled node type: ${JSON.stringify(x)}`);
}

View File

@@ -0,0 +1,549 @@
import { describe, it, expect } from "vitest";
import { validateFlowForActivation, reachableFromEntry } from "./validate";
const validFlow = {
name: "Welcome",
trigger_type: "keyword" as const,
trigger_config: { keywords: ["support"] },
entry_node_id: "start",
};
const validNodes = [
{ node_key: "start", node_type: "start", config: { next_node_key: "menu" } },
{
node_key: "menu",
node_type: "send_buttons",
config: {
text: "How can we help?",
buttons: [
{ reply_id: "a", title: "A", next_node_key: "ho" },
{ reply_id: "b", title: "B", next_node_key: "ho" },
],
},
},
{ node_key: "ho", node_type: "handoff", config: {} },
];
describe("validateFlowForActivation — happy path", () => {
it("produces no issues on a well-formed flow", () => {
expect(validateFlowForActivation(validFlow, validNodes)).toEqual([]);
});
});
describe("validateFlowForActivation — flow-level", () => {
it("flags empty name", () => {
expect(
validateFlowForActivation({ ...validFlow, name: "" }, validNodes),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ scope: "flow", field: "name" }),
]),
);
});
it("flags whitespace-only name", () => {
const issues = validateFlowForActivation(
{ ...validFlow, name: " " },
validNodes,
);
expect(issues.some((i) => i.field === "name")).toBe(true);
});
it("flags missing entry_node_id", () => {
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: null },
validNodes,
);
expect(issues.some((i) => i.field === "entry_node_id")).toBe(true);
});
it("flags entry_node_id that doesn't exist in nodes", () => {
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "ghost" },
validNodes,
);
expect(
issues.some(
(i) =>
i.field === "entry_node_id" &&
i.message.includes('"ghost"'),
),
).toBe(true);
});
it("flags empty node list", () => {
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: null },
[],
);
expect(
issues.some((i) => i.message.includes("at least one node")),
).toBe(true);
});
it("flags duplicate node_key", () => {
const dupes = [
{ node_key: "a", node_type: "start", config: { next_node_key: "b" } },
{ node_key: "a", node_type: "end", config: {} },
{ node_key: "b", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "a" },
dupes,
);
expect(
issues.some(
(i) =>
i.message.includes("Duplicate node_key") &&
i.node_key === "a",
),
).toBe(true);
});
});
describe("validateFlowForActivation — trigger", () => {
it("flags keyword trigger with no keywords", () => {
const issues = validateFlowForActivation(
{
...validFlow,
trigger_config: { keywords: [] },
},
validNodes,
);
expect(
issues.some(
(i) =>
i.scope === "trigger" &&
i.message.includes("at least one keyword"),
),
).toBe(true);
});
it("flags keyword trigger missing keywords field entirely", () => {
const issues = validateFlowForActivation(
{ ...validFlow, trigger_config: {} },
validNodes,
);
expect(issues.some((i) => i.scope === "trigger")).toBe(true);
});
it("warns when keywords contain blanks", () => {
const issues = validateFlowForActivation(
{
...validFlow,
trigger_config: { keywords: ["support", "", " "] },
},
validNodes,
);
expect(
issues.some(
(i) =>
i.scope === "trigger" &&
i.severity === "warning" &&
i.message.includes("blank"),
),
).toBe(true);
});
it("first_inbound_message trigger needs no config", () => {
const issues = validateFlowForActivation(
{
...validFlow,
trigger_type: "first_inbound_message",
trigger_config: {},
},
validNodes,
);
expect(issues.filter((i) => i.scope === "trigger")).toEqual([]);
});
});
describe("validateFlowForActivation — nodes", () => {
it("flags send_buttons without text", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
buttons: [{ reply_id: "x", title: "X", next_node_key: "h" }],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some((i) => i.node_key === "b" && i.field === "text"),
).toBe(true);
});
it("flags send_buttons with zero buttons", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: { text: "Hi", buttons: [] },
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.node_key === "b" &&
i.field === "buttons" &&
i.message.includes("at least one"),
),
).toBe(true);
});
it("flags send_buttons with more than 3 buttons (Meta limit)", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
text: "Hi",
buttons: [
{ reply_id: "1", title: "1", next_node_key: "h" },
{ reply_id: "2", title: "2", next_node_key: "h" },
{ reply_id: "3", title: "3", next_node_key: "h" },
{ reply_id: "4", title: "4", next_node_key: "h" },
],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.node_key === "b" &&
i.field === "buttons" &&
i.message.includes("at most 3"),
),
).toBe(true);
});
it("flags button title over 20 chars", () => {
const longTitle = "x".repeat(21);
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
text: "Hi",
buttons: [
{ reply_id: "1", title: longTitle, next_node_key: "h" },
],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.node_key === "b" &&
i.field === "buttons.0.title" &&
i.message.includes("over 20"),
),
).toBe(true);
});
it("flags button pointing at non-existent next node", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
text: "Hi",
buttons: [
{ reply_id: "1", title: "Go", next_node_key: "ghost" },
],
},
},
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.field === "buttons.0.next_node_key" &&
i.message.includes("ghost"),
),
).toBe(true);
});
it("flags duplicate button reply_ids", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
text: "Hi",
buttons: [
{ reply_id: "x", title: "X1", next_node_key: "h" },
{ reply_id: "x", title: "X2", next_node_key: "h" },
],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some((i) => i.message.includes("Duplicate button reply id")),
).toBe(true);
});
it("flags send_list with more than 10 rows total", () => {
const eleven = Array.from({ length: 11 }, (_, i) => ({
reply_id: `r${i}`,
title: `Row ${i}`,
next_node_key: "h",
}));
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "l" } },
{
node_key: "l",
node_type: "send_list",
config: {
text: "Pick",
button_label: "Pick",
sections: [{ rows: eleven }],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.node_key === "l" &&
i.field === "sections" &&
i.message.includes("at most 10"),
),
).toBe(true);
});
it("flags list row title over 24 chars", () => {
const longTitle = "x".repeat(25);
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "l" } },
{
node_key: "l",
node_type: "send_list",
config: {
text: "Pick",
button_label: "Pick",
sections: [
{
rows: [
{
reply_id: "x",
title: longTitle,
next_node_key: "h",
},
],
},
],
},
},
{ node_key: "h", node_type: "handoff", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some((i) => i.message.includes("exceeds 24 chars")),
).toBe(true);
});
it("warns about unreachable nodes", () => {
const nodes = [
{ node_key: "s", node_type: "start", config: { next_node_key: "h" } },
{ node_key: "h", node_type: "handoff", config: {} },
// Orphaned — nothing points at it.
{ node_key: "orphan", node_type: "end", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some(
(i) =>
i.node_key === "orphan" &&
i.severity === "warning" &&
i.message.includes("unreachable"),
),
).toBe(true);
});
it("doesn't crash on unknown node_type — flags it", () => {
const nodes = [
{ node_key: "s", node_type: "wibble", config: {} },
];
const issues = validateFlowForActivation(
{ ...validFlow, entry_node_id: "s" },
nodes,
);
expect(
issues.some((i) => i.message.includes("Unknown node type")),
).toBe(true);
});
});
describe("validateFlowForActivation — send_media", () => {
const baseFlow = { ...validFlow, entry_node_id: "s" };
const nodesWith = (mediaConfig: Record<string, unknown>) => [
{ node_key: "s", node_type: "start", config: { next_node_key: "m" } },
{ node_key: "m", node_type: "send_media", config: mediaConfig },
{ node_key: "h", node_type: "handoff", config: {} },
];
it("passes on a fully-populated send_media node", () => {
const issues = validateFlowForActivation(
baseFlow,
nodesWith({
media_type: "document",
media_url: "https://cdn.example/invoice.pdf",
caption: "Your invoice",
filename: "invoice.pdf",
next_node_key: "h",
}),
);
expect(issues).toEqual([]);
});
it("flags missing media_url", () => {
const issues = validateFlowForActivation(
baseFlow,
nodesWith({
media_type: "image",
media_url: "",
next_node_key: "h",
}),
);
expect(
issues.some((i) => i.node_key === "m" && i.field === "media_url"),
).toBe(true);
});
it("flags missing media_type", () => {
const issues = validateFlowForActivation(
baseFlow,
nodesWith({
media_url: "https://cdn.example/x.png",
next_node_key: "h",
}),
);
expect(
issues.some((i) => i.node_key === "m" && i.field === "media_type"),
).toBe(true);
});
it("flags next_node_key pointing at a non-existent node", () => {
const issues = validateFlowForActivation(
baseFlow,
nodesWith({
media_type: "image",
media_url: "https://cdn.example/x.png",
next_node_key: "ghost",
}),
);
expect(
issues.some(
(i) =>
i.node_key === "m" &&
i.field === "next_node_key" &&
i.message.includes("ghost"),
),
).toBe(true);
});
it("flags caption exceeding 1024 chars", () => {
const issues = validateFlowForActivation(
baseFlow,
nodesWith({
media_type: "image",
media_url: "https://cdn.example/x.png",
caption: "x".repeat(1025),
next_node_key: "h",
}),
);
expect(
issues.some((i) => i.node_key === "m" && i.field === "caption"),
).toBe(true);
});
it("contributes its next_node_key to reachability", () => {
const set = reachableFromEntry(
"s",
nodesWith({
media_type: "image",
media_url: "https://cdn.example/x.png",
next_node_key: "h",
}),
);
expect(set).toEqual(new Set(["s", "m", "h"]));
});
});
describe("reachableFromEntry", () => {
it("walks the graph from the entry", () => {
const set = reachableFromEntry("start", validNodes);
expect(set.has("start")).toBe(true);
expect(set.has("menu")).toBe(true);
expect(set.has("ho")).toBe(true);
});
it("returns the entry alone when no edges lead out", () => {
const set = reachableFromEntry("only", [
{ node_key: "only", node_type: "handoff", config: {} },
]);
expect(set).toEqual(new Set(["only"]));
});
it("survives a cycle (visited guard)", () => {
const nodes = [
{ node_key: "a", node_type: "start", config: { next_node_key: "b" } },
{
node_key: "b",
node_type: "send_buttons",
config: {
text: "Loop",
buttons: [{ reply_id: "x", title: "Back", next_node_key: "a" }],
},
},
];
const set = reachableFromEntry("a", nodes);
expect(set).toEqual(new Set(["a", "b"]));
});
});

View File

@@ -0,0 +1,793 @@
/**
* Save-time validation for flows.
*
* Run before activation (not on every draft save) — drafts are
* intentionally allowed to be incomplete so users can save progress
* mid-build. The builder calls these from BOTH client (so the user
* sees issues live) and server (so a broken POST/PUT can't slip in
* via direct API call).
*
* Three rule categories:
* 1. Trigger sanity — keyword flows need keywords, etc.
* 2. Graph integrity — entry node exists, all next_node_key
* references resolve, no unreachable nodes, non-terminal nodes
* have an outgoing edge.
* 3. Meta API limits — button title ≤20 chars, ≤3 buttons per
* send_buttons, ≤10 list rows total, ≤24 chars per list row
* title. Mirrors the runtime checks inside
* `src/lib/whatsapp/meta-api.ts` so save-time and send-time
* can never disagree.
*
* Issues carry enough field info that the builder can highlight the
* exact input that triggered them. Node-scoped issues include
* `node_key`; trigger-scoped use `scope: 'trigger'`.
*/
import { INTERACTIVE_LIMITS } from "@/lib/whatsapp/meta-api";
export interface ValidationIssue {
severity: "error" | "warning";
scope: "flow" | "trigger" | "node";
/** Stable node_key the issue is attached to, when scope === 'node'. */
node_key?: string;
/** Dotted path to the bad field, e.g. 'buttons.0.title'. */
field?: string;
message: string;
}
interface FlowInput {
name: string;
trigger_type: "keyword" | "first_inbound_message" | "manual";
trigger_config: Record<string, unknown>;
entry_node_id: string | null;
}
interface NodeInput {
node_key: string;
node_type: string;
config: Record<string, unknown>;
}
export function validateFlowForActivation(
flow: FlowInput,
nodes: NodeInput[],
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
// ---- name ----
if (!flow.name || !flow.name.trim()) {
issues.push({
severity: "error",
scope: "flow",
field: "name",
message: "Flow name is required.",
});
}
// ---- trigger ----
issues.push(...validateTrigger(flow.trigger_type, flow.trigger_config));
// ---- graph integrity ----
if (!flow.entry_node_id) {
issues.push({
severity: "error",
scope: "flow",
field: "entry_node_id",
message: "Pick an entry node before activating.",
});
}
const keys = new Set(nodes.map((n) => n.node_key));
if (nodes.length === 0) {
issues.push({
severity: "error",
scope: "flow",
message: "A flow needs at least one node before activation.",
});
}
if (flow.entry_node_id && !keys.has(flow.entry_node_id)) {
issues.push({
severity: "error",
scope: "flow",
field: "entry_node_id",
message: `Entry node "${flow.entry_node_id}" doesn't exist.`,
});
}
// Duplicate node_key (the DB UNIQUE constraint catches this on save
// too, but surfacing it client-side gives a friendlier error path).
const seen = new Set<string>();
for (const n of nodes) {
if (seen.has(n.node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: n.node_key,
message: `Duplicate node_key "${n.node_key}".`,
});
}
seen.add(n.node_key);
}
// Per-node rules (Meta limits + dead-end + edge resolution).
for (const n of nodes) {
issues.push(...validateNode(n, keys));
}
// Reachability — every non-orphan node must be reachable from the
// entry. Done after per-node validation so we don't double-report
// when a node has bad config AND is unreachable.
if (flow.entry_node_id && keys.has(flow.entry_node_id)) {
const reached = reachableFromEntry(flow.entry_node_id, nodes);
for (const n of nodes) {
if (!reached.has(n.node_key)) {
issues.push({
severity: "warning",
scope: "node",
node_key: n.node_key,
message: `Node "${n.node_key}" is unreachable from the entry node.`,
});
}
}
}
return issues;
}
// ============================================================
// Trigger
// ============================================================
function validateTrigger(
trigger_type: FlowInput["trigger_type"],
trigger_config: Record<string, unknown>,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
if (trigger_type === "keyword") {
const keywords = Array.isArray(trigger_config.keywords)
? (trigger_config.keywords as unknown[])
: null;
if (!keywords || keywords.length === 0) {
issues.push({
severity: "error",
scope: "trigger",
field: "trigger_config.keywords",
message: "Keyword triggers need at least one keyword.",
});
} else {
// Empty / whitespace-only keywords are silent no-ops at match
// time — call them out so the user doesn't think they configured
// a keyword that never fires.
const blanks = keywords.filter(
(k) => typeof k !== "string" || !k.trim(),
).length;
if (blanks > 0) {
issues.push({
severity: "warning",
scope: "trigger",
field: "trigger_config.keywords",
message: `${blanks} keyword${blanks === 1 ? " is" : "s are"} blank — they won't match anything.`,
});
}
}
}
// first_inbound_message / manual have no config; nothing to validate.
return issues;
}
// ============================================================
// Per-node
// ============================================================
function validateNode(
node: NodeInput,
knownKeys: Set<string>,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
switch (node.node_type) {
case "start": {
const cfg = node.config as { next_node_key?: string };
if (!cfg.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: "Start node must point to a next node.",
});
} else if (!knownKeys.has(cfg.next_node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: `Start points to non-existent node "${cfg.next_node_key}".`,
});
}
break;
}
case "send_message": {
const cfg = node.config as { text?: string; next_node_key?: string };
if (!cfg.text?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "text",
message: "Send-message node needs a text body.",
});
}
if (!cfg.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: "Send-message node must point to a next node.",
});
} else if (!knownKeys.has(cfg.next_node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: `Send-message points to non-existent node "${cfg.next_node_key}".`,
});
}
break;
}
case "send_media": {
const cfg = node.config as {
media_type?: "image" | "video" | "document";
media_url?: string;
caption?: string;
next_node_key?: string;
};
if (
!cfg.media_type ||
!["image", "video", "document"].includes(cfg.media_type)
) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "media_type",
message: "Send-media node needs a media type (image, video, or document).",
});
}
if (!cfg.media_url?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "media_url",
message: "Send-media node needs a file (upload one before activating).",
});
}
// Caption cap mirrors Meta's interactive body cap; documented as a
// hard limit in the WhatsApp Cloud API media-message reference.
if (cfg.caption && cfg.caption.length > INTERACTIVE_LIMITS.bodyMaxLength) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "caption",
message: `Caption exceeds ${INTERACTIVE_LIMITS.bodyMaxLength} chars (WhatsApp limit).`,
});
}
if (!cfg.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: "Send-media node must point to a next node.",
});
} else if (!knownKeys.has(cfg.next_node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: `Send-media points to non-existent node "${cfg.next_node_key}".`,
});
}
break;
}
case "send_buttons": {
const cfg = node.config as {
text?: string;
buttons?: Array<{
reply_id?: string;
title?: string;
next_node_key?: string;
}>;
};
if (!cfg.text?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "text",
message: "Send-buttons node needs a text body.",
});
}
const btns = cfg.buttons ?? [];
if (btns.length < 1) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "buttons",
message: "Send-buttons needs at least one button.",
});
}
if (btns.length > INTERACTIVE_LIMITS.maxButtons) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "buttons",
message: `WhatsApp allows at most ${INTERACTIVE_LIMITS.maxButtons} buttons per message.`,
});
}
const seenIds = new Set<string>();
btns.forEach((b, i) => {
const field = `buttons.${i}`;
if (!b.reply_id?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.reply_id`,
message: `Button ${i + 1} needs a reply id.`,
});
} else if (seenIds.has(b.reply_id)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.reply_id`,
message: `Duplicate button reply id "${b.reply_id}".`,
});
}
if (b.reply_id) seenIds.add(b.reply_id);
if (!b.title?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.title`,
message: `Button ${i + 1} needs a title.`,
});
} else if (b.title.length > INTERACTIVE_LIMITS.buttonTitleMaxLength) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.title`,
message: `Button ${i + 1} title is over ${INTERACTIVE_LIMITS.buttonTitleMaxLength} chars (WhatsApp limit).`,
});
}
if (!b.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.next_node_key`,
message: `Button ${i + 1} needs a next node.`,
});
} else if (!knownKeys.has(b.next_node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.next_node_key`,
message: `Button ${i + 1} points to non-existent node "${b.next_node_key}".`,
});
}
});
break;
}
case "send_list": {
const cfg = node.config as {
text?: string;
button_label?: string;
sections?: Array<{
title?: string;
rows?: Array<{
reply_id?: string;
title?: string;
description?: string;
next_node_key?: string;
}>;
}>;
};
if (!cfg.text?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "text",
message: "Send-list node needs a text body.",
});
}
if (!cfg.button_label?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "button_label",
message: "Send-list needs a button label (the tap-to-expand text).",
});
}
const sections = cfg.sections ?? [];
const totalRows = sections.reduce(
(sum, s) => sum + (s.rows?.length ?? 0),
0,
);
if (totalRows < 1) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "sections",
message: "Send-list needs at least one row.",
});
}
if (totalRows > INTERACTIVE_LIMITS.maxListRowsTotal) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "sections",
message: `Send-list allows at most ${INTERACTIVE_LIMITS.maxListRowsTotal} rows total across sections.`,
});
}
const seenIds = new Set<string>();
sections.forEach((section, si) => {
const rows = section.rows ?? [];
rows.forEach((row, ri) => {
const field = `sections.${si}.rows.${ri}`;
if (!row.reply_id?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.reply_id`,
message: `Row ${ri + 1} in section ${si + 1} needs a reply id.`,
});
} else if (seenIds.has(row.reply_id)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.reply_id`,
message: `Duplicate list row id "${row.reply_id}".`,
});
}
if (row.reply_id) seenIds.add(row.reply_id);
if (!row.title?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.title`,
message: `Row ${ri + 1} needs a title.`,
});
} else if (
row.title.length > INTERACTIVE_LIMITS.listRowTitleMaxLength
) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.title`,
message: `Row ${ri + 1} title exceeds ${INTERACTIVE_LIMITS.listRowTitleMaxLength} chars.`,
});
}
if (
row.description &&
row.description.length >
INTERACTIVE_LIMITS.listRowDescriptionMaxLength
) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.description`,
message: `Row ${ri + 1} description exceeds ${INTERACTIVE_LIMITS.listRowDescriptionMaxLength} chars.`,
});
}
if (!row.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.next_node_key`,
message: `Row ${ri + 1} needs a next node.`,
});
} else if (!knownKeys.has(row.next_node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: `${field}.next_node_key`,
message: `Row ${ri + 1} points to non-existent node "${row.next_node_key}".`,
});
}
});
});
break;
}
case "collect_input": {
const cfg = node.config as {
prompt_text?: string;
var_key?: string;
next_node_key?: string;
};
if (!cfg.prompt_text?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "prompt_text",
message: "Collect-input needs a prompt to send the customer.",
});
}
if (!cfg.var_key?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "var_key",
message: "Collect-input needs a var_key to store the answer under.",
});
} else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cfg.var_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "var_key",
message: `var_key "${cfg.var_key}" must be alphanumeric+underscore and start with a letter or underscore.`,
});
}
if (!cfg.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: "Collect-input must point to a next node.",
});
} else if (!knownKeys.has(cfg.next_node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: `Collect-input points to non-existent node "${cfg.next_node_key}".`,
});
}
break;
}
case "condition": {
const cfg = node.config as {
subject?: "var" | "tag" | "contact_field";
subject_key?: string;
operator?: "equals" | "contains" | "present" | "absent";
value?: string;
true_next?: string;
false_next?: string;
};
if (!cfg.subject || !["var", "tag", "contact_field"].includes(cfg.subject)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "subject",
message: "Condition needs a subject (var / tag / contact_field).",
});
}
if (!cfg.subject_key?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "subject_key",
message: "Condition needs a subject_key (var name, tag id, or field name).",
});
}
if (
!cfg.operator ||
!["equals", "contains", "present", "absent"].includes(cfg.operator)
) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "operator",
message: "Condition needs an operator.",
});
} else if (
(cfg.operator === "equals" || cfg.operator === "contains") &&
(cfg.value === undefined || cfg.value === "")
) {
issues.push({
severity: "warning",
scope: "node",
node_key: node.node_key,
field: "value",
message: `Operator "${cfg.operator}" usually expects a comparison value — empty value will only match empty subjects.`,
});
}
for (const branch of ["true_next", "false_next"] as const) {
const key = cfg[branch];
if (!key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: branch,
message: `Condition needs a node for the "${branch === "true_next" ? "true" : "false"}" branch.`,
});
} else if (!knownKeys.has(key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: branch,
message: `Condition's "${branch}" points to non-existent node "${key}".`,
});
}
}
break;
}
case "set_tag": {
const cfg = node.config as {
mode?: "add" | "remove";
tag_id?: string;
next_node_key?: string;
};
if (!cfg.mode || !["add", "remove"].includes(cfg.mode)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "mode",
message: "Set-tag needs a mode (add or remove).",
});
}
if (!cfg.tag_id) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "tag_id",
message: "Set-tag needs a tag to apply.",
});
}
if (!cfg.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: "Set-tag must point to a next node.",
});
} else if (!knownKeys.has(cfg.next_node_key)) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: `Set-tag points to non-existent node "${cfg.next_node_key}".`,
});
}
break;
}
case "handoff":
case "end":
// Terminal nodes have no outgoing edges; nothing to validate
// beyond their existence.
break;
default:
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
message: `Unknown node type "${node.node_type}".`,
});
}
return issues;
}
// ============================================================
// Reachability — BFS from the entry, follow outgoing edges per node
// ============================================================
export function reachableFromEntry(
entryKey: string,
nodes: NodeInput[],
): Set<string> {
const byKey = new Map<string, NodeInput>();
for (const n of nodes) byKey.set(n.node_key, n);
const visited = new Set<string>();
const queue: string[] = [entryKey];
while (queue.length > 0) {
const key = queue.shift() as string;
if (visited.has(key)) continue;
visited.add(key);
const node = byKey.get(key);
if (!node) continue;
for (const next of outgoingEdges(node)) {
if (!visited.has(next)) queue.push(next);
}
}
return visited;
}
function outgoingEdges(node: NodeInput): string[] {
switch (node.node_type) {
case "start":
case "send_message":
case "send_media":
case "collect_input":
case "set_tag": {
const cfg = node.config as { next_node_key?: string };
return cfg.next_node_key ? [cfg.next_node_key] : [];
}
case "condition": {
const cfg = node.config as {
true_next?: string;
false_next?: string;
};
const out: string[] = [];
if (cfg.true_next) out.push(cfg.true_next);
if (cfg.false_next) out.push(cfg.false_next);
return out;
}
case "send_buttons": {
const cfg = node.config as {
buttons?: Array<{ next_node_key?: string }>;
};
return (cfg.buttons ?? [])
.map((b) => b.next_node_key)
.filter((k): k is string => !!k);
}
case "send_list": {
const cfg = node.config as {
sections?: Array<{ rows?: Array<{ next_node_key?: string }> }>;
};
const out: string[] = [];
for (const s of cfg.sections ?? []) {
for (const r of s.rows ?? []) {
if (r.next_node_key) out.push(r.next_node_key);
}
}
return out;
}
case "handoff":
case "end":
default:
return [];
}
}