wacrm/src/lib/flows/validate.ts
Arnas Donauskas ed15493f95
feat(flows): templates + v1.5 nodes (collect_input/condition/set_tag) + run-history viewer (PR 4/4)
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>
2026-05-22 14:27:01 +03:00

734 lines
22 KiB
TypeScript

/**
* 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_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 "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 [];
}
}