From 62073d99157d4c623c4b42875c4af522b33d162c Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 29 May 2026 19:11:29 +0200 Subject: [PATCH] feat(flows): add send_media node for image / video / document sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/flows/flow-builder.tsx | 267 +++++++++++++++++++++++++ src/lib/flows/engine.test.ts | 4 +- src/lib/flows/engine.ts | 33 +++ src/lib/flows/meta-send.ts | 118 +++++++++++ src/lib/flows/types.ts | 30 +++ src/lib/flows/validate.test.ts | 96 +++++++++ src/lib/flows/validate.ts | 60 ++++++ src/lib/whatsapp/meta-api.ts | 58 ++++++ supabase/migrations/016_flow_media.sql | 114 +++++++++++ 9 files changed, 779 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/016_flow_media.sql diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 5576766..27bad69 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -40,6 +40,9 @@ import { Inbox, GitFork, Tag, + Paperclip, + Upload, + X, } from "lucide-react"; import { toast } from "sonner"; @@ -66,6 +69,7 @@ import { type ValidationIssue, } from "@/lib/flows/validate"; import type { FlowNodeRow, FlowRow } from "@/lib/flows/types"; +import { createClient } from "@/lib/supabase/client"; interface FlowBuilderProps { initialFlow: FlowRow; @@ -83,6 +87,7 @@ type NodeType = | "send_message" | "send_buttons" | "send_list" + | "send_media" | "collect_input" | "condition" | "set_tag" @@ -130,6 +135,11 @@ const NODE_META: Record< icon: ListPlus, color: "text-indigo-400", }, + send_media: { + label: "Send media", + icon: Paperclip, + color: "text-cyan-400", + }, collect_input: { label: "Collect input", 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"}` : 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": { const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : ""; const varKey = typeof cfg.var_key === "string" ? cfg.var_key : ""; @@ -301,6 +326,14 @@ function defaultConfigFor(type: NodeType): Record { }, ], }; + case "send_media": + return { + media_type: "image", + media_url: "", + caption: "", + filename: "", + next_node_key: "", + }; case "collect_input": return { prompt_text: "", @@ -1133,6 +1166,15 @@ function NodeConfigForm({ /> )} + {node.node_type === "send_media" && ( + + )} + {node.node_type === "collect_input" && ( <> , 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) => void; +}) { + const fileInputRef = useRef(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 ( + <> +
+ + +
+ +
+ + {cfg.media_url ? ( + + ) : ( + + )} + { + const f = e.target.files?.[0]; + if (f) void handleFile(f); + // Reset so picking the same file twice still fires onChange. + e.target.value = ""; + }} + /> +
+ + onUpdateConfig({ caption: v })} + rows={2} + /> + + {isDocument && ( +
+ + onUpdateConfig({ filename: e.target.value })} + placeholder="invoice.pdf" + className="bg-slate-800 text-xs" + /> +
+ )} + + onUpdateConfig({ next_node_key: v })} + label="After sending, advance to" + /> + + ); +} + // ---- Smaller field components ---- function TextRow({ @@ -2006,6 +2272,7 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) { "send_buttons", "send_list", "send_message", + "send_media", "collect_input", "condition", "set_tag", diff --git a/src/lib/flows/engine.test.ts b/src/lib/flows/engine.test.ts index 6797523..9c95487 100644 --- a/src/lib/flows/engine.test.ts +++ b/src/lib/flows/engine.test.ts @@ -144,9 +144,10 @@ describe("matchesKeywordTrigger", () => { }); 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("send_message")).toBe(true); + expect(isAutoAdvancing("send_media")).toBe(true); expect(isAutoAdvancing("condition")).toBe(true); expect(isAutoAdvancing("set_tag")).toBe(true); expect(isAutoAdvancing("send_buttons")).toBe(false); @@ -182,6 +183,7 @@ describe("node classification helpers", () => { "send_message", "send_buttons", "send_list", + "send_media", "collect_input", "condition", "set_tag", diff --git a/src/lib/flows/engine.ts b/src/lib/flows/engine.ts index e08330d..e6ff4a2 100644 --- a/src/lib/flows/engine.ts +++ b/src/lib/flows/engine.ts @@ -36,6 +36,7 @@ import { supabaseAdmin } from "./admin-client"; import { engineSendInteractiveButtons, engineSendInteractiveList, + engineSendMedia, engineSendText, } from "./meta-send"; import { decideFallback, resolveFallbackPolicy } from "./fallback"; @@ -50,6 +51,7 @@ import { type ParsedInbound, type SendButtonsNodeConfig, type SendListNodeConfig, + type SendMediaNodeConfig, type SendMessageNodeConfig, type SetTagNodeConfig, type StartNodeConfig, @@ -112,6 +114,7 @@ export function isAutoAdvancing(node_type: string): boolean { return ( node_type === "start" || node_type === "send_message" || + node_type === "send_media" || node_type === "condition" || node_type === "set_tag" ); @@ -593,6 +596,36 @@ async function advanceFromNodeKey( currentKey = cfg.next_node_key; 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") { // Send the prompt and suspend. Customer's next TEXT reply will // wake us up via handleReplyForActiveRun's collect_input branch. diff --git a/src/lib/flows/meta-send.ts b/src/lib/flows/meta-send.ts index bdd72ee..3ec10cf 100644 --- a/src/lib/flows/meta-send.ts +++ b/src/lib/flows/meta-send.ts @@ -1,9 +1,11 @@ import { sendInteractiveButtons, sendInteractiveList, + sendMediaMessage, sendTextMessage, type InteractiveButton, type InteractiveListSection, + type MediaKind, } from '@/lib/whatsapp/meta-api' import { decrypt } from '@/lib/whatsapp/encryption' import { @@ -135,6 +137,122 @@ export async function engineSendText( 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 => { + 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 { userId: string conversationId: string diff --git a/src/lib/flows/types.ts b/src/lib/flows/types.ts index a2ad5ca..f552567 100644 --- a/src/lib/flows/types.ts +++ b/src/lib/flows/types.ts @@ -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 { /** Optional internal note written to flow_run_events.payload.note. */ note?: string; @@ -160,6 +189,7 @@ export type FlowNodeConfig = | { node_type: "send_message"; config: SendMessageNodeConfig } | { node_type: "send_buttons"; config: SendButtonsNodeConfig } | { node_type: "send_list"; config: SendListNodeConfig } + | { node_type: "send_media"; config: SendMediaNodeConfig } | { node_type: "collect_input"; config: CollectInputNodeConfig } | { node_type: "condition"; config: ConditionNodeConfig } | { node_type: "set_tag"; config: SetTagNodeConfig } diff --git a/src/lib/flows/validate.test.ts b/src/lib/flows/validate.test.ts index 11c43f9..8acd082 100644 --- a/src/lib/flows/validate.test.ts +++ b/src/lib/flows/validate.test.ts @@ -420,6 +420,102 @@ describe("validateFlowForActivation — nodes", () => { }); }); +describe("validateFlowForActivation — send_media", () => { + const baseFlow = { ...validFlow, entry_node_id: "s" }; + const nodesWith = (mediaConfig: Record) => [ + { 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", () => { it("walks the graph from the entry", () => { const set = reachableFromEntry("start", validNodes); diff --git a/src/lib/flows/validate.ts b/src/lib/flows/validate.ts index 214a25f..aac7d11 100644 --- a/src/lib/flows/validate.ts +++ b/src/lib/flows/validate.ts @@ -242,6 +242,65 @@ function validateNode( 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": { const cfg = node.config as { text?: string; @@ -690,6 +749,7 @@ function outgoingEdges(node: NodeInput): string[] { switch (node.node_type) { case "start": case "send_message": + case "send_media": case "collect_input": case "set_tag": { const cfg = node.config as { next_node_key?: string }; diff --git a/src/lib/whatsapp/meta-api.ts b/src/lib/whatsapp/meta-api.ts index 9bea86b..2c91b7f 100644 --- a/src/lib/whatsapp/meta-api.ts +++ b/src/lib/whatsapp/meta-api.ts @@ -259,6 +259,64 @@ export async function sendTextMessage( 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 { + 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 = { link } + if (caption) media.caption = caption + if (kind === 'document' && filename) media.filename = filename + + const body: Record = { + 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 { buildSendComponents, diff --git a/supabase/migrations/016_flow_media.sql b/supabase/migrations/016_flow_media.sql new file mode 100644 index 0000000..3aefe39 --- /dev/null +++ b/supabase/migrations/016_flow_media.sql @@ -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()}/-. +-- 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] + );