mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
553 lines
16 KiB
TypeScript
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 [];
|
|
}
|
|
}
|