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 { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption' import { decrypt } from '@/lib/whatsapp/encryption'
import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder' import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder'
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
import { import {
sanitizePhoneForMeta, sanitizePhoneForMeta,
isValidE164, isValidE164,
@ -137,13 +138,25 @@ export async function POST(request: Request) {
// Load the template row once so sendTemplateMessage can build // Load the template row once so sendTemplateMessage can build
// header + button components on each iteration. Loading inside // header + button components on each iteration. Loading inside
// the loop would N+1 against Supabase for every recipient. // 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') .from('message_templates')
.select('*') .select('*')
.eq('user_id', user.id) .eq('user_id', user.id)
.eq('name', template_name) .eq('name', template_name)
.eq('language', template_language || 'en_US') .eq('language', template_language || 'en_US')
.maybeSingle() .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[] = [] const results: BroadcastResult[] = []
let sentCount = 0 let sentCount = 0

View File

@ -15,6 +15,7 @@ import {
RATE_LIMITS, RATE_LIMITS,
} from '@/lib/rate-limit' } from '@/lib/rate-limit'
import type { MessageTemplate } from '@/types' import type { MessageTemplate } from '@/types'
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@ -186,6 +187,10 @@ export async function POST(request: Request) {
// index enforces — so multi-language templates work correctly. // index enforces — so multi-language templates work correctly.
// Missing template falls through with `templateRow = null` and // Missing template falls through with `templateRow = null` and
// the legacy body-only path runs. // 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 let templateRow: MessageTemplate | null = null
if (message_type === 'template' && template_name) { if (message_type === 'template' && template_name) {
const { data } = await supabase const { data } = await supabase
@ -195,7 +200,16 @@ export async function POST(request: Request) {
.eq('name', template_name) .eq('name', template_name)
.eq('language', template_language || 'en_US') .eq('language', template_language || 'en_US')
.maybeSingle() .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> => { 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']) 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 { function isDryRun(): boolean {
return ( return (
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' || process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
@ -43,6 +49,12 @@ export async function PATCH(
) { ) {
try { try {
const { id } = await context.params const { id } = await context.params
if (!UUID_RE.test(id)) {
return NextResponse.json(
{ error: 'Invalid template id.' },
{ status: 400 },
)
}
const supabase = await createClient() const supabase = await createClient()
const { const {
data: { user }, data: { user },
@ -197,6 +209,12 @@ export async function DELETE(
) { ) {
try { try {
const { id } = await context.params const { id } = await context.params
if (!UUID_RE.test(id)) {
return NextResponse.json(
{ error: 'Invalid template id.' },
{ status: 400 },
)
}
const supabase = await createClient() const supabase = await createClient()
const { const {
data: { user }, data: { user },

View File

@ -1,4 +1,5 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption' import { decrypt } from '@/lib/whatsapp/encryption'
import { submitMessageTemplate } from '@/lib/whatsapp/meta-api' import { submitMessageTemplate } from '@/lib/whatsapp/meta-api'
@ -9,6 +10,54 @@ import {
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components' import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize' 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. * Submit a template to Meta for approval AND persist it locally.
* *
@ -108,30 +157,16 @@ export async function POST(request: Request) {
metaStatus = meta.status metaStatus = meta.status
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : 'Meta submit failed.' const message = e instanceof Error ? e.message : 'Meta submit failed.'
// Persist the failure so the user can retry; tag with a 429 // Persist the failure so the user can retry; row stays DRAFT
// hint if Meta's rate-limit response leaked through. // until they fix and re-submit.
await supabase await upsertTemplateRow(
.from('message_templates') supabase,
.upsert( buildUpsertRow(user.id, payload, {
{ status: 'DRAFT',
user_id: user.id, metaTemplateId: null,
name: payload.name, submissionError: message,
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' },
)
const isRateLimit = /\b429\b/.test(message) const isRateLimit = /\b429\b/.test(message)
return NextResponse.json( return NextResponse.json(
{ {
@ -144,32 +179,14 @@ export async function POST(request: Request) {
} }
} }
const { data: row, error: upsertErr } = await supabase const { data: row, error: upsertErr } = await upsertTemplateRow(
.from('message_templates') supabase,
.upsert( buildUpsertRow(user.id, payload, {
{ status: normalizeStatus(metaStatus),
user_id: user.id, metaTemplateId,
name: payload.name, submissionError: null,
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()
if (upsertErr) { if (upsertErr) {
// The submit succeeded on Meta's side but we failed to persist // 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 { MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader2, FileText, ArrowRight } from 'lucide-react'; import { Loader2, FileText, ArrowRight } from 'lucide-react';
import { templateStatusConfig } from '@/lib/template-status';
const categoryColors: Record<string, string> = { const categoryColors: Record<string, string> = {
Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20', 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> <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"> <div className="flex items-center gap-2 text-[10px] text-slate-500">
<span>{template.language ?? 'en_US'}</span> <span>{template.language ?? 'en_US'}</span>
{template.status && ( {/* Status is omitted on purpose — every template
<> shown here is already filtered to APPROVED,
<span>-</span> so the chip carried no information. */}
<span>{templateStatusConfig[template.status].label}</span>
</>
)}
</div> </div>
</button> </button>
); );

View File

@ -132,6 +132,11 @@ export function TemplateManager() {
// dialog title + CTA. Set to the template id to pre-fill from a row. // dialog title + CTA. Set to the template id to pre-fill from a row.
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = 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 // 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
@ -240,12 +245,9 @@ export function TemplateManager() {
} }
async function handleSubmit() { async function handleSubmit() {
if (form.category === 'Authentication') { // AUTHENTICATION is blocked by the persistent banner + disabled
toast.error( // submit button; this is a defensive second line of defense.
'AUTHENTICATION templates aren’t supported here yet — create them in Meta WhatsApp Manager and use Sync from Meta.', if (form.category === 'Authentication') return;
);
return;
}
try { try {
setSubmitting(true); setSubmitting(true);
const isEdit = editingId !== null; const isEdit = editingId !== null;
@ -263,19 +265,21 @@ export function TemplateManager() {
data?.error || `${isEdit ? 'Edit' : 'Submit'} failed (HTTP ${res.status})`, 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( toast.success(
data.dry_run data.dry_run
? isEdit ? isEdit
? 'Template updated (dry-run — no Meta call)' ? 'Template updated (dry-run — no Meta call)'
: 'Template saved (dry-run — no Meta call)' : 'Template saved (dry-run — no Meta call)'
: isEdit : isEdit
? 'Edit submitted — Meta will re-review.' ? 'Edit submitted — Meta typically reviews within 24 hours.'
: 'Submitted to Meta — status will update once approved.', : 'Submitted to Meta — typical review time is 24 hours. Status updates automatically.',
); );
setDialogOpen(false); setDialogOpen(false);
setForm(emptyForm); setForm(emptyForm);
setEditingId(null); setEditingId(null);
if (user) await fetchTemplates(user.id);
} catch (err) { } catch (err) {
console.error('Submit error:', err); console.error('Submit error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to submit'); 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}`); toast.error(`Failed to sync: ${preview.join(', ')}${suffix}`);
} }
if (data.truncated) { if (data.truncated) {
toast.warning( // Use error (not warning) so the message survives long
'Hit Meta pagination cap — more templates may exist. Contact support if this persists.', // 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); await fetchTemplates(user.id);
@ -322,14 +330,15 @@ export function TemplateManager() {
} }
} }
async function handleDelete(id: string) { async function confirmDelete() {
if (deletingId) return; const target = templateToDelete;
setDeletingId(id); if (!target || deletingId) return;
setDeletingId(target.id);
try { try {
// Route handler scopes the Meta delete via hsm_id (so sibling // Route handler scopes the Meta delete via hsm_id (so sibling
// language variants survive) and falls through to remove the // language variants survive) and falls through to remove the
// local row. Local-only rows skip the Meta call. // 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', method: 'DELETE',
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@ -337,7 +346,8 @@ export function TemplateManager() {
throw new Error(data?.error || `Delete failed (HTTP ${res.status})`); 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 !== target.id));
setTemplateToDelete(null);
} catch (err) { } catch (err) {
console.error('Delete error:', err); console.error('Delete error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to delete template'); 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) => { setForm((prev) => {
const current = prev.buttons[index];
if (!current) return prev;
const next = [...prev.buttons]; 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 }; return { ...prev, buttons: next };
}); });
} }
@ -487,40 +544,49 @@ export function TemplateManager() {
</div> </div>
)} )}
</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' && ( {statusKey === 'APPROVED' && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
onClick={() => openEdit(template)} onClick={() => openEdit(template)}
title="Edit — Meta will re-review the changes" title="Editing triggers Meta re-review — status flips to PENDING."
className="text-slate-400 hover:text-primary hover:bg-primary/10" 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> </Button>
)} )}
{(statusKey === 'REJECTED' || statusKey === 'PAUSED') && ( {(statusKey === 'REJECTED' || statusKey === 'PAUSED') && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
onClick={() => openEdit(template)} onClick={() => openEdit(template)}
title="Edit and resubmit for approval" title="Edit the template and resubmit to Meta for review."
className="text-slate-400 hover:text-primary hover:bg-primary/10" 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>
)} )}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleDelete(template.id)} onClick={() => setTemplateToDelete(template)}
disabled={deletingId === template.id} disabled={deletingId === template.id}
aria-label={
template.meta_template_id
? 'Delete template from Meta and locally'
: 'Delete template locally'
}
title={ title={
template.meta_template_id template.meta_template_id
? 'Delete from Meta and locally' ? 'Delete from Meta and locally'
: 'Delete 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 ? ( {deletingId === template.id ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
@ -651,15 +717,15 @@ export function TemplateManager() {
<Select <Select
value={form.header_format} value={form.header_format}
onValueChange={(val) => 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({ setForm({
...form, ...form,
header_format: (val || 'none') as HeaderFormat, 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' && ( {form.header_format === 'text' && (
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2">
<Input <Input
id="template-header-text"
aria-label="Header text"
placeholder="Header text (max 60 chars, optional {{1}})" placeholder="Header text (max 60 chars, optional {{1}})"
value={form.header_content} value={form.header_content}
onChange={(e) => onChange={(e) =>
@ -694,6 +762,8 @@ export function TemplateManager() {
/> />
{headerVarCount > 0 && ( {headerVarCount > 0 && (
<Input <Input
id="template-header-sample"
aria-label="Sample value for header variable"
placeholder="Sample value for {{1}} (required for Meta review)" placeholder="Sample value for {{1}} (required for Meta review)"
value={form.header_sample} value={form.header_sample}
onChange={(e) => onChange={(e) =>
@ -708,16 +778,23 @@ export function TemplateManager() {
{headerNeedsMedia && ( {headerNeedsMedia && (
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2">
<Input <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} value={form.header_media_url}
onChange={(e) => onChange={(e) =>
setForm({ ...form, header_media_url: e.target.value }) setForm({ ...form, header_media_url: e.target.value })
} }
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/> />
<p className="text-[11px] text-slate-500"> <p className="text-[11px] text-slate-500 leading-relaxed">
Meta fetches this once for the approval review. Direct file Must be publicly accessible HTTPS. Meta fetches it once
upload is coming in a follow-up. 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> </p>
</div> </div>
)} )}
@ -745,19 +822,24 @@ export function TemplateManager() {
<Label className="text-[11px] text-slate-400"> <Label className="text-[11px] text-slate-400">
Sample values (Meta uses these to review your template) Sample values (Meta uses these to review your template)
</Label> </Label>
{form.body_samples.map((val, i) => ( {form.body_samples.map((val, i) => {
<Input const inputId = `template-body-sample-${i}`;
key={i} return (
placeholder={`Sample for {{${i + 1}}}`} <Input
value={val} key={i}
onChange={(e) => { id={inputId}
const next = [...form.body_samples]; aria-label={`Sample value for body variable {{${i + 1}}}`}
next[i] = e.target.value; placeholder={`Sample for {{${i + 1}}}`}
setForm({ ...form, body_samples: next }); value={val}
}} onChange={(e) => {
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" 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>
)} )}
</div> </div>
@ -805,9 +887,13 @@ export function TemplateManager() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select <Select
value={btn.type} value={btn.type}
onValueChange={(val) => onValueChange={(val) => {
changeButtonType(i, val as TemplateButton['type']) // 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"> <SelectTrigger className="w-40 bg-slate-800 border-slate-700 text-white h-8 text-xs">
<SelectValue /> <SelectValue />
@ -934,6 +1020,51 @@ export function TemplateManager() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </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;
}