diff --git a/src/app/api/whatsapp/templates/[id]/route.ts b/src/app/api/whatsapp/templates/[id]/route.ts new file mode 100644 index 0000000..3fbb0e0 --- /dev/null +++ b/src/app/api/whatsapp/templates/[id]/route.ts @@ -0,0 +1,269 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { decrypt } from '@/lib/whatsapp/encryption' +import { + deleteMessageTemplate, + editMessageTemplate, +} from '@/lib/whatsapp/meta-api' +import { + validateTemplatePayload, + type TemplatePayload, +} from '@/lib/whatsapp/template-validators' +import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components' + +/** + * Per-template lifecycle endpoint. + * + * PATCH — edit an existing Meta-side template (and re-submit). Used + * by the "Edit" action on APPROVED rows and the "Resubmit" + * action on REJECTED / PAUSED rows. Meta replaces components + * wholesale on edit and bumps status back to PENDING. + * + * DELETE — remove the template on Meta (when meta_template_id is set, + * scoped to a single language variant via hsm_id) AND drop + * the local row. Local-only rows skip the Meta call. + * + * Initial submission (DRAFT → PENDING) lives at the sibling + * /submit endpoint — keep this route narrowly about lifecycle of + * already-submitted templates. + */ + +const EDITABLE_STATUSES = new Set(['APPROVED', 'REJECTED', 'PAUSED']) + +function isDryRun(): boolean { + return ( + process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' || + process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1' + ) +} + +export async function PATCH( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await context.params + const supabase = await createClient() + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser() + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let payload: TemplatePayload + try { + payload = (await request.json()) as TemplatePayload + } catch { + return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 }) + } + + // RLS handles ownership, but we need the existing row to read + // meta_template_id and status — fetch explicitly. + const { data: existing, error: lookupErr } = await supabase + .from('message_templates') + .select('id, name, status, meta_template_id, language') + .eq('id', id) + .eq('user_id', user.id) + .maybeSingle() + if (lookupErr || !existing) { + return NextResponse.json({ error: 'Template not found.' }, { status: 404 }) + } + + if (!existing.meta_template_id) { + return NextResponse.json( + { + error: + 'This template was never submitted to Meta — use New Template to submit it instead.', + }, + { status: 400 }, + ) + } + + if (!EDITABLE_STATUSES.has(existing.status)) { + return NextResponse.json( + { + error: `Templates in status ${existing.status} cannot be edited. Allowed: APPROVED, REJECTED, PAUSED.`, + }, + { status: 400 }, + ) + } + + if (payload.category === 'Authentication') { + return NextResponse.json( + { + error: + 'AUTHENTICATION templates are not editable here — manage them in Meta WhatsApp Manager.', + }, + { status: 400 }, + ) + } + + try { + validateTemplatePayload(payload) + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'Validation failed.' }, + { status: 400 }, + ) + } + + const metaPayload = buildMetaTemplatePayload(payload) + + if (!isDryRun()) { + const { data: config, error: configError } = await supabase + .from('whatsapp_config') + .select('*') + .eq('user_id', user.id) + .single() + if (configError || !config) { + return NextResponse.json( + { error: 'WhatsApp not configured.' }, + { status: 400 }, + ) + } + const accessToken = decrypt(config.access_token) + try { + await editMessageTemplate({ + metaTemplateId: existing.meta_template_id, + accessToken, + components: metaPayload.components, + }) + } catch (e) { + const message = e instanceof Error ? e.message : 'Meta edit failed.' + await supabase + .from('message_templates') + .update({ + submission_error: message, + last_submitted_at: new Date().toISOString(), + }) + .eq('id', id) + return NextResponse.json({ error: message }, { status: 502 }) + } + } + + // Meta accepted the edit — status flips back to PENDING for review. + const { data: row, error: updErr } = await supabase + .from('message_templates') + .update({ + category: payload.category, + header_type: payload.header_type ?? null, + header_content: payload.header_content ?? null, + header_media_url: payload.header_media_url ?? null, + header_handle: payload.header_handle ?? null, + body_text: payload.body_text, + footer_text: payload.footer_text ?? null, + buttons: payload.buttons ?? null, + sample_values: payload.sample_values ?? null, + status: 'PENDING', + submission_error: null, + rejection_reason: null, + last_submitted_at: new Date().toISOString(), + }) + .eq('id', id) + .select() + .single() + + if (updErr) { + return NextResponse.json( + { + error: `Edited on Meta but failed to save locally: ${updErr.message}. Run "Sync from Meta" to recover.`, + }, + { status: 500 }, + ) + } + + return NextResponse.json({ + success: true, + template: row, + dry_run: isDryRun(), + }) + } catch (error) { + console.error('Error editing template:', error) + return NextResponse.json( + { + error: + error instanceof Error ? error.message : 'Failed to edit template.', + }, + { status: 500 }, + ) + } +} + +export async function DELETE( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await context.params + const supabase = await createClient() + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser() + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { data: existing, error: lookupErr } = await supabase + .from('message_templates') + .select('id, name, meta_template_id') + .eq('id', id) + .eq('user_id', user.id) + .maybeSingle() + if (lookupErr || !existing) { + return NextResponse.json({ error: 'Template not found.' }, { status: 404 }) + } + + if (existing.meta_template_id && !isDryRun()) { + const { data: config, error: configError } = await supabase + .from('whatsapp_config') + .select('*') + .eq('user_id', user.id) + .single() + if (configError || !config || !config.waba_id) { + return NextResponse.json( + { error: 'WhatsApp not configured — cannot delete on Meta.' }, + { status: 400 }, + ) + } + const accessToken = decrypt(config.access_token) + try { + await deleteMessageTemplate({ + wabaId: config.waba_id, + accessToken, + name: existing.name, + metaTemplateId: existing.meta_template_id, + }) + } catch (e) { + const message = e instanceof Error ? e.message : 'Meta delete failed.' + return NextResponse.json({ error: message }, { status: 502 }) + } + } + + const { error: delErr } = await supabase + .from('message_templates') + .delete() + .eq('id', id) + if (delErr) { + return NextResponse.json( + { + error: `Deleted on Meta but failed to delete locally: ${delErr.message}.`, + }, + { status: 500 }, + ) + } + + return NextResponse.json({ success: true, dry_run: isDryRun() }) + } catch (error) { + console.error('Error deleting template:', error) + return NextResponse.json( + { + error: + error instanceof Error ? error.message : 'Failed to delete template.', + }, + { status: 500 }, + ) + } +} diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx index c3e72eb..cacbe53 100644 --- a/src/components/settings/template-manager.tsx +++ b/src/components/settings/template-manager.tsx @@ -9,6 +9,8 @@ import { RefreshCw, AlertCircle, X, + Pencil, + RotateCcw, } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; @@ -125,6 +127,11 @@ export function TemplateManager() { const [submitting, setSubmitting] = useState(false); const [syncing, setSyncing] = useState(false); const [form, setForm] = useState(emptyForm); + // Non-null when the dialog is editing an existing row — switches the + // submit handler from POST /submit to PATCH /[id] and changes the + // dialog title + CTA. Set to the template id to pre-fill from a row. + const [editingId, setEditingId] = useState(null); + const [deletingId, setDeletingId] = useState(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 @@ -208,6 +215,30 @@ export function TemplateManager() { }; } + function openEdit(template: MessageTemplate) { + setEditingId(template.id); + setForm({ + name: template.name, + category: template.category, + language: template.language || 'en_US', + header_format: (template.header_type ?? 'none') as HeaderFormat, + header_content: template.header_content ?? '', + header_media_url: template.header_media_url ?? '', + header_sample: template.sample_values?.header?.[0] ?? '', + body_text: template.body_text, + body_samples: template.sample_values?.body ?? [], + footer_text: template.footer_text ?? '', + buttons: template.buttons ?? [], + }); + setDialogOpen(true); + } + + function openCreate() { + setEditingId(null); + setForm(emptyForm); + setDialogOpen(true); + } + async function handleSubmit() { if (form.category === 'Authentication') { toast.error( @@ -217,22 +248,33 @@ export function TemplateManager() { } try { setSubmitting(true); - const res = await fetch('/api/whatsapp/templates/submit', { - method: 'POST', + const isEdit = editingId !== null; + const url = isEdit + ? `/api/whatsapp/templates/${editingId}` + : '/api/whatsapp/templates/submit'; + const res = await fetch(url, { + method: isEdit ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildSubmitPayload()), }); const data = await res.json(); if (!res.ok) { - throw new Error(data?.error || `Submit failed (HTTP ${res.status})`); + throw new Error( + data?.error || `${isEdit ? 'Edit' : 'Submit'} failed (HTTP ${res.status})`, + ); } toast.success( data.dry_run - ? 'Template saved (dry-run — no Meta call)' - : 'Submitted to Meta — status will update once approved.', + ? isEdit + ? 'Template updated (dry-run — no Meta call)' + : 'Template saved (dry-run — no Meta call)' + : isEdit + ? 'Edit submitted — Meta will re-review.' + : 'Submitted to Meta — status will update once approved.', ); setDialogOpen(false); setForm(emptyForm); + setEditingId(null); if (user) await fetchTemplates(user.id); } catch (err) { console.error('Submit error:', err); @@ -281,17 +323,26 @@ export function TemplateManager() { } async function handleDelete(id: string) { + if (deletingId) return; + setDeletingId(id); try { - const { error } = await supabase - .from('message_templates') - .delete() - .eq('id', id); - if (error) throw error; + // Route handler scopes the Meta delete via hsm_id (so sibling + // language variants survive) and falls through to remove the + // local row. Local-only rows skip the Meta call. + const res = await fetch(`/api/whatsapp/templates/${id}`, { + method: 'DELETE', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data?.error || `Delete failed (HTTP ${res.status})`); + } toast.success('Template deleted'); setTemplates((prev) => prev.filter((t) => t.id !== id)); } catch (err) { console.error('Delete error:', err); - toast.error('Failed to delete template'); + toast.error(err instanceof Error ? err.message : 'Failed to delete template'); + } finally { + setDeletingId(null); } } @@ -359,10 +410,7 @@ export function TemplateManager() { {syncing ? 'Syncing…' : 'Sync from Meta'} +
+ {statusKey === 'APPROVED' && ( + + )} + {(statusKey === 'REJECTED' || statusKey === 'PAUSED') && ( + + )} + +
); @@ -454,13 +536,25 @@ export function TemplateManager() { )} - + { + setDialogOpen(open); + if (!open) { + setEditingId(null); + setForm(emptyForm); + } + }} + > - New Message Template + + {editingId ? 'Edit Message Template' : 'New Message Template'} + - Build a template and submit it to Meta for approval. Once - approved, you can use it in broadcasts and the inbox. + {editingId + ? 'Save your changes to re-submit to Meta. Status will flip back to PENDING during review.' + : 'Build a template and submit it to Meta for approval. Once approved, you can use it in broadcasts and the inbox.'} @@ -483,10 +577,13 @@ export function TemplateManager() { placeholder="e.g. order_confirmation" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} - className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" + disabled={editingId !== null} + className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 disabled:opacity-60 disabled:cursor-not-allowed" />

- Lowercase letters, digits, and underscores only. + {editingId + ? 'Name is fixed once a template exists on Meta — create a new template to change it.' + : 'Lowercase letters, digits, and underscores only.'}

@@ -528,7 +625,8 @@ export function TemplateManager() { onChange={(e) => setForm({ ...form, language: e.target.value }) } - className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" + disabled={editingId !== null} + className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 disabled:opacity-60 disabled:cursor-not-allowed" /> {COMMON_LANGUAGE_CODES.map((code) => ( @@ -536,8 +634,14 @@ export function TemplateManager() { ))}

- Must match the exact code on Meta — en_US and{' '} - en are distinct. + {editingId + ? 'Language is fixed once a template exists on Meta.' + : ( + <> + Must match the exact code on Meta — en_US{' '} + and en are distinct. + + )}

@@ -819,8 +923,10 @@ export function TemplateManager() { {submitting ? ( <> - Submitting… + {editingId ? 'Saving…' : 'Submitting…'} + ) : editingId ? ( + 'Save & Resubmit' ) : ( 'Submit for Approval' )} diff --git a/src/lib/whatsapp/meta-api.ts b/src/lib/whatsapp/meta-api.ts index 2686e5b..db52760 100644 --- a/src/lib/whatsapp/meta-api.ts +++ b/src/lib/whatsapp/meta-api.ts @@ -240,6 +240,87 @@ export async function submitMessageTemplate( } } +export interface EditMessageTemplateArgs { + /** Meta's template id (stored locally as `meta_template_id`). */ + metaTemplateId: string + accessToken: string + /** Send the full components array — Meta replaces, not patches. */ + components: MetaTemplateSubmitPayload['components'] + /** Optional — only certain category transitions are allowed by Meta. */ + category?: MetaTemplateSubmitPayload['category'] +} + +export interface EditMessageTemplateResult { + success: boolean +} + +/** + * Edit an existing (APPROVED or REJECTED) message template. + * + * Meta caps edits at 10 per 30 days (and 1 per 24h for APPROVED + * templates). Every edit re-triggers review, so the status flips + * back to PENDING until Meta approves the new components. + * + * Note: PENDING / DISABLED / IN_APPEAL templates cannot be edited + * — the route handler enforces that before calling here. + */ +export async function editMessageTemplate( + args: EditMessageTemplateArgs +): Promise { + const { metaTemplateId, accessToken, components, category } = args + const body: Record = { components } + if (category) body.category = category + const response = await fetch(`${META_API_BASE}/${metaTemplateId}`, { + 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().catch(() => ({})) + return { success: data?.success !== false } +} + +export interface DeleteMessageTemplateArgs { + wabaId: string + accessToken: string + name: string + /** + * Without `hsm_id`, Meta deletes EVERY language variant of the + * template with this `name`. Pass the row's `meta_template_id` + * to scope to a single variant. + */ + metaTemplateId?: string +} + +/** + * Delete a message template on Meta. Pass `metaTemplateId` to scope + * to a single language variant — otherwise Meta nukes every variant + * sharing the same `name`. + */ +export async function deleteMessageTemplate( + args: DeleteMessageTemplateArgs +): Promise { + const { wabaId, accessToken, name, metaTemplateId } = args + const params = new URLSearchParams({ name }) + if (metaTemplateId) params.set('hsm_id', metaTemplateId) + const url = `${META_API_BASE}/${wabaId}/message_templates?${params.toString()}` + const response = await fetch(url, { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + }) + // Treat a 404 as a no-op — the template is already gone on Meta's + // side, and we still want the local row removed. + if (response.status === 404) return + if (!response.ok) { + await throwMetaError(response, `Meta API error: ${response.status}`) + } +} + // ============================================================ // Reactions // ============================================================ diff --git a/src/lib/whatsapp/template-lifecycle.test.ts b/src/lib/whatsapp/template-lifecycle.test.ts new file mode 100644 index 0000000..bb06a6f --- /dev/null +++ b/src/lib/whatsapp/template-lifecycle.test.ts @@ -0,0 +1,224 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + deleteMessageTemplate, + editMessageTemplate, + submitMessageTemplate, +} from './meta-api'; + +// We mock fetch and assert on the request URL/method/body — these +// helpers have no validation of their own (they trust the validators +// upstream), so the contract we care about is the exact wire shape. + +function okResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function errorResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('submitMessageTemplate', () => { + let fetchMock: ReturnType; + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue( + okResponse({ id: '123', status: 'PENDING', category: 'UTILITY' }), + ); + vi.stubGlobal('fetch', fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('POSTs to /{wabaId}/message_templates with the payload as JSON', async () => { + const result = await submitMessageTemplate({ + wabaId: 'WABA1', + accessToken: 'tok', + payload: { + name: 't', + category: 'UTILITY', + language: 'en_US', + components: [{ type: 'BODY', text: 'hi' }], + }, + }); + expect(result).toEqual({ id: '123', status: 'PENDING', category: 'UTILITY' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain('/WABA1/message_templates'); + expect(init.method).toBe('POST'); + expect(init.headers.Authorization).toBe('Bearer tok'); + expect(JSON.parse(init.body)).toEqual({ + name: 't', + category: 'UTILITY', + language: 'en_US', + components: [{ type: 'BODY', text: 'hi' }], + }); + }); + + it('throws Meta\'s error message on non-OK responses', async () => { + fetchMock.mockResolvedValueOnce( + errorResponse(429, { + error: { message: 'Rate limit (#80007).' }, + }), + ); + await expect( + submitMessageTemplate({ + wabaId: 'W', + accessToken: 't', + payload: { + name: 'n', + category: 'UTILITY', + language: 'en_US', + components: [], + }, + }), + ).rejects.toThrow(/Rate limit/); + }); + + it('throws if Meta accepts but returns no id (data integrity guard)', async () => { + fetchMock.mockResolvedValueOnce(okResponse({ status: 'PENDING' })); + await expect( + submitMessageTemplate({ + wabaId: 'W', + accessToken: 't', + payload: { + name: 'n', + category: 'UTILITY', + language: 'en_US', + components: [], + }, + }), + ).rejects.toThrow(/no id/); + }); +}); + +describe('editMessageTemplate', () => { + let fetchMock: ReturnType; + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue(okResponse({ success: true })); + vi.stubGlobal('fetch', fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('POSTs to /{templateId} with only `components` in the body by default', async () => { + await editMessageTemplate({ + metaTemplateId: 'TMPL_42', + accessToken: 'tok', + components: [{ type: 'BODY', text: 'new body' }], + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain('/TMPL_42'); + expect(url).not.toContain('message_templates'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ + components: [{ type: 'BODY', text: 'new body' }], + }); + }); + + it('includes `category` when provided', async () => { + await editMessageTemplate({ + metaTemplateId: 'TMPL_42', + accessToken: 'tok', + components: [{ type: 'BODY', text: 'x' }], + category: 'MARKETING', + }); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(init.body)).toEqual({ + components: [{ type: 'BODY', text: 'x' }], + category: 'MARKETING', + }); + }); + + it('returns success:true on Meta success', async () => { + expect( + await editMessageTemplate({ + metaTemplateId: 'T', + accessToken: 't', + components: [], + }), + ).toEqual({ success: true }); + }); + + it('treats { success: false } as failure', async () => { + fetchMock.mockResolvedValueOnce(okResponse({ success: false })); + expect( + await editMessageTemplate({ + metaTemplateId: 'T', + accessToken: 't', + components: [], + }), + ).toEqual({ success: false }); + }); +}); + +describe('deleteMessageTemplate', () => { + let fetchMock: ReturnType; + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue(okResponse({ success: true })); + vi.stubGlobal('fetch', fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('DELETEs with name only when no metaTemplateId is given', async () => { + await deleteMessageTemplate({ + wabaId: 'W', + accessToken: 't', + name: 'order_confirmation', + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain('/W/message_templates'); + expect(url).toContain('name=order_confirmation'); + expect(url).not.toContain('hsm_id'); + expect(init.method).toBe('DELETE'); + }); + + it('scopes to one language variant by including hsm_id', async () => { + // This is THE bug the plan flagged: without hsm_id, Meta deletes + // every language variant of `name`. Verifying it's always sent. + await deleteMessageTemplate({ + wabaId: 'W', + accessToken: 't', + name: 'order_confirmation', + metaTemplateId: '12345', + }); + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain('name=order_confirmation'); + expect(url).toContain('hsm_id=12345'); + }); + + it('treats 404 as a no-op (template already gone on Meta)', async () => { + fetchMock.mockResolvedValueOnce( + errorResponse(404, { error: { message: 'not found' } }), + ); + await expect( + deleteMessageTemplate({ + wabaId: 'W', + accessToken: 't', + name: 'x', + metaTemplateId: 'y', + }), + ).resolves.toBeUndefined(); + }); + + it('throws on non-404 errors', async () => { + fetchMock.mockResolvedValueOnce( + errorResponse(500, { error: { message: 'boom' } }), + ); + await expect( + deleteMessageTemplate({ + wabaId: 'W', + accessToken: 't', + name: 'x', + metaTemplateId: 'y', + }), + ).rejects.toThrow(/boom/); + }); +});