Commit Graph

4 Commits

Author SHA1 Message Date
Arnas Donauskas
66dd4ef97e
fix(broadcasts): senior-review hardening pass
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>
2026-04-17 17:38:52 +03:00
Arnas Donauskas
f3c68967f1
refactor: Meta API helpers use named params (prevents swapped-args bugs)
Over the course of the project the same swapped-args bug was found
four separate times against the Meta API helpers (config in PR #8,
send in PR #12, broadcast in PR #13, webhook media in PR #14). Each
time the compiler accepted the call because all the args were
strings, so the bug only surfaced at runtime as an opaque Meta error.

This refactor eliminates the entire bug class by converting every
helper to a single options object. A typo like
  sendTextMessage({ accessToken: id, phoneNumberId: tok, ... })
is now a TypeScript error at the call site rather than a 400 from
Meta two layers deep.

Helpers updated in src/lib/whatsapp/meta-api.ts:
- verifyPhoneNumber({ phoneNumberId, accessToken })
- sendTextMessage({ phoneNumberId, accessToken, to, text })
- sendTemplateMessage({ phoneNumberId, accessToken, to, templateName, language?, params? })
- getMediaUrl({ mediaId, accessToken })
- downloadMedia({ downloadUrl, accessToken })

Each exported Args interface so routes can type local variables
holding partial argument sets (useful for the broadcast retry loop).

Also factored the duplicated "parse Meta error JSON" block into a
shared throwMetaError() helper — was repeated in 5 places.

Call sites updated (all were already correct after prior fixes, but
now the types enforce it):
- src/app/api/whatsapp/config/route.ts (GET + POST)
- src/app/api/whatsapp/send/route.ts (attempt() closure)
- src/app/api/whatsapp/broadcast/route.ts (retry loop)
- src/app/api/whatsapp/webhook/route.ts (verifyAndBuildUrl)
- src/app/api/whatsapp/media/[mediaId]/route.ts (proxy pipeline)

No behavior change. Build passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:52:25 +03:00
Arnas Donauskas
e97d5b5064
feat: auto-retry with phone variants on Meta "not in allowed list"
Meta WhatsApp returns error #131030 ("Recipient phone number not in
allowed list") in two situations:
  1. Sandbox: the recipient isn't registered as a test number.
  2. The registered number format differs from what we sent, usually
     because of trunk-prefix 0 quirks (e.g. Lithuanian numbers stored
     as +370063949836 vs international +37063949836).

Today that error bubbles up to the UI and the user has to manually
edit the contact. This PR makes the send pipeline self-heal: on 131030
it retries with alternate phone-number formats, and if an alternate
works it persists the corrected format back to the contact so future
sends are a single API call.

src/lib/whatsapp/phone-utils.ts
- Added phoneVariants(sanitized) — yields original, then variants with
  a trunk 0 inserted/removed after each plausible country-code length
  (1-3 digits). Deduplicated. Original is always first so the fast
  path for correctly-formatted numbers is one attempt.
- Added isRecipientNotAllowedError(message) helper — matches
  code 131030 or the English error text, so we only retry when the
  failure is actually about the recipient (other errors fail fast).

src/app/api/whatsapp/send/route.ts
- Reply send now iterates phoneVariants() and stops at the first that
  succeeds. If a non-original variant works, contacts.phone is updated
  to the working format.

src/app/api/whatsapp/broadcast/route.ts
- Same retry applied per recipient in the broadcast loop.
- Also fixed a separate bug: broadcast was calling
  sendTemplateMessage(accessToken, phone_number_id, ...) — wrong order.
  Correct signature is (phoneNumberId, accessToken, to, templateName,
  language?, params?). This was silently rejecting every broadcast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:34:53 +03:00
Arnas Donauskas
658d070811
Initial scaffold: WhatsApp CRM with Next.js + Supabase
Complete WhatsApp CRM application built with Next.js 14 (App Router),
Supabase (PostgreSQL + Auth + Realtime), and shadcn/ui.

Features:
- Auth: Login, signup, forgot password with Supabase Auth
- Dashboard: Analytics overview with stats, recent activity
- Inbox: Real-time WhatsApp conversations with message threading
- Contacts: CRUD with tags, custom fields, notes, CSV import
- Broadcasts: 4-step wizard with audience selection, template sending
- Pipelines: Kanban board with deal management
- Settings: WhatsApp API config, templates, tags
- WhatsApp API: Webhook (verify + receive), send, broadcast, media proxy
- Database: 16 tables with RLS policies, indexes, realtime subscriptions
- Dark theme throughout with emerald accents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:17:31 +03:00