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:
16
wacrm/src/lib/flows/admin-client.ts
Normal file
16
wacrm/src/lib/flows/admin-client.ts
Normal 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
|
||||
}
|
||||
591
wacrm/src/lib/flows/edges.test.ts
Normal file
591
wacrm/src/lib/flows/edges.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
412
wacrm/src/lib/flows/edges.ts
Normal file
412
wacrm/src/lib/flows/edges.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
299
wacrm/src/lib/flows/engine.test.ts
Normal file
299
wacrm/src/lib/flows/engine.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1117
wacrm/src/lib/flows/engine.ts
Normal file
1117
wacrm/src/lib/flows/engine.ts
Normal file
File diff suppressed because it is too large
Load Diff
124
wacrm/src/lib/flows/fallback.test.ts
Normal file
124
wacrm/src/lib/flows/fallback.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
91
wacrm/src/lib/flows/fallback.ts
Normal file
91
wacrm/src/lib/flows/fallback.ts
Normal 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" };
|
||||
}
|
||||
128
wacrm/src/lib/flows/layout.test.ts
Normal file
128
wacrm/src/lib/flows/layout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
131
wacrm/src/lib/flows/layout.ts
Normal file
131
wacrm/src/lib/flows/layout.ts
Normal 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;
|
||||
}
|
||||
434
wacrm/src/lib/flows/meta-send.ts
Normal file
434
wacrm/src/lib/flows/meta-send.ts
Normal 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 }
|
||||
}
|
||||
304
wacrm/src/lib/flows/templates.ts
Normal file
304
wacrm/src/lib/flows/templates.ts
Normal 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 Mon–Fri, 9am–6pm 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);
|
||||
}
|
||||
374
wacrm/src/lib/flows/types.ts
Normal file
374
wacrm/src/lib/flows/types.ts
Normal 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)}`);
|
||||
}
|
||||
549
wacrm/src/lib/flows/validate.test.ts
Normal file
549
wacrm/src/lib/flows/validate.test.ts
Normal 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"]));
|
||||
});
|
||||
});
|
||||
793
wacrm/src/lib/flows/validate.ts
Normal file
793
wacrm/src/lib/flows/validate.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user