mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #151 from ArnasDon/feat/template-lifecycle-endpoints
feat(templates): edit / resubmit / delete lifecycle endpoints
This commit is contained in:
commit
26d65303e5
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,
|
RefreshCw,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
X,
|
X,
|
||||||
|
Pencil,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
@ -125,6 +127,11 @@ export function TemplateManager() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [form, setForm] = useState<TemplateFormData>(emptyForm);
|
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
|
// 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
|
||||||
@ -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() {
|
async function handleSubmit() {
|
||||||
if (form.category === 'Authentication') {
|
if (form.category === 'Authentication') {
|
||||||
toast.error(
|
toast.error(
|
||||||
@ -217,22 +248,33 @@ export function TemplateManager() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const res = await fetch('/api/whatsapp/templates/submit', {
|
const isEdit = editingId !== null;
|
||||||
method: 'POST',
|
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' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(buildSubmitPayload()),
|
body: JSON.stringify(buildSubmitPayload()),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
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(
|
toast.success(
|
||||||
data.dry_run
|
data.dry_run
|
||||||
? 'Template saved (dry-run — no Meta call)'
|
? isEdit
|
||||||
: 'Submitted to Meta — status will update once approved.',
|
? '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);
|
setDialogOpen(false);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
|
setEditingId(null);
|
||||||
if (user) await fetchTemplates(user.id);
|
if (user) await fetchTemplates(user.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Submit error:', err);
|
console.error('Submit error:', err);
|
||||||
@ -281,17 +323,26 @@ export function TemplateManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
|
if (deletingId) return;
|
||||||
|
setDeletingId(id);
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
// Route handler scopes the Meta delete via hsm_id (so sibling
|
||||||
.from('message_templates')
|
// language variants survive) and falls through to remove the
|
||||||
.delete()
|
// local row. Local-only rows skip the Meta call.
|
||||||
.eq('id', id);
|
const res = await fetch(`/api/whatsapp/templates/${id}`, {
|
||||||
if (error) throw error;
|
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');
|
toast.success('Template deleted');
|
||||||
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete error:', 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'}
|
{syncing ? 'Syncing…' : 'Sync from Meta'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={openCreate}
|
||||||
setForm(emptyForm);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
@ -439,14 +487,48 @@ export function TemplateManager() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-0.5 shrink-0 ml-2">
|
||||||
variant="ghost"
|
{statusKey === 'APPROVED' && (
|
||||||
size="icon"
|
<Button
|
||||||
onClick={() => handleDelete(template.id)}
|
variant="ghost"
|
||||||
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 shrink-0 ml-2"
|
size="icon"
|
||||||
>
|
onClick={() => openEdit(template)}
|
||||||
<Trash2 className="size-4" />
|
title="Edit — Meta will re-review the changes"
|
||||||
</Button>
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@ -454,13 +536,25 @@ export function TemplateManager() {
|
|||||||
</div>
|
</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">
|
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<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">
|
<DialogDescription className="text-slate-400">
|
||||||
Build a template and submit it to Meta for approval. Once
|
{editingId
|
||||||
approved, you can use it in broadcasts and the inbox.
|
? '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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -483,10 +577,13 @@ export function TemplateManager() {
|
|||||||
placeholder="e.g. order_confirmation"
|
placeholder="e.g. order_confirmation"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -528,7 +625,8 @@ export function TemplateManager() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm({ ...form, language: e.target.value })
|
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">
|
<datalist id="template-language-codes">
|
||||||
{COMMON_LANGUAGE_CODES.map((code) => (
|
{COMMON_LANGUAGE_CODES.map((code) => (
|
||||||
@ -536,8 +634,14 @@ export function TemplateManager() {
|
|||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
<p className="text-[11px] text-slate-500">
|
<p className="text-[11px] text-slate-500">
|
||||||
Must match the exact code on Meta — <code>en_US</code> and{' '}
|
{editingId
|
||||||
<code>en</code> are distinct.
|
? '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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -819,8 +923,10 @@ export function TemplateManager() {
|
|||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Submitting…
|
{editingId ? 'Saving…' : 'Submitting…'}
|
||||||
</>
|
</>
|
||||||
|
) : editingId ? (
|
||||||
|
'Save & Resubmit'
|
||||||
) : (
|
) : (
|
||||||
'Submit for Approval'
|
'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
|
// 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