Commit Graph

11 Commits

Author SHA1 Message Date
Arnas Donauskas
a2100a3076
feat(templates): edit / resubmit / delete lifecycle endpoints
Adds the missing lifecycle operations so users can manage existing
templates without leaving wacrm.

API surface
-----------

PATCH /api/whatsapp/templates/[id]
  Edits (and re-submits) an existing Meta-side template. Used by
  the "Edit" action on APPROVED rows and "Resubmit" on REJECTED /
  PAUSED rows — same operation, different UI labels. Meta replaces
  components wholesale and flips status back to PENDING for
  re-review. Status guard rejects edits on PENDING / DISABLED /
  IN_APPEAL / PENDING_DELETION and on local-only (no
  meta_template_id) rows. Honors WHATSAPP_TEMPLATES_DRY_RUN.

DELETE /api/whatsapp/templates/[id]
  Calls Meta `DELETE /{waba_id}/message_templates?name=X&hsm_id=Y`
  when meta_template_id is set — hsm_id is critical, without it
  Meta deletes EVERY language variant of the same name. Treats
  Meta 404 as a no-op (template already gone). Always removes the
  local row.

New Meta helpers
----------------

src/lib/whatsapp/meta-api.ts gains editMessageTemplate and
deleteMessageTemplate following the established named-args +
throwMetaError pattern. Eleven new fetch-mock tests assert the
exact wire shape — especially that hsm_id always rides along when
provided, which is the difference between "delete one variant"
and "delete every variant."

UI changes
----------

src/components/settings/template-manager.tsx:
  * Edit (pencil) button visible for APPROVED rows
  * Resubmit (rotate) button visible for REJECTED / PAUSED rows
    — same flow as Edit, different label
  * Delete now calls the route handler (so Meta gets the call too)
    with a spinner during the in-flight request
  * Dialog pre-fills form from the row when opened in edit mode
  * Dialog title / CTA / description switch between "New Message
    Template" + "Submit for Approval" and "Edit Message Template"
    + "Save & Resubmit"
  * Name + language inputs are disabled in edit mode (Meta keys
    templates by id; changing either is effectively a new template)
  * Closing the dialog clears editingId so the next open starts
    fresh

Design decision
---------------

One PATCH endpoint instead of the two endpoints the plan called
for (PATCH for edit, POST for resubmit). The Meta call is the same
operation either way — the difference is UI semantics, handled in
the component. Cuts duplication; easy to split later if needed.

Out of scope (final PR remaining in the sequence):
  * Webhook handler for real-time status (PR 5)
  * sendTemplateMessage media + button param support (PR 6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:57:24 +02:00
Arnas Donauskas
3113738c7e
feat(templates): submit-to-Meta endpoint + component-based form builder
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>
2026-05-29 15:34:58 +02:00
Arnas Donauskas
dc245d5914
feat(flows): foundation for interactive WhatsApp messages (PR 1/4)
Foundation for the upcoming WhatsApp chatbot flow builder. No runner,
no UI yet — just the plumbing that lets the codebase SEND interactive
messages (button replies + lists) and RECEIVE the taps as structured
message rows. Plan: ~/.claude/plans/understood-research-such-functionality-binary-lake.md

## What's in this PR

### Migration 010_flows.sql
- New tables: flows, flow_nodes, flow_runs, flow_run_events
- Linchpin partial unique index on flow_runs(user_id, contact_id) WHERE status='active' — at most one active run per contact, makes concurrent webhook deliveries idempotent at the DB layer
- Widens messages.content_type CHECK to allow 'interactive'
- New messages.interactive_reply_id column for the tapped button/row id
- RLS on every table; flow_runs added to supabase_realtime publication
- Idempotent throughout — matches the 009_message_actions.sql template

### Interactive Meta senders (src/lib/whatsapp/meta-api.ts)
- sendInteractiveButtons: type='interactive', interactive.type='button', up to 3 reply buttons
- sendInteractiveList: type='interactive', interactive.type='list', up to 10 rows across sections
- Limits exported as INTERACTIVE_LIMITS so flow validation can share the constants
- All Meta caps validated BEFORE the network call (button title ≤20 chars, list row title ≤24 chars, ≤10 total rows, no duplicate row ids, body ≤1024 chars, etc.) — misconfigured flows fail at save time instead of as a customer-facing 400 mid-conversation

### Webhook (src/app/api/whatsapp/webhook/route.ts)
- Extends WhatsAppMessage interface with `interactive?: { type, button_reply?, list_reply? }`
- parseMessageContent returns a new `interactiveReplyId` field; refactored to use a shared `empty` base so every return site picks up the new field without 9 explicit copies
- New `case 'interactive'`: uses the tapped option's TITLE as content_text (so the inbox bubble reads "Existing customer", not a UUID) and stashes the id separately
- 'interactive' added to ALLOWED_CONTENT_TYPES so the messages insert no longer coerces it to 'text'

### Inbox bubble (src/components/inbox/message-bubble.tsx)
- New `case "interactive"` in MessageContent renders the tap with a small "↩ Button reply" affordance so agents reading the inbox can tell at a glance that this was a tap rather than the customer typing the same words

### Types (src/types/index.ts)
- ContentType union extended with 'interactive'
- Message.interactive_reply_id field

### Engine helpers (src/lib/flows/)
- admin-client.ts: thin service-role client wrapper, mirrors automations/admin-client.ts
- meta-send.ts: engineSendInteractiveButtons / engineSendInteractiveList — adapts the Meta senders for service-role use, persists messages with sender_type='bot' content_type='interactive', uses the same phone-variant retry as automations/meta-send.ts. Unused in PR #1; ready for the runner in PR #2

### Tests
- 13 new vitest cases in meta-api.test.ts covering Meta's limit validation + payload-shape assertions for both interactive senders

## Validation
- npm test → 102/102 (was 89; 13 new)
- npm run lint → 0 errors
- npm run typecheck → clean
- npm run build → clean

## What's NOT in this PR
- The runner (dispatchInboundToFlows) — PR #2
- Builder UI / API surface — PR #3
- collect_input / condition / set_tag nodes + templates — PR #4
- react-flow canvas + http_fetch — v2 (PR #5)

## Risks tracked from the plan
- messages.content_type CHECK widening: grep'd every consumer; message-bubble.tsx, reply-quote.tsx, send/route.ts, automations/meta-send.ts all handle the new variant correctly (or accept it as a defaulted fallthrough)
- engineSendText / engineSendInteractive share ~80% of code; deliberately not pre-factored yet — let both stabilize, extract a shared base in PR #2 once the shape settles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:12:27 +03:00
Arnas Donauskas
339b1fe94f
feat(inbox): reactions, reply-to, and copy on chat messages
Adds three WhatsApp-standard affordances to the inbox: per-message
emoji reactions, reply-with-quote, and copy-text. Bubbles stay pure
presenters — actions layer on by composition (MessageActions wraps
MessageBubble). Hover toolbar on desktop, long-press (onContextMenu)
on touch.

Data model (migration 009):
- messages.reply_to_message_id (self-FK, ON DELETE SET NULL) so replies
  survive a deleted parent
- message_reactions table — one row per (message, actor) with a unique
  constraint, conversation_id denormalised so Supabase Realtime can
  filter without a join
- RLS scoped via conversation ownership; service-role bypass for the
  webhook

API:
- /api/whatsapp/send accepts reply_to_message_id, resolves the parent's
  Meta message_id and forwards it as context.message_id
- /api/whatsapp/react (new) sends Meta reaction payloads and mirrors
  the upsert/delete into message_reactions
- Webhook stops storing inbound reactions as fake text messages and
  instead upserts/deletes message_reactions; inbound replies (context.id)
  resolve to internal UUID and populate reply_to_message_id

UI:
- MessageActions hover toolbar with inline 6-emoji Popover (static set;
  no emoji-picker library)
- ReplyQuote shared between the in-bubble quote and the composer chip
- MessageReactions pill row with click-to-toggle
- Thread subscribes to message_reactions filtered by conversation_id;
  optimistic upsert with rollback on failure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:17:26 +03:00
Arnas Donauskas
db11f76226
test: foundational Vitest suite for security + critical pure logic
Adds the first test layer to the repo. Vitest is wired into the
existing CI workflow between typecheck and build so every PR gets the
same gating before merge. No source code under test was modified; this
PR only adds the test framework + tests.

Coverage focuses on the highest-impact pure modules — security-critical
crypto/HMAC, the phone-number pipeline every outbound message touches,
the rate-limiter that protects send/broadcast, automation activation
validation, and dashboard date math. 89 tests across 7 files; full run
is ~300ms.

Modules covered:
- lib/whatsapp/encryption — GCM round-trip, tamper detection (auth tag,
  ciphertext, IV length, auth-tag length), legacy CBC compat, format
  detection, malformed input
- lib/whatsapp/webhook-signature — valid/invalid HMAC, missing header,
  wrong prefix, length-mismatch guard, fail-closed when secret unset
- lib/whatsapp/phone-utils — sanitize, normalize, phonesMatch with
  trunk-prefix tail fallback, isValidE164 boundaries (7–15 digits,
  non-zero start), phoneVariants insert/remove with dedupe,
  isRecipientNotAllowedError detection
- lib/rate-limit — first-request math, exact-limit boundary, per-key
  isolation, window expiry with fake timers, 429 response shape,
  Retry-After clamp
- lib/automations/validate — every step type's required fields, wait
  amount/unit boundaries, webhook URL validation (missing / non-http /
  malformed), assign_conversation mode branching, recursive condition
  walking with stable dot-paths, unknown step types
- lib/broadcast-status — known status lookups, pulse flag, dark-theme
  class shape, fallback for unknown strings
- lib/dashboard/date-utils — startOfLocalDay, daysAgoStart, localDayKey
  zero-padding, lastNDayKeys (1, N, month crossing), Monday-first DOW

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:06:02 +03:00
Arnas Donauskas
e570e1a8f2
security: switch WhatsApp token encryption from AES-CBC to AES-GCM
CBC without a MAC is unauthenticated — anyone who can write rows to
whatsapp_config can flip bits in the stored access_token or
verify_token without the decrypt throwing. You'd silently get garbled
bytes; worst case, if the mutation happens to produce a valid token,
messages go out under a spoofed account. GCM appends a 16-byte
authentication tag; any tampering fails the decrypt.

## What changed

src/lib/whatsapp/encryption.ts
  - encrypt() now emits AES-256-GCM ciphertexts with a 12-byte IV,
    formatted as `<iv>:<ciphertext>:<authTag>` (three colons).
  - decrypt() auto-detects format by splitting on `:`:
      3 parts → GCM, validated via auth tag.
      2 parts → legacy CBC, read-only compatibility path.
    Callers are unchanged; same signature, same return type.
  - isLegacyFormat() exposes the format check so hot paths can
    opportunistically upgrade old rows.
  - IV length is validated on decrypt (12 for GCM, 16 for CBC) to
    fail fast on corrupted input.

## Self-healing existing rows

Two call sites opportunistically upgrade a legacy row to GCM after a
successful decrypt — fire-and-forget, no latency impact on the request
path, idempotent under concurrent writes.

  - src/app/api/whatsapp/send/route.ts (hot path)
      After decrypting access_token, if it was CBC, we schedule an
      UPDATE that writes a fresh GCM ciphertext back to the same row.
      The first send after deploy upgrades the token; subsequent
      sends are no-ops because isLegacyFormat returns false.

  - src/app/api/whatsapp/webhook/route.ts (subscribe handshake)
      When a config's verify_token matches the inbound
      hub.verify_token, we upgrade it in place if it was legacy.
      Runs during Meta's webhook re-subscription events, which
      happen on config changes and periodic refreshes.

No schema migration required — the DB column holds whatever string
encrypt() produces. Operators who want to force an upgrade for a
particular user can clear and re-save the WhatsApp settings form.

## Verified (tsx smoke test)
  - GCM roundtrip works (plaintext in → same plaintext out).
  - Legacy CBC decrypt works on 2-part format.
  - isLegacyFormat distinguishes new/legacy correctly.
  - Bit-flipped GCM ciphertext throws on decrypt.
  - Forged auth tag throws on decrypt.
  - Same plaintext produces different ciphertexts across calls
    (IV randomness).

## Rollback

Revert this file. Existing GCM rows would then fail to decrypt
because the previous encrypt.ts has no branch for the 3-part format.
Safer rollback: keep the decrypt branches, revert only the encrypt()
body to CBC — but since GCM roundtrips cleanly and the PR's bug
surface is small (pure crypto utility), a straight revert should only
matter if a forker had time between merging this PR and reverting to
accumulate GCM rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:25:48 +03:00
Arnas Donauskas
be7edfd180
fix(webhook): require META_APP_SECRET, fail closed
The webhook signature verifier used to fall open with a warning log
when META_APP_SECRET was unset — a deliberate soft-rollout choice that
is unsafe for a public template. A forker who forgets the env var
would be running a fully spoofable webhook: anyone knowing the URL
could POST fabricated status updates and drift broadcast counts.

Now:
- `verifyMetaWebhookSignature` returns false when the secret is missing
  and logs at error level, so the route responds 401 to every POST
  until the operator configures it.
- Docs mark META_APP_SECRET as **required**:
  - docs/environment-variables.md lists it under Required, not
    Recommended.
  - docs/whatsapp-setup.md §6 is now "Signature verification (required)".
  - docs/troubleshooting.md updated accordingly.
- .env.local.example lists the var with guidance on where to find it.

Also fixes a pre-existing gitignore bug: `.env*` was swallowing
`.env.local.example`, which meant forkers running
`cp .env.local.example .env.local` from the getting-started guide
hit "no such file". Added a negation for `*.example`.

Upgrade note for existing deploys: set META_APP_SECRET (Meta → App
Settings → Basic → App Secret) before updating, or inbound webhooks
will 401 until it's configured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:54:52 +03:00
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