wacrm/src/lib/flows/validate.ts
Arnas Donauskas 4871d7aa06
feat(flows): no-code builder UI + flows API + validator (PR 3/4)
Ships the surface that lets opted-in beta accounts build, save, and
activate WhatsApp conversation flows entirely from the web UI — no
SQL fixtures needed. Sidebar gets a "Flows" entry (hidden for
non-beta accounts), the /flows list page surfaces existing flows,
/flows/[id] is the linear-list editor.

Wraps the runner from PR 2 with everything a real user needs to
build the welcome-menu example end-to-end.

What landed:

  1. `src/lib/flows/validate.ts` — pure validator. Checks entry
     node exists, all next_node_key refs resolve, no unreachable
     nodes, Meta-limit violations (button title ≤20, ≤3 buttons,
     ≤10 list rows, ≤24 chars per list row). 24 unit tests.

  2. `src/app/api/flows/route.ts` — GET (list user's flows),
     POST (create draft).

  3. `src/app/api/flows/[id]/route.ts` — GET (load with nodes),
     PUT (replace name + trigger + nodes; delete-then-insert),
     DELETE (cascade clean-up).

  4. `src/app/api/flows/[id]/activate/route.ts` — status toggle.
     Activation runs the validator; returns 422 + the full issue
     list when blocked.

  5. `src/app/(dashboard)/flows/page.tsx` — list of cards, "New
     flow" dialog, delete with confirm.

  6. `src/app/(dashboard)/flows/[id]/page.tsx` — editor shell
     that loads data and hands it to FlowBuilder.

  7. `src/components/flows/flow-builder.tsx` — the editor itself.
     Header with name + save/activate/pause/delete; trigger
     panel; entry-node picker; node list with expandable per-node
     config forms (start, send_buttons, send_list, send_message,
     handoff, end); bottom validation panel that mirrors what the
     server's activate endpoint would reject.

  8. `src/components/layout/sidebar.tsx` — Flows entry between
     Automations and Settings, rendered only when isFlowsEnabled.

Every API route + page is gated server-side by isFlowsEnabled —
non-beta accounts get 404 (not 403) so the routes don't even hint
at existing. UI gate is for snappy redirect; security is at the
API layer.

Validation: 166/166 tests pass (added 24 validator tests);
typecheck + lint + build all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:57:38 +03:00

553 lines
16 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 "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": {
const cfg = node.config as { next_node_key?: string };
return cfg.next_node_key ? [cfg.next_node_key] : [];
}
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 [];
}
}