diff --git a/.env.local.example b/.env.local.example index 39162d0..92a2d7e 100644 --- a/.env.local.example +++ b/.env.local.example @@ -72,10 +72,12 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com # See docs/automations-and-cron.md. # AUTOMATION_CRON_SECRET=generate-a-long-random-string -# Meta App ID (Meta for Developers → App Settings → Basic). Required -# only for the (planned) Resumable Upload flow that lets users attach -# local media files as template header samples. Submitting templates -# with a public sample URL works without it. Pair with META_APP_SECRET. +# Meta App ID (Meta for Developers → App Settings → Basic). Required to +# create/edit message templates with an IMAGE header: Meta only accepts a +# Resumable-Upload media handle (not a plain URL) as the header sample, and +# that upload is app-scoped. Without it, image-header template submission +# returns a clear error; text/body-only templates are unaffected. Pair +# with META_APP_SECRET. # META_APP_ID=your-meta-app-id # When "true", POST /api/whatsapp/templates/submit skips the Meta call diff --git a/src/app/api/whatsapp/templates/[id]/route.ts b/src/app/api/whatsapp/templates/[id]/route.ts index f259ded..d331cdd 100644 --- a/src/app/api/whatsapp/templates/[id]/route.ts +++ b/src/app/api/whatsapp/templates/[id]/route.ts @@ -10,6 +10,7 @@ import { type TemplatePayload, } from '@/lib/whatsapp/template-validators' import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components' +import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle' /** * Per-template lifecycle endpoint. @@ -136,8 +137,6 @@ export async function PATCH( ) } - const metaPayload = buildMetaTemplatePayload(payload) - if (!isDryRun()) { const { data: config, error: configError } = await supabase .from('whatsapp_config') @@ -151,6 +150,19 @@ export async function PATCH( ) } const accessToken = decrypt(config.access_token) + + // Image headers need a fresh Resumable-Upload handle on every edit + // (Meta replaces components wholesale). Derive from header_media_url. + try { + await ensureImageHeaderHandle(payload, accessToken) + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'Header image upload failed.' }, + { status: 400 }, + ) + } + + const metaPayload = buildMetaTemplatePayload(payload) try { await editMessageTemplate({ metaTemplateId: existing.meta_template_id, diff --git a/src/app/api/whatsapp/templates/submit/route.ts b/src/app/api/whatsapp/templates/submit/route.ts index 8056223..2569f59 100644 --- a/src/app/api/whatsapp/templates/submit/route.ts +++ b/src/app/api/whatsapp/templates/submit/route.ts @@ -8,6 +8,7 @@ import { type TemplatePayload, } from '@/lib/whatsapp/template-validators' import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components' +import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle' import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize' /** @@ -137,8 +138,6 @@ export async function POST(request: Request) { ) } - const metaPayload = buildMetaTemplatePayload(payload) - const dryRun = process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' || process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1' @@ -175,6 +174,21 @@ export async function POST(request: Request) { } const accessToken = decrypt(config.access_token) + + // Image headers need a Resumable-Upload handle (Meta rejects a + // plain URL at creation). Derive it from header_media_url before + // building the payload. Surfaces a 400 with an actionable message + // (missing META_APP_ID, unreachable URL, wrong type/size). + try { + await ensureImageHeaderHandle(payload, accessToken) + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'Header image upload failed.' }, + { status: 400 }, + ) + } + + const metaPayload = buildMetaTemplatePayload(payload) try { const meta = await submitMessageTemplate({ wabaId: config.waba_id, diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx index 1e74964..b145b04 100644 --- a/src/components/settings/template-manager.tsx +++ b/src/components/settings/template-manager.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { Plus, @@ -11,8 +11,13 @@ import { X, Pencil, RotateCcw, + Upload, } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; +import { + uploadAccountMedia, + MEDIA_MAX_BYTES_BY_KIND, +} from '@/lib/storage/upload-media'; import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -137,6 +142,11 @@ export function TemplateManager() { // doesn't take the template off Meta as well as locally. const [templateToDelete, setTemplateToDelete] = useState(null); + // Header-image upload (issue #230). Uploads to the account-scoped + // chat-media bucket and stores the public URL in header_media_url; the + // submit route turns that into a Meta Resumable-Upload handle. + const [uploadingHeader, setUploadingHeader] = useState(false); + const headerFileRef = useRef(null); // Body variable indices — `[1, 2, 3]` for "{{1}} {{2}} {{3}}". We // re-run the extractor on every render to keep the sample-value rows @@ -445,6 +455,29 @@ export function TemplateManager() { const headerNeedsMedia = form.header_format !== 'none' && form.header_format !== 'text'; + async function handleHeaderImageFile(file: File) { + if (!['image/jpeg', 'image/png'].includes(file.type)) { + toast.error('Header image must be a JPEG or PNG.'); + return; + } + if (file.size > MEDIA_MAX_BYTES_BY_KIND.image) { + toast.error( + `Image is ${(file.size / 1024 / 1024).toFixed(1)} MB — Meta's limit is 5 MB.`, + ); + return; + } + setUploadingHeader(true); + try { + const { publicUrl } = await uploadAccountMedia('chat-media', file); + setForm((f) => ({ ...f, header_media_url: publicUrl })); + toast.success('Image uploaded.'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Upload failed.'); + } finally { + setUploadingHeader(false); + } + } + return (
@@ -777,24 +810,62 @@ export function TemplateManager() { {headerNeedsMedia && (
+ {form.header_format === 'image' && ( +
+ { + const f = e.target.files?.[0]; + if (f) void handleHeaderImageFile(f); + e.target.value = ''; + }} + /> + + + JPEG or PNG, ≤5 MB + +
+ )} setForm({ ...form, header_media_url: e.target.value }) } className="bg-muted border-border text-foreground placeholder:text-muted-foreground" /> + {form.header_format === 'image' && form.header_media_url && ( + // eslint-disable-next-line @next/next/no-img-element + Header sample + )}

- Must be publicly accessible HTTPS. Meta fetches it once - during review, so the file needs to stay live for ~24 hrs. - {form.header_format === 'image' && - ' Recommended: JPEG or PNG, ≥800×418 px, ≤5 MB.'} + {form.header_format === 'image' + ? 'Upload a JPEG/PNG (≤5 MB, ≥800×418 px recommended) or paste a public HTTPS link — we upload it to Meta for review automatically.' + : 'Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.'} {form.header_format === 'video' && ' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'} {form.header_format === 'document' && ' Recommended: PDF, ≤100 MB.'} - {' '}Direct file upload is coming in a follow-up.

)} diff --git a/src/lib/whatsapp/meta-api.resumable.test.ts b/src/lib/whatsapp/meta-api.resumable.test.ts new file mode 100644 index 0000000..81729a9 --- /dev/null +++ b/src/lib/whatsapp/meta-api.resumable.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { uploadResumableMedia } from './meta-api'; + +// Capture the two requests the resumable upload makes. +const calls: Array<{ url: string; init?: RequestInit }> = []; + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { + ok, + status, + json: async () => body, + } as Response; +} + +describe('uploadResumableMedia', () => { + beforeEach(() => { + calls.length = 0; + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('opens a session then uploads the bytes and returns the handle', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ url, init }); + if (url.includes('/uploads?')) { + return jsonResponse({ id: 'upload:SESSION123' }); + } + return jsonResponse({ h: '2:HANDLE' }); + }), + ); + + const { handle } = await uploadResumableMedia({ + appId: 'app-1', + accessToken: 'tok', + fileName: 'header.jpg', + mimeType: 'image/jpeg', + bytes: new Uint8Array([1, 2, 3, 4]), + }); + + expect(handle).toBe('2:HANDLE'); + expect(calls).toHaveLength(2); + + // Step 1: app-scoped session with file metadata + access_token query. + expect(calls[0].url).toContain('/app-1/uploads?'); + expect(calls[0].url).toContain('file_length=4'); + expect(calls[0].url).toContain('file_type=image%2Fjpeg'); + expect(calls[0].url).toContain('access_token=tok'); + + // Step 2: posts to the returned session id with OAuth + file_offset. + expect(calls[1].url).toContain('/upload:SESSION123'); + const headers = calls[1].init?.headers as Record; + expect(headers.Authorization).toBe('OAuth tok'); + expect(headers.file_offset).toBe('0'); + }); + + it('throws if the session response has no id', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}))); + await expect( + uploadResumableMedia({ + appId: 'a', + accessToken: 't', + fileName: 'h.png', + mimeType: 'image/png', + bytes: new Uint8Array([0]), + }), + ).rejects.toThrow(/session id/); + }); + + it('throws if the upload response has no handle', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => + url.includes('/uploads?') + ? jsonResponse({ id: 'upload:S' }) + : jsonResponse({}), + ), + ); + await expect( + uploadResumableMedia({ + appId: 'a', + accessToken: 't', + fileName: 'h.png', + mimeType: 'image/png', + bytes: new Uint8Array([0]), + }), + ).rejects.toThrow(/file handle/); + }); +}); diff --git a/src/lib/whatsapp/meta-api.ts b/src/lib/whatsapp/meta-api.ts index 4789bfa..bf9f28d 100644 --- a/src/lib/whatsapp/meta-api.ts +++ b/src/lib/whatsapp/meta-api.ts @@ -443,6 +443,83 @@ export async function sendTemplateMessage( return { messageId: data.messages[0].id } } +// ============================================================ +// Resumable Upload (media handles for template headers) +// ============================================================ +// +// Creating a message template with a media HEADER (image/video/ +// document) requires an `example.header_handle` — Meta does NOT accept +// a plain public URL at creation time. The handle comes from the +// two-step Resumable Upload API, which is keyed on the Meta APP id (not +// the phone number / WABA): +// +// 1. POST /{app_id}/uploads?file_name&file_length&file_type&access_token +// → { id: "upload:" } +// 2. POST /{id} (Authorization: OAuth , file_offset: 0, raw bytes) +// → { h: "" } +// +// See https://developers.facebook.com/docs/graph-api/guides/upload + +export interface UploadResumableMediaArgs { + /** Meta App id (env META_APP_ID) — resumable upload is app-scoped. */ + appId: string + accessToken: string + fileName: string + mimeType: string + bytes: Uint8Array +} + +/** + * Upload a file via the Resumable Upload API and return the media + * handle to use as `example.header_handle` when creating/editing a + * template with a media header. + */ +export async function uploadResumableMedia( + args: UploadResumableMediaArgs, +): Promise<{ handle: string }> { + const { appId, accessToken, fileName, mimeType, bytes } = args + + // Step 1 — open an upload session. + const startParams = new URLSearchParams({ + file_name: fileName, + file_length: String(bytes.byteLength), + file_type: mimeType, + access_token: accessToken, + }) + const startRes = await fetch( + `${META_API_BASE}/${appId}/uploads?${startParams.toString()}`, + { method: 'POST' }, + ) + if (!startRes.ok) { + await throwMetaError(startRes, `Resumable upload start failed: ${startRes.status}`) + } + const startData = (await startRes.json()) as { id?: string } + if (!startData.id) { + throw new Error('Resumable upload did not return a session id.') + } + + // Step 2 — upload the bytes. Note the `OAuth` auth scheme (not Bearer) + // and the file_offset header, both required by this endpoint. + const uploadRes = await fetch(`${META_API_BASE}/${startData.id}`, { + method: 'POST', + headers: { + Authorization: `OAuth ${accessToken}`, + file_offset: '0', + }, + // Uint8Array is a valid BodyInit at runtime; cast around the + // lib.dom ArrayBufferLike-vs-ArrayBuffer generic mismatch. + body: bytes as unknown as BodyInit, + }) + if (!uploadRes.ok) { + await throwMetaError(uploadRes, `Resumable upload failed: ${uploadRes.status}`) + } + const uploadData = (await uploadRes.json()) as { h?: string } + if (!uploadData.h) { + throw new Error('Resumable upload did not return a file handle.') + } + return { handle: uploadData.h } +} + // ============================================================ // Template submission (Business Management API) // ============================================================ diff --git a/src/lib/whatsapp/template-header-handle.test.ts b/src/lib/whatsapp/template-header-handle.test.ts new file mode 100644 index 0000000..f73af6d --- /dev/null +++ b/src/lib/whatsapp/template-header-handle.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Stub the Meta resumable upload so the helper is tested in isolation. +vi.mock('./meta-api', () => ({ + uploadResumableMedia: vi.fn(async () => ({ handle: 'HANDLE123' })), +})); + +import { ensureImageHeaderHandle } from './template-header-handle'; +import { uploadResumableMedia } from './meta-api'; +import type { TemplatePayload } from './template-validators'; + +function payload(over: Partial = {}): TemplatePayload { + return { + name: 't', + category: 'Utility', + language: 'en_US', + body_text: 'hi', + header_type: 'image', + header_media_url: 'https://x.test/img.jpg', + ...over, + }; +} + +function imgResponse(type = 'image/jpeg', size = 1024, ok = true, status = 200): Response { + return { + ok, + status, + headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? type : null) }, + arrayBuffer: async () => new ArrayBuffer(size), + } as unknown as Response; +} + +describe('ensureImageHeaderHandle', () => { + beforeEach(() => { + vi.mocked(uploadResumableMedia).mockClear(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('is a no-op for non-image headers', async () => { + const p = payload({ header_type: 'text', header_content: 'Hi' }); + await ensureImageHeaderHandle(p, 'tok'); + expect(uploadResumableMedia).not.toHaveBeenCalled(); + expect(p.header_handle).toBeUndefined(); + }); + + it('is a no-op when a handle already exists', async () => { + const p = payload({ header_handle: 'existing' }); + await ensureImageHeaderHandle(p, 'tok'); + expect(uploadResumableMedia).not.toHaveBeenCalled(); + expect(p.header_handle).toBe('existing'); + }); + + it('throws an actionable error when META_APP_ID is unset', async () => { + const p = payload(); + await expect(ensureImageHeaderHandle(p, 'tok')).rejects.toThrow(/META_APP_ID/); + }); + + it('derives + sets header_handle from a valid image URL', async () => { + vi.stubEnv('META_APP_ID', 'app-1'); + vi.stubGlobal('fetch', vi.fn(async () => imgResponse('image/jpeg', 2048))); + const p = payload(); + await ensureImageHeaderHandle(p, 'tok'); + expect(uploadResumableMedia).toHaveBeenCalledOnce(); + expect(p.header_handle).toBe('HANDLE123'); + }); + + it('rejects a non-image content type', async () => { + vi.stubEnv('META_APP_ID', 'app-1'); + vi.stubGlobal('fetch', vi.fn(async () => imgResponse('text/html'))); + await expect(ensureImageHeaderHandle(payload(), 'tok')).rejects.toThrow(/JPEG or PNG/); + }); + + it('rejects an image over 5 MB', async () => { + vi.stubEnv('META_APP_ID', 'app-1'); + vi.stubGlobal('fetch', vi.fn(async () => imgResponse('image/png', 6 * 1024 * 1024))); + await expect(ensureImageHeaderHandle(payload(), 'tok')).rejects.toThrow(/5 MB/); + }); +}); diff --git a/src/lib/whatsapp/template-header-handle.ts b/src/lib/whatsapp/template-header-handle.ts new file mode 100644 index 0000000..eb05183 --- /dev/null +++ b/src/lib/whatsapp/template-header-handle.ts @@ -0,0 +1,74 @@ +import { uploadResumableMedia } from '@/lib/whatsapp/meta-api' +import type { TemplatePayload } from '@/lib/whatsapp/template-validators' + +/** + * Meta requires an `example.header_handle` (from the Resumable Upload + * API) to create/edit a template with an IMAGE header — a plain public + * URL is not accepted at creation time. This helper turns the template's + * `header_media_url` (whether the user uploaded a file or pasted a link) + * into a handle and writes it onto the payload, so both the upload path + * and the legacy URL path actually succeed. + * + * No-op unless the header is an image that has a URL but no handle yet. + * Image-only for now (the #230 scope); video/document handles can follow + * the same shape. + */ + +// Meta's image-header sample limits. +const IMAGE_MAX_BYTES = 5 * 1024 * 1024 +const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png'] + +export async function ensureImageHeaderHandle( + payload: TemplatePayload, + accessToken: string, +): Promise { + if (payload.header_type !== 'image') return + if (payload.header_handle) return // already have one + if (!payload.header_media_url) return // validator already requires url-or-handle + + const appId = process.env.META_APP_ID + if (!appId) { + throw new Error( + 'Image-header templates need META_APP_ID set (used for Meta’s Resumable Upload). Add it to your environment, or remove the image header.', + ) + } + + // Fetch the sample image bytes (works for our uploaded chat-media URL + // and for a manually-pasted public link). + let res: Response + try { + res = await fetch(payload.header_media_url) + } catch { + throw new Error('Could not fetch the header image URL. Make sure it is publicly reachable.') + } + if (!res.ok) { + throw new Error(`Header image URL returned ${res.status}. It must be publicly reachable.`) + } + + const contentType = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() + if (contentType && !ALLOWED_IMAGE_TYPES.includes(contentType)) { + throw new Error(`Header image must be JPEG or PNG (got ${contentType}).`) + } + + const bytes = new Uint8Array(await res.arrayBuffer()) + if (bytes.byteLength === 0) { + throw new Error('Header image is empty.') + } + if (bytes.byteLength > IMAGE_MAX_BYTES) { + throw new Error( + `Header image is ${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB — Meta's limit is 5 MB.`, + ) + } + + const mimeType = ALLOWED_IMAGE_TYPES.includes(contentType) ? contentType : 'image/jpeg' + const fileName = mimeType === 'image/png' ? 'header.png' : 'header.jpg' + + const { handle } = await uploadResumableMedia({ + appId, + accessToken, + fileName, + mimeType, + bytes, + }) + payload.header_handle = handle +} diff --git a/src/lib/whatsapp/template-send-builder.test.ts b/src/lib/whatsapp/template-send-builder.test.ts index c32cce6..625f77e 100644 --- a/src/lib/whatsapp/template-send-builder.test.ts +++ b/src/lib/whatsapp/template-send-builder.test.ts @@ -116,7 +116,9 @@ describe('buildSendComponents — header', () => { }); }); - it('prefers media id over url when both are available', () => { + it('does NOT use the resumable header_handle at send — falls back to the stored link', () => { + // header_handle is a template-CREATION sample handle, invalid as a + // send-time media id. The send must use the public link instead. const components = buildSendComponents( row({ header_type: 'document', @@ -126,7 +128,18 @@ describe('buildSendComponents — header', () => { ); expect(components[0]).toEqual({ type: 'header', - parameters: [{ type: 'document', document: { id: '4::aBc' } }], + parameters: [{ type: 'document', document: { link: 'https://x.com/doc.pdf' } }], + }); + }); + + it('uses an explicit headerMediaId override as the media id', () => { + const components = buildSendComponents( + row({ header_type: 'image', header_media_url: 'https://x.com/s.jpg' }), + { headerMediaId: '9:realMediaId' }, + ); + expect(components[0]).toEqual({ + type: 'header', + parameters: [{ type: 'image', image: { id: '9:realMediaId' } }], }); }); diff --git a/src/lib/whatsapp/template-send-builder.ts b/src/lib/whatsapp/template-send-builder.ts index 2bdbbc3..8e72237 100644 --- a/src/lib/whatsapp/template-send-builder.ts +++ b/src/lib/whatsapp/template-send-builder.ts @@ -95,10 +95,16 @@ function buildHeaderComponent( } // image / video / document — Meta requires the media component on - // every send. Prefer the caller's explicit override; fall back to - // the template's stored sample. + // every send. Prefer the caller's explicit override; fall back to the + // template's stored public URL. + // + // NOTE: `template.header_handle` is intentionally NOT used here. It's a + // Resumable-Upload handle that's only valid as the *creation-time* + // sample (`example.header_handle`); it is NOT a reusable send-time + // media id, and passing it as `{ id }` makes Meta reject the send. Only + // an explicit `headerMediaId` (a real /media upload id) is honored. const link = params.headerMediaUrl ?? template.header_media_url; - const id = params.headerMediaId ?? template.header_handle; + const id = params.headerMediaId; if (!link && !id) { throw new Error( `${headerType} header requires a media link or id at send time — set header_media_url on the template or pass headerMediaUrl/headerMediaId.`,