Commit Graph

11 Commits

Author SHA1 Message Date
Arnas Donauskas
c63a68c01e
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>
2026-05-29 17:50:11 +02:00
Arnas Donauskas
11475840e1
feat(templates): media headers + URL/copy-code button params on send
Closes the last hole left by PR 3 — until now, sendTemplateMessage
only emitted a body component, so templates with image/video/
document headers or URL-with-{{1}} buttons failed at send time
with Meta's "missing component" 400. After this PR, every
APPROVED template that wacrm can submit is also fully sendable.

New helper
----------

src/lib/whatsapp/template-send-builder.ts builds the per-send
Meta components array from a MessageTemplate row + caller-supplied
SendTimeParams. It's a different beast from template-components.ts
(which builds the SUBMISSION shape) because the send-time payload
has a different schema: header type-specific parameter objects,
button components with sub_type + index, body_text without a 2D
example wrapper, etc.

Auto-fills as much as possible from the template:

  * static IMAGE/VIDEO/DOCUMENT headers ride along using the row's
    header_media_url (or header_handle when present — preferred)
  * URL buttons without {{1}} need no per-send payload
  * QUICK_REPLY / PHONE_NUMBER buttons need nothing
  * COPY_CODE buttons fall back to the template's example so the
    most common "static promo code" case Just Works without UI

Variable-bearing fields require per-send values:

  * TEXT header with {{1}}        → messageParams.headerText
  * Body variables                 → messageParams.body[]
  * URL button with {{1}}          → messageParams.buttonParams[i]
  * COPY_CODE override             → messageParams.buttonParams[i]

19 unit tests assert the wire shape per scenario — header types,
URL button at correct index when QR buttons precede it, example
fallback, and the throw-with-specific-message paths.

sendTemplateMessage extension
-----------------------------

src/lib/whatsapp/meta-api.ts gains optional `template` +
`messageParams` args. When `template` is provided, the full
components array is built via the new helper. Legacy `params:
string[]` (body only) path is preserved as-is — old callers
behave identically. When both shapes are present, structured
messageParams.body wins.

Route + UI wiring
-----------------

POST /api/whatsapp/send and /api/whatsapp/broadcast now load the
template row by (user_id, name, language) and pass it through.
Broadcast loads once per request (not per recipient — avoids N+1).
Both accept `template_message_params` / per-recipient
`messageParams` from the client.

src/components/inbox/template-picker.tsx scans the selected
template for variable-bearing fields and renders an input per
need: header text variable, body variables (existing), URL
button suffixes. Per-button preview shows the final URL with
the substitution so the user sees what the recipient will see.
The callback shape changes to `(template, { body, headerText?,
buttonParams? })`; message-thread.tsx updated accordingly.

Out of scope
------------

Broadcast composer doesn't yet collect per-recipient header /
button params — the legacy body-only flow continues to work for
broadcasts. Templates with header / URL-button variables broadcast
fine if every recipient gets the same value via API call shape;
the UI for varying it per recipient lands in a follow-up.

This is the last PR of the Issue #147 overhaul sequence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:38:15 +02:00
Arnas Donauskas
4e89b2b902
feat(flows): soft-GA — open to all users + Beta label
The Flows feature was previously gated on a per-account
`profiles.beta_features` opt-in: the sidebar entry was invisible
to non-beta accounts, every /api/flows route 404'd, the webhook
dispatcher was a no-op, and the agent-handoff pause was skipped.

This PR opens the surface to every authenticated user. A small
"Beta" chip in the sidebar nav + on the /flows page H1 keeps the
"young feature" signal without holding anyone out.

Removed:
- `src/lib/flows/feature-flag.ts` + its tests (-11 tests).
- The `requireFlowsBeta` / `requireFlowsBetaAndOwnership` helpers
  in the API routes — collapsed to plain `requireUser` / RLS
  ownership.
- The `isProfileFlowsEnabled` helper + branch in the webhook
  dispatcher — the runner takes the cheap "no_match" exit on
  accounts with no active flows (single indexed SELECT).
- The agent-handoff pause gate in /api/whatsapp/send — the UPDATE
  matches zero rows when no active run exists, also cheap.
- Client-side beta gates in the 3 flows pages (and the redundant
  `useAuth` + `profileLoading` plumbing they needed).
- The defensive message-bubble gate that would have rendered
  interactive replies as plain text for non-beta accounts.
- The `betaKey` field on the sidebar NavItem schema, replaced by a
  simpler boolean `beta` that purely drives the visual chip.

Kept:
- `profiles.beta_features` column. Generic mechanism for future
  beta gates; no current feature reads it. Comments in
  `src/types/index.ts` + `use-auth.tsx` updated to say so.
- The chip can be removed in a one-liner once the team's satisfied
  Flows has bedded in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:16:58 +03:00
Arnas Donauskas
d0b50f4e61
feat(flows): runner engine + webhook integration + agent-handoff + cron sweep (PR 2/4)
Brings the Flows feature alive end-to-end for beta-opted-in accounts.
Still no UI — flows are defined via direct SQL inserts in v1; the
no-code builder lands in PR 3. Everything in this PR is gated behind
`isFlowsEnabled(profile)` so non-beta accounts behave identically to
before.

What landed:

  1. `src/lib/flows/types.ts` — DB row + discriminated-union node
     config types, plus the `ParsedInbound` shape and a
     `DEFAULT_FALLBACK_POLICY`.

  2. `src/lib/flows/fallback.ts` — pure policy resolver. Decides
     whether an unknown reply triggers a reprompt (up to N times),
     a handoff, or end. 11 unit tests.

  3. `src/lib/flows/engine.ts` — `dispatchInboundToFlows()` entry
     point. Loads the active run (if any), checks idempotency via
     `flow_run_events`, advances the run through a synchronous
     auto-advance loop that suspends only at nodes that need
     customer input. Each suspending node calls
     `engineSendInteractive{Buttons,List}` and persists the new
     current_node_key via an optimistic UPDATE with a precondition
     on the OLD key — concurrent inbounds collide cleanly.
     11 unit tests for the pure-logic helpers.

  4. `src/app/api/whatsapp/webhook/route.ts` — invokes the runner
     between the broadcast-reply flag and the automation dispatch.
     When the runner consumes the inbound (started a flow, advanced
     one, applied a fallback), suppresses `new_message_received`
     and `keyword_match` automation triggers — the customer is
     navigating the bot menu, not throwing a keyword. Relationship
     triggers (new_contact_created, first_inbound_message) still
     fire regardless.

  5. `src/app/api/whatsapp/send/route.ts` — after a successful
     agent message, marks any active flow run for that contact as
     `paused_by_agent`. Best-effort: a write failure is logged but
     doesn't block the send response.

  6. `src/app/api/flows/cron/route.ts` — sweeps active runs whose
     `last_advanced_at` is older than the flow's
     `fallback_policy.on_timeout_hours` (default 24h). Marks them
     `timed_out` and writes a `flow_run_events.timeout` row. Re-uses
     `AUTOMATION_CRON_SECRET` so operators don't have to provision
     a second secret.

Test surface in this PR:
  - 142/142 tests pass (added 22 new unit tests across fallback +
    engine pure logic)
  - typecheck clean
  - 0 new lint errors
  - build clean

Manual end-to-end verification arrives via the SQL fixture in the
PR description (a 3-node Welcome → buttons → handoff flow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:36:39 +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
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
1f295a831a
security: rate-limit send and broadcast per user
A fork running WaCRM publicly had no server-side guard on the
WhatsApp send endpoints — a compromised session or an aggressive UI
loop could call /api/whatsapp/send or /broadcast as fast as the
network allowed and blow through Meta's per-number quota (or worse,
get the number suspended).

This PR adds a minimal in-memory fixed-window rate limiter keyed by
user id.

Budgets (tuned for a template default — forkers can edit):
  - /api/whatsapp/send      60 per minute per user  (1/s sustained)
  - /api/whatsapp/broadcast  5 per minute per user  (campaign dispatch
                                                    rate, not fan-out)

Behaviour:
  - Check runs right after the Supabase auth check; unauthenticated
    calls 401 as before and never consume a bucket entry.
  - When a user exceeds the budget the route returns 429 with
    Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining,
    X-RateLimit-Reset headers.
  - Send and broadcast buckets are independent; two users are
    independent.

Design trade-off:
  The limiter lives in a process-local Map. On a single Hostinger
  Managed Node.js instance — the template's default deployment —
  that's the whole limit. On a horizontally-scaled deploy (multiple
  regions, serverless fan-out) each instance has its own Map and the
  effective rate is N * limit. Swap src/lib/rate-limit.ts for a
  Redis/Upstash/Durable-Objects backend if you scale past one
  instance; keep the checkRateLimit return shape and the call sites
  don't move.

Memory is self-cleaning: a 1-in-1000 opportunistic sweep drops
expired keys without a background timer, so the Map stays bounded
without running anything outside the request path.

Smoke test (tsx):
  - 7 broadcasts → 5 ok, 2 denied.
  - Different user → independent bucket.
  - Same user, different route → independent bucket.
  - Retry-after = 60s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:48:30 +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
cf706a8c9d
fix: outbound messages silently failing (wrong Meta args + schema)
Two silent bugs in the send pipeline that made agent messages
never reach WhatsApp AND disappear on page reload:

1. /api/whatsapp/send passed Meta API args in the wrong order.
   The helpers have signature (phoneNumberId, accessToken, to, ...)
   but the route was calling sendTextMessage(accessToken, phoneNumberId,
   to, text). So the request went to
     POST https://graph.facebook.com/v21.0/<ACCESS_TOKEN>/messages
   with Authorization: Bearer <PHONE_NUMBER_ID>
   which Meta rejects. The try block caught the error and the outer
   catch returned a generic "Failed to send message" with no detail.

2. Even if Meta had succeeded, the DB insert would have failed because
   the route was using field names that don't exist in the messages
   table — same class of bug as the webhook had before PR #10:
     user_id           → not a column on messages
     whatsapp_message_id → should be message_id
     direction          → should be sender_type ('customer'|'agent'|'bot')
     message_type       → should be content_type
     timestamp          → should be created_at
   Insert would have failed, DB row would never exist, and the optimistic
   message in the UI would vanish on the next page load / fetchMessages.

3. The frontend swallowed errors silently (console.error only), so the
   UI looked like the message had been sent even when nothing reached
   Meta and nothing was saved.

Changes:

src/app/api/whatsapp/send/route.ts
- Swap Meta API args to the correct order
  (phoneNumberId first, then accessToken)
- Wrap the Meta call in its own try/catch so the error message from
  Meta bubbles up to the client (returned as 502 with the actual text)
- Fix messages insert to use schema-correct columns: sender_type,
  content_type, content_text, media_url, template_name, message_id,
  status. Dropped non-existent user_id/direction/message_type/timestamp.
- Also surface the underlying DB error if the insert fails.

src/components/inbox/message-thread.tsx
- Added onUpdateMessage prop.
- handleSend parses the error JSON, toasts the actual reason, and
  flips the optimistic bubble's status to 'failed' so MessageBubble
  renders the red XCircle. On success flips to 'sent' immediately
  (realtime INSERT still replaces the temp bubble with the real row).

src/app/(dashboard)/inbox/page.tsx
- Added handleUpdateMessage that mutates one message by id.
- Passed as onUpdateMessage to MessageThread.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:21:01 +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