mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Agents can now attach a photo/video/document or record a voice note in the inbox composer and send it live in a 1:1 thread — previously media could only be received from customers or sent via the Flows builder. - 023_chat_media.sql: new account-scoped `chat-media` Storage bucket (mirrors flow-media RLS from 020) plus Meta-accepted audio MIME types. - meta-api: sendMediaMessage gains an `audio` kind (link-only — Meta rejects caption/filename on audio; WhatsApp renders OGG/Opus as a voice note). - /api/whatsapp/send: media branch reusing the phone-variant retry loop. - /api/whatsapp/transcode-audio: Node-runtime route remuxing the WebM/Opus that Chromium's MediaRecorder produces to OGG/Opus via ffmpeg-static (Firefox/Safari record accepted formats and skip it). - composer: attach menu, file pickers, voice recorder + auto-transcode, preview with caption; thread: optimistic handleSendMedia. - shared uploadAccountMedia helper (flows form refactored onto it). - next.config: allow microphone=(self) (was fully blocked) + CSP media-src; document optional FFMPEG_PATH override. Tests: meta-api audio payload shape, buildMediaPath, audio-transcode helpers (14 new). typecheck/lint/build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
4.6 KiB
SQL
123 lines
4.6 KiB
SQL
-- ============================================================
|
|
-- 023_chat_media.sql
|
|
--
|
|
-- Adds the `chat-media` Supabase Storage bucket used when an agent
|
|
-- sends a photo / video / document / voice note from the inbox
|
|
-- composer (issue #213). Today media can only be RECEIVED from
|
|
-- customers or sent via the Flows `send_media` node — never typed
|
|
-- and sent live in a 1:1 thread.
|
|
--
|
|
-- Mirrors the `flow-media` bucket (migration 016) and its
|
|
-- account-scoped storage RLS (migration 020), with two differences:
|
|
--
|
|
-- 1. A separate bucket so chat attachments and flow-builder media
|
|
-- stay conceptually distinct (and so a future per-bucket size /
|
|
-- retention policy can diverge without touching flows).
|
|
--
|
|
-- 2. The allowed MIME list adds the audio types Meta accepts for
|
|
-- outbound voice notes — audio/ogg (Opus), audio/mpeg, audio/aac,
|
|
-- audio/mp4, audio/amr. Browser recordings (WebM/Opus) are
|
|
-- transcoded to audio/ogg BEFORE upload, so WebM never lands
|
|
-- here and isn't allow-listed.
|
|
--
|
|
-- Path convention (same as flow-media post-020):
|
|
-- chat-media/account-<account_id>/<timestamp>-<basename>.<ext>
|
|
-- The bucket is public so Meta can fetch the URL without auth; writes
|
|
-- are scoped to account members via the path's first segment.
|
|
--
|
|
-- Size limit 16 MB — Meta's tightest universal cap (video). Documents
|
|
-- can technically be 100 MB on Meta, but we hold the universal cap to
|
|
-- match flow-media and keep one limit to reason about.
|
|
--
|
|
-- Idempotent — safe to re-run.
|
|
-- ============================================================
|
|
|
|
-- ============================================================
|
|
-- 1. chat-media storage bucket
|
|
-- ============================================================
|
|
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
|
VALUES (
|
|
'chat-media',
|
|
'chat-media',
|
|
TRUE,
|
|
16777216, -- 16 MB (Meta video cap; documents/images/audio 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',
|
|
-- Audio (voice notes) — only Meta-accepted outbound types. Browser
|
|
-- WebM/Opus is transcoded to audio/ogg before upload.
|
|
'audio/ogg',
|
|
'audio/mpeg',
|
|
'audio/aac',
|
|
'audio/mp4',
|
|
'audio/amr'
|
|
]
|
|
)
|
|
ON CONFLICT (id) DO UPDATE
|
|
SET
|
|
public = EXCLUDED.public,
|
|
file_size_limit = EXCLUDED.file_size_limit,
|
|
allowed_mime_types = EXCLUDED.allowed_mime_types;
|
|
|
|
-- ============================================================
|
|
-- 2. Storage RLS — account-scoped writes, public reads
|
|
--
|
|
-- Same predicate shape as migration 020's flow-media policies:
|
|
-- writes are allowed when the path's first segment is
|
|
-- `account-<account_id>` for an account the caller belongs to.
|
|
-- Reads are public (the bucket is public so Meta can fetch links).
|
|
--
|
|
-- Drop-then-create (Postgres has no CREATE POLICY IF NOT EXISTS).
|
|
-- ============================================================
|
|
DROP POLICY IF EXISTS "Chat media is publicly readable" ON storage.objects;
|
|
CREATE POLICY "Chat media is publicly readable"
|
|
ON storage.objects FOR SELECT
|
|
USING (bucket_id = 'chat-media');
|
|
|
|
DROP POLICY IF EXISTS "Members can upload chat media" ON storage.objects;
|
|
CREATE POLICY "Members can upload chat media"
|
|
ON storage.objects FOR INSERT
|
|
WITH CHECK (
|
|
bucket_id = 'chat-media'
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.profiles p
|
|
WHERE p.user_id = auth.uid()
|
|
AND ('account-' || p.account_id::text) = (storage.foldername(name))[1]
|
|
)
|
|
);
|
|
|
|
DROP POLICY IF EXISTS "Members can update chat media" ON storage.objects;
|
|
CREATE POLICY "Members can update chat media"
|
|
ON storage.objects FOR UPDATE
|
|
USING (
|
|
bucket_id = 'chat-media'
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.profiles p
|
|
WHERE p.user_id = auth.uid()
|
|
AND ('account-' || p.account_id::text) = (storage.foldername(name))[1]
|
|
)
|
|
);
|
|
|
|
DROP POLICY IF EXISTS "Members can delete chat media" ON storage.objects;
|
|
CREATE POLICY "Members can delete chat media"
|
|
ON storage.objects FOR DELETE
|
|
USING (
|
|
bucket_id = 'chat-media'
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.profiles p
|
|
WHERE p.user_id = auth.uid()
|
|
AND ('account-' || p.account_id::text) = (storage.foldername(name))[1]
|
|
)
|
|
);
|