mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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:
commit
260af1b901
@ -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
|
||||
|
||||
@ -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> => {
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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,29 +157,15 @@ 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,
|
||||
// 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',
|
||||
submission_error: message,
|
||||
last_submitted_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'user_id,name,language' },
|
||||
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,
|
||||
const { data: row, error: upsertErr } = await upsertTemplateRow(
|
||||
supabase,
|
||||
buildUpsertRow(user.id, payload, {
|
||||
status: normalizeStatus(metaStatus),
|
||||
meta_template_id: metaTemplateId,
|
||||
submission_error: null,
|
||||
rejection_reason: null,
|
||||
last_submitted_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'user_id,name,language' },
|
||||
metaTemplateId,
|
||||
submissionError: null,
|
||||
}),
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (upsertErr) {
|
||||
// The submit succeeded on Meta's side but we failed to persist
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,9 +822,13 @@ 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) => (
|
||||
{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) => {
|
||||
@ -757,7 +838,8 @@ export function TemplateManager() {
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
51
src/lib/whatsapp/template-row-guard.ts
Normal file
51
src/lib/whatsapp/template-row-guard.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user