mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
6c0c70486c
commit
62073d9915
@ -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<string, unknown> {
|
||||
},
|
||||
],
|
||||
};
|
||||
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" && (
|
||||
<SendMediaForm
|
||||
cfg={cfg as SendMediaCfg}
|
||||
allNodes={allNodes}
|
||||
currentKey={node.node_key}
|
||||
onUpdateConfig={onUpdateConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
{node.node_type === "collect_input" && (
|
||||
<>
|
||||
<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 ----
|
||||
|
||||
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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<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 {
|
||||
userId: string
|
||||
conversationId: string
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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", () => {
|
||||
it("walks the graph from the entry", () => {
|
||||
const set = reachableFromEntry("start", validNodes);
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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<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 {
|
||||
buildSendComponents,
|
||||
|
||||
114
supabase/migrations/016_flow_media.sql
Normal file
114
supabase/migrations/016_flow_media.sql
Normal 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]
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user