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,
|
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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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