Closes the second half of #147. Users can now create a template in
wacrm and submit it to Meta for approval without leaving the app —
no more dummy local rows that fail with Meta error #132001 on send.
API surface
-----------
POST /api/whatsapp/templates/submit
Auth → fetch whatsapp_config → validate payload → build Meta
components → POST to /{waba_id}/message_templates → persist row
with meta_template_id + status + sample_values +
last_submitted_at. Per-field validators throw with specific
messages before the network call, so misconfigured templates
fail with "button #3 missing url" instead of a 400 from Meta.
429s are detected and surfaced as "rate limit hit (100/hr)".
WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and
issues a synthetic `dry-run-<uuid>` meta_template_id so CI and
local dev can exercise the full form without a real WABA.
New modules
-----------
src/lib/whatsapp/template-validators.ts
Pure functions encoding Meta's caps: 1024-char body, 60-char
footer/header, 25-char button text, 10 buttons max, 2 URL / 1
PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL
example required when {{1}} is present, COPY_CODE example
required, header media URL must be http(s), variables must be
contiguous starting at {{1}}. 41 unit tests cover each rule.
src/lib/whatsapp/template-components.ts
Translates our local row shape into Meta's components array
(HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D
array per spec, header_handle preferred over header_url when
both are present). Snapshot tests assert the wire shape.
src/lib/whatsapp/template-status-normalize.ts
Single source of truth for upstream→local status mapping.
Replaces the duplicated normalizer in sync/route.ts; the
upcoming webhook handler will share it too.
src/lib/whatsapp/meta-api.ts
New submitMessageTemplate() helper following the existing
named-args + throwMetaError pattern.
Form rewrite
------------
src/components/settings/template-manager.tsx replaces the flat
form with a component-based builder:
* Header: format select (None / Text / Image / Video / Document)
+ conditional inputs — text + variable-aware sample row for
TEXT, URL paste for media (file upload slot ready for the
Resumable Upload follow-up)
* Body: textarea with live {{N}} extraction → sample-value rows
auto-appear and shrink as variables are added/removed
* Footer: 60-char capped input
* Buttons editor: add up to 10, per-row type picker
(Quick Reply / URL / Phone / Copy Code), conditional URL +
example + phone + code fields, X to remove
* Card display now surfaces rejection_reason / submission_error
(red banner) and quality_score (GREEN/YELLOW/RED chip)
* AUTHENTICATION shows a "create in Meta Manager + Sync" banner
and disables Submit — that category has a different shape
(fixed body, mandatory OTP button) and lands in a follow-up
Side effects
------------
* Broadcast Step 1 picker (step1-choose-template.tsx) now
filters to status='APPROVED' to match the inbox picker —
selecting a non-approved template would 400 on send.
* .env.local.example documents META_APP_ID (Resumable Upload
follow-up) and WHATSAPP_TEMPLATES_DRY_RUN.
Out of scope (subsequent PRs):
* Edit / resubmit / delete endpoints (PR 4)
* Webhook handler for real-time status (PR 5)
* sendTemplateMessage media + button param support (PR 6)
* Resumable Upload + AUTHENTICATION builder (follow-up)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches message_templates.status from the local TitleCase set
(Draft|Pending|Approved|Rejected) to Meta's raw enum (DRAFT|PENDING|
APPROVED|REJECTED|PAUSED|DISABLED|IN_APPEAL|PENDING_DELETION) and
adds the columns the upcoming submit / edit / resubmit / webhook
flows need. No new user-facing behavior — this is groundwork.
Migration 014 also adds:
* sample_values JSONB Meta requires these for variable templates
* meta_template_id TEXT hsm_id for scoped edit/delete (avoids
nuking sibling-language variants)
* rejection_reason TEXT surfaced from webhook on REJECTED
* quality_score TEXT GREEN | YELLOW | RED, from webhook
* header_handle TEXT from Resumable Upload (media headers)
* header_media_url TEXT URL fallback for media headers (v1 path)
* submission_error TEXT last 4xx from Meta, for retry surface
* last_submitted_at rate-limit awareness (100 creates/hour)
* buttons JSONB shape CHECK permissive array-of-typed-objects guard
(strict per-type validation lives in API)
* UNIQUE (user_id, name, language)
so sync upsert is atomic and prevents
duplicate language variants
* idx on meta_template_id fast webhook lookup
Sync route now:
* persists Meta's status verbatim (PAUSED no longer collapses to
REJECTED — these are different states with different recoverability)
* parses BUTTONS components into the new discriminated-union shape
* extracts sample_values from body.example.body_text + header.example
* persists meta_template_id, quality_score, header_handle
Shared template-status.ts gives the manager / inbox picker / broadcast
picker one source of truth for labels + dark-theme badge classes.
Part of the Issue #147 overhaul plan (PR 2 of 6 in the sequence).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #132 (color themes) only tokenized the high-visibility chrome.
Inbox / contacts / pipelines / broadcasts / automations / auth /
settings sub-forms still had ~150 hard-coded `violet-*` classes
across ~32 files, so picking a non-violet theme felt half-broken —
sidebar and header changed colour, but message bubbles, deal cards,
and the broadcast wizard all stayed violet.
This is the mechanical follow-up: replace every `violet-N(/N)?`
with `primary(/N)?` so the theme picker themes the whole app.
Two non-trivial swaps inside the pass:
- `text-white` on a `bg-primary` button → `text-primary-foreground`.
Emerald and Amber define a *dark* `--primary-foreground` because
their primaries are bright; `text-white` over those would tank
contrast.
- `bg-primary hover:bg-primary` (hover identical to base — original
code had this latent bug from `hover:bg-violet-500`) → adds `/90`
on hover so the lift is visible.
Test update:
- `broadcast-status.test.ts` asserted shade numbers (`bg-X-N/10`).
Relaxed the regex to also accept token form (`bg-X/10`) since
brand-accent status badges now ride the active theme.
Files touched: 49. Lint clean (existing warnings only), 173/173
tests, typecheck + build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Pro-badged Schedule for Later tile in step 4 was a non-interactive
div — no handler, no scheduling backend, and no Pro tier in the product.
Send Immediately was the only path that did anything, so the picker
itself was decorative. Remove the section (and the now-unused timing
state / Clock / Lock imports). The wizard still completes Send
Immediately end-to-end via the existing Send Broadcast button below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The legal brief on third-party trademark use says: avoid the WhatsApp
teal/green palette and any visual treatment that could read as a
WhatsApp/Meta product. Emerald-500 was used as the primary brand colour
across hero, CTAs, sidebar, charts, badges, and the favicon — close
enough in hue to WhatsApp's signature green to muddy the line.
Swapping to Tailwind violet (which matches the Hostinger brand purple
#7c3aed) makes the visual identity unambiguously ours.
Scope:
- Mechanical Tailwind class swap: emerald-* → violet-* across src/.
- src/app/globals.css: --primary, --ring, --sidebar-primary, --sidebar-ring,
--chart-1 oklch tokens swapped from emerald (oklch 0.696 0.17 162) to
Hostinger-aligned violet (oklch 0.526 0.247 293).
- src/app/icon.tsx: favicon background #10b981 → #7c3aed.
- src/app/opengraph-image.tsx: badge bg, accent text, gradient.
- src/components/landing/hero.tsx + cta-banner.tsx: radial-gradient hex.
- src/components/landing/mock/analytics-mock.tsx: marketing chart line.
- src/components/dashboard/conversations-chart.tsx + response-time-chart.tsx:
"Outgoing" series colour. Distinguishable from "Incoming" blue at a
glance; we can revisit the chart pair later for stronger colourblind
contrast (separate concern).
- README.md: license badge colour parameter.
Out of scope:
- src/components/settings/tag-manager.tsx still offers "Emerald" as one
of the user-pickable contact-tag colours alongside Blue / Yellow / Red
/ etc. — that's a user data choice, not a brand mark.
Contrast check: white text on #7c3aed measures ~4.78:1 (WCAG AA passing
for normal body text). Violet-400 (#a78bfa) on slate-950 measures ~10:1
for accent text, well clear of AA.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes 8 real issues found during a thorough review of the broadcast
feature (PRs #21-#29).
Security
* Webhook POSTs had zero spoof protection. Anyone could forge a
status update and drift broadcast counts arbitrarily. Added
HMAC-SHA256 signature verification via the X-Hub-Signature-256
header (Meta's standard). New helper src/lib/whatsapp/webhook-
signature.ts with timing-safe comparison. Rollout fails open
with a warning when META_APP_SECRET is unset so existing
deploys don't break at rollout; set the env var to enforce.
Correctness
* CSV audience was completely broken — synthesized "csv-N" strings
as contact_id, which failed the UUID cast on insert. Every CSV
broadcast silently created zero recipients. Replaced with a
real find-or-create against the contacts table (batched lookup
by phone, single INSERT for missing rows, de-duplicates CSV
inputs). CSV contacts now end up as real contacts.rows with
real UUIDs flowing through the rest of the pipeline.
* Recipient insert errors were logged with console.error and the
loop marched on — broadcasts then ran with incomplete recipient
sets, webhook updates couldn't find rows, and aggregate counts
drifted. Now: on batch insert failure, flip the broadcast to
failed + failed_count=total + throw so handleSend's toast fires.
* Webhook status guard ordered "failed" above "replied", which
meant a webhook replay delivering "failed" for an already-
replied recipient would upgrade replied → failed. Split the
ladder: forward-only ordering across pending → sent →
delivered → read → replied; failed is a terminal side branch
that can only be reached from pending or sent (never from any
success state). Net effect: a message that was read can no
longer regress to failed on a replay.
* Template sync only pulled page 1 (ignored paging.next). Users
with >100 templates silently missed some, and broadcasts with
those names failed with Meta's "template not found". Now
follows paging.next with a 20-page safety cap, reports
truncated=true in the response if hit.
* Template sync reported success:true even when individual
upserts failed. Now accumulates per-template errors and
surfaces them via a separate toast.error in the UI — green
toast no longer hides silent drift.
Performance
* Migration 005: replace the broadcast_recipients aggregate
trigger installed in migration 003. Old version ran
COUNT(*) FILTER on every row change — O(n²) across a send
loop (10k recipients × 10k index scans). New version
increments / decrements by ±1 per status transition: O(1)
per change. Kept the old recompute_broadcast_counts() as a
callable safety-net in case of drift.
* List page poll kept running while the tab was hidden. Added
visibilitychange handler — polling pauses when hidden,
resumes (and does an immediate fetch) on refocus.
UX
* Step 3 (Personalize) let users skip past unmapped placeholders
→ broadcasts then sent to Meta with empty-string params.
Next is now disabled until every {{N}} has a value; an amber
warning lists the missing placeholders.
Documented accepted risks in the PR body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stacked on PR B (#22). Merge A → B → C in order.
Step 2 — Audience
- Implement the "Custom Field" audience option (was a stub): field
picker + operator (is / is not / contains) + value input. Estimated
count query joins contact_custom_values with the operator mapped
to eq / neq / ilike. PostgREST ilike handles "contains" case-
insensitively.
- New "Exclude contacts with these tags" section — applies to every
audience type. Estimated count subtracts excluded contacts
client-side against the resolved base set.
- Switching audience type clears stale shape fields (tagIds /
customField / csvContacts) so old selections don't leak across.
Step 3 — Personalize
- Add "Custom Field" mapping option alongside static + contact field.
- Live Preview now pulls the most recent real contact (+ their custom
values) instead of hard-coded sample data. Falls back to sample if
the user has no contacts yet. Rendered in a WhatsApp-style bubble
so the user gets a realistic feel for how the recipient sees it.
Hook — use-broadcast-sending
- AudienceConfig gains customField + excludeTagIds.
- resolveAudience: custom-field branch + exclude-tag subtraction
applied after the base resolution (works for all / tags / custom
field).
- resolveVariables accepts a pre-built (fieldId → value) map per
contact. Before the send loop we bulk-fetch contact_custom_values
for all resolved contacts in one query (paged by 500 for PostgREST
limits) so we never N+1 per recipient.
new/page.tsx — passes customField + excludeTagIds into the hook;
variables state widens to allow type='custom_field'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UI polish for the Broadcasts feature. Stacked on PR A (correctness).
List page /broadcasts
- Replaces raw Sent/Delivered/Read/Failed columns with Delivery Rate +
Read Rate cells (percent + inline mini progress bar). Raw counts
still available on the detail page.
- Sending badge gets a live-looking pulsing dot.
- Indeterminate top-of-page progress bar appears while any broadcast
has status='sending'. Page polls the broadcasts table every 5 s
while that condition holds, and stops polling otherwise.
- Mobile: table wrapped in overflow-x-auto.
Detail page /broadcasts/[id]
- Adds a Replied stat card (was missing). Final grid: Total / Sent /
Delivered / Read / Replied / Failed — 6 cards on desktop, 2 on
mobile. Color spectrum matches the spec's funnel gradient.
- Pure-CSS horizontal funnel chart: Sent → Delivered → Read → Replied
with decreasing widths in emerald → teal → blue → indigo. Each bar
shows its absolute value and percent of Sent.
- Status filter dropdown above the recipients table. Filters
client-side; header reflects filtered / total.
- "Export CSV" downloads all recipients with status + every milestone
timestamp + error_message. RFC-4180 quoting.
- Recipients table gains Delivered / Read timestamp columns so the
CSV columns are also visible at a glance.
Wizard Step 4
- Save as Draft button next to Send Broadcast. Persists a broadcasts
row with status='draft' and zero recipients — enough for the user
to recognize the draft in the list. Full resume-into-wizard is a
future follow-up.
- Confirmation modal before send was already in place; kept as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>