Merge pull request #154 from ArnasDon/chore/templates-cleanup-pass

chore(templates): polish pass from senior backend / FE / UX review
This commit is contained in:
Arnas Donauskas 2026-05-29 17:52:36 +02:00 committed by GitHub
commit 260af1b901
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 354 additions and 114 deletions

View File

@ -3,6 +3,7 @@ import { createClient } from '@/lib/supabase/server'
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption'
import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder'
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
import {
sanitizePhoneForMeta,
isValidE164,
@ -137,13 +138,25 @@ export async function POST(request: Request) {
// Load the template row once so sendTemplateMessage can build
// header + button components on each iteration. Loading inside
// the loop would N+1 against Supabase for every recipient.
const { data: templateRow } = await supabase
// Guard against a malformed local row crashing every send in
// the loop with the same opaque TypeError — fail loudly once.
const { data: rawTemplateRow } = await supabase
.from('message_templates')
.select('*')
.eq('user_id', user.id)
.eq('name', template_name)
.eq('language', template_language || 'en_US')
.maybeSingle()
if (rawTemplateRow && !isMessageTemplate(rawTemplateRow)) {
return NextResponse.json(
{
error:
'Template row is malformed locally — run "Sync from Meta" in Settings to repair it before broadcasting.',
},
{ status: 500 },
)
}
const templateRow = rawTemplateRow ?? null
const results: BroadcastResult[] = []
let sentCount = 0

View File

@ -15,6 +15,7 @@ import {
RATE_LIMITS,
} from '@/lib/rate-limit'
import type { MessageTemplate } from '@/types'
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
export async function POST(request: Request) {
try {
@ -186,6 +187,10 @@ export async function POST(request: Request) {
// index enforces — so multi-language templates work correctly.
// Missing template falls through with `templateRow = null` and
// the legacy body-only path runs.
// Load the template row so sendTemplateMessage can build header
// + button components from the definition. isMessageTemplate
// guards against a malformed row (e.g. from a partial sync)
// crashing the send-builder later in the stack.
let templateRow: MessageTemplate | null = null
if (message_type === 'template' && template_name) {
const { data } = await supabase
@ -195,7 +200,16 @@ export async function POST(request: Request) {
.eq('name', template_name)
.eq('language', template_language || 'en_US')
.maybeSingle()
templateRow = (data ?? null) as MessageTemplate | null
if (data && !isMessageTemplate(data)) {
return NextResponse.json(
{
error:
'Template row is malformed locally — run "Sync from Meta" in Settings to repair it.',
},
{ status: 500 },
)
}
templateRow = data ?? null
}
const attempt = async (phone: string): Promise<string> => {

View File

@ -30,6 +30,12 @@ import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
const EDITABLE_STATUSES = new Set(['APPROVED', 'REJECTED', 'PAUSED'])
// uuid v4 plus the looser shape Postgres gen_random_uuid emits.
// We don't need exhaustive RFC parsing — just enough to reject
// "../etc/passwd"-style payloads before they hit Supabase.
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
function isDryRun(): boolean {
return (
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
@ -43,6 +49,12 @@ export async function PATCH(
) {
try {
const { id } = await context.params
if (!UUID_RE.test(id)) {
return NextResponse.json(
{ error: 'Invalid template id.' },
{ status: 400 },
)
}
const supabase = await createClient()
const {
data: { user },
@ -197,6 +209,12 @@ export async function DELETE(
) {
try {
const { id } = await context.params
if (!UUID_RE.test(id)) {
return NextResponse.json(
{ error: 'Invalid template id.' },
{ status: 400 },
)
}
const supabase = await createClient()
const {
data: { user },

View File

@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption'
import { submitMessageTemplate } from '@/lib/whatsapp/meta-api'
@ -9,6 +10,54 @@ import {
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
/**
* Shared upsert payload builder — both the Meta-failure path and the
* Meta-success path write nearly identical rows; dropping the shared
* fields here means adding a column later only touches one spot.
*/
function buildUpsertRow(
userId: string,
payload: TemplatePayload,
extras: {
status: 'DRAFT' | string
metaTemplateId: string | null
submissionError: string | null
},
) {
return {
user_id: userId,
name: payload.name,
category: payload.category,
language: payload.language,
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: extras.status,
meta_template_id: extras.metaTemplateId,
submission_error: extras.submissionError,
// Clear stale rejection_reason whenever we re-submit; the
// webhook will set it again if Meta still rejects.
rejection_reason: extras.submissionError ? null : null,
last_submitted_at: new Date().toISOString(),
}
}
async function upsertTemplateRow(
supabase: SupabaseClient,
row: ReturnType<typeof buildUpsertRow>,
) {
return supabase
.from('message_templates')
.upsert(row, { onConflict: 'user_id,name,language' })
.select()
.single()
}
/**
* Submit a template to Meta for approval AND persist it locally.
*
@ -108,30 +157,16 @@ export async function POST(request: Request) {
metaStatus = meta.status
} catch (e) {
const message = e instanceof Error ? e.message : 'Meta submit failed.'
// Persist the failure so the user can retry; tag with a 429
// hint if Meta's rate-limit response leaked through.
await supabase
.from('message_templates')
.upsert(
{
user_id: user.id,
name: payload.name,
category: payload.category,
language: payload.language,
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: 'DRAFT',
submission_error: message,
last_submitted_at: new Date().toISOString(),
},
{ onConflict: 'user_id,name,language' },
)
// Persist the failure so the user can retry; row stays DRAFT
// until they fix and re-submit.
await upsertTemplateRow(
supabase,
buildUpsertRow(user.id, payload, {
status: 'DRAFT',
metaTemplateId: null,
submissionError: message,
}),
)
const isRateLimit = /\b429\b/.test(message)
return NextResponse.json(
{
@ -144,32 +179,14 @@ export async function POST(request: Request) {
}
}
const { data: row, error: upsertErr } = await supabase
.from('message_templates')
.upsert(
{
user_id: user.id,
name: payload.name,
category: payload.category,
language: payload.language,
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: normalizeStatus(metaStatus),
meta_template_id: metaTemplateId,
submission_error: null,
rejection_reason: null,
last_submitted_at: new Date().toISOString(),
},
{ onConflict: 'user_id,name,language' },
)
.select()
.single()
const { data: row, error: upsertErr } = await upsertTemplateRow(
supabase,
buildUpsertRow(user.id, payload, {
status: normalizeStatus(metaStatus),
metaTemplateId,
submissionError: null,
}),
)
if (upsertErr) {
// The submit succeeded on Meta's side but we failed to persist

View File

@ -5,7 +5,6 @@ import { createClient } from '@/lib/supabase/client';
import { MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button';
import { Loader2, FileText, ArrowRight } from 'lucide-react';
import { templateStatusConfig } from '@/lib/template-status';
const categoryColors: Record<string, string> = {
Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
@ -108,12 +107,9 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
<p className="line-clamp-3 text-xs text-slate-400">{template.body_text}</p>
<div className="flex items-center gap-2 text-[10px] text-slate-500">
<span>{template.language ?? 'en_US'}</span>
{template.status && (
<>
<span>-</span>
<span>{templateStatusConfig[template.status].label}</span>
</>
)}
{/* Status is omitted on purpose — every template
shown here is already filtered to APPROVED,
so the chip carried no information. */}
</div>
</button>
);

View File

@ -132,6 +132,11 @@ export function TemplateManager() {
// 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);
// Template selected for the confirm-delete dialog. The destructive
// action goes through this two-step so a slip on the trash icon
// doesn't take the template off Meta as well as locally.
const [templateToDelete, setTemplateToDelete] =
useState<MessageTemplate | 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
@ -240,12 +245,9 @@ export function TemplateManager() {
}
async function handleSubmit() {
if (form.category === 'Authentication') {
toast.error(
'AUTHENTICATION templates aren’t supported here yet — create them in Meta WhatsApp Manager and use Sync from Meta.',
);
return;
}
// AUTHENTICATION is blocked by the persistent banner + disabled
// submit button; this is a defensive second line of defense.
if (form.category === 'Authentication') return;
try {
setSubmitting(true);
const isEdit = editingId !== null;
@ -263,19 +265,21 @@ export function TemplateManager() {
data?.error || `${isEdit ? 'Edit' : 'Submit'} failed (HTTP ${res.status})`,
);
}
// Refresh first, then close — re-opening the dialog
// immediately should not show a stale list.
if (user) await fetchTemplates(user.id);
toast.success(
data.dry_run
? 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.',
? 'Edit submitted — Meta typically reviews within 24 hours.'
: 'Submitted to Meta — typical review time is 24 hours. Status updates automatically.',
);
setDialogOpen(false);
setForm(emptyForm);
setEditingId(null);
if (user) await fetchTemplates(user.id);
} catch (err) {
console.error('Submit error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to submit');
@ -309,8 +313,12 @@ export function TemplateManager() {
toast.error(`Failed to sync: ${preview.join(', ')}${suffix}`);
}
if (data.truncated) {
toast.warning(
'Hit Meta pagination cap — more templates may exist. Contact support if this persists.',
// Use error (not warning) so the message survives long
// enough to read — sonner's `warning` auto-dismisses on
// the same short timer as `success`.
toast.error(
'Synced the first 2000 templates only — your account has more. Sync again to continue, or contact support if this persists.',
{ duration: 10000 },
);
}
await fetchTemplates(user.id);
@ -322,14 +330,15 @@ export function TemplateManager() {
}
}
async function handleDelete(id: string) {
if (deletingId) return;
setDeletingId(id);
async function confirmDelete() {
const target = templateToDelete;
if (!target || deletingId) return;
setDeletingId(target.id);
try {
// 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}`, {
const res = await fetch(`/api/whatsapp/templates/${target.id}`, {
method: 'DELETE',
});
const data = await res.json().catch(() => ({}));
@ -337,7 +346,8 @@ export function TemplateManager() {
throw new Error(data?.error || `Delete failed (HTTP ${res.status})`);
}
toast.success('Template deleted');
setTemplates((prev) => prev.filter((t) => t.id !== id));
setTemplates((prev) => prev.filter((t) => t.id !== target.id));
setTemplateToDelete(null);
} catch (err) {
console.error('Delete error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to delete template');
@ -346,10 +356,57 @@ export function TemplateManager() {
}
}
function updateButton(index: number, patch: Partial<TemplateButton>) {
// The patch type unions every field across button variants. The
// conditional rendering below ensures only fields valid for the
// current button's `type` reach this function, so the runtime
// assertion + per-type spread preserves discriminated-union
// invariants without forcing every call site to thread the type
// through generics (which TS can't infer from a partial literal).
type ButtonPatch = {
text?: string;
url?: string;
phone_number?: string;
example?: string;
};
function updateButton(index: number, patch: ButtonPatch) {
setForm((prev) => {
const current = prev.buttons[index];
if (!current) return prev;
const next = [...prev.buttons];
next[index] = { ...next[index], ...patch } as TemplateButton;
// Per-variant spread keeps the discriminant pinned. Switch
// exhaustiveness is enforced by TypeScript.
switch (current.type) {
case 'QUICK_REPLY':
next[index] = {
...current,
...(patch.text !== undefined && { text: patch.text }),
};
break;
case 'URL':
next[index] = {
...current,
...(patch.text !== undefined && { text: patch.text }),
...(patch.url !== undefined && { url: patch.url }),
...(patch.example !== undefined && { example: patch.example }),
};
break;
case 'PHONE_NUMBER':
next[index] = {
...current,
...(patch.text !== undefined && { text: patch.text }),
...(patch.phone_number !== undefined && {
phone_number: patch.phone_number,
}),
};
break;
case 'COPY_CODE':
next[index] = {
...current,
...(patch.text !== undefined && { text: patch.text }),
...(patch.example !== undefined && { example: patch.example }),
};
break;
}
return { ...prev, buttons: next };
});
}
@ -487,40 +544,49 @@ export function TemplateManager() {
</div>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0 ml-2">
<div className="flex items-center gap-1 shrink-0 ml-2">
{statusKey === 'APPROVED' && (
<Button
variant="ghost"
size="icon"
size="sm"
onClick={() => openEdit(template)}
title="Edit — Meta will re-review the changes"
className="text-slate-400 hover:text-primary hover:bg-primary/10"
title="Editing triggers Meta re-review — status flips to PENDING."
aria-label="Edit template"
className="text-slate-300 hover:text-primary hover:bg-primary/10 h-8 px-2"
>
<Pencil className="size-4" />
<Pencil className="size-3.5" />
Edit
</Button>
)}
{(statusKey === 'REJECTED' || statusKey === 'PAUSED') && (
<Button
variant="ghost"
size="icon"
size="sm"
onClick={() => openEdit(template)}
title="Edit and resubmit for approval"
className="text-slate-400 hover:text-primary hover:bg-primary/10"
title="Edit the template and resubmit to Meta for review."
aria-label="Edit and resubmit template"
className="text-slate-300 hover:text-primary hover:bg-primary/10 h-8 px-2"
>
<RotateCcw className="size-4" />
<RotateCcw className="size-3.5" />
Resubmit
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(template.id)}
onClick={() => setTemplateToDelete(template)}
disabled={deletingId === template.id}
aria-label={
template.meta_template_id
? 'Delete template from Meta and locally'
: 'Delete template locally'
}
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"
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 h-8 w-8"
>
{deletingId === template.id ? (
<Loader2 className="size-4 animate-spin" />
@ -651,15 +717,15 @@ export function TemplateManager() {
<Select
value={form.header_format}
onValueChange={(val) =>
// Preserve header_content, header_media_url, and
// header_sample across format switches. The submit
// payload builder only reads the field that matches
// the active format, so an orphan value on a hidden
// field is harmless — and keeping it lets the user
// switch formats to compare without losing typing.
setForm({
...form,
header_format: (val || 'none') as HeaderFormat,
header_content: val === 'text' ? form.header_content : '',
header_media_url:
val && val !== 'none' && val !== 'text'
? form.header_media_url
: '',
header_sample: val === 'text' ? form.header_sample : '',
})
}
>
@ -684,6 +750,8 @@ export function TemplateManager() {
{form.header_format === 'text' && (
<div className="space-y-2 mt-2">
<Input
id="template-header-text"
aria-label="Header text"
placeholder="Header text (max 60 chars, optional {{1}})"
value={form.header_content}
onChange={(e) =>
@ -694,6 +762,8 @@ export function TemplateManager() {
/>
{headerVarCount > 0 && (
<Input
id="template-header-sample"
aria-label="Sample value for header variable"
placeholder="Sample value for {{1}} (required for Meta review)"
value={form.header_sample}
onChange={(e) =>
@ -708,16 +778,23 @@ export function TemplateManager() {
{headerNeedsMedia && (
<div className="space-y-2 mt-2">
<Input
placeholder={`Public URL to a sample ${form.header_format} (https://…)`}
placeholder={`https://… (public link to a sample ${form.header_format})`}
value={form.header_media_url}
onChange={(e) =>
setForm({ ...form, header_media_url: e.target.value })
}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
<p className="text-[11px] text-slate-500">
Meta fetches this once for the approval review. Direct file
upload is coming in a follow-up.
<p className="text-[11px] text-slate-500 leading-relaxed">
Must be publicly accessible HTTPS. Meta fetches it once
during review, so the file needs to stay live for ~24 hrs.
{form.header_format === 'image' &&
' Recommended: JPEG or PNG, ≥800×418 px, ≤5 MB.'}
{form.header_format === 'video' &&
' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'}
{form.header_format === 'document' &&
' Recommended: PDF, ≤100 MB.'}
{' '}Direct file upload is coming in a follow-up.
</p>
</div>
)}
@ -745,19 +822,24 @@ export function TemplateManager() {
<Label className="text-[11px] text-slate-400">
Sample values (Meta uses these to review your template)
</Label>
{form.body_samples.map((val, i) => (
<Input
key={i}
placeholder={`Sample for {{${i + 1}}}`}
value={val}
onChange={(e) => {
const next = [...form.body_samples];
next[i] = e.target.value;
setForm({ ...form, body_samples: next });
}}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
))}
{form.body_samples.map((val, i) => {
const inputId = `template-body-sample-${i}`;
return (
<Input
key={i}
id={inputId}
aria-label={`Sample value for body variable {{${i + 1}}}`}
placeholder={`Sample for {{${i + 1}}}`}
value={val}
onChange={(e) => {
const next = [...form.body_samples];
next[i] = e.target.value;
setForm({ ...form, body_samples: next });
}}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
);
})}
</div>
)}
</div>
@ -805,9 +887,13 @@ export function TemplateManager() {
<div className="flex items-center gap-2">
<Select
value={btn.type}
onValueChange={(val) =>
changeButtonType(i, val as TemplateButton['type'])
}
onValueChange={(val) => {
// Same null guard as the Header Select
// (per PR 148): @base-ui Select fires
// onValueChange(null) on deselect.
if (!val) return;
changeButtonType(i, val as TemplateButton['type']);
}}
>
<SelectTrigger className="w-40 bg-slate-800 border-slate-700 text-white h-8 text-xs">
<SelectValue />
@ -934,6 +1020,51 @@ export function TemplateManager() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirm-delete dialog. Surfacing the meta_template_id case
separately so users understand a real Meta delete is happening,
not just a local cleanup. */}
<Dialog
open={templateToDelete !== null}
onOpenChange={(open) => {
if (!open) setTemplateToDelete(null);
}}
>
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-white">Delete template?</DialogTitle>
<DialogDescription className="text-slate-400">
{templateToDelete?.meta_template_id
? `"${templateToDelete?.name}" will be deleted from Meta and from wacrm. Active broadcasts using this template will start failing on their next send. This can't be undone.`
: `"${templateToDelete?.name}" will be deleted from wacrm. It was never submitted to Meta, so no remote cleanup is needed.`}
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-slate-900 border-slate-700">
<Button
variant="outline"
onClick={() => setTemplateToDelete(null)}
disabled={deletingId !== null}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
Cancel
</Button>
<Button
onClick={confirmDelete}
disabled={deletingId !== null}
className="bg-red-600 hover:bg-red-700 text-white"
>
{deletingId !== null ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting…
</>
) : (
'Delete'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,51 @@
/**
* Minimal shape check for a `message_templates` row loaded via Supabase.
*
* Supabase queries return `any` for untyped clients, so the routes
* cast with `as MessageTemplate`. That cast is a lie — a row from
* sync, a webhook race, or a malformed insert can land without the
* fields the send-builder needs. When that happens, the builder
* crashes deep inside the call stack with a TypeError that looks
* like a 500 to the user and gives no hint about which row was bad.
*
* Catch it at the boundary: assert the few fields the send path
* actually requires (name + language + body_text — strings) and
* fail fast with a specific message naming the row id.
*
* Per-property validators (buttons shape, sample_values shape) live
* in template-validators.ts; this is just the "is it the right kind
* of object at all" check.
*/
import type { MessageTemplate } from '@/types';
export function isMessageTemplate(row: unknown): row is MessageTemplate {
if (!row || typeof row !== 'object') return false;
const r = row as Record<string, unknown>;
return (
typeof r.id === 'string' &&
typeof r.user_id === 'string' &&
typeof r.name === 'string' &&
typeof r.body_text === 'string'
);
}
/**
* Convenience wrapper for routes — narrows or throws a descriptive
* Error the route can render as a 500 with the row id mentioned.
*/
export function assertMessageTemplate(
row: unknown,
context: string,
): MessageTemplate {
if (!isMessageTemplate(row)) {
const id =
row && typeof row === 'object' && 'id' in row
? String((row as { id: unknown }).id)
: '(unknown id)';
throw new Error(
`Malformed message_templates row ${id} in ${context} — missing required fields (id, user_id, name, body_text).`,
);
}
return row;
}