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