feat(flows): add send_media node for image / video / document sends

Lets flows attach an image, video, or document (e.g. PDF invoice /
receipt) to a customer message — the most-requested gap in v1 was that
nodes were text-only. Single node with a `media_type` discriminator
rather than three node types: Meta's wire payload differs only in the
top-level key, so three types would triple the surface area (forms,
engine cases, menu entries) for no behavioural difference.

Files are uploaded directly from the builder to a new public
`flow-media` Supabase Storage bucket (per-user RLS, 16 MB cap, MIME
allowlist) and the resulting public URL is what Meta fetches at send
time. Same trade-off as the existing avatars bucket.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-29 19:11:29 +02:00
parent 6c0c70486c
commit 62073d9915
No known key found for this signature in database
9 changed files with 779 additions and 1 deletions

View File

@ -40,6 +40,9 @@ import {
Inbox, Inbox,
GitFork, GitFork,
Tag, Tag,
Paperclip,
Upload,
X,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -66,6 +69,7 @@ import {
type ValidationIssue, type ValidationIssue,
} from "@/lib/flows/validate"; } from "@/lib/flows/validate";
import type { FlowNodeRow, FlowRow } from "@/lib/flows/types"; import type { FlowNodeRow, FlowRow } from "@/lib/flows/types";
import { createClient } from "@/lib/supabase/client";
interface FlowBuilderProps { interface FlowBuilderProps {
initialFlow: FlowRow; initialFlow: FlowRow;
@ -83,6 +87,7 @@ type NodeType =
| "send_message" | "send_message"
| "send_buttons" | "send_buttons"
| "send_list" | "send_list"
| "send_media"
| "collect_input" | "collect_input"
| "condition" | "condition"
| "set_tag" | "set_tag"
@ -130,6 +135,11 @@ const NODE_META: Record<
icon: ListPlus, icon: ListPlus,
color: "text-indigo-400", color: "text-indigo-400",
}, },
send_media: {
label: "Send media",
icon: Paperclip,
color: "text-cyan-400",
},
collect_input: { collect_input: {
label: "Collect input", label: "Collect input",
icon: Inbox, icon: Inbox,
@ -225,6 +235,21 @@ function summarizeNode(node: BuilderNode): string | null {
? `${rowCount} option${rowCount === 1 ? "" : "s"} across ${sections.length} section${sections.length === 1 ? "" : "s"}` ? `${rowCount} option${rowCount === 1 ? "" : "s"} across ${sections.length} section${sections.length === 1 ? "" : "s"}`
: null; : null;
} }
case "send_media": {
const mediaType =
typeof cfg.media_type === "string" ? cfg.media_type : "";
const filename = typeof cfg.filename === "string" ? cfg.filename : "";
const url = typeof cfg.media_url === "string" ? cfg.media_url : "";
const caption = typeof cfg.caption === "string" ? cfg.caption : "";
const label = mediaType
? mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
: "Media";
if (!url) return `${label} (no file uploaded)`;
const name = filename || url.split("/").pop() || "file";
return caption
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
: `${label}: ${truncate(name, 60)}`;
}
case "collect_input": { case "collect_input": {
const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : ""; const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : "";
const varKey = typeof cfg.var_key === "string" ? cfg.var_key : ""; const varKey = typeof cfg.var_key === "string" ? cfg.var_key : "";
@ -301,6 +326,14 @@ function defaultConfigFor(type: NodeType): Record<string, unknown> {
}, },
], ],
}; };
case "send_media":
return {
media_type: "image",
media_url: "",
caption: "",
filename: "",
next_node_key: "",
};
case "collect_input": case "collect_input":
return { return {
prompt_text: "", prompt_text: "",
@ -1133,6 +1166,15 @@ function NodeConfigForm({
/> />
)} )}
{node.node_type === "send_media" && (
<SendMediaForm
cfg={cfg as SendMediaCfg}
allNodes={allNodes}
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
/>
)}
{node.node_type === "collect_input" && ( {node.node_type === "collect_input" && (
<> <>
<TextRow <TextRow
@ -1891,6 +1933,230 @@ function SetTagForm({
); );
} }
// ---- send_media form ----
interface SendMediaCfg {
media_type?: "image" | "video" | "document";
media_url?: string;
caption?: string;
filename?: string;
next_node_key?: string;
}
// Mirrors the bucket's allowed_mime_types from migration 016. Kept in
// sync with the storage policy so the picker rejects unsupported files
// before they hit the network rather than failing with a confusing
// Supabase RLS / mime-type error.
const MEDIA_ACCEPT: Record<NonNullable<SendMediaCfg["media_type"]>, string> = {
image: "image/png,image/jpeg,image/webp",
video: "video/mp4,video/3gpp",
document:
"application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain",
};
const FLOW_MEDIA_BUCKET = "flow-media";
// 16 MB — matches the bucket file_size_limit set in migration 016.
const FLOW_MEDIA_MAX_BYTES = 16 * 1024 * 1024;
function SendMediaForm({
cfg,
allNodes,
currentKey,
onUpdateConfig,
}: {
cfg: SendMediaCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const mediaType = cfg.media_type ?? "image";
const isDocument = mediaType === "document";
const displayName =
cfg.filename ||
(cfg.media_url ? cfg.media_url.split("/").pop() ?? "" : "");
const handleFile = useCallback(
async (file: File) => {
if (file.size > FLOW_MEDIA_MAX_BYTES) {
toast.error(
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`,
);
return;
}
setUploading(true);
try {
const supabase = createClient();
const {
data: { user },
error: userErr,
} = await supabase.auth.getUser();
if (userErr || !user) {
throw new Error("Not signed in.");
}
// Path convention matches migration 016's RLS policy: first
// segment must equal auth.uid(). Timestamp + random suffix
// prevents collisions between two builders open at once.
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
const safeBase = file.name
.replace(/\.[^.]+$/, "")
.replace(/[^a-zA-Z0-9_-]+/g, "_")
.slice(0, 40) || "file";
const path = `${user.id}/${Date.now()}-${safeBase}.${ext}`;
const { error: upErr } = await supabase.storage
.from(FLOW_MEDIA_BUCKET)
.upload(path, file, {
cacheControl: "3600",
upsert: false,
contentType: file.type,
});
if (upErr) throw new Error(upErr.message);
const {
data: { publicUrl },
} = supabase.storage.from(FLOW_MEDIA_BUCKET).getPublicUrl(path);
// Patch all three fields in one call so the form doesn't
// re-render with a half-uploaded state.
onUpdateConfig({
media_url: publicUrl,
filename: file.name,
});
toast.success("File uploaded.");
} catch (err) {
const msg = err instanceof Error ? err.message : "Upload failed.";
toast.error(msg);
} finally {
setUploading(false);
}
},
[onUpdateConfig],
);
const handleClear = () => {
onUpdateConfig({ media_url: "", filename: "" });
};
return (
<>
<div>
<label className="mb-1 block text-xs text-slate-400">Media type</label>
<Select
value={mediaType}
onValueChange={(v) => {
// Changing type clears the existing file — the bucket
// accepts different MIME sets per type and a previously
// uploaded PDF can't be sent as an image.
onUpdateConfig({
media_type: v as NonNullable<SendMediaCfg["media_type"]>,
media_url: "",
filename: "",
});
}}
>
<SelectTrigger className="bg-slate-800">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="image">Image (PNG, JPEG, WebP)</SelectItem>
<SelectItem value="video">Video (MP4, 3GP)</SelectItem>
<SelectItem value="document">
Document (PDF, Word, Excel, PowerPoint, TXT)
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400">File</label>
{cfg.media_url ? (
<div className="flex items-center gap-2 rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-xs">
<Paperclip className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
<a
href={cfg.media_url}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 flex-1 truncate text-slate-200 hover:text-cyan-300"
title={displayName || cfg.media_url}
>
{displayName || cfg.media_url}
</a>
<button
type="button"
onClick={handleClear}
className="rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-slate-200"
aria-label="Remove file"
disabled={uploading}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed border-slate-700 bg-slate-900 px-3 py-4 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:bg-slate-800 hover:text-slate-200 disabled:cursor-not-allowed disabled:opacity-60"
>
{uploading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Uploading…
</>
) : (
<>
<Upload className="h-3.5 w-3.5" />
Click to upload (max 16 MB)
</>
)}
</button>
)}
<input
ref={fileInputRef}
type="file"
accept={MEDIA_ACCEPT[mediaType]}
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
// Reset so picking the same file twice still fires onChange.
e.target.value = "";
}}
/>
</div>
<TextRow
label="Caption (optional, shown under the media)"
value={cfg.caption ?? ""}
onChange={(v) => onUpdateConfig({ caption: v })}
rows={2}
/>
{isDocument && (
<div>
<label className="mb-1 block text-xs text-slate-400">
Filename shown to the customer (documents only)
</label>
<Input
value={cfg.filename ?? ""}
onChange={(e) => onUpdateConfig({ filename: e.target.value })}
placeholder="invoice.pdf"
className="bg-slate-800 text-xs"
/>
</div>
)}
<NextNodeRow
value={cfg.next_node_key ?? ""}
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="After sending, advance to"
/>
</>
);
}
// ---- Smaller field components ---- // ---- Smaller field components ----
function TextRow({ function TextRow({
@ -2006,6 +2272,7 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
"send_buttons", "send_buttons",
"send_list", "send_list",
"send_message", "send_message",
"send_media",
"collect_input", "collect_input",
"condition", "condition",
"set_tag", "set_tag",

View File

@ -144,9 +144,10 @@ describe("matchesKeywordTrigger", () => {
}); });
describe("node classification helpers", () => { describe("node classification helpers", () => {
it("isAutoAdvancing covers start + send_message + condition + set_tag", () => { it("isAutoAdvancing covers start + send_message + send_media + condition + set_tag", () => {
expect(isAutoAdvancing("start")).toBe(true); expect(isAutoAdvancing("start")).toBe(true);
expect(isAutoAdvancing("send_message")).toBe(true); expect(isAutoAdvancing("send_message")).toBe(true);
expect(isAutoAdvancing("send_media")).toBe(true);
expect(isAutoAdvancing("condition")).toBe(true); expect(isAutoAdvancing("condition")).toBe(true);
expect(isAutoAdvancing("set_tag")).toBe(true); expect(isAutoAdvancing("set_tag")).toBe(true);
expect(isAutoAdvancing("send_buttons")).toBe(false); expect(isAutoAdvancing("send_buttons")).toBe(false);
@ -182,6 +183,7 @@ describe("node classification helpers", () => {
"send_message", "send_message",
"send_buttons", "send_buttons",
"send_list", "send_list",
"send_media",
"collect_input", "collect_input",
"condition", "condition",
"set_tag", "set_tag",

View File

@ -36,6 +36,7 @@ import { supabaseAdmin } from "./admin-client";
import { import {
engineSendInteractiveButtons, engineSendInteractiveButtons,
engineSendInteractiveList, engineSendInteractiveList,
engineSendMedia,
engineSendText, engineSendText,
} from "./meta-send"; } from "./meta-send";
import { decideFallback, resolveFallbackPolicy } from "./fallback"; import { decideFallback, resolveFallbackPolicy } from "./fallback";
@ -50,6 +51,7 @@ import {
type ParsedInbound, type ParsedInbound,
type SendButtonsNodeConfig, type SendButtonsNodeConfig,
type SendListNodeConfig, type SendListNodeConfig,
type SendMediaNodeConfig,
type SendMessageNodeConfig, type SendMessageNodeConfig,
type SetTagNodeConfig, type SetTagNodeConfig,
type StartNodeConfig, type StartNodeConfig,
@ -112,6 +114,7 @@ export function isAutoAdvancing(node_type: string): boolean {
return ( return (
node_type === "start" || node_type === "start" ||
node_type === "send_message" || node_type === "send_message" ||
node_type === "send_media" ||
node_type === "condition" || node_type === "condition" ||
node_type === "set_tag" node_type === "set_tag"
); );
@ -593,6 +596,36 @@ async function advanceFromNodeKey(
currentKey = cfg.next_node_key; currentKey = cfg.next_node_key;
continue; continue;
} }
if (node.node_type === "send_media") {
const cfg = node.config as unknown as SendMediaNodeConfig;
try {
const { whatsapp_message_id } = await engineSendMedia({
userId: run.user_id,
conversationId: run.conversation_id!,
contactId: run.contact_id!,
kind: cfg.media_type,
link: cfg.media_url,
caption: cfg.caption
? interpolateVars(cfg.caption, run.vars)
: undefined,
filename: cfg.filename,
});
await logEvent(db, run.id, "message_sent", node.node_key, {
node_type: "send_media",
media_type: cfg.media_type,
whatsapp_message_id,
});
} catch (err) {
await logEvent(db, run.id, "error", node.node_key, {
reason: "send_media_failed",
detail: err instanceof Error ? err.message : String(err),
});
await endRun(db, run.id, "failed", "send_media_failed");
return { outcome: "completed" };
}
currentKey = cfg.next_node_key;
continue;
}
if (node.node_type === "collect_input") { if (node.node_type === "collect_input") {
// Send the prompt and suspend. Customer's next TEXT reply will // Send the prompt and suspend. Customer's next TEXT reply will
// wake us up via handleReplyForActiveRun's collect_input branch. // wake us up via handleReplyForActiveRun's collect_input branch.

View File

@ -1,9 +1,11 @@
import { import {
sendInteractiveButtons, sendInteractiveButtons,
sendInteractiveList, sendInteractiveList,
sendMediaMessage,
sendTextMessage, sendTextMessage,
type InteractiveButton, type InteractiveButton,
type InteractiveListSection, type InteractiveListSection,
type MediaKind,
} from '@/lib/whatsapp/meta-api' } from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption' import { decrypt } from '@/lib/whatsapp/encryption'
import { import {
@ -135,6 +137,122 @@ export async function engineSendText(
return { whatsapp_message_id: waMessageId } return { whatsapp_message_id: waMessageId }
} }
interface SendMediaEngineArgs {
userId: string
conversationId: string
contactId: string
kind: MediaKind
/** Public URL Meta fetches at send time. */
link: string
caption?: string
/** Document-only; ignored by Meta for image/video. */
filename?: string
}
/**
* Send an image / video / document from the Flows engine.
*
* Used by the runner's `send_media` node. Auto-advances after the
* send lands (same suspend semantics as send_message). Same
* phone-variant retry + DB persistence as the text/interactive
* senders; persists the outgoing message with `content_type` matching
* the media kind so the inbox renders the right preview.
*/
export async function engineSendMedia(
args: SendMediaEngineArgs,
): Promise<{ whatsapp_message_id: string }> {
const db = supabaseAdmin()
const { data: contact, error: contactErr } = await db
.from('contacts')
.select('id, phone')
.eq('id', args.contactId)
.eq('user_id', args.userId)
.maybeSingle()
if (contactErr || !contact?.phone) {
throw new Error('contact not found for this user')
}
const sanitized = sanitizePhoneForMeta(contact.phone)
if (!isValidE164(sanitized)) {
throw new Error(`contact phone invalid: ${contact.phone}`)
}
const { data: config, error: configErr } = await db
.from('whatsapp_config')
.select('*')
.eq('user_id', args.userId)
.single()
if (configErr || !config) {
throw new Error('WhatsApp not configured for this account')
}
const accessToken = decrypt(config.access_token)
const attempt = async (phone: string): Promise<string> => {
const r = await sendMediaMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
kind: args.kind,
link: args.link,
caption: args.caption,
filename: args.filename,
})
return r.messageId
}
const variants = phoneVariants(sanitized)
let workingPhone = sanitized
let waMessageId = ''
let lastError: unknown = null
for (const v of variants) {
try {
waMessageId = await attempt(v)
workingPhone = v
lastError = null
break
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isRecipientNotAllowedError(msg)) throw err
lastError = err
}
}
if (lastError) throw lastError
if (workingPhone !== sanitized) {
await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id)
}
// content_type='image'|'video'|'document' — these are already in the
// messages_content_type_check constraint (migration 001 + 010).
// content_text carries the caption (or empty) so the conversation
// list preview shows something meaningful when the user glances at it.
const preview = args.caption?.trim() || `[${args.kind}]`
const { error: msgErr } = await db.from('messages').insert({
conversation_id: args.conversationId,
sender_type: 'bot',
content_type: args.kind,
content_text: args.caption ?? null,
message_id: waMessageId,
status: 'sent',
})
if (msgErr) {
throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`)
}
await db
.from('conversations')
.update({
last_message_text: preview,
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', args.conversationId)
return { whatsapp_message_id: waMessageId }
}
interface SendInteractiveButtonsEngineArgs { interface SendInteractiveButtonsEngineArgs {
userId: string userId: string
conversationId: string conversationId: string

View File

@ -68,6 +68,35 @@ export interface SendListNodeConfig {
}>; }>;
} }
/**
* Sends a single image / video / document via WhatsApp, then
* auto-advances. The media file is uploaded to the `flow-media`
* Supabase Storage bucket by the builder; `media_url` is the public
* URL Meta fetches at send time.
*
* Why one node with a `media_type` discriminator (rather than three
* separate node types): Meta's send-side payload differs only in the
* top-level key (`image` / `video` / `document`) and the
* filename-on-document quirk. Modeling three node types would triple
* the builder forms, engine cases, and add-menu entries for no
* meaningful behavioural difference.
*/
export interface SendMediaNodeConfig {
media_type: "image" | "video" | "document";
/** Public URL Meta will fetch. Uploaded via the builder's file picker. */
media_url: string;
/** Optional caption shown under the media (Meta caps at 1024 chars). */
caption?: string;
/**
* Filename shown in the recipient's chat. Documents only — Meta
* ignores it for image/video. Defaults to the file's original name
* at upload time; the user can edit it.
*/
filename?: string;
/** Auto-advance target after the send lands at Meta. */
next_node_key: string;
}
export interface HandoffNodeConfig { export interface HandoffNodeConfig {
/** Optional internal note written to flow_run_events.payload.note. */ /** Optional internal note written to flow_run_events.payload.note. */
note?: string; note?: string;
@ -160,6 +189,7 @@ export type FlowNodeConfig =
| { node_type: "send_message"; config: SendMessageNodeConfig } | { node_type: "send_message"; config: SendMessageNodeConfig }
| { node_type: "send_buttons"; config: SendButtonsNodeConfig } | { node_type: "send_buttons"; config: SendButtonsNodeConfig }
| { node_type: "send_list"; config: SendListNodeConfig } | { node_type: "send_list"; config: SendListNodeConfig }
| { node_type: "send_media"; config: SendMediaNodeConfig }
| { node_type: "collect_input"; config: CollectInputNodeConfig } | { node_type: "collect_input"; config: CollectInputNodeConfig }
| { node_type: "condition"; config: ConditionNodeConfig } | { node_type: "condition"; config: ConditionNodeConfig }
| { node_type: "set_tag"; config: SetTagNodeConfig } | { node_type: "set_tag"; config: SetTagNodeConfig }

View File

@ -420,6 +420,102 @@ describe("validateFlowForActivation — nodes", () => {
}); });
}); });
describe("validateFlowForActivation — send_media", () => {
const baseFlow = { ...validFlow, entry_node_id: "s" };
const nodesWith = (mediaConfig: Record<string, unknown>) => [
{ node_key: "s", node_type: "start", config: { next_node_key: "m" } },
{ node_key: "m", node_type: "send_media", config: mediaConfig },
{ node_key: "h", node_type: "handoff", config: {} },
];
it("passes on a fully-populated send_media node", () => {
const issues = validateFlowForActivation(
baseFlow,
nodesWith({
media_type: "document",
media_url: "https://cdn.example/invoice.pdf",
caption: "Your invoice",
filename: "invoice.pdf",
next_node_key: "h",
}),
);
expect(issues).toEqual([]);
});
it("flags missing media_url", () => {
const issues = validateFlowForActivation(
baseFlow,
nodesWith({
media_type: "image",
media_url: "",
next_node_key: "h",
}),
);
expect(
issues.some((i) => i.node_key === "m" && i.field === "media_url"),
).toBe(true);
});
it("flags missing media_type", () => {
const issues = validateFlowForActivation(
baseFlow,
nodesWith({
media_url: "https://cdn.example/x.png",
next_node_key: "h",
}),
);
expect(
issues.some((i) => i.node_key === "m" && i.field === "media_type"),
).toBe(true);
});
it("flags next_node_key pointing at a non-existent node", () => {
const issues = validateFlowForActivation(
baseFlow,
nodesWith({
media_type: "image",
media_url: "https://cdn.example/x.png",
next_node_key: "ghost",
}),
);
expect(
issues.some(
(i) =>
i.node_key === "m" &&
i.field === "next_node_key" &&
i.message.includes("ghost"),
),
).toBe(true);
});
it("flags caption exceeding 1024 chars", () => {
const issues = validateFlowForActivation(
baseFlow,
nodesWith({
media_type: "image",
media_url: "https://cdn.example/x.png",
caption: "x".repeat(1025),
next_node_key: "h",
}),
);
expect(
issues.some((i) => i.node_key === "m" && i.field === "caption"),
).toBe(true);
});
it("contributes its next_node_key to reachability", () => {
const set = reachableFromEntry(
"s",
nodesWith({
media_type: "image",
media_url: "https://cdn.example/x.png",
next_node_key: "h",
}),
);
expect(set).toEqual(new Set(["s", "m", "h"]));
});
});
describe("reachableFromEntry", () => { describe("reachableFromEntry", () => {
it("walks the graph from the entry", () => { it("walks the graph from the entry", () => {
const set = reachableFromEntry("start", validNodes); const set = reachableFromEntry("start", validNodes);

View File

@ -242,6 +242,65 @@ function validateNode(
break; break;
} }
case "send_media": {
const cfg = node.config as {
media_type?: "image" | "video" | "document";
media_url?: string;
caption?: string;
next_node_key?: string;
};
if (
!cfg.media_type ||
!["image", "video", "document"].includes(cfg.media_type)
) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "media_type",
message: "Send-media node needs a media type (image, video, or document).",
});
}
if (!cfg.media_url?.trim()) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "media_url",
message: "Send-media node needs a file (upload one before activating).",
});
}
// Caption cap mirrors Meta's interactive body cap; documented as a
// hard limit in the WhatsApp Cloud API media-message reference.
if (cfg.caption && cfg.caption.length > INTERACTIVE_LIMITS.bodyMaxLength) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "caption",
message: `Caption exceeds ${INTERACTIVE_LIMITS.bodyMaxLength} chars (WhatsApp limit).`,
});
}
if (!cfg.next_node_key) {
issues.push({
severity: "error",
scope: "node",
node_key: node.node_key,
field: "next_node_key",
message: "Send-media 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-media points to non-existent node "${cfg.next_node_key}".`,
});
}
break;
}
case "send_buttons": { case "send_buttons": {
const cfg = node.config as { const cfg = node.config as {
text?: string; text?: string;
@ -690,6 +749,7 @@ function outgoingEdges(node: NodeInput): string[] {
switch (node.node_type) { switch (node.node_type) {
case "start": case "start":
case "send_message": case "send_message":
case "send_media":
case "collect_input": case "collect_input":
case "set_tag": { case "set_tag": {
const cfg = node.config as { next_node_key?: string }; const cfg = node.config as { next_node_key?: string };

View File

@ -259,6 +259,64 @@ export async function sendTextMessage(
return { messageId: data.messages[0].id } return { messageId: data.messages[0].id }
} }
export type MediaKind = 'image' | 'video' | 'document'
export interface SendMediaMessageArgs {
phoneNumberId: string
accessToken: string
to: string
kind: MediaKind
/** Public URL Meta fetches at send time. */
link: string
/** Optional caption — Meta caps at 1024 chars. Documents + images + videos all accept it. */
caption?: string
/** Document-only. Shown in the recipient's chat as the file name. Ignored for image/video. */
filename?: string
contextMessageId?: string
}
/**
* Send an image, video, or document via a public URL.
*
* Used by the Flows engine's `send_media` node. Mirrors
* `sendTextMessage` — single fetch, throws on non-2xx, returns Meta's
* message id.
*/
export async function sendMediaMessage(
args: SendMediaMessageArgs,
): Promise<MetaSendResult> {
const { phoneNumberId, accessToken, to, kind, link, caption, filename, contextMessageId } = args
if (!link) throw new Error('sendMediaMessage requires a link.')
const url = `${META_API_BASE}/${phoneNumberId}/messages`
const media: Record<string, unknown> = { link }
if (caption) media.caption = caption
if (kind === 'document' && filename) media.filename = filename
const body: Record<string, unknown> = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: kind,
[kind]: media,
}
if (contextMessageId) body.context = { message_id: contextMessageId }
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(body),
})
if (!response.ok) {
await throwMetaError(response, `Meta API error: ${response.status}`)
}
const data = await response.json()
return { messageId: data.messages[0].id }
}
import type { MessageTemplate } from '@/types' import type { MessageTemplate } from '@/types'
import { import {
buildSendComponents, buildSendComponents,

View File

@ -0,0 +1,114 @@
-- ============================================================
-- 016_flow_media.sql
--
-- Adds support for media nodes in conversational flows:
--
-- 1. New 'send_media' value on `flow_nodes.node_type` CHECK
-- constraint. Mirrors the same drop-and-recreate pattern migration
-- 010 used to land the original list. The node config lives in
-- JSONB and is shape-checked by the validator + TS types, not the
-- DB.
--
-- 2. `flow-media` Supabase Storage bucket where the builder uploads
-- the file the customer will receive. Public bucket so Meta can
-- pull the URL without auth — same trade-off as the avatars
-- bucket (see migration 008). Per-user RLS on writes scopes the
-- bucket so one tenant can't read/overwrite another's media.
--
-- Path convention:
-- flow-media/{auth.uid()}/<timestamp>-<basename>.<ext>
-- First path segment must equal auth.uid()::text — same shape
-- migration 008 uses for avatars so the policy code reads the
-- same.
--
-- Size limit 16 MB — Meta's WhatsApp Cloud API caps documents at
-- 100 MB but videos at 16 MB and images at 5 MB; we pick the
-- tightest universal cap that still works for the document case
-- that prompted this feature (PDF invoices / receipts).
--
-- Idempotent — safe to re-run.
-- ============================================================
-- ============================================================
-- 1. flow_nodes.node_type — add 'send_media'
-- ============================================================
ALTER TABLE flow_nodes
DROP CONSTRAINT IF EXISTS flow_nodes_node_type_check;
ALTER TABLE flow_nodes
ADD CONSTRAINT flow_nodes_node_type_check
CHECK (node_type IN (
'start',
'send_buttons',
'send_list',
'send_message',
'send_media',
'collect_input',
'condition',
'set_tag',
'handoff',
'http_fetch',
'end'
));
-- ============================================================
-- 2. flow-media storage bucket
-- ============================================================
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'flow-media',
'flow-media',
TRUE,
16777216, -- 16 MB (Meta video cap; documents/images fit under this)
ARRAY[
-- Images
'image/png', 'image/jpeg', 'image/webp',
-- Videos
'video/mp4', 'video/3gpp',
-- Documents
'application/pdf',
'application/vnd.ms-powerpoint',
'application/msword',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain'
]
)
ON CONFLICT (id) DO UPDATE
SET
public = EXCLUDED.public,
file_size_limit = EXCLUDED.file_size_limit,
allowed_mime_types = EXCLUDED.allowed_mime_types;
-- Policies live on storage.objects. Same drop-then-create pattern as
-- migration 008 (no CREATE POLICY IF NOT EXISTS in Postgres).
DROP POLICY IF EXISTS "Flow media is publicly readable" ON storage.objects;
CREATE POLICY "Flow media is publicly readable"
ON storage.objects FOR SELECT
USING (bucket_id = 'flow-media');
DROP POLICY IF EXISTS "Users can upload their own flow media" ON storage.objects;
CREATE POLICY "Users can upload their own flow media"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'flow-media'
AND auth.uid()::text = (storage.foldername(name))[1]
);
DROP POLICY IF EXISTS "Users can update their own flow media" ON storage.objects;
CREATE POLICY "Users can update their own flow media"
ON storage.objects FOR UPDATE
USING (
bucket_id = 'flow-media'
AND auth.uid()::text = (storage.foldername(name))[1]
);
DROP POLICY IF EXISTS "Users can delete their own flow media" ON storage.objects;
CREATE POLICY "Users can delete their own flow media"
ON storage.objects FOR DELETE
USING (
bucket_id = 'flow-media'
AND auth.uid()::text = (storage.foldername(name))[1]
);