mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(templates): edit / resubmit / delete lifecycle endpoints
Adds the missing lifecycle operations so users can manage existing
templates without leaving wacrm.
API surface
-----------
PATCH /api/whatsapp/templates/[id]
Edits (and re-submits) an existing Meta-side template. Used by
the "Edit" action on APPROVED rows and "Resubmit" on REJECTED /
PAUSED rows — same operation, different UI labels. Meta replaces
components wholesale and flips status back to PENDING for
re-review. Status guard rejects edits on PENDING / DISABLED /
IN_APPEAL / PENDING_DELETION and on local-only (no
meta_template_id) rows. Honors WHATSAPP_TEMPLATES_DRY_RUN.
DELETE /api/whatsapp/templates/[id]
Calls Meta `DELETE /{waba_id}/message_templates?name=X&hsm_id=Y`
when meta_template_id is set — hsm_id is critical, without it
Meta deletes EVERY language variant of the same name. Treats
Meta 404 as a no-op (template already gone). Always removes the
local row.
New Meta helpers
----------------
src/lib/whatsapp/meta-api.ts gains editMessageTemplate and
deleteMessageTemplate following the established named-args +
throwMetaError pattern. Eleven new fetch-mock tests assert the
exact wire shape — especially that hsm_id always rides along when
provided, which is the difference between "delete one variant"
and "delete every variant."
UI changes
----------
src/components/settings/template-manager.tsx:
* Edit (pencil) button visible for APPROVED rows
* Resubmit (rotate) button visible for REJECTED / PAUSED rows
— same flow as Edit, different label
* Delete now calls the route handler (so Meta gets the call too)
with a spinner during the in-flight request
* Dialog pre-fills form from the row when opened in edit mode
* Dialog title / CTA / description switch between "New Message
Template" + "Submit for Approval" and "Edit Message Template"
+ "Save & Resubmit"
* Name + language inputs are disabled in edit mode (Meta keys
templates by id; changing either is effectively a new template)
* Closing the dialog clears editingId so the next open starts
fresh
Design decision
---------------
One PATCH endpoint instead of the two endpoints the plan called
for (PATCH for edit, POST for resubmit). The Meta call is the same
operation either way — the difference is UI semantics, handled in
the component. Cuts duplication; easy to split later if needed.
Out of scope (final PR remaining in the sequence):
* Webhook handler for real-time status (PR 5)
* sendTemplateMessage media + button param support (PR 6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f329b8268f
commit
a2100a3076
269
src/app/api/whatsapp/templates/[id]/route.ts
Normal file
269
src/app/api/whatsapp/templates/[id]/route.ts
Normal file
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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<TemplateFormData>(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<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(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'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onClick={openCreate}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
@ -439,14 +487,48 @@ export function TemplateManager() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(template.id)}
|
||||
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 shrink-0 ml-2"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-0.5 shrink-0 ml-2">
|
||||
{statusKey === 'APPROVED' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(template)}
|
||||
title="Edit — Meta will re-review the changes"
|
||||
className="text-slate-400 hover:text-primary hover:bg-primary/10"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{(statusKey === 'REJECTED' || statusKey === 'PAUSED') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(template)}
|
||||
title="Edit and resubmit for approval"
|
||||
className="text-slate-400 hover:text-primary hover:bg-primary/10"
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(template.id)}
|
||||
disabled={deletingId === template.id}
|
||||
title={
|
||||
template.meta_template_id
|
||||
? 'Delete from Meta and locally'
|
||||
: 'Delete locally'
|
||||
}
|
||||
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30"
|
||||
>
|
||||
{deletingId === template.id ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@ -454,13 +536,25 @@ export function TemplateManager() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">New Message Template</DialogTitle>
|
||||
<DialogTitle className="text-white">
|
||||
{editingId ? 'Edit Message Template' : 'New Message Template'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
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.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
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.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
<datalist id="template-language-codes">
|
||||
{COMMON_LANGUAGE_CODES.map((code) => (
|
||||
@ -536,8 +634,14 @@ export function TemplateManager() {
|
||||
))}
|
||||
</datalist>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Must match the exact code on Meta — <code>en_US</code> and{' '}
|
||||
<code>en</code> are distinct.
|
||||
{editingId
|
||||
? 'Language is fixed once a template exists on Meta.'
|
||||
: (
|
||||
<>
|
||||
Must match the exact code on Meta — <code>en_US</code>{' '}
|
||||
and <code>en</code> are distinct.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -819,8 +923,10 @@ export function TemplateManager() {
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Submitting…
|
||||
{editingId ? 'Saving…' : 'Submitting…'}
|
||||
</>
|
||||
) : editingId ? (
|
||||
'Save & Resubmit'
|
||||
) : (
|
||||
'Submit for Approval'
|
||||
)}
|
||||
|
||||
@ -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<EditMessageTemplateResult> {
|
||||
const { metaTemplateId, accessToken, components, category } = args
|
||||
const body: Record<string, unknown> = { 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<void> {
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
224
src/lib/whatsapp/template-lifecycle.test.ts
Normal file
224
src/lib/whatsapp/template-lifecycle.test.ts
Normal file
@ -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<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user