Final feature PR before GA cleanup. Closes the gap between "flow
runner works" (PR 2) and "users have everything they need to build
real automation" — three new node types, plain-text sends actually
sending, three starter templates, a run-history page for debugging.
What landed:
1. engineSendText helper (src/lib/flows/meta-send.ts) — wraps
sendTextMessage with the phone-variant retry + DB persistence
pattern. Unblocks send_message + collect_input nodes that
previously auto-advanced without sending.
2. Three new node types in the engine:
- collect_input: sends a prompt, suspends, captures the next
text reply into flow_runs.vars[var_key], advances. Supports
{{vars.X}} interpolation in downstream prompts + handoff
notes.
- condition: branches on subject (var / tag / contact_field)
with operators (equals / contains / present / absent).
Auto-advancing, no Meta call. Pure predicate logic exported
as evaluateConditionPredicate with 7 new unit tests.
- set_tag: upsert/delete on contact_tags then advance. Non-
fatal on write failure — logged + advances anyway so a tag
glitch doesn't strand the customer.
3. Real send_message — calls engineSendText, logs the result,
handles errors by ending the run with status='failed'. Same
{{vars.X}} interpolation.
4. Three starter templates (src/lib/flows/templates.ts):
- Welcome menu (the owner's brief example)
- FAQ bot (keyword → list → answers → end)
- Lead capture (first_inbound → collect_input chain → handoff
with vars in the note)
POST /api/flows now accepts {template_slug} to clone a
template into a fresh draft. GET /api/flows/templates exposes
the gallery shape (no nodes payload — fetched on actual clone).
5. Builder UI extensions:
- NODE_META + config sub-forms for collect_input, condition,
set_tag.
- Template gallery on the /flows New-flow dialog (loads from
/api/flows/templates, falls back gracefully if absent).
- "Runs" button in the editor header → /flows/[id]/runs.
6. Run-history viewer at /flows/[id]/runs — newest-first list of
the 50 most recent runs, with expandable per-run event
timeline (started, node_entered, message_sent,
reply_received, fallback_fired, handoff, timeout, error,
completed). Useful for debugging "why didn't my flow advance?"
from the brief's use case.
7. Validator updates: new error rules for each new node type
(prompt_text required, var_key alphanumeric, operator valid,
true_next/false_next exist, etc.). outgoingEdges helper now
covers collect_input/condition/set_tag for reachability.
All gated behind isFlowsEnabled(profile). Non-beta accounts see no
change. Run-history API is also 404 for non-beta.
Validation: 173/173 tests pass (added 7 new for the predicate);
typecheck + lint + build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships the surface that lets opted-in beta accounts build, save, and
activate WhatsApp conversation flows entirely from the web UI — no
SQL fixtures needed. Sidebar gets a "Flows" entry (hidden for
non-beta accounts), the /flows list page surfaces existing flows,
/flows/[id] is the linear-list editor.
Wraps the runner from PR 2 with everything a real user needs to
build the welcome-menu example end-to-end.
What landed:
1. `src/lib/flows/validate.ts` — pure validator. Checks entry
node exists, all next_node_key refs resolve, no unreachable
nodes, Meta-limit violations (button title ≤20, ≤3 buttons,
≤10 list rows, ≤24 chars per list row). 24 unit tests.
2. `src/app/api/flows/route.ts` — GET (list user's flows),
POST (create draft).
3. `src/app/api/flows/[id]/route.ts` — GET (load with nodes),
PUT (replace name + trigger + nodes; delete-then-insert),
DELETE (cascade clean-up).
4. `src/app/api/flows/[id]/activate/route.ts` — status toggle.
Activation runs the validator; returns 422 + the full issue
list when blocked.
5. `src/app/(dashboard)/flows/page.tsx` — list of cards, "New
flow" dialog, delete with confirm.
6. `src/app/(dashboard)/flows/[id]/page.tsx` — editor shell
that loads data and hands it to FlowBuilder.
7. `src/components/flows/flow-builder.tsx` — the editor itself.
Header with name + save/activate/pause/delete; trigger
panel; entry-node picker; node list with expandable per-node
config forms (start, send_buttons, send_list, send_message,
handoff, end); bottom validation panel that mirrors what the
server's activate endpoint would reject.
8. `src/components/layout/sidebar.tsx` — Flows entry between
Automations and Settings, rendered only when isFlowsEnabled.
Every API route + page is gated server-side by isFlowsEnabled —
non-beta accounts get 404 (not 403) so the routes don't even hint
at existing. UI gate is for snappy redirect; security is at the
API layer.
Validation: 166/166 tests pass (added 24 validator tests);
typecheck + lint + build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Scaffolding for the upcoming WhatsApp Flows feature. The whole Flows
stack (interactive button/list send + parse, server-side runner, no-
code builder, templates) will land across the next ~4 PRs *behind*
this gate so the owner can dogfood the feature on their own account
before exposing it to teammates or downstream pullers of the OSS.
What this lands:
1. `profiles.beta_features TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]`
— generic opt-in column, lives on the existing profiles table.
Empty by default for every existing row.
2. `src/lib/flows/feature-flag.ts` — `isFlowsEnabled(profile)`
predicate + `BETA_FEATURE_FLOWS` constant. Single source of
truth for the gate; later PRs import this in every guarded
surface (sidebar nav, /flows page, /api/flows routes, webhook
dispatcher).
3. `Profile.beta_features` added to the shared TS type and the
auth hook's local copy. Auth hook loads the column and
defaults it to `[]` defensively if the DB read returns null.
4. 11 unit tests covering null/undefined/empty/mismatched cases,
including case sensitivity (only lowercase `'flows'` opts in)
and defensive guards against malformed input.
How the owner enables it for their own account, once merged:
UPDATE profiles
SET beta_features = array_append(beta_features, 'flows')
WHERE user_id = '<their-auth-uid>';
How we ship it to everyone, when ready: grep-and-delete the
`isFlowsEnabled()` callsites in one cleanup PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Forks of this repo no longer inherit the wacrm.tech landing page
and docs — those move to the dedicated marketing-site repo at
https://github.com/ArnasDon/wacrm-site (deployed to wacrm.tech).
Changes here:
- Replace src/app/page.tsx with a redirect to /dashboard.
Middleware already handles the unauth → /login bounce, so this
works for both auth states.
- Drop src/app/{docs,opengraph-image.tsx,robots.ts,sitemap.ts}.
- Drop src/components/{landing,docs,seo} and src/lib/{seo,docs}.
- Drop the top-level docs/ markdown — moved to wacrm-site/docs.
- Slim src/app/layout.tsx metadata down to product chrome
(no public OG cards, robots: noindex).
- Drop `marked` from dependencies; refresh package-lock.
- Refocus README on self-hosting; point readers at wacrm.tech/docs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Meta's brand-use guidance for third-party products, the registered
trademark symbol (®) should appear on the FIRST mention of "WhatsApp"
on each user-facing page. Subsequent mentions on the same page are
left plain.
Surfaces touched (one-per-page first mention):
- Landing hero badge ("Built on the official WhatsApp® Business API").
- src/lib/seo/site-config.ts: SITE_TAGLINE and SITE_DESCRIPTION
(rendered into <title> and <meta description> on every page).
- src/app/opengraph-image.tsx: hero text in social-share image.
- Settings, Inbox connection banner, Automations page subtitle.
- README.md tagline.
- All public docs pages (docs/README.md, docs/whatsapp-setup.md,
docs/getting-started.md, docs/supabase-setup.md,
docs/environment-variables.md, docs/deployment-hostinger.md,
docs/automations-and-cron.md, docs/architecture.md,
docs/troubleshooting.md).
Out of scope: SITE_KEYWORDS (the meta-keywords array isn't
user-rendered text — putting ® on every keyword would look broken in
the meta tag).
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>
Drops the "WaCRM" branded name across user-facing surfaces in favour
of a descriptive name. Branded names that imply WhatsApp ownership or
sponsorship are a trademark fair-use risk; a relationship-based name
("CRM Template for WhatsApp") sidesteps that.
Scope:
- Brand text in nav, sidebar, footer, docs shell, mock window chrome.
- SITE_NAME / SITE_DESCRIPTION / legalName in src/lib/seo/site-config.ts.
- README, CONTRIBUTING, docs/, .github/ templates and policies.
- package.json description.
- Auth pages, settings copy, FAQ, code comments.
Out of scope (intentionally — these are infra renames the project
owner should drive separately so links and the package lockfile stay
consistent):
- The github.com/ArnasDon/wacrm repo URL.
- The wacrm.tech domain / homepage field.
- The package.json "name" field.
- The Hostinger application name and Meta system-user name in deploy
docs (existing-deployment instructions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The template is about shipping on Hostinger. Offering Vercel / Railway
/ raw VPS / Docker alongside it blurs that story and creates support
surface for hosts we don't actually test against.
- Delete docs/deployment-alternatives.md.
- README Deploy list drops the "Alternative hosts" bullet.
- docs/README.md reading-order drops step 6 and renumbers.
- docs/architecture.md drops its trailing "Next step" pointer to
the removed page.
- src/lib/docs/content.ts drops the deployment-alternatives entry;
remaining pages renumbered to 5..8.
Verified: /docs sidebar now lists 8 pages,
/docs/deployment-alternatives → 404.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Finishes the template-readiness sequence. All docs — nothing runtime.
README.md
Rewrites the landing page of the repo to actually sell what it is
to someone finding the repo cold on GitHub search.
- Shields.io badges: MIT, CI status, Next.js 16, Supabase, GitHub
stars.
- "What you get out of the box" bullet list — inbox, contacts,
pipelines, broadcasts, automations, dashboard, account
management — so the feature surface is visible before the reader
scrolls.
- "Why fork this?" section: full ownership, full customisation,
zero ops to start, real security primitives. Frames WaCRM as a
template, not an SDK.
- Docs section reorganised into Setup / Deploy / Reference to
match the in-app /docs viewer and docs/README.md.
- Contributing section rewrites the stale "Pull requests welcome"
line to match the fork-first framing from PR #73.
docs/architecture.md (new)
One-page tour for forkers: stack table, full folder layout,
two ASCII request-lifecycle diagrams (inbound webhook + outbound
send), security primitives rundown, and a "where to change things"
index that maps common customisation tasks to source files. This
is the thing I wish I'd had the first time I opened a forked
template.
docs/deployment-alternatives.md (new)
Short blocks for Vercel, Railway, raw VPS, and Docker. Each covers
env-var setup, cron strategy (Vercel cron without custom headers
is a footgun worth calling out), and trade-offs. Keeps Hostinger
as the primary recommendation — this page is for people whose
constraints rule it out.
docs/README.md
- Reading order grouped as Setup / Deploy / Reference, adding the
two new pages.
- Stack-at-a-glance updated: encryption is AES-256-GCM, not CBC
(the PR #71 change that earlier docs hadn't caught up to).
src/lib/docs/content.ts
Registers the two new markdown files in the ORDER map so they
appear in the in-app /docs sidebar under the Reference section,
with prev/next pagination wiring.
Verified: /docs, /docs/architecture, /docs/deployment-alternatives,
and /docs/getting-started all return 200 from the dev server.
Sidebar shows 9 pages grouped Setup / Deploy / Reference with the
new entries. Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
Renders every file in docs/*.md as a proper documentation site styled
like Stripe / Vercel / Linear docs: sticky header, grouped left sidebar
(Setup / Deploy / Operate), dark prose with inline code pills, syntax
-ready code blocks, responsive table styling, and prev/next pagination.
- src/lib/docs/content.ts — parses md with marked and decorates the
HTML with Tailwind utility classes via post-processing regex. Also
rewrites inter-doc `./other.md` links to `/docs/other` and extracts
an h2/h3 TOC for future use.
- src/app/docs/layout.tsx + src/components/docs/shell.tsx — shared
frame with docs-scoped header (GitHub, Home, Get started), collapsing
sidebar on mobile.
- src/app/docs/page.tsx — grouped index cards per section.
- src/app/docs/[slug]/page.tsx — individual doc + prev/next nav.
- Adds "Docs" to landing nav, footer, and a "Read the full
documentation" CTA under the open-source cards.
- Adds `marked` dependency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-applies the original SEO work from PR #47 that was reverted in
PR #54 because its opengraph-image.tsx crashed `next build` during
static prerender of /opengraph-image. Satori (next/og's renderer)
errored with:
Error: Expected <div> to have explicit "display: flex",
"display: contents", or "display: none" if it has more
than one child node.
Failed to load dynamic font for ✓ . Status: 400
Root cause in the OG image JSX:
- Several <div>s held text + a sibling element (e.g. "Run your
WhatsApp business from <span>one inbox.</span>") without an
explicit display value. Satori requires it whenever there is
more than one child.
- The ✓ Unicode glyph isn't covered by the default font Satori
loads, so next/og tried to download a dynamic font and failed.
Fix:
- Every <div> with more than one child now has display: flex.
- Multi-span headline sits in a flex-wrap row of two <span>s.
- ✓ replaced with plain text dot separators in the feature row.
Verified end-to-end: `npm run build` now completes with 27/27
pages prerendered, /opengraph-image among them.
All the other SEO-pass content is identical to the originally
merged-and-reverted work: metadataBase + title template, OpenGraph
+ Twitter Cards defaults, JSON-LD (WebSite + Organization +
SoftwareApplication + FAQPage), sitemap.ts, robots.ts, noindex on
auth + dashboard route-group layouts, shared faq-data + site-config
modules.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the site from bare-bones metadata to production-ready SEO.
Covers technical crawlability, rich-result eligibility, social
sharing, and duplicate-content prevention.
Added:
- src/lib/seo/site-config.ts — single source of truth for base URL,
brand, description, keywords. Referenced by every consumer so a
domain / rename is a one-file edit.
- src/lib/seo/faq-data.ts — FAQ Q&A extracted from the component so
the UI and the FAQPage JSON-LD render the same content.
- src/lib/seo/structured-data.ts — builders for WebSite,
Organization, SoftwareApplication, and FAQPage JSON-LD nodes.
FAQPage is eligible for Google's FAQ rich result; SoftwareApplication
with a free Offer can earn a "Free" badge on SERP.
- src/components/seo/json-ld.tsx — emits one <script
type="application/ld+json"> per node (Google's preferred shape).
- src/app/opengraph-image.tsx — Next auto-generates a 1200×630 PNG
for the landing page's OG / Twitter previews.
- src/app/sitemap.ts — lists only public, indexable pages.
- src/app/robots.ts — Allow /, Disallow every app / auth route,
references the sitemap.
Updated:
- src/app/layout.tsx — metadataBase, title template (%s — WaCRM),
default OpenGraph + Twitter Cards (inherited everywhere), canonical,
icons config, viewport (themeColor + colorScheme=dark for browser
chrome on mobile).
- src/app/page.tsx — injects the 4 JSON-LD blocks, sets explicit
canonical for /, overrides the title.
- src/app/(auth)/layout.tsx — noindex+nofollow for login / signup /
forgot-password (no SEO value; would compete with landing page).
- src/app/(dashboard)/layout.tsx — split into thin server layout
(exports noindex metadata) + DashboardShell client component. App
routes won't be indexed even if a URL leaks.
- src/components/landing/faq.tsx — consumes the shared FAQ data
module (no more drift between UI and JSON-LD).
Verified end-to-end against the running dev server:
- GET / HTML contains: canonical, description, keywords, robots,
og:title/url/image/type, twitter:card/title, theme-color, + 4
JSON-LD blocks (WebSite, Organization, SoftwareApplication,
FAQPage).
- GET /opengraph-image → 200 OK, content-type image/png.
- GET /login HTML contains <meta name="robots" content="noindex,
nofollow, nocache">.
- GET /sitemap.xml serves valid XML with /.
- GET /robots.txt serves Allow /, Disallow all app + auth paths,
sitemap reference.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the dashboard page as a data-driven analytics surface using
existing Supabase tables only — no schema changes, no other pages
touched.
Widgets (all render independent skeletons while their data fetches,
and show a shared empty state when there's not enough data to be
useful):
- Metric cards (Active Conversations, New Contacts Today, Open Deals
Value, Messages Sent Today) with up/down/no-change delta indicator.
- Quick actions row (New Contact / Deal / Broadcast / Automation).
- Conversations Over Time: SVG line chart, 7/30/90-day tabs, per-day
hover crosshair + tooltip. Cached per range so tab switches are
instant.
- Pipeline Value: SVG donut with per-stage arc segments, total in the
center, legend with deal count + value.
- Average First Response Time: SVG bar chart by weekday with a
configurable dashed target line and this-week / last-week summary.
- Activity feed: latest 20 items merged across messages, contacts,
deals, broadcasts, and automation logs — click-through deep-links
where applicable.
Deviations from spec, noted up-front:
- Strictly dark theme — spec's #f9fafb alternating rows translate to
slate-900 / slate-900/40 stripes.
- Kept Inter (tabular-nums, bold 28px) for metric values instead of
Space Grotesk to avoid touching root layout.
- Quick actions link to the relevant list pages (/contacts, etc.)
rather than auto-opening their create modals — that would require
modifying those pages.
All aggregation is client-side via RLS-scoped queries. If a tenant's
dataset outgrows a few thousand messages per range window, the
response-time and line-chart queries should migrate to SQL RPCs —
noted as follow-up, not shipped here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Activating an automation with obviously-broken config (add_tag step
with empty tag_id, send_message with empty text, keyword_match
trigger with no keywords, etc.) used to succeed — and then every
dispatch produced a cryptic failed log row that users often missed.
- New validate.ts centralises the checks (mirrors engine.ts's
runtime guards one layer earlier so failures surface at save time).
Returns { path, message } issues so the UI can point users at the
exact problem.
- POST /api/automations rejects with 400 when is_active === true and
the merged config fails validation.
- PATCH /api/automations/[id] computes the post-patch "effective"
state (merging body fields over the existing row, loading current
steps if body.steps isn't provided) and applies the same gate.
Drafts (is_active=false) remain free to be incomplete so users can
still save progress mid-build.
- Builder surfaces the first issue in a toast with path as the
description, so users don't need DevTools to find what to fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The engine did a client-side read-modify-write on
automations.execution_count, so two concurrent dispatches of the
same automation (e.g. fired for two different contacts within the
same second) could both read N and both write N+1, permanently
losing one count.
- Migration 007 adds increment_automation_execution_count(p_automation_id)
as a SECURITY DEFINER sql function. Only service_role can EXECUTE
it — an authenticated user can't juice someone else's counter by
calling the RPC directly.
- Engine now calls this RPC instead of reading automation.execution_count
and adding 1. Atomic in one statement; concurrent dispatches both
land correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The template seeded trigger_type as new_contact_created, which only
fires when the webhook auto-creates a contact row. A user who added
or imported the contact manually before the contact first messaged
would not hit the welcome flow — that's almost certainly not what the
user setting up "Welcome Message" wants.
first_inbound_message (added in PR #33) is the superset: fires the
first time any contact — auto-created or pre-existing — sends us a
customer message. Correct semantic for a welcome/greeting template.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The engine runs with the service-role client (bypassing RLS) and the
public POST /api/automations/engine endpoint accepts contact_id from
the request body. Without a user_id filter on the contacts lookup,
an authenticated user could fire their own automations against
another tenant's contact UUID and send via their own WhatsApp config
to that contact's phone. UUIDs are unguessable so practical risk is
low, but this is cheap defense-in-depth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Meta's template placeholders are positional ({{1}}, {{2}}, …). We
previously sorted the keys of step_config.variables lexicographically,
so a template with ≥10 variables got params in the order
"1", "10", "2", …, silently scrambling every render past the 9th
placeholder. Fix: sort numeric-looking keys by numeric value, fall back
to locale compare for non-numeric keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
new_contact_created only fires when the webhook auto-creates the
contact row, so it misses the case of manually-added / imported
contacts sending their first-ever message.
first_inbound_message fires whenever the incoming message is the
contact's first customer-sent message (checked by counting prior
customer-sender messages in the conversation before insert), which
covers both auto-created and pre-existing contacts.
- Added first_inbound_message to AutomationTriggerType.
- New pill color + dropdown entry labeled "First Message from Contact".
- Webhook now counts prior customer messages before inserting the
incoming one; dispatches first_inbound_message when the count is 0.
- new_contact_created still fires for brand-new contacts; they're
independent triggers with overlapping coverage, so users pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
send_message and send_template steps were inserting a row into
messages with status='sending' but never calling Meta's WhatsApp API,
so automation replies appeared in the inbox but never reached the
phone. send_template was a plain noop ("template send recorded").
- New helper src/lib/automations/meta-send.ts mirrors the logic in
/api/whatsapp/send (decrypt config, phone-variant retry, persist
messages row with status='sent' and the real whatsapp message id).
Kept as a separate helper to avoid touching the working manual-send
route; the two can converge in a later refactor.
- Engine's send_message / send_template now await the helper and
surface Meta errors through the existing per-step failure path,
so a bad token / invalid template shows up in automation_logs with
status='failed' instead of silently "sending" forever.
- Consolidated automation dispatches in the webhook to fire after the
conversation is created. Previously new_contact_created raced the
conversation insert, which could have made send_message fail to
resolve a target conversation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the /automations placeholder with a full feature:
- Migration 006 adds automations, automation_steps, automation_logs,
and automation_pending_executions with RLS matching existing tables.
- List page with active/inactive switch, quick-start templates,
duplicate, delete, and View Logs actions.
- Full-screen vertical flow builder with Trigger + Action + Condition
(Yes/No branches) + Wait cards. Inline edit forms, up/down reorder,
dotted-grid canvas.
- Execution engine (src/lib/automations/engine.ts) dispatched from the
WhatsApp webhook for new_message_received, new_contact_created, and
keyword_match. Wait steps enqueue to automation_pending_executions;
/api/automations/cron drains them behind AUTOMATION_CRON_SECRET.
- Logs viewer with per-step checkmarks and expandable detail.
Existing files touched only in webhook/route.ts (fire-and-forget
trigger dispatch) and types/index.ts (appended automation types).
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>
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>
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>
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>
Fixes two runtime errors:
1. "Lock 'lock:sb-xxx-auth-token' was released because another request
stole it" — caused by multiple Supabase client instances competing
for the same auth storage lock.
2. "Failed to fetch conversations: {}" — related to #1. When the auth
lock is stolen mid-request, the session becomes temporarily
unavailable and queries fail.
The codebase had 65 call sites invoking createClient() — each creating
a new browser client with its own auth listener. This change makes
createClient() cache and return a single shared instance.
Also improved error logging in conversation-list.tsx to show Supabase
error fields (message/details/hint/code) instead of an empty {}.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>