The INSERT into broadcasts in use-broadcast-sending.ts never set
user_id, but broadcasts.user_id is NOT NULL + RLS-guarded. The
INSERT rejected (23502 / 42501), the hook threw, handleSend caught
with console.error only. UI showed nothing — the wizard stayed on
Step 4 and the flow appeared dead.
Fix:
- Resolve session.user at the top of createAndSendBroadcast and
include user_id on the broadcasts INSERT.
- handleSend in /broadcasts/new now toasts the error via sonner so
future failures are visible immediately instead of vanishing.
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>
Three correctness bugs were making the existing broadcast flow wrong,
not just incomplete.
1. Per-recipient variables were broken
use-broadcast-sending.ts computed templateParams per contact but shipped
only templateParams[0] to the API, so every contact in a batch received
contact 0's personalization (name/email/etc).
Fix: extend POST /api/whatsapp/broadcast to accept a per-recipient
shape `{ recipients: [{ phone, params }], ... }`. Legacy
`{ phone_numbers, template_params }` still accepted. Hook sends per-
recipient payloads; API returns whatsapp_message_id per phone.
2. Webhook never updated broadcast_recipients
handleStatusUpdate in webhook/route.ts only mirrored Meta status onto
the `messages` table. broadcast_recipients had no correlation column,
so delivered_count / read_count / replied_count never advanced for
real broadcasts.
Fix (migration 003):
- broadcast_recipients.whatsapp_message_id (+ UNIQUE index) for
Meta-message-id correlation
- composite index (broadcast_id, status) for fast aggregate scans
- AFTER INSERT/UPDATE/DELETE trigger that recomputes parent
broadcasts counts via COUNT(*) FILTER — sender + webhook only
touch the recipient row, aggregates stay consistent
Webhook:
- handleStatusUpdate also updates broadcast_recipients with forward-
only status guard (read cannot regress to sent on a replay)
- processMessage flags the replied recipient row when an inbound
message arrives from a recent broadcast contact
3. Rate limiting did not match spec
Batch 50 / no delay → Batch 10 / 1s between batches, matching the spec
and Meta's per-phone-number rate ceiling.
Also: extract statusConfig from both page components into
src/lib/broadcast-status.ts (single source of truth for PR B). Add
whatsapp_message_id to BroadcastRecipient type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>