mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
chore(templates): polish pass from senior backend / FE / UX review
A focused cleanup PR that addresses the high-signal findings from a self-review of PRs #148-153. Every change is local to the templates surface; no schema or wire-format changes. Safety ------ * Delete now goes through a confirmation Dialog (mirrors the existing tag-manager pattern). Copy explicitly tells the user when Meta will be hit, so a slip on the trash icon doesn't take the template off Meta as well as locally. * AUTHENTICATION submit is now blocked by a persistent in-dialog banner + disabled submit button. The disappearing toast that used to fire on submit is gone — defensive `return` remains in handleSubmit as a second line of defense. * updateButton uses a per-variant switch + ButtonPatch union instead of the partial-spread + `as TemplateButton` cast. Each variant's spread keeps its discriminant pinned; TS enforces exhaustiveness. * New template-row-guard.ts surfaces malformed Supabase rows at the route boundary instead of letting buildSendComponents crash deep in the stack with an opaque TypeError. * UUID regex check on /[id] PATCH+DELETE so non-UUID payloads return a clean 400 before hitting Supabase. Submit-route cleanup -------------------- * Two near-identical upsert blocks (error path + success path) collapsed into a buildUpsertRow + upsertTemplateRow helper. Adding a column now touches one place, not two. UX copy + feedback ------------------ * Submit success toast sets the right expectation: "typical review time is 24 hours. Status updates automatically." Old copy just said "status will update once approved." * Header media URL helper text is now actually useful: HTTPS requirement, ~24h lifetime, per-format size/format recommendations (JPEG/PNG ≥800×418 ≤5 MB for images, MP4 ≤16 MB for video, etc). * Truncated-sync toast switches from `warning` (which auto-dismisses fast) to `error` with `duration: 10000` and concrete copy: "Synced the first 2000 templates only — your account has more. Sync again to continue." * Edit and Resubmit buttons gain visible text labels alongside their icons + better aria-label/title pairs. A lone rotate icon wasn't self-explanatory. * Broadcast picker drops the redundant "Approved" chip — every template in that list is APPROVED by filter, so the chip carried zero information. Unused templateStatusConfig import removed. State + a11y ------------ * handleSubmit now awaits fetchTemplates BEFORE closing the dialog so re-opening immediately doesn't see a stale list. * Header content / media URL / sample are preserved across format switches in the New Template form. The payload builder is already format-aware, so orphan values on hidden fields are harmless — and keeping them lets users switch formats to compare without losing typing. * Button-type Select gains the same null-guard the header Select got in PR #148 (per @base-ui semantics, onValueChange can fire with null on deselect). * Dynamic body-sample inputs get id + aria-label so each one is individually addressable (WCAG A — screen-reader announcement beat "edit text"). Header text + header sample inputs too. Deliberately NOT here --------------------- * The 940-line template-manager component is not split. It's cohesive (one feature surface) and splitting for size alone would be over-engineering. * extractVariableIndices in the buttons loop is not memoized. Regex on a short URL string is sub-microsecond; a real profiler would never flag it. * No JS heap secret-clearing for access tokens. Strings are immutable in JS — there's no way to actually clear one. Tests: 256/256 pass (no new tests required — pure refactor + copy + new UI elements). Lint 0 errors. Typecheck clean. Build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e60fea701e
commit
c63a68c01e
@ -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
|
||||||
|
|||||||
@ -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> => {
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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,29 +157,15 @@ 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, {
|
||||||
{
|
|
||||||
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',
|
status: 'DRAFT',
|
||||||
submission_error: message,
|
metaTemplateId: null,
|
||||||
last_submitted_at: new Date().toISOString(),
|
submissionError: message,
|
||||||
},
|
}),
|
||||||
{ 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, {
|
||||||
{
|
|
||||||
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),
|
status: normalizeStatus(metaStatus),
|
||||||
meta_template_id: metaTemplateId,
|
metaTemplateId,
|
||||||
submission_error: null,
|
submissionError: 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
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,9 +822,13 @@ 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) => {
|
||||||
|
const inputId = `template-body-sample-${i}`;
|
||||||
|
return (
|
||||||
<Input
|
<Input
|
||||||
key={i}
|
key={i}
|
||||||
|
id={inputId}
|
||||||
|
aria-label={`Sample value for body variable {{${i + 1}}}`}
|
||||||
placeholder={`Sample for {{${i + 1}}}`}
|
placeholder={`Sample for {{${i + 1}}}`}
|
||||||
value={val}
|
value={val}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -757,7 +838,8 @@ export function TemplateManager() {
|
|||||||
}}
|
}}
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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