mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Foundation for the upcoming WhatsApp chatbot flow builder. No runner,
no UI yet — just the plumbing that lets the codebase SEND interactive
messages (button replies + lists) and RECEIVE the taps as structured
message rows. Plan: ~/.claude/plans/understood-research-such-functionality-binary-lake.md
## What's in this PR
### Migration 010_flows.sql
- New tables: flows, flow_nodes, flow_runs, flow_run_events
- Linchpin partial unique index on flow_runs(user_id, contact_id) WHERE status='active' — at most one active run per contact, makes concurrent webhook deliveries idempotent at the DB layer
- Widens messages.content_type CHECK to allow 'interactive'
- New messages.interactive_reply_id column for the tapped button/row id
- RLS on every table; flow_runs added to supabase_realtime publication
- Idempotent throughout — matches the 009_message_actions.sql template
### Interactive Meta senders (src/lib/whatsapp/meta-api.ts)
- sendInteractiveButtons: type='interactive', interactive.type='button', up to 3 reply buttons
- sendInteractiveList: type='interactive', interactive.type='list', up to 10 rows across sections
- Limits exported as INTERACTIVE_LIMITS so flow validation can share the constants
- All Meta caps validated BEFORE the network call (button title ≤20 chars, list row title ≤24 chars, ≤10 total rows, no duplicate row ids, body ≤1024 chars, etc.) — misconfigured flows fail at save time instead of as a customer-facing 400 mid-conversation
### Webhook (src/app/api/whatsapp/webhook/route.ts)
- Extends WhatsAppMessage interface with `interactive?: { type, button_reply?, list_reply? }`
- parseMessageContent returns a new `interactiveReplyId` field; refactored to use a shared `empty` base so every return site picks up the new field without 9 explicit copies
- New `case 'interactive'`: uses the tapped option's TITLE as content_text (so the inbox bubble reads "Existing customer", not a UUID) and stashes the id separately
- 'interactive' added to ALLOWED_CONTENT_TYPES so the messages insert no longer coerces it to 'text'
### Inbox bubble (src/components/inbox/message-bubble.tsx)
- New `case "interactive"` in MessageContent renders the tap with a small "↩ Button reply" affordance so agents reading the inbox can tell at a glance that this was a tap rather than the customer typing the same words
### Types (src/types/index.ts)
- ContentType union extended with 'interactive'
- Message.interactive_reply_id field
### Engine helpers (src/lib/flows/)
- admin-client.ts: thin service-role client wrapper, mirrors automations/admin-client.ts
- meta-send.ts: engineSendInteractiveButtons / engineSendInteractiveList — adapts the Meta senders for service-role use, persists messages with sender_type='bot' content_type='interactive', uses the same phone-variant retry as automations/meta-send.ts. Unused in PR #1; ready for the runner in PR #2
### Tests
- 13 new vitest cases in meta-api.test.ts covering Meta's limit validation + payload-shape assertions for both interactive senders
## Validation
- npm test → 102/102 (was 89; 13 new)
- npm run lint → 0 errors
- npm run typecheck → clean
- npm run build → clean
## What's NOT in this PR
- The runner (dispatchInboundToFlows) — PR #2
- Builder UI / API surface — PR #3
- collect_input / condition / set_tag nodes + templates — PR #4
- react-flow canvas + http_fetch — v2 (PR #5)
## Risks tracked from the plan
- messages.content_type CHECK widening: grep'd every consumer; message-bubble.tsx, reply-quote.tsx, send/route.ts, automations/meta-send.ts all handle the new variant correctly (or accept it as a defaulted fallthrough)
- engineSendText / engineSendInteractive share ~80% of code; deliberately not pre-factored yet — let both stabilize, extract a shared base in PR #2 once the shape settles
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
270 lines
7.1 KiB
TypeScript
270 lines
7.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
INTERACTIVE_LIMITS,
|
|
sendInteractiveButtons,
|
|
sendInteractiveList,
|
|
} from "./meta-api";
|
|
|
|
// All assertions in this file run BEFORE the network call. We stub fetch
|
|
// to a never-resolving mock so a test that accidentally falls through to
|
|
// the request body would hang (and fail) rather than silently hit
|
|
// graph.facebook.com.
|
|
const neverFetch = () =>
|
|
new Promise<Response>(() => {
|
|
/* intentionally never resolves */
|
|
});
|
|
|
|
const BASE_ARGS = {
|
|
phoneNumberId: "test-phone",
|
|
accessToken: "test-token",
|
|
to: "1234567890",
|
|
bodyText: "Body text",
|
|
} as const;
|
|
|
|
describe("sendInteractiveButtons — validation", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", vi.fn(neverFetch));
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("rejects an empty buttons array", async () => {
|
|
await expect(
|
|
sendInteractiveButtons({ ...BASE_ARGS, buttons: [] }),
|
|
).rejects.toThrow(/1-3 buttons/);
|
|
});
|
|
|
|
it(`rejects more than ${INTERACTIVE_LIMITS.maxButtons} buttons (Meta cap)`, async () => {
|
|
await expect(
|
|
sendInteractiveButtons({
|
|
...BASE_ARGS,
|
|
buttons: [
|
|
{ id: "a", title: "A" },
|
|
{ id: "b", title: "B" },
|
|
{ id: "c", title: "C" },
|
|
{ id: "d", title: "D" },
|
|
],
|
|
}),
|
|
).rejects.toThrow(/1-3 buttons/);
|
|
});
|
|
|
|
it("rejects a button title longer than 20 chars (Meta cap)", async () => {
|
|
await expect(
|
|
sendInteractiveButtons({
|
|
...BASE_ARGS,
|
|
buttons: [
|
|
{ id: "a", title: "x".repeat(INTERACTIVE_LIMITS.buttonTitleMaxLength + 1) },
|
|
],
|
|
}),
|
|
).rejects.toThrow(/exceeds 20 chars/);
|
|
});
|
|
|
|
it("rejects a button missing its id", async () => {
|
|
await expect(
|
|
sendInteractiveButtons({
|
|
...BASE_ARGS,
|
|
buttons: [{ id: "", title: "Choose me" }],
|
|
}),
|
|
).rejects.toThrow(/missing id/);
|
|
});
|
|
|
|
it("rejects an empty body text", async () => {
|
|
await expect(
|
|
sendInteractiveButtons({
|
|
...BASE_ARGS,
|
|
bodyText: "",
|
|
buttons: [{ id: "a", title: "A" }],
|
|
}),
|
|
).rejects.toThrow(/requires bodyText/);
|
|
});
|
|
|
|
it("rejects a header text over the limit", async () => {
|
|
await expect(
|
|
sendInteractiveButtons({
|
|
...BASE_ARGS,
|
|
headerText: "x".repeat(INTERACTIVE_LIMITS.headerTextMaxLength + 1),
|
|
buttons: [{ id: "a", title: "A" }],
|
|
}),
|
|
).rejects.toThrow(/headerText exceeds/);
|
|
});
|
|
|
|
it("sends the right payload shape when all inputs are valid", async () => {
|
|
let captured: { url: string; body: unknown; method: string } | null = null;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (url: string, init: RequestInit) => {
|
|
captured = {
|
|
url,
|
|
method: init.method ?? "GET",
|
|
body: JSON.parse(String(init.body)),
|
|
};
|
|
return new Response(
|
|
JSON.stringify({ messages: [{ id: "wamid.PASS" }] }),
|
|
{ status: 200 },
|
|
);
|
|
}),
|
|
);
|
|
|
|
const result = await sendInteractiveButtons({
|
|
...BASE_ARGS,
|
|
headerText: "Hello",
|
|
footerText: "Tap one",
|
|
buttons: [
|
|
{ id: "yes", title: "Yes" },
|
|
{ id: "no", title: "No" },
|
|
],
|
|
});
|
|
|
|
expect(result).toEqual({ messageId: "wamid.PASS" });
|
|
expect(captured).not.toBeNull();
|
|
expect(captured!.method).toBe("POST");
|
|
expect(captured!.url).toContain("test-phone/messages");
|
|
expect(captured!.body).toMatchObject({
|
|
messaging_product: "whatsapp",
|
|
recipient_type: "individual",
|
|
to: "1234567890",
|
|
type: "interactive",
|
|
interactive: {
|
|
type: "button",
|
|
body: { text: "Body text" },
|
|
header: { type: "text", text: "Hello" },
|
|
footer: { text: "Tap one" },
|
|
action: {
|
|
buttons: [
|
|
{ type: "reply", reply: { id: "yes", title: "Yes" } },
|
|
{ type: "reply", reply: { id: "no", title: "No" } },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("sendInteractiveList — validation", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", vi.fn(neverFetch));
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
const ROW = { id: "r1", title: "Row 1" };
|
|
|
|
it("rejects zero sections", async () => {
|
|
await expect(
|
|
sendInteractiveList({
|
|
...BASE_ARGS,
|
|
buttonLabel: "Open",
|
|
sections: [],
|
|
}),
|
|
).rejects.toThrow(/1-10 sections/);
|
|
});
|
|
|
|
it(`rejects more than ${INTERACTIVE_LIMITS.maxListRowsTotal} rows total across sections (Meta cap)`, async () => {
|
|
const rows = Array.from({ length: 11 }, (_, i) => ({
|
|
id: `r${i}`,
|
|
title: `Row ${i}`,
|
|
}));
|
|
await expect(
|
|
sendInteractiveList({
|
|
...BASE_ARGS,
|
|
buttonLabel: "Open",
|
|
sections: [{ rows }],
|
|
}),
|
|
).rejects.toThrow(/1-10 rows total/);
|
|
});
|
|
|
|
it("rejects a row title longer than 24 chars (Meta cap)", async () => {
|
|
await expect(
|
|
sendInteractiveList({
|
|
...BASE_ARGS,
|
|
buttonLabel: "Open",
|
|
sections: [
|
|
{
|
|
rows: [
|
|
{
|
|
id: "r1",
|
|
title: "x".repeat(INTERACTIVE_LIMITS.listRowTitleMaxLength + 1),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
).rejects.toThrow(/exceeds 24 chars/);
|
|
});
|
|
|
|
it("rejects duplicate row ids across sections", async () => {
|
|
await expect(
|
|
sendInteractiveList({
|
|
...BASE_ARGS,
|
|
buttonLabel: "Open",
|
|
sections: [
|
|
{ rows: [{ id: "dupe", title: "First" }] },
|
|
{ rows: [{ id: "dupe", title: "Second" }] },
|
|
],
|
|
}),
|
|
).rejects.toThrow(/duplicate row id/);
|
|
});
|
|
|
|
it("rejects an empty buttonLabel", async () => {
|
|
await expect(
|
|
sendInteractiveList({
|
|
...BASE_ARGS,
|
|
buttonLabel: "",
|
|
sections: [{ rows: [ROW] }],
|
|
}),
|
|
).rejects.toThrow(/requires a buttonLabel/);
|
|
});
|
|
|
|
it("sends the right payload shape when valid", async () => {
|
|
let captured: { body: unknown } | null = null;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (_url: string, init: RequestInit) => {
|
|
captured = { body: JSON.parse(String(init.body)) };
|
|
return new Response(
|
|
JSON.stringify({ messages: [{ id: "wamid.LIST" }] }),
|
|
{ status: 200 },
|
|
);
|
|
}),
|
|
);
|
|
|
|
const result = await sendInteractiveList({
|
|
...BASE_ARGS,
|
|
buttonLabel: "Open menu",
|
|
sections: [
|
|
{
|
|
title: "Orders",
|
|
rows: [
|
|
{ id: "order_1", title: "Order #1", description: "€12" },
|
|
{ id: "order_2", title: "Order #2" },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result).toEqual({ messageId: "wamid.LIST" });
|
|
expect(captured).not.toBeNull();
|
|
expect(captured!.body).toMatchObject({
|
|
type: "interactive",
|
|
interactive: {
|
|
type: "list",
|
|
body: { text: "Body text" },
|
|
action: {
|
|
button: "Open menu",
|
|
sections: [
|
|
{
|
|
title: "Orders",
|
|
rows: [
|
|
{ id: "order_1", title: "Order #1", description: "€12" },
|
|
{ id: "order_2", title: "Order #2" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|