Merge pull request #264 from ArnasDon/feat/template-image-upload

feat(templates): upload image for template headers; fix image-URL submit (#230)
This commit is contained in:
Arnas Donauskas 2026-06-18 14:06:03 +03:00 committed by GitHub
commit 3b55b3f1d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 461 additions and 20 deletions

View File

@ -72,10 +72,12 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com
# See docs/automations-and-cron.md. # See docs/automations-and-cron.md.
# AUTOMATION_CRON_SECRET=generate-a-long-random-string # AUTOMATION_CRON_SECRET=generate-a-long-random-string
# Meta App ID (Meta for Developers → App Settings → Basic). Required # Meta App ID (Meta for Developers → App Settings → Basic). Required to
# only for the (planned) Resumable Upload flow that lets users attach # create/edit message templates with an IMAGE header: Meta only accepts a
# local media files as template header samples. Submitting templates # Resumable-Upload media handle (not a plain URL) as the header sample, and
# with a public sample URL works without it. Pair with META_APP_SECRET. # 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 # META_APP_ID=your-meta-app-id
# When "true", POST /api/whatsapp/templates/submit skips the Meta call # When "true", POST /api/whatsapp/templates/submit skips the Meta call

View File

@ -10,6 +10,7 @@ import {
type TemplatePayload, type TemplatePayload,
} from '@/lib/whatsapp/template-validators' } from '@/lib/whatsapp/template-validators'
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components' import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
/** /**
* Per-template lifecycle endpoint. * Per-template lifecycle endpoint.
@ -136,8 +137,6 @@ export async function PATCH(
) )
} }
const metaPayload = buildMetaTemplatePayload(payload)
if (!isDryRun()) { if (!isDryRun()) {
const { data: config, error: configError } = await supabase const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
@ -151,6 +150,19 @@ export async function PATCH(
) )
} }
const accessToken = decrypt(config.access_token) 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 { try {
await editMessageTemplate({ await editMessageTemplate({
metaTemplateId: existing.meta_template_id, metaTemplateId: existing.meta_template_id,

View File

@ -8,6 +8,7 @@ import {
type TemplatePayload, type TemplatePayload,
} from '@/lib/whatsapp/template-validators' } from '@/lib/whatsapp/template-validators'
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components' import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize' import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
/** /**
@ -137,8 +138,6 @@ export async function POST(request: Request) {
) )
} }
const metaPayload = buildMetaTemplatePayload(payload)
const dryRun = const dryRun =
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' || process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1' process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
@ -175,6 +174,21 @@ export async function POST(request: Request) {
} }
const accessToken = decrypt(config.access_token) 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 { try {
const meta = await submitMessageTemplate({ const meta = await submitMessageTemplate({
wabaId: config.waba_id, wabaId: config.waba_id,

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Plus, Plus,
@ -11,8 +11,13 @@ import {
X, X,
Pencil, Pencil,
RotateCcw, RotateCcw,
Upload,
} from 'lucide-react'; } from 'lucide-react';
import { createClient } from '@/lib/supabase/client'; 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 { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -137,6 +142,11 @@ export function TemplateManager() {
// doesn't take the template off Meta as well as locally. // doesn't take the template off Meta as well as locally.
const [templateToDelete, setTemplateToDelete] = const [templateToDelete, setTemplateToDelete] =
useState<MessageTemplate | null>(null); useState<MessageTemplate | null>(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<HTMLInputElement>(null);
// Body variable indices — `[1, 2, 3]` for "{{1}} {{2}} {{3}}". We // Body variable indices — `[1, 2, 3]` for "{{1}} {{2}} {{3}}". We
// re-run the extractor on every render to keep the sample-value rows // re-run the extractor on every render to keep the sample-value rows
@ -445,6 +455,29 @@ export function TemplateManager() {
const headerNeedsMedia = const headerNeedsMedia =
form.header_format !== 'none' && form.header_format !== 'text'; 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 ( return (
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
@ -777,24 +810,62 @@ export function TemplateManager() {
{headerNeedsMedia && ( {headerNeedsMedia && (
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2">
{form.header_format === 'image' && (
<div className="flex items-center gap-2">
<input
ref={headerFileRef}
type="file"
accept="image/jpeg,image/png"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleHeaderImageFile(f);
e.target.value = '';
}}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingHeader}
onClick={() => headerFileRef.current?.click()}
>
{uploadingHeader ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
Upload image
</Button>
<span className="text-[11px] text-muted-foreground">
JPEG or PNG, ≤5 MB
</span>
</div>
)}
<Input <Input
placeholder={`https://… (public link to a sample ${form.header_format})`} placeholder={`https://… (or paste a public ${form.header_format} link)`}
value={form.header_media_url} value={form.header_media_url}
onChange={(e) => onChange={(e) =>
setForm({ ...form, header_media_url: e.target.value }) setForm({ ...form, header_media_url: e.target.value })
} }
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" 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
<img
src={form.header_media_url}
alt="Header sample"
className="max-h-28 rounded-md border border-border object-contain"
/>
)}
<p className="text-[11px] text-muted-foreground leading-relaxed"> <p className="text-[11px] text-muted-foreground leading-relaxed">
Must be publicly accessible HTTPS. Meta fetches it once {form.header_format === 'image'
during review, so the file needs to stay live for ~24 hrs. ? 'Upload a JPEG/PNG (≤5 MB, ≥800×418 px recommended) or paste a public HTTPS link — we upload it to Meta for review automatically.'
{form.header_format === 'image' && : 'Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.'}
' Recommended: JPEG or PNG, ≥800×418 px, ≤5 MB.'}
{form.header_format === 'video' && {form.header_format === 'video' &&
' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'} ' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'}
{form.header_format === 'document' && {form.header_format === 'document' &&
' Recommended: PDF, ≤100 MB.'} ' Recommended: PDF, ≤100 MB.'}
{' '}Direct file upload is coming in a follow-up.
</p> </p>
</div> </div>
)} )}

View File

@ -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<string, string>;
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/);
});
});

View File

@ -443,6 +443,83 @@ export async function sendTemplateMessage(
return { messageId: data.messages[0].id } 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:<session>" }
// 2. POST /{id} (Authorization: OAuth <token>, file_offset: 0, raw bytes)
// → { h: "<handle>" }
//
// 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) // Template submission (Business Management API)
// ============================================================ // ============================================================

View File

@ -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> = {}): 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/);
});
});

View File

@ -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<void> {
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
}

View File

@ -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( const components = buildSendComponents(
row({ row({
header_type: 'document', header_type: 'document',
@ -126,7 +128,18 @@ describe('buildSendComponents — header', () => {
); );
expect(components[0]).toEqual({ expect(components[0]).toEqual({
type: 'header', 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' } }],
}); });
}); });

View File

@ -95,10 +95,16 @@ function buildHeaderComponent(
} }
// image / video / document — Meta requires the media component on // image / video / document — Meta requires the media component on
// every send. Prefer the caller's explicit override; fall back to // every send. Prefer the caller's explicit override; fall back to the
// the template's stored sample. // 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 link = params.headerMediaUrl ?? template.header_media_url;
const id = params.headerMediaId ?? template.header_handle; const id = params.headerMediaId;
if (!link && !id) { if (!link && !id) {
throw new Error( 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.`, `${headerType} header requires a media link or id at send time — set header_media_url on the template or pass headerMediaUrl/headerMediaId.`,