Addresses the L1 finding from the final security review. The six
admin-class write endpoints under /api/account had no rate
limits, so a compromised admin session (or a script run in a
loop) could spam invite creation / role flips / member removes
fast enough to flood the DB or audit logs.
Adds a new `RATE_LIMITS.adminAction` bucket — 30/min/user — and
wires it into:
- POST /api/account/invitations → admin:inviteCreate
- DELETE /api/account/invitations/[id] → admin:inviteRevoke
- PATCH /api/account/members/[userId] → admin:memberRole
- DELETE /api/account/members/[userId] → admin:memberRemove
- POST /api/account/transfer-ownership → admin:transferOwnership
- PATCH /api/account → admin:rename
Each endpoint keys its own bucket so spamming one doesn't starve
another. 30/min is comfortably above any realistic human pace
(the Members tab is a clicks-only UI; a legit admin would
struggle to hit ten of these per minute by hand) while still
bounding accidental abuse.
The bucket joins the existing send / broadcast / react /
invitationPeek / invitationRedeem buckets in `RATE_LIMITS` —
same in-memory fixed-window limiter, same `rateLimitResponse`
helper for the 429 shape.
Severity in the final review: LOW. The admin-trust model means
this isn't blocking — but the cost of adding it is six
near-identical 4-line additions and zero net risk, so worth
landing for hardening.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defense-in-depth for the request-derived branch of `getBaseUrl()`
in /api/account/invitations.
The threat model
- On a typical proxied deploy (Vercel / Hostinger / Cloudflare /
nginx with a fixed server_name), the proxy overwrites the
`Host` and `X-Forwarded-Host` headers with the deployment's
canonical hostname, so the request-derived path produces the
right URL. NEXT_PUBLIC_SITE_URL pinning is also unnecessary in
that case.
- On a *bare* deployment exposed to the public internet (no proxy
in front, no SITE_URL set), an attacker can POST directly to
/api/account/invitations with `Host: phishing.example` and
receive an invite URL pointing at their site. They'd still need
admin role on *some* account to call the endpoint, and they'd
need to set up `phishing.example/join/<token>` to actually
phish anyone — but the resolution chain shouldn't trust
un-validated client input by default.
The fix
- New env var `ALLOWED_INVITE_HOSTS` (comma-separated hostnames).
- When set, `getBaseUrl` validates each candidate host against
the list and falls through to wacrm.tech if neither matches.
- When unset, behavior is unchanged — the request-derived host
is trusted (preserves the "just works" experience for the
typical proxied case).
- Distinct console.warn for the rejection path vs the
no-Host-at-all path so operators can tell their allow-list is
actually catching something.
Documented in .env.local.example with a clear explanation of
when operators should set it (bare deploys + multi-tenant
hosting) and when they probably don't need to (the typical case
where NEXT_PUBLIC_SITE_URL already pins the host).
Severity: MEDIUM in the final review, downgraded in practice
because (a) the attack window is narrow, (b) requires admin
role + a phishing site, and (c) most deployments are behind a
proxy that strips the spoofable headers. Still worth the small
amount of code to give security-conscious operators the knob.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported by @ArnasDon: a teammate accepting an invite landed in the
shared account but the WhatsApp Config tab still showed "not
connected." Inbox banner read "WhatsApp not connected" too.
Cause: PR 8 of the multi-user series converted the *webhook* and
the *automations/flows engines* to account-scoped lookups, but
left every user-facing API route + UI page still filtering by
`user_id`. Post-017 `whatsapp_config` and `message_templates` are
account-scoped (one row per account, with `account_id NOT NULL`),
so `.eq('user_id', user.id)` returned zero rows for any teammate
who didn't personally save the config — including everyone who
joined via invite.
This PR completes the conversion across the user-facing surface.
Surface (11 files)
- `src/components/settings/whatsapp-config.tsx` — read accountId
from `useAuth()`; trigger fetch on profile-ready, not just
user-ready; pass accountId through to the supabase query.
- `src/app/(dashboard)/inbox/page.tsx` — resolve account_id from
profiles, query whatsapp_config by account_id for the
connected-banner state.
- `src/app/api/whatsapp/config/route.ts` — GET/POST/DELETE all
resolve account_id from profile. The phone_number_id collision
check (service-role) now compares against `account_id`, not
`user_id` — two accounts still can't share a Meta number, two
teammates in one account share one row by design. INSERT
populates `account_id` (tenancy) + `user_id` (audit).
- `src/app/api/whatsapp/config/verify-registration/route.ts` —
same conversion.
- `src/app/api/whatsapp/send/route.ts` — conversation,
whatsapp_config, message_templates lookups all switch to
account_id; rate-limit key stays per-user.
- `src/app/api/whatsapp/react/route.ts` — conversation +
whatsapp_config switch to account_id.
- `src/app/api/whatsapp/broadcast/route.ts` — whatsapp_config +
message_templates + broadcasts switch to account_id.
- `src/app/api/whatsapp/media/[mediaId]/route.ts` — config
lookup switches to account_id.
- `src/app/api/whatsapp/templates/submit/route.ts` — config
lookup + every message_templates SELECT/upsert now uses
account_id. The upsert ROW gains `account_id` so the
NOT NULL constraint added by migration 017 stops rejecting
new templates (this was an incidental bug — every new
template post-017 would have been broken regardless of
multi-user).
- `src/app/api/whatsapp/templates/sync/route.ts` — same.
- `src/app/api/whatsapp/templates/[id]/route.ts` PATCH +
DELETE — same.
Pattern at every route:
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) return 403
then every downstream `.eq('user_id', user.id)` → `.eq('account_id', accountId)`.
Known follow-up not in this PR
- `message_templates` still has its UNIQUE index on
`(user_id, name, language)` from migration 014; the submit
upsert's `onConflict` targets that same triple. As a result,
two teammates can still INSERT same-named templates without
conflicting. The fix needs a small migration (drop legacy
index, add `(account_id, name, language)` index, switch the
`onConflict` target) — TODO comment left in the route file so
it's not forgotten.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-fix: `getBaseUrl()` in /api/account/invitations defaulted to
`https://wacrm.tech` whenever `NEXT_PUBLIC_SITE_URL` was unset.
wacrm.tech is the docs/marketing site (separate ArnasDon/wacrm-site
repo); it has no `/join/[token]` route. Any fork that didn't set the
env var got invite links pointing into the marketing site, which
404'd on click. Reported by @ArnasDon while testing the flow on
production.
Post-fix: `getBaseUrl(request)` resolves in this order:
1. `NEXT_PUBLIC_SITE_URL` — explicit operator config, still wins.
2. `X-Forwarded-Host` (+ `X-Forwarded-Proto`) — set by every
reverse proxy in front of the app (Hostinger Managed Node.js,
Vercel, Cloudflare). This is what makes invite links Just
Work in production without any env-var fiddling.
3. `Host` header + the request's protocol — bare deployments
without a proxy in front.
4. `https://wacrm.tech` last-resort fallback with a console.warn
so operators spot the misconfig in logs. Effectively
unreachable from a real browser; only triggers if there's no
Host header at all.
Also tightened `.env.local.example` so the variable's purpose
matches the new behavior: it's now only needed when the request-
derived origin would be wrong (cron jobs, background workers).
Day-to-day forks don't have to remember it.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (after `rm -rf .next` to clear
stale [locale]/* type stubs from the
i18n revert that landed on main)
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces an internationalization layer so wacrm can ship in multiple
languages — initially en (canonical), es, lt (untranslated mirrors of
en, ready for translators).
Foundation
- next-intl ^4.13 (Next 16 + React 19 compatible)
- src/i18n/{routing,navigation,request}.ts — locale config, locale-aware
Link/router/redirect, server-side message loader
- next.config.ts wired with `createNextIntlPlugin`
- src/app/* moved under src/app/[locale]/ (excluding /api and globals.css)
- [locale]/layout.tsx is now the root layout: <html lang={locale}>,
hasLocale guard, setRequestLocale, NextIntlClientProvider,
generateStaticParams for build-time prerendering of all locales
- middleware.ts composes next-intl's createMiddleware with the existing
Supabase auth check: intl middleware runs first to handle locale
prefix; auth redirects retain the active locale (e.g. signed-out user
on /es/dashboard → /es/login); intl cookies (NEXT_LOCALE) are carried
onto auth-issued responses so the sticky preference survives
Strings extracted (~80 keys across en/es/lt.json)
- common, nav (sidebar + header), roles (Owner/Admin/Agent/Viewer chips)
- auth.login, auth.signup, auth.forgotPassword (all forms + success states)
- join (all four UI states: loading, fail, accept, sign-up CTA + conflict modal)
- localeSwitcher
UI
- New `<LocaleSwitcher />` in the header — globe icon + locale dropdown
using next-intl's useLocale + locale-aware useRouter
Out of scope for this PR (intentional — keeps diff reviewable)
- Dashboard feature pages (dashboard, inbox, contacts, pipelines,
broadcasts, automations, flows, settings) and their components/
namespaces still have hardcoded English; they continue to render
fine because next/link redirects through the locale middleware
- Shared UI a11y strings (dialog close, etc.)
- Translating es.json / lt.json — they currently mirror en.json so the
app runs in any locale without crashes (English fallback)
- Renaming middleware.ts → proxy.ts per Next 16 deprecation
Verification
- npm run build → 62 static pages prerendered across en/es/lt
- npm run typecheck → clean
- npm run test → 366 passing
- npm run lint → 0 errors (18 pre-existing warnings untouched)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR D — the final pass of the post-review fix series. Bundles the
seven visual / UX polish items the design review flagged.
P1 + P5 — sidebar account strip (`src/components/layout/sidebar.tsx`)
- Owner used to be invisible in the role-chip slot (the previous
check was `accountRole && accountRole !== "owner"`), so owners
looked indistinguishable from admins. Now the chip always
renders, with a per-role icon + colour pulled from a new
`ROLE_CHIP` table.
- Added `title={account.name}` on the truncated name so the full
account name is visible on hover when it's clipped (long names
+ narrow sidebars).
P2 + P3 + P10 — members tab (`members-tab.tsx`)
- **P2** — destructive Remove / Revoke buttons used a neutral
default that only turned red on hover. The destructive intent
was invisible until the user moused over. Both buttons now have
a red default state (`border-red-500/40 bg-red-500/10
text-red-300`) with a darker shade on hover.
- **P3** — role chips were all slate. Replaced with a per-role
colour scale: owner amber (scarce, immutable), admin primary
(significant), agent slate (operational default), viewer muted
slate (read-only). Mirrors the sidebar's `ROLE_CHIP` table so
the two surfaces don't drift; once the surface stabilises this
should hoist into a shared module.
- **P10** — pending invitations section now opens with a one-line
hint: "The plaintext invite URL is only shown once at creation
for security — to re-share, revoke the invite below and create
a new one." Pre-fix, admins kept looking for a "copy link"
button on the pending rows; the design decision (don't store
plaintext) is now self-explanatory.
- Cleanup: removed the now-unused `ROLE_LABELS` constant
(subsumed by `ROLE_CHIP[role].label`).
P4 — amber "Save this link now" callout contrast
- Pre-fix: `bg-amber-500/10 + text-amber-200` on slate-900 was
borderline for WCAG AAA body-text contrast.
- Post-fix: `bg-amber-500/15`, border bumped to `/50`, strong
intro promoted to `amber-100` so the urgent copy reads with
proper weight against the dark backdrop.
P7 — Try-again button on join page server_error
- Pre-fix: the only recourse for a server_error peek was to
follow "Create a new account" / "Sign in" links — both lose
the invite token.
- Post-fix: extracted the peek+auth fetch as `loadPeekAndAuth`,
call it from a new "Try again" primary button on the
server_error card. Other failure reasons (not_found / used /
expired) are terminal for this token so they keep the
signup/sign-in escape hatches; only server_error gets retry.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Closes out the post-review fix series. Combined with PRs A
(#178, backend perf), B (#179, UI blocking), and C (#180,
front-end correctness), every item from the consolidated review
report is addressed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR C of the post-review fix series. Bundles six related front-end
correctness / portability fixes. No behavior change for happy-path
users — these are belt-and-braces against edge cases.
S1 — useCan exhaustiveness (`src/hooks/use-can.ts`)
- Add a `default` branch with `const _: never = action` so adding
a new `CanAction` without a case fails the typecheck instead of
silently returning `undefined` at runtime.
S2 + P8 — GatedButton helper (`src/components/ui/gated-button.tsx`)
- `<Button disabled title="...">` doesn't render a tooltip in
Safari or older Firefox: those browsers don't fire mouse events
on disabled controls, so the title never surfaces. Viewers on
those browsers saw silently-disabled CTAs with no explanation.
- New `<GatedButton canAct={...} gateReason="...">` wraps the
Button in a non-disabled `<span>` that owns the `title`. The
span receives mouseover everywhere; the tooltip works.
- Also subsumes the duplicated `READ_ONLY_TITLE = "..."` constants
across 5 list pages. `gateReason` is the verb phrase that
completes "Read-only — your role can't <gateReason>".
- Migrated call sites: broadcasts / automations / flows / contacts
/ pipelines list pages + inbox composer (Send + Templates
buttons). The composer's textarea keeps its inline title since
the GatedButton pattern only applies to buttons.
S6 — Revert optimistic role-change on error (`members-tab.tsx`)
- Pre-fix: PATCH failure showed a toast and left the local
member-row state with the new role. Next interaction
(re-toggling to the same role) was a no-op via the
`member.role === nextRole` guard at the top, so the row
appeared stuck. Now we capture `previousRole` before the
optimistic flip and revert on both 4xx and network failure.
S7 — Sidebar flag check matches settings (`sidebar.tsx`)
- Pre-fix: sidebar's `accountSharingEnabled` didn't guard on
`profileLoading`, so the account-name strip briefly was hidden
during the initial profile fetch then popped in. Settings page
did this guard correctly. Now they match.
S8 — Referrer-Policy: no-referrer on /join (`join/layout.tsx`)
- The plaintext invite token lives in the URL path. Belt-and-
braces against any future external resource on the join page
leaking the token via the Referer header. Set via Next 16's
`metadata` export → `<meta name="referrer" content="no-referrer">`.
Also marks the route `noindex, nofollow` so a join URL can't end
up in search results.
P9 — Client-side label length validation (`invite-member-dialog.tsx`)
- Server caps `label` at 80 chars and returns 400 on overflow.
Add a `MAX_LABEL_LEN` constant on the client, point both the
`<Input maxLength>` and the pre-submit check at it. Catches the
paste-then-submit path where a paste lands an over-limit string
in state before the keystroke-level cap engages.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (the new `never` cast catches typos)
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR B of the post-review fix series. Closes the three user-blocking
UI gaps the design / UX review flagged.
M3 — Charlie's 409 redeem dead-end (join page)
- Pre-fix: a user with existing data clicking an invite saw a
3-second toast then the Accept button re-enabled. The same
retry would 409 forever.
- Post-fix: 409 status is detected explicitly and surfaces a
blocking dialog with an explanation + "Sign out & use a
different email" action. The action calls supabase.signOut()
then reloads the page; the page rebuilds on the signed-out
branch with the "Create account & join" CTA. The invite token
stays in the URL through the reload so the recovery is one
click. Non-409 errors still take the regular toast path.
- Files: `src/app/join/[token]/page.tsx` — new state
`conflictMessage`, new handler `handleSignOutAndRetry`, new
Dialog component in the auth-in render branch.
M4 — WhatsApp share message includes the account name
- Pre-fix: "Join our wacrm account using this link…" — generic,
no team context. In multi-team contexts the recipient
couldn't tell which team they were being invited to.
- Post-fix: "Join <Account Name> on wacrm using this link…".
The account name is snapshotted into the `CreatedInvite`
result at creation time so a later rename can't retroactively
rewrite the wa.me message.
- Files: `src/components/settings/invite-member-dialog.tsx` —
read `account` from `useAuth()`, thread `accountName` through
`CreatedInvite`, update `whatsappShareUrl` copy.
S3 — Members tab roster mobile reflow
- Pre-fix: the member row used a single flex-row with a fixed-
width 128px role Select and a 36px Remove button. On a 375px
viewport the name + email got crushed to ~50px.
- Post-fix: at base width the row stacks vertically — identity
(avatar + name + email) on top, actions (role + Remove) below.
At sm+ it returns to the original inline layout. The joined-
date column stays desktop-only.
- Files: `src/components/settings/members-tab.tsx` — `flex-col`
base, `sm:flex-row sm:items-center` for desktop. Actions
wrapped in their own flex container so the role Select and
Remove button cluster together on both breakpoints.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR A of the post-review fix series. Addresses the two must-fix
backend findings (flow-media account scoping; webhook contact
lookup at scale) plus the two should-fix composite-index
additions, all in one migration + two TS files.
Migration 020 (`supabase/migrations/020_account_sharing_followups.sql`)
- Composite partial index `idx_automations_account_active_trigger`
on `automations(account_id, trigger_type) WHERE is_active`.
The 006-era partial index was account-blind so the planner had
to post-filter by account_id on every webhook dispatch. With
100+ admins in a shared account the post-filter step shows up
in query plans; this index answers all three predicates from
one lookup.
- Same shape for flows: `idx_flows_account_active` on
`flows(account_id) WHERE status='active'` — paired to the
`findEntryFlow` query in the runner.
- `flow-media` storage RLS rewritten to allow uploads / updates
/ deletes by any member of the account whose folder name
matches `account-<account_id>/...`. Legacy `<auth.uid()>/...`
paths remain writable by the original uploader so files
uploaded pre-020 don't break. Reads stay public (the bucket
was already public so Meta can fetch URLs without creds).
Webhook contact lookup (`src/app/api/whatsapp/webhook/route.ts`)
- The PR-8 conversion swapped `eq('user_id', userId)` for
`eq('account_id', accountId)` but kept "pull every row, filter
in JS" semantics. That was fine when account = one user but
scales linearly with the team size × per-user contact count.
- Now pre-filters in SQL by the phone's last-8-digit suffix
(mirroring `phonesMatch`'s trunk-prefix tolerance). The
trailing-suffix `like` can't use a B-tree index, but the
account_id filter on top of `idx_contacts_account` keeps
the scan to a small account-scoped subset. Re-applies the
strict `phonesMatch` check in JS on the candidate set for
correctness — typically 0-2 rows, basically free.
Flow-media upload path (`src/components/flows/forms/node-config-form.tsx`)
- Resolves the caller's `profile.account_id` before upload and
pathes the file under `account-<account_id>/...` to match the
new 020 RLS policy. Legacy `<user_id>/...` paths keep working
via the back-compat clause in the policy.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR 9 of the multi-user accounts series — the final piece. Visual
polish so non-owners see consistent disabled states across the
app and so every user always knows which account they're acting in.
Sidebar
- New account-name strip above the user dropdown, gated behind
the `account_sharing` beta flag (same flag the Members tab
from PR 6 reads). For solo users (the default) the account is
named after them, so showing it would just duplicate the user
name below — flag-gating keeps the original layout untouched
for single-user installs.
- When the caller's role is admin/agent/viewer (not owner), the
role is surfaced as a small uppercase chip alongside the
account name so they have an unambiguous signal of their
permission level without opening Settings.
- Nav items stay always-visible per the planning doc's
"show-but-disable" decision — hiding nav rows by role would
make the app feel broken.
Inbox composer
- Send button + Templates button + textarea are disabled for
viewers with a "Read-only — viewers can browse but not reply"
placeholder and tooltip. The existing session-expired branch
still wins when it applies (session-expired is the more
specific condition and surfaces template-only sending).
List-page create buttons
- broadcasts/page.tsx: "New Broadcast" CTA (header + empty
state).
- automations/page.tsx: "Create Automation" CTA.
- flows/page.tsx: "New flow" CTA (header + empty-state).
- contacts/page.tsx: "Add Contact" + "Import" CTAs.
- pipelines/page.tsx: "Add Pipeline" (gates on edit-settings —
pipelines are settings-class under the 017 RLS) and "Add
Deal" (gates on send-messages).
All use `useCan(...)` from PR 5. Disabled buttons get a tooltip
explaining why (`title="Read-only — your role can't ..."`).
Solo users see no visual change — single-owner accounts pass
every capability check.
Why show-but-disable, not hide
- A viewer scanning the app should still see what's possible
in the product, both to understand what they can ask for an
upgrade for and to avoid the UI feeling silently broken
("the New button just disappeared?"). The tooltip carries
the explanation cheaply.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR 8 of the multi-user accounts series. Flips the WhatsApp webhook,
automations engine, and flows engine from per-user tenancy to
per-account so a shared inbox actually behaves like one.
Why this matters
- Pre-this-PR: an inbound message resolved the recipient by
whatsapp_config.user_id and dispatched only that user's
automations / flows. Teammates joining via an invite would see
the message in the shared inbox but their automations / flows
never fired.
- Also fixes incipient NOT NULL violations from migration 017.
That migration added `account_id NOT NULL` to automation_logs,
automation_pending_executions, flow_runs, and deals. Inserts in
the engines hadn't been updated yet — any production run would
500 with a column violation. This PR adds account_id to every
such insert.
Surface
- `src/app/api/whatsapp/webhook/route.ts`
- Resolves `config.account_id` AND `config.user_id` from the
matched whatsapp_config row. Passes accountId through every
downstream call. user_id is kept as `configOwnerUserId` and
used only for NOT NULL FK audit columns on contact /
conversation inserts.
- `findOrCreateContact` / `findOrCreateConversation` look up by
account_id and insert with both columns.
- `flagBroadcastReplyIfAny` matches broadcasts by account_id so
any teammate's broadcast is updated when a customer replies.
- `dispatchInboundToFlows` and `runAutomationsForTrigger` now
take `accountId` instead of `userId`.
- `src/lib/automations/engine.ts`
- `DispatchInput.userId` → `accountId`.
- `runAutomationsForTrigger` queries automations by account_id.
- `executeAutomation` inserts into automation_logs with both
account_id (tenancy) and user_id (audit, from automation row).
- `wait` step inserts into automation_pending_executions with
account_id.
- `create_deal` step inserts into deals with account_id.
- `assign_conversation` / `close_conversation` /
`resolveConversationId` filter by account_id.
- `resumePendingExecution` row shape now includes account_id;
cron passes it through.
- engineSendText / engineSendTemplate calls pass
`accountId: args.automation.account_id`.
- `src/lib/flows/engine.ts`
- `DispatchInboundInput.userId` is kept (audit) and
`accountId` is added (tenancy).
- `loadActiveRunForContact`, `isDuplicateInbound`,
`findEntryFlow` all query by account_id.
- `startNewRun` inserts into flow_runs with account_id.
- All `engineSend*` calls pass `accountId: run.account_id`.
- `src/lib/automations/meta-send.ts` + `src/lib/flows/meta-send.ts`
- All four engine senders (text, template, media, interactive
buttons/list) accept `accountId` and look up the contact +
whatsapp_config by account_id. user_id stays for inserts.
- `src/lib/flows/types.ts`
- `FlowRow`, `FlowRunRow`, `DispatchInboundInput` gain
`account_id` / `accountId`.
- `src/types/index.ts`
- `Automation` gains `account_id`. Doc note on the user_id
field clarifying it's audit-only post-multi-user.
- `src/app/api/automations/cron/route.ts`
- Pass account_id from the pending row to resumePendingExecution.
- `src/app/api/automations/engine/route.ts`
- Uses `getCurrentAccount()` from #169 to resolve the caller's
account, then dispatches with accountId.
- `CHANGELOG.md` — Fixed entry explaining the inbox / NOT NULL fix.
Backwards compatibility
- All affected tables already have account_id from migration 017.
This PR populates it on insert paths that hadn't been updated.
- The `user_id` columns are kept everywhere — they still identify
the agent / author for audit display. They just no longer
enforce tenancy.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (had 4 errors mid-refactor; all
resolved by the call-site updates)
- `npm test` → 366/366 pass (engine tests are pure unit
tests over helper functions; not affected)
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR 7 of the multi-user accounts series. Closes the invite loop —
PR 6 lets admins generate links, this PR is what the recipient
sees when they click one.
What lands
- `src/app/join/layout.tsx` — minimal centered dark shell. Sits
outside both `(auth)` and `(dashboard)` route groups because
the page is hybrid: it renders for anonymous AND signed-in
visitors. Reusing either neighbouring layout would funnel one
audience through the wrong middleware redirect.
- `src/app/join/[token]/page.tsx` — client component. Calls
/api/invitations/[token]/peek + supabase.auth.getUser() in
parallel on mount and routes through four UI states:
peek | auth | render
--------------|--------------|----------------------------------
loading | — | spinner
ok:false (any)| — | friendly error + signup/signin CTAs
ok:true | signed out | "Sign up & join" + "Sign in" links
ok:true | signed in | "Accept invitation" button → redeem
Redeem is intentionally manual — auto-accepting on mount would
race with the signup → email-verification → return flow and
hide what's being accepted from the visitor.
AuthContext doesn't reach this route group (AuthProvider lives
inside (dashboard)), so we call Supabase directly here the same
way /login and /signup already do.
After a successful accept, the page does a full reload to
/dashboard (not router.push) so AuthProvider re-fetches the
profile with the new account_id and account_role.
- `src/app/(auth)/signup/page.tsx`:
- Reads `?invite=<token>` via useSearchParams.
- Passes emailRedirectTo: `${origin}/join/<token>` to
supabase.auth.signUp so the verification email returns the
user to the redeem step.
- Re-skins the card header for invite signups ("Create account
& join", different icon).
- Preserves the token through the "Sign in" link and the
"Back to sign in" button on the success screen.
- Default export now wraps the form in `<Suspense>` — Next 16
refuses to prerender pages calling useSearchParams without
it.
- `src/app/(auth)/login/page.tsx`:
- Reads `?invite=<token>`.
- On successful sign-in, routes to /join/<token> instead of
/dashboard.
- Re-skins the card header for invite logins.
- Preserves the token through the "Create account" link.
- Default export wrapped in `<Suspense>` for the same prerender
reason.
- `src/middleware.ts`:
- When an authenticated user hits /login?invite=<t> or
/signup?invite=<t>, redirect to /join/<t> instead of the
default /dashboard. Without this, a forwarded invite link
landing on an already-signed-in browser would silently drop
on /dashboard and the visitor would never see the accept
prompt.
- Both pre-existing redirect cases (no invite present, OR
/forgot-password with an invite — which doesn't make sense)
fall through to /dashboard as before. The search string is
cleared on both paths so we don't leak the invite token
into the dashboard URL bar.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds:
ƒ /join/[token] — dynamic (peek + auth fetch)
○ /login — static, hydrates the form
○ /signup — static, hydrates the form
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR 6 of the multi-user accounts series. First user-facing surface
for the feature — the Members tab in Settings.
What lands
- `src/components/settings/members-tab.tsx` — roster + pending
invitations. Every member can see the roster (read-only).
Admin+ gets an inline role-select dropdown (excluding the
owner row and self), a Remove button (same restrictions), and
the "Pending invitations" section with revoke.
- `src/components/settings/invite-member-dialog.tsx` — two-step
modal. Form (role + expiry + optional label) → result
(read-only URL with copy-to-clipboard + a "Send via WhatsApp"
deep link). The result step is dismissed-once: the plaintext
token is only ever shown here and never resurfaced, so the
copy carries a warning ("Save this link now").
- `src/app/(dashboard)/settings/page.tsx` — adds `members` to
the tab list, gated behind the existing
`profiles.beta_features.includes('account_sharing')` flag
(same column migration 011 added; flip it via Supabase Studio
on a per-profile basis to dogfood). When the flag is off the
tab trigger AND content render nothing — the page degrades
to the original 5-tab layout. A user landing on
`/settings?tab=members` without the flag is silently bounced
to `?tab=profile` rather than seeing an empty pane.
Why a feature flag at all
- Locked decision from the planning phase: ship the schema and
API unconditionally (they're structural, harmless to existing
single-user installs) but keep the *user-visible* invite
surface behind a flag until the full UI series ships (Join
page, sidebar gating, webhook account routing). Mirrors how
migration 011 / Flows beta worked.
Role-gating
- The tab is reachable by any member of an account. Inside the
tab, mutation controls (Invite member button, role dropdown,
Remove button, pending invitations section + revoke buttons)
are wrapped in `<RequireRole min="admin">` or hidden via
`useAuth().canManageMembers` from #172.
- Server-side RPCs (set_member_role, remove_account_member from
#170; invitation create/revoke from #171) double-check role
anyway — the client gate is UX, not security.
Owner row
- Non-editable. Inline role select and Remove button are hidden
on the owner's row across the board. Owner role changes go
through the (deferred) Transfer Ownership flow.
Self row
- "You" badge on the caller's own row. Role select and Remove
are hidden on it — admins can't accidentally demote or remove
themselves, even though the server RPC would also catch it.
Email visibility
- Admin+ sees teammate emails; agent/viewer sees name + avatar
+ role + joined date only. Implemented server-side by the
`/api/account/members` route in #170; the component just
renders `member.email` when present.
UI patterns
- Mirrors existing tag-manager / template-manager card +
divide-y list layout and the slate-900/slate-700/primary
color tokens from the dark-only theme.
- Dialogs follow the established `bg-slate-900 border-slate-700`
pattern. Destructive actions get the `bg-red-600 hover:bg-red-700`
treatment with `Loader2` spinner.
- "Send via WhatsApp" anchor uses `buttonVariants` directly
rather than `<Button asChild>` — the wacrm Button is the
Base UI ButtonPrimitive and has no Radix-style asChild.
- Base UI Select's `onValueChange` is
`(value: string | null) => void`; every consumer guards on
null. We don't expose a clear affordance, so null shouldn't
fire at runtime — but typescript requires the guard.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds; /settings stays static (data
fetches happen client-side via useEffect)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR 4 of the multi-user accounts series. Lands the link-only
invitation flow — admin generates a shareable URL, copies it,
sends it via any channel. No email service needed.
API surface
- GET /api/account/invitations — list pending (admin+)
- POST /api/account/invitations — create (admin+)
- DELETE /api/account/invitations/[id] — revoke (admin+)
- GET /api/invitations/[token]/peek — public, rate-limited per-IP
- POST /api/invitations/[token]/redeem — authenticated, rate-limited per-IP
Token lifecycle
- POST returns the plaintext token + share URL exactly ONCE in
the response. We store only the SHA-256 hash. There is no
endpoint that resurfaces it; if the admin dismisses the dialog
without copying, the only recourse is to revoke and re-issue.
- Peek and redeem take the plaintext token in the URL path, hash
it in TS via `hashInviteToken`, and look up by `token_hash`.
Two new SECURITY DEFINER RPCs back the cross-RLS work
(migration 019):
- `peek_invitation` — anonymous read. Returns a uniform JSON
envelope: `{ ok, account_name, role, expires_at }` or
`{ ok: false, reason: not_found|used|expired }`. The route
forwards verbatim so the join page can branch cleanly.
- `redeem_invitation` — authenticated. Locks the invite row
`FOR UPDATE`, validates expiry/usage, refuses if the caller is
already in a shared account OR their personal account has any
domain rows (checked across all 11 user-creatable parent
tables), then atomically moves profile.account_id +
account_role, stamps the invitation accepted, and deletes the
orphan personal account. Single transaction.
Error contract
- 22023 (invalid_parameter_value) → 400 — not_found / used / expired
- 42501 (insufficient_privilege) → 401 — caller not authenticated
- 23505 (unique_violation, reused) → 409 — caller's account has data,
or they're already a
member of the inviter's
account / another shared
account
Rate limiting
- Two new buckets in `RATE_LIMITS`: invitationPeek (30/min/IP)
and invitationRedeem (10/min/IP). Belt-and-braces against
token enumeration even though 256-bit entropy makes it
theoretical. Uses the existing fixed-window in-memory limiter.
Why no peek-then-redeem two-step on the server
- Peek is read-only and idempotent; redeem locks `FOR UPDATE` so
two concurrent redemptions can't both win. The peek result is
cosmetic ("show me what I'm joining"); the actual write
happens entirely inside the redeem RPC's transaction, so a
TOCTOU between peek and redeem just means the user sees the
same `{ ok: false, reason }` on the next render.
Verification
- `npm run lint` → 0 errors (same 20 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds; the 4 new routes register as ƒ
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR 3 of the multi-user accounts series. Lands the server-side
endpoints the upcoming Members tab UI will call. Built on the
helpers from #169 (`requireRole`, `getCurrentAccount`,
`toErrorResponse`).
API surface
- GET /api/account — caller's account + role
- PATCH /api/account — rename (admin+)
- GET /api/account/members — roster; email visible to admin+ only
- PATCH /api/account/members/[userId] — change role (admin+)
- DELETE /api/account/members/[userId] — remove (admin+)
- POST /api/account/transfer-ownership — atomic owner handoff (owner)
Member-role writes can't ride on the regular `profiles_update` RLS
policy (which only allows self-edits — admins can't modify a
teammate's row that way). The three SECURITY DEFINER RPCs in the
new migration are the scoped escape hatches:
- `set_member_role(p_user_id, p_new_role)` — admin+ only. Blocks
promotion to / demotion from owner (use transfer instead) and
self-targeting.
- `remove_account_member(p_user_id)` — admin+ only. Creates a
fresh personal account for the removed user and reassigns
their profile as its 'owner' — mirror of the signup flow, so
they keep their login and just "start over". Blocks removing
the owner or self.
- `transfer_account_ownership(p_new_owner_user_id)` — owner only.
Atomically demotes current owner → admin, promotes target →
owner, and points accounts.owner_user_id at the new owner.
Demote-first ordering means the temporary "zero owners" state
is never visible.
Error contract
- Each RPC raises with SQLSTATE 42501 (forbidden) or 22023 (bad
input) and a human-readable RAISE message. The route handlers
map these onto 403 / 400 respectively; anything else collapses
to a generic 500.
- The TS guards (e.g. `requireRole('owner')` on the transfer
route, `isAccountRole(role)` on the PATCH route) are
belt-and-braces — they fail fast before the Supabase round
trip but the RPCs would catch the same violations regardless.
Field visibility
- `GET /api/account/members` returns `email` only when the
caller is admin+. Agents and viewers see name + avatar + role
+ joined date — matches the planning decision so a viewer
can't harvest the team's email list.
Verification
- `npm run lint` → 0 errors (same 20 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds; the 4 new routes register as ƒ
(dynamic — they all use `next/headers`
cookies via the SSR client)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A long reply quote was forcing the entire 3-panel inbox layout
wider, pushing the contact sidebar off-screen and making the page
horizontally scrollable. Two root causes (both correctly diagnosed
in the issue by @tekrexllp):
1. ReplyQuote applied `truncate` to the preview text, which sets
`white-space: nowrap`. Long previews became one impossibly-wide
line instead of wrapping.
2. The flex chain from MessageBubble outward (MessageActions →
middle-panel wrapper → page) had no `min-w-0` on its flex
children. Default `min-width: auto` means a flex child grows to
its content's natural width, so the long quote propagated all the
way up to the inbox-page flex container and pushed the contact
sidebar past the viewport.
Fix:
- ReplyQuote preview now uses `whitespace-pre-wrap break-words`
instead of `truncate`. The author label stays single-line (it's
a name, ellipsis is right for it). `overflow-hidden` on the
inner text container as belt-and-braces.
- MessageActions' .group/actions wrapper gets `min-w-0` so the
`max-w-[75%]` cap actually holds even with wide content.
- inbox/page.tsx middle-panel wrapper gets `min-w-0` so the
MessageThread can shrink below its natural content width when
the conversation list and contact sidebar are also flexing.
Audited the other 38 `truncate` usages across the codebase —
they're all on intentionally single-line content (contact names,
file paths, last-message previews in lists, headers, node
summaries) where ellipsis is the right behavior. ReplyQuote was
the only multi-line `truncate` misuse.
Reported and diagnosed by @tekrexllp in #165.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The second half of the user feedback that drove send_media: the
linear-list editor reads as a checklist, not a branching flow, which
made conversational structures hard to understand at a glance. This
introduces a canvas view alongside the list — boxes + arrows, drag-
zoom-pan, labeled edges per branch slot. Canvas is the new default;
the list stays as a fallback toggle (per-browser preference,
persisted to localStorage).
PR 1 scope is intentionally read-only: render, don't edit. The
foundation is testable in isolation before adding drag-to-persist,
side-panel editing, and drag-to-connect (PR 2) and polish like
keyboard shortcuts, mobile fallback, and validator integration
(PR 3).
Pieces:
- New @xyflow/react (~30 KB gz) + @dagrejs/dagre (auto-layout for
pre-existing flows whose position_x / position_y are all zero —
flow_nodes already reserved those columns in migration 010
precisely for this).
- Shared module src/components/flows/shared.tsx lifts NodeType,
BuilderNode, NODE_META, summarizeNode, truncate so both views
render nodes identically.
- Pure helpers (no React, fully unit-tested):
src/lib/flows/edges.ts — derive labeled canvas edges per
outgoing slot (button, list row,
condition branch)
src/lib/flows/layout.ts — dagre wrapper + shouldAutoLayout
guard so manually-positioned flows
never get rearranged on open
- FlowCanvas — client component, custom node renderer matching the
list view's collapsed-card styling, MiniMap + Controls, attribution
hidden.
- FlowEditorShell — owns the Canvas / List toggle, conditionally
mounts the right view.
21 new unit tests (layout + edges); existing 271 still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the silent-failure bug a user reported: when multiple phone
numbers are connected to the same WABA, only the first one actually
receives webhook events from Meta. The wacrm UI shows "Connected"
because the metadata probe succeeds, but Meta's activity log is
empty for every subsequent number.
Root cause
----------
The POST /api/whatsapp/config handler verified credentials with a
GET /{phone_number_id} probe and saved them to Supabase — but
never called Meta's two registration endpoints that actually wire
a number for inbound events:
1. POST /{phone_number_id}/register
Subscribes the number to THIS app's webhook. Requires a
6-digit 2FA PIN the user previously set in Meta WhatsApp
Manager → Two-step verification. Without it, Meta routes
events for that number to whichever app last claimed it.
2. POST /{waba_id}/subscribed_apps
Subscribes the WABA itself to this app. Required once per
WABA, but idempotent on Meta's side so we call on every
save without harm.
The first number "worked" because someone (the user, Embedded
Signup, or Meta dashboard) had already done both calls for it
out-of-band. Every subsequent number silently failed.
What this PR does
-----------------
* New helpers in meta-api.ts: registerPhoneNumber (treats Meta's
"already registered" 400 as success), subscribeWabaToApp
(idempotent), getSubscribedApps (for the diagnostic).
* POST /api/whatsapp/config now:
- Accepts an optional `pin` (6-digit, validated).
- Calls subscribeWabaToApp on every save (idempotent).
- Calls registerPhoneNumber on first save and whenever a PIN
is supplied. Skipped when the same number is already
registered locally and no fresh PIN is provided — re-running
/register with a stale PIN would actually undo the active
subscription.
- Persists registered_at / subscribed_apps_at timestamps and
last_registration_error for diagnostic surfacing.
- Returns {success, registered, registration_error} so the UI
can distinguish "creds valid" from "actually live".
- Saves the row even when /register fails so the user can
retry without re-entering everything.
* New diagnostic GET /api/whatsapp/config/verify-registration —
hits Meta to confirm the saved number is reachable + the WABA
is subscribed. Returns a per-check breakdown the UI renders.
Solves the misleading-green-banner UX failure that caused the
bug to go unnoticed in the first place.
* Migration 015 adds registered_at, subscribed_apps_at,
last_registration_error to whatsapp_config. All nullable so
existing rows survive — the UI labels them "registration status
unknown" until the user clicks Verify or re-saves.
* whatsapp-config.tsx:
- New PIN input with help text pointing to the exact Meta
Manager setting and explaining why it's needed.
- Two-tier status: existing "Credentials valid" alert (relabeled
from "Connected" since it was misleadingly broad) PLUS a new
Registration Status alert that flips emerald/amber based on
the registered_at timestamp.
- "Verify with Meta" button on the registration alert pings
the diagnostic endpoint and renders the per-check result
inline. Surfaces last_registration_error verbatim with a
pointer to retry.
- PIN clears from state after a successful save so it can't
accidentally retrigger /register on the next save.
Tests + checks
--------------
* 9 new fetch-mock tests cover registerPhoneNumber (happy path,
already-registered passthrough, PIN-required error verbatim,
generic Meta error), subscribeWabaToApp (URL + bearer + error),
getSubscribedApps (data, empty, error).
* 265/265 tests pass. Lint 0 errors. Typecheck clean. Build
registers both /api/whatsapp/config and
/api/whatsapp/config/verify-registration.
Out of scope
------------
* Multi-number-per-CRM-user (current schema is UNIQUE(user_id)
so one CRM user = one number). The reported bug was
multi-number-per-WABA across multiple CRM users, which this
fully resolves.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Wires Meta's three template-lifecycle webhook events into the
existing /api/whatsapp/webhook route so APPROVED / REJECTED /
PAUSED transitions land locally without a manual Sync click.
Handled events
--------------
message_template_status_update
→ flip status to the raw Meta enum (re-using normalizeStatus
from PR 3 so PENDING_REVIEW collapses to PENDING etc.)
→ persist `reason` to rejection_reason on REJECTED so the
red banner in template-manager shows what to fix
→ clear rejection_reason + submission_error on any other
transition so a stale REJECTED message doesn't linger
after re-approval
message_template_quality_update
→ set quality_score (GREEN/YELLOW/RED). Unknown values land
as NULL so the chip just disappears.
message_template_components_update
→ info-log only for v1. Auto-persisting Meta's modified
components would silently change what the user thought
they submitted; instead we point them to "Sync from Meta"
in the log line. A follow-up PR could surface a banner.
Lookup by meta_template_id (indexed in migration 014) — globally
unique per WABA, so no user_id filter needed. Unknown rows log a
warning instead of failing; >1 matches log a warning too in case
of cross-tenant drift.
New module
----------
src/lib/whatsapp/template-webhook.ts isolates the dispatch + each
handler. The route handler just calls
`handleTemplateWebhookChange(change, supabaseAdmin())` when
`isTemplateWebhookField(change.field)` matches, and the existing
messaging branches are short-circuited so a template event doesn't
try to read message-shaped fields off the value.
12 new tests cover: status APPROVED happy path, REJECTED with
reason, REJECTED missing reason → generic fallback, PENDING_REVIEW
normalization, missing meta_template_id no-op, unknown row warning,
quality score update, invalid quality value → NULL, components
update no-op, unknown field defensive no-op, and the field detector.
Setup requirement
-----------------
Subscribing to these fields is a one-time manual step in Meta App
Dashboard → WhatsApp → Configuration → Webhooks (no API for Cloud
API apps). Documented in a top-of-file comment block in
template-webhook.ts. Until it's done, status updates still arrive
via the manual "Sync from Meta" button — that fallback stays.
Out of scope (final PR in sequence):
* sendTemplateMessage media + button param support (PR 6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Switches message_templates.status from the local TitleCase set
(Draft|Pending|Approved|Rejected) to Meta's raw enum (DRAFT|PENDING|
APPROVED|REJECTED|PAUSED|DISABLED|IN_APPEAL|PENDING_DELETION) and
adds the columns the upcoming submit / edit / resubmit / webhook
flows need. No new user-facing behavior — this is groundwork.
Migration 014 also adds:
* sample_values JSONB Meta requires these for variable templates
* meta_template_id TEXT hsm_id for scoped edit/delete (avoids
nuking sibling-language variants)
* rejection_reason TEXT surfaced from webhook on REJECTED
* quality_score TEXT GREEN | YELLOW | RED, from webhook
* header_handle TEXT from Resumable Upload (media headers)
* header_media_url TEXT URL fallback for media headers (v1 path)
* submission_error TEXT last 4xx from Meta, for retry surface
* last_submitted_at rate-limit awareness (100 creates/hour)
* buttons JSONB shape CHECK permissive array-of-typed-objects guard
(strict per-type validation lives in API)
* UNIQUE (user_id, name, language)
so sync upsert is atomic and prevents
duplicate language variants
* idx on meta_template_id fast webhook lookup
Sync route now:
* persists Meta's status verbatim (PAUSED no longer collapses to
REJECTED — these are different states with different recoverability)
* parses BUTTONS components into the new discriminated-union shape
* extracts sample_values from body.example.body_text + header.example
* persists meta_template_id, quality_score, header_handle
Shared template-status.ts gives the manager / inbox picker / broadcast
picker one source of truth for labels + dark-theme badge classes.
Part of the Issue #147 overhaul plan (PR 2 of 6 in the sequence).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The webhook routes inbound messages by phone_number_id and uses
`.single()` to find the owning config. If two users save the same
phone_number_id, `.single()` errors PGRST116 ("multiple rows") and every
inbound message is silently dropped while the log misleadingly reports
"No config found".
Three layers of fix:
- POST /api/whatsapp/config now rejects with 409 when another user has
already claimed the phone_number_id. The check uses a service-role
client because RLS hides the conflicting row from the saving user.
- Webhook lookup no longer uses `.single()`. It distinguishes 0 rows
(true "no config") from ≥2 rows (duplicate claim) so logs name the
real cause and list conflicting user_ids.
- Migration 013 adds UNIQUE(phone_number_id) to whatsapp_config so the
same can't happen via direct DB writes or a race. Fails loudly with a
copy-pasteable resolution hint if duplicates already exist, rather
than auto-deduping (which would destroy tokens).
Closes#136.
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>
PR #132 (color themes) only tokenized the high-visibility chrome.
Inbox / contacts / pipelines / broadcasts / automations / auth /
settings sub-forms still had ~150 hard-coded `violet-*` classes
across ~32 files, so picking a non-violet theme felt half-broken —
sidebar and header changed colour, but message bubbles, deal cards,
and the broadcast wizard all stayed violet.
This is the mechanical follow-up: replace every `violet-N(/N)?`
with `primary(/N)?` so the theme picker themes the whole app.
Two non-trivial swaps inside the pass:
- `text-white` on a `bg-primary` button → `text-primary-foreground`.
Emerald and Amber define a *dark* `--primary-foreground` because
their primaries are bright; `text-white` over those would tank
contrast.
- `bg-primary hover:bg-primary` (hover identical to base — original
code had this latent bug from `hover:bg-violet-500`) → adds `/90`
on hover so the lift is visible.
Test update:
- `broadcast-status.test.ts` asserted shade numbers (`bg-X-N/10`).
Relaxed the regex to also accept token form (`bg-X/10`) since
brand-accent status badges now ride the active theme.
Files touched: 49. Lint clean (existing warnings only), 173/173
tests, typecheck + build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new "Appearance" tab in Settings with 5 dark color themes
(Violet, Emerald, Cobalt, Amber, Rose). Pick a theme → applied
immediately and persisted to localStorage. Boot script in the root
layout replays the pick before first paint so there's no
flash-of-default on subsequent loads.
Implementation, ordered the way it's read:
1. `globals.css` — 5 `html[data-theme="..."]` blocks extracted
verbatim from the design handoff at project/Color Themes.html.
`:root` mirrors Violet so SSR / first-paint renders correctly
even before the boot script runs. Three new tokens
(`--primary-hover`, `--primary-soft`, `--primary-soft-2`) added
to all themes — used by tinted-primary surfaces.
2. `src/lib/themes.ts` — single source of truth for the catalog
(id, name, tagline, swatch). Adding a theme = append a CSS
block + append an entry here.
3. `src/hooks/use-theme.tsx` — ThemeProvider client component. Reads
what the boot script already applied, syncs across tabs via the
`storage` event, persists to localStorage. Falls back to a no-op
setter when used outside the provider.
4. `src/app/layout.tsx` — inline `beforeInteractive` script that
reads localStorage and sets `documentElement.dataset.theme`
before React hydrates. Knowledge of valid theme IDs comes from
the THEME_IDS constant so adding a theme doesn't silently break
the boot path. Wraps the tree in ThemeProvider.
5. `src/components/settings/appearance-panel.tsx` — picker UI. 5
cards, swatch + name + tagline + a 4-tone preview strip. Active
card carries a primary-tinted ring + "Active" chip.
6. `src/app/(dashboard)/settings/page.tsx` — new "appearance" tab
alongside profile/whatsapp/templates/tags.
7. Key-surface tokenization — replaced hard-coded `violet-*` with
`primary` tokens on the high-visibility surfaces so the theme
switch feels real: sidebar logo + active nav + unread dot +
avatar fallback, header avatar, dashboard-shell loading
spinner, Flows header icon + entry-node picker icon + entry
badge + flash ring, Flows list page (template card hover,
FlowCard icon), Flows editor + runs "back" links, Settings tabs
active state.
NOT touched (intentional follow-ups):
- Per-node-type colors in flow-builder.tsx NODE_META — those are a
deliberate per-type rainbow, not a brand accent.
- The inbox/contacts/pipelines surfaces — many `violet-*` usages
there. Sweep is a contained follow-up; the user explicitly
scoped this PR to "tokens + tab + key surfaces".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #129 added `max-w-3xl` to the create-flow DialogContent thinking
it would widen the modal. It was a no-op. shadcn's DialogContent has
`sm:max-w-sm` baked into its default classes, and Tailwind-merge
treats `max-w-*` (base) and `sm:max-w-*` (sm-prefixed) as different
variants — both survive, and at every real desktop breakpoint the
sm-scoped 384px wins.
So the modal stayed at 384px regardless. Template cards continued to
look squished because the dialog never actually grew.
Override at the sm: variant directly. Use 4xl (896px) — gives each of
the three template cards roughly 270px of breathing room, enough for
the description to wrap to 4-5 lines instead of 10+.
Left a comment at the call site so the next person doesn't repeat
the same mistake.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dialog was max-w-2xl with sm:grid-cols-3, leaving each template
card ~150px wide. With p-3 padding and line-clamp-3 the descriptions
truncated to four-word fragments — the gallery looked squished even
on desktop.
- Dialog: max-w-2xl → max-w-3xl
- Grid: stack on mobile, 2-col on sm, 3-col on lg (matches the
flow-card list grid elsewhere in the page).
- Card: p-3 → p-4, gap-2 → gap-2.5, h-4 icon → h-5.
- Drop the line-clamp on the description — at the new width it fits.
- Move the "X nodes" footer above a top-border so it reads as
metadata, not part of the description.
- Add a divider above the "Or start blank" section so the two
paths feel like distinct choices.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
use-auth's `loading` flips false as soon as the session settles —
the profile fetch is fire-and-forget so the page renders quickly
with just the user object. The flows pages, though, gate access on
`isFlowsEnabled(profile.beta_features)`. During the gap between
session-ready and profile-ready they saw `{ loading: false,
profile: null }` and read it as "not opted in", redirecting a
legitimate beta user back to /dashboard.
Expose a separate `profileLoading` boolean from the auth context
that stays true until fetchProfile settles (success, missing row,
or error). Pages that branch on `profile.*` gate on it; chrome
that only needs the user object keeps using `loading` as before.
Three call sites updated: /flows, /flows/[id], /flows/[id]/runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `/api/flows/cron` route gated on `supplied !== expected`, which
compares strings byte-by-byte with early exit on first mismatch.
An attacker who can hit the endpoint could in principle recover the
secret one byte at a time by measuring response-time deltas. With
a high-entropy 64-char secret this is mostly theoretical, but the
upgrade is a one-liner.
Switch to crypto.timingSafeEqual with a length pre-check (the
function throws on mismatched lengths; the length itself isn't
sensitive when the expected value is set by the operator).
Not touching the equivalent line in /api/automations/cron in this
PR — one fix per branch keeps the diff easy to review and revert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
A small icon button (lucide RefreshCw) in the message-thread header,
sitting just left of the Status dropdown. Clicking it bumps the
parent's `resyncToken` — the same channel that already drives the
WS-reconnect and tab-visibility-resume refetches. So the button goes
through the existing dedupe + fetch plumbing in ConversationList and
MessageThread; no parallel code path to keep in sync.
The icon spins for 700ms as click feedback (the refetch itself is
fire-and-forget — the children just refire their fetch effects). The
button is gated behind the optional `onRefresh` prop, so existing
callers of MessageThread (and tests, if any are added later) keep
working unchanged.
Useful when the agent suspects realtime missed something — or just
wants to be sure the list and thread reflect the latest DB state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The badge used to linger for the duration of the
click → unread-reset effect → server UPDATE → realtime UPDATE → setState
round-trip — long enough to be noticeable, and visible indefinitely if
the realtime UPDATE was ever dropped. There was also a brief flicker
when a customer sent a new message while the user was already viewing
the thread: the webhook bumps unread_count on the server, the realtime
UPDATE arrives at the client and overwrites the local 0 with a positive
value for the ~100ms it takes the reset effect's server UPDATE to
round-trip back.
Fix in two places:
1. handleSelectConversation (and the deep-link auto-select path)
optimistically zero the list copy's unread_count immediately. We
deliberately do *not* touch activeConversation.unread_count — the
reset effect inside MessageThread keys off that value to decide
whether to fire the server UPDATE, so zeroing it locally would
silently skip the persist.
2. handleConversationEvent UPDATE suppresses the incoming unread_count
for the currently-active conv. Non-active convs take the value
as-is; the active one stays at 0 until the user navigates away.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses #106 (issue stays open until the reporter confirms in prod).
#105's `let foundInList = false; setState(p => { foundInList = ...; ... })`
pattern is broken: setState updaters run during reconciliation, *after*
the synchronous handler returns, so `foundInList` reads `false` in the
same tick and `hydrateConversation()` fires on every code path the
flag was meant to gate. Every incoming message and every conversation
UPDATE (read receipts, status flips, agent assignment) triggered a
redundant DB fetch, queuing HTTP behind realtime WS frames on the same
supabase client and starving the channel — which surfaced as exactly
what #106 reports: messages that never arrive, threads stuck on "No
messages yet", events landing out of order.
The fix:
1. Synchronous known-IDs ref. `knownConvIdsRef` mirrors the conv ids
currently in state, kept in sync via a useEffect on `conversations`.
Event handlers check it synchronously to decide between a cheap
in-place patch and a hydrate refetch — no more closure-flag pattern.
2. Reconnect + visibility resync. Realtime is best-effort: events sent
while the WS was disconnected or while the tab was background-throttled
are gone. A `resyncToken` state bumped on (a) `isConnected` false→true
transitions after the initial connect and (b) tab visibility →
visible drives `ConversationList` and `MessageThread` to refire their
fetch effects. The reactions effect in MessageThread is split into
fetch + channel-subscribe so a resync only refetches the rows
without tearing down and rebuilding the realtime channel.
3. Keep the conv-INSERT hydrate. Still the right fix for #104 (realtime
payloads never carry the `contact` join); now it only fires when the
conversation is genuinely unknown to state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#104.
Supabase Realtime payloads only carry the row's own columns — the
`contact:contacts(*)` join the initial fetch uses is absent. A
freshly-inserted conversation arrived in state with `contact: undefined`,
displaying "Unknown" and a blank contact sidebar. When the
message-INSERT for that conversation arrived before the conv-INSERT
event had landed in state, the preview update was a silent no-op and
the conversation appeared stuck on "No messages yet" until something
else triggered a refetch (typically the user sending a reply, which is
why replying made the missing inbound messages "suddenly appear").
Add a deduped `hydrateConversation(id)` helper that fetches the row
with the contact joined and merges it into state. Call it from:
- conv-INSERT realtime handler — to backfill the contact join
- message-INSERT handler when the conv isn't in state yet
- conv-UPDATE handler when the row is missing (self-heal for any
realtime event ever dropped)
Existing fields in state win on merge: a realtime UPDATE may have
patched last_message_text / unread_count to fresher values than the
single-row read returns, and we don't want to clobber them.
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>
Replace the no-op Assign button in the thread header with a dropdown that
reads/writes conversations.assigned_agent_id. Lists profiles visible
under RLS (single-tenant today, ready for shared workspaces) and offers
Unassign when the conversation is already claimed.
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>
Fixes the 14 pre-existing lint errors surfaced by React 19's new
hook rules so CI can enforce lint on every PR instead of tolerating
failures. The errors split roughly into three camps:
1) react-hooks/refs — "Cannot access refs during render"
4 occurrences of the "latest ref" pattern that assigned
`ref.current = x` during render. Moved each assignment into a
`useEffect(() => { ref.current = x })` — the canonical React-19
pattern. Consumers only read `.current` inside async callbacks
that run after commit, so the very slight delay to the update is
fine.
Touched: src/hooks/use-realtime.ts (onMessageRef,
onConversationRef), src/components/inbox/message-thread.tsx
(onMessagesLoadedRef), src/components/inbox/conversation-list.tsx
(onConversationsLoadedRef).
2) react-hooks/set-state-in-effect — genuine issues
- contacts/page.tsx: the `setPage(0)` on search change moved from
a useEffect into the `onChange` handler of the search input,
which is the cleaner pattern anyway.
- settings/page.tsx: the local `tab` state + sync effect were
removed entirely; the URL query param is now the single source
of truth, and the `onChange` handler just calls
`router.replace(...)`. Simpler and fixes the lint error.
3) react-hooks/set-state-in-effect — false positives for
load-on-mount and prop-driven sync
Suppressed per-call (or per-block for effects with many setters)
with explanatory comments. These effects use setters inside
async Supabase-query callbacks, not synchronously in the effect
body, so the cascade the rule warns about doesn't apply. Files:
contacts/page.tsx, pipelines/page.tsx, pipelines/deal-form.tsx,
pipelines/pipeline-settings.tsx,
components/inbox/contact-sidebar.tsx.
4) react-hooks/preserve-manual-memoization — one useCallback
Rule complained that the inferred dep was `contact` but the
source dep list was `[contact?.phone]`. Broadened the manual dep
to `[contact]` to match — callback behaviour is unchanged (it
still only reads `contact?.phone`), and the React Compiler can
now preserve the memoization.
And:
- .github/workflows/ci.yml: dropped `continue-on-error: true` on
the lint step. Lint now blocks merge on any new error.
Verified: `npm run lint` → 0 errors (24 warnings remain; all
pre-existing unused-import / missing-dep / <img> warnings that don't
fail CI). `npm run typecheck` clean. Landing, /docs, /login, and
/settings redirects all still respond correctly from the dev server.
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>
Reports: clicking a conversation sometimes left the message thread
stuck on "No messages yet" until the user hit browser refresh.
Root cause — a three-way race kicked off by any click on a conversation
in the list:
1. handleSelectConversation runs:
setActiveConversation(B)
setMessages([])
router.replace("/inbox?c=B")
2. The URL change flips `deepLinkConvId` (from useSearchParams).
handleConversationsLoaded is a useCallback with
[deepLinkConvId], so its identity changes. That cascades into
ConversationList, whose fetchConversations useCallback has
[onConversationsLoaded], whose useEffect has [fetchConversations] —
so the list refetches conversations purely because the URL changed.
3. When the refetch lands, handleConversationsLoaded runs its deep-link
auto-select branch. autoSelectedForDeepLinkRef still points at its
previous value (null, or the previous deep-link), so the guard
`ref !== deepLinkConvId` passes and the branch re-applies B:
setActiveConversation(B) // no-op, same id
setMessages([]) // BLOWS AWAY the messages
// MessageThread just fetched
4. MessageThread's fetch effect only re-runs when conversationId
changes. B is still B. It does not refetch. Messages stuck at [].
A hard reload unsticks things because state starts from scratch and
the initial deep-link flow is the only path that runs the auto-select.
Three defense-in-depth fixes:
- handleSelectConversation now writes the clicked id into
autoSelectedForDeepLinkRef BEFORE the router.replace, so by the
time any downstream refetch lands, the auto-select branch sees
ref === deepLinkConvId and skips.
- handleConversationsLoaded early-returns if the deep-linked
conversation is already the active one. This is redundant with
the ref update above but protects against any other code path
we don't foresee triggering a refetch.
- ConversationList's fetch is now driven by a plain useEffect with
[] deps, using a ref for the latest onConversationsLoaded
callback. URL changes no longer cause a conversations refetch.
Matches the same stabilisation pattern used in
src/hooks/use-auth.tsx and src/components/inbox/message-thread.tsx.
Verified: typecheck passes; lint count unchanged (37 pre-existing
problems, zero added). Couldn't exercise the rendered /inbox from the
preview sandbox — it's auth-gated and no test credentials are
available. Please confirm in the authenticated inbox:
- Click back and forth between two conversations with different
message histories; messages should populate every time.
- Open /inbox?c=<id> directly; thread opens with messages.
- Switch conversations after a deep-link landing; still works.
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>
Adds a profile tab to /settings with the five things every B2B SaaS
user expects:
- Display name — edits profiles.full_name.
- Avatar — upload / replace / remove, stored in a new `avatars`
Supabase Storage bucket with RLS scoped to the owner's folder.
Migration 008_profile_avatars_storage.sql creates the bucket (public
read, 2 MB cap, image mimes only) and the four storage.objects
policies.
- Email — goes through supabase.auth.updateUser({ email }), which
confirms to both the old and new addresses. UI surfaces a "check
both inboxes" banner after save.
- Password — three-field form with a current-password gate implemented
via a throwaway signInWithPassword() before calling updateUser.
- Active sessions — one-click "sign out of all devices" using
supabase.auth.signOut({ scope: 'global' }), with a confirm dialog.
Plus read-only role, joined date, and user id for operator debugging.
The header avatar and sidebar user row are now both dropdown menus
linking to /settings?tab=profile and /settings?tab=whatsapp; the
settings page reads ?tab= to land on the right tab. useAuth gets a
refreshProfile() callback so the header/sidebar update immediately
after a save without a full reload.
Deferred (separate work): account deletion, 2FA, phone/timezone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
layout.tsx still had `icons.shortcut: ["/favicon.ico"]`, which kept
emitting the default Next.js favicon link in <head> even after the
file was deleted. icon.tsx is the single source now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
We already have src/app/icon.tsx generating the WaCRM emerald mark,
but the default create-next-app favicon.ico was still in src/app/
so Next.js kept emitting <link rel="shortcut icon" href="/favicon.ico">
in <head>. Browsers prefer the ICO, so tabs still showed the default
Vercel triangle on the landing page.
Removing favicon.ico leaves icon.tsx as the sole source, which is
what the comment in that file already claimed.
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>
Adds a #self-host landing section with two cards — one linking to
the GitHub repo (fork / clone) and one recommending Hostinger VPS
for deploying the template. Also surfaces the GitHub link from the
nav (desktop icon + mobile drawer) and the footer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every column rendered at every width, relying on horizontal scroll.
A 375px phone had to swipe sideways just to read a name. Matches
the tiering contacts already uses:
- Mobile (<sm): Name + Status only.
- sm+: adds Recipients, Date.
- md+: adds Template.
- lg+: adds Delivery + Read rate bars.
Desktop (≥lg) unchanged — all seven columns visible as before.
Salvaged from the closed PR #52 — the OG-image portion of that PR
is already on main via PR #55, so only the broadcasts-table change
is shipped here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of a series to make the app mobile-friendly. The sidebar used
to be a fixed 240px column always visible, eating most of a 375px
phone screen and leaving ~135px for content. Now:
- Sidebar becomes a slide-in drawer on <lg, static on ≥lg. Controlled
via a drawer-open prop wired from the shell; transform + duration
feels native on touch devices.
- Backdrop overlay closes it on tap; Escape closes it; body scroll
locks while open; route changes auto-close it.
- Header gains a 44×44 hamburger button on <lg that opens the drawer,
hidden on ≥lg. Title shrinks slightly on narrow widths so the
hamburger + avatar + sign-out row never wraps.
- Nav rows are taller (py-2.5) on mobile for comfortable touch
targets, slim (py-2) back on desktop.
- Main content padding is p-4 on mobile, p-6 on desktop so cards
have room to breathe without shouldering into the viewport edge.
No desktop changes visible beyond tighter padding on very narrow
lg breakpoints (main content width is unchanged).
Next PRs in this series will adapt:
- Inbox (3-pane → single-pane with back)
- Pipelines (kanban snap-scroll)
- Automation builder (card widths + bottom-sheet edit)
- Tables + forms polish
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>
The inbox was a 3-pane flex row (list + thread + contact sidebar).
On a 375px phone both list and thread tried to share ~187px each
and the whole UI was unusable. Now:
- On <lg the inbox shows ONE pane at a time: list when nothing's
selected, thread when a conversation is open.
- Thread gains a back-arrow in its header (mobile only) that pops
back to the list and clears the ?c= URL param so refreshing lands
on the list, not on the thread the user just backed out of.
- Conversation list grows to w-full on mobile (was fixed w-80)
so each row uses the whole viewport width.
- Thread header tightens: name/phone truncate with min-w-0, session
timer badge is hidden under sm to save room for back arrow + name.
- Dashboard shell's p-6 is already p-4 on mobile (PR #48); matched
the -m-6 offset in the inbox to -m-4 sm:-m-6.
Desktop layout (lg+) is unchanged: list + thread + contact sidebar
side-by-side exactly as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of a series to make the app mobile-friendly. The sidebar used
to be a fixed 240px column always visible, eating most of a 375px
phone screen and leaving ~135px for content. Now:
- Sidebar becomes a slide-in drawer on <lg, static on ≥lg. Controlled
via a drawer-open prop wired from the shell; transform + duration
feels native on touch devices.
- Backdrop overlay closes it on tap; Escape closes it; body scroll
locks while open; route changes auto-close it.
- Header gains a 44×44 hamburger button on <lg that opens the drawer,
hidden on ≥lg. Title shrinks slightly on narrow widths so the
hamburger + avatar + sign-out row never wraps.
- Nav rows are taller (py-2.5) on mobile for comfortable touch
targets, slim (py-2) back on desktop.
- Main content padding is p-4 on mobile, p-6 on desktop so cards
have room to breathe without shouldering into the viewport edge.
No desktop changes visible beyond tighter padding on very narrow
lg breakpoints (main content width is unchanged).
Next PRs in this series will adapt:
- Inbox (3-pane → single-pane with back)
- Pipelines (kanban snap-scroll)
- Automation builder (card widths + bottom-sheet edit)
- Tables + forms polish
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>
Replaces the root redirect to /login with a real marketing landing so
first-time visitors can learn about WaCRM and convert. Authed users
still see it and get a "Go to Dashboard" CTA instead of "Sign in" /
"Get started".
Sections, in order:
- Sticky nav with smooth scroll to Features / How it works / FAQ.
Auth-aware CTA: pending/signed-in/signed-out states render the
right buttons without layout shift.
- Hero with clear one-liner, benefit subhead, primary + secondary
CTA, trust cues, and a miniature inbox preview (pure CSS/SVG
recreation of the real UI — no screenshot to age out).
- Features grid: 6 benefit-first cards.
- 4x feature spotlights (alternating): shared inbox, automations,
pipelines, analytics. Each with a bespoke product miniature.
- How It Works: 3 steps framed around "live in 30 minutes".
- FAQ: 6 questions answering the real objections (BSP access, team
sharing, setup speed, data ownership, templates, broadcasts).
- Final CTA banner + footer with nav + product/account link columns.
Implementation notes:
- All visuals are CSS/SVG miniatures in src/components/landing/mock —
intentionally not real screenshots so the page doesn't drift from
the product over time.
- Dark theme throughout to preserve the app's brand (per the repo's
dark-only convention). No light sections.
- Per-viewport verified: desktop (1440x900) two-column hero with
mock; mobile (375x812) stacks hero and reveals a hamburger menu.
- Middleware untouched — the page itself did the /login redirect;
replacing page.tsx is enough.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With real data the feed showed 20 rows by default and filled most of
the viewport. Switched to a 5-row default with an inline size picker
(5 / 10 / 20 / 50) so users choose how much history is visible.
- Dashboard page bumps the fetch from 20 → 50 so every size option is
already in memory. Switching sizes is a pure client-side slice, no
round trip.
- ActivityFeed owns the pageSize state, renders "Showing N of M"
plus a button group in the footer. The smallest option is always
enabled; larger options unlock only once we have enough rows to
meaningfully reveal more (e.g. "10" enables once loaded > 5).
- Existing "View all" header link left alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With grid items-stretch (the default) the two cards in the charts
row try to share the tallest sibling's height — but the Pipeline
Value card was rendering at its natural (shorter) height because
neither its col-wrapper nor its inner section had h-full. So the
line chart's taller content drove the row but the pipeline card
hung at the top with trailing whitespace.
Added h-full on both col wrappers and flex h-full flex-col on
each inner section so they actually fill the row. The existing
flex-1 wrapper inside PipelineDonut now has a defined height to
stretch against, so the empty state (and the populated donut +
legend) span the card cleanly.
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>
Clicking a row in the dashboard's "Recent Conversations" routed to
/inbox with no hint about which conversation to open, so the user
landed on the empty center panel and had to find the thread again.
- Dashboard now routes to /inbox?c=<conversation_id> for per-row clicks.
"View all" still goes to plain /inbox.
- Inbox page reads ?c=<id> and auto-selects the matching conversation
in handleConversationsLoaded (event handler, not an effect, so
react-hooks/set-state-in-effect stays happy). A ref guards against
resnapping the selection on later realtime list refreshes.
- Inbox also now replaces the URL with ?c=<id> when the user picks a
conversation, so a page refresh / shared link lands on the same
thread.
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>
Addresses two recurring issues:
1) Delete broadcast campaign
Broadcast detail page gets a Delete button (red, in the header, next
to the status badge) with an inline confirm pattern matching
pipeline-settings. Disabled while status='sending' — we don't want
to orphan in-flight Meta messages. On confirm, supabase delete
cascades broadcast_recipients and redirects to /broadcasts.
2) Persistent Meta error #132001 "Template name does not exist in the
translation"
Root cause that wasn't fixed by PR #27's language default: the local
template manager never actually submitted templates to Meta. Users
would create welcome_message locally, try to broadcast, and get
rejected because Meta had never seen the template.
Meta template submission requires a human review flow in Meta's
WhatsApp Manager — we can't automate that from inside WaCRM. What
we CAN do is pull Meta's approved list so the local catalog never
drifts from reality.
New: POST /api/whatsapp/templates/sync
- Reads whatsapp_config for waba_id + access_token (decrypted)
- GET /v21.0/{waba_id}/message_templates?limit=100 with BODY/HEADER/
FOOTER components
- Normalizes Meta's UPPER_SNAKE category/status to our TitleCase
- Upserts each row in message_templates by (user_id, name, language)
- Locally-created rows with no Meta counterpart stay visible so the
user can notice drift and clean up
Template manager UI: "Sync from Meta" button next to New Template,
with a spinning refresh icon while in flight and a toast summarizing
how many inserted / updated. Header copy clarifies that approval
happens in Meta's WhatsApp Manager.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1) Favicon
Next.js was serving its default triangle icon because src/app/
favicon.ico was the stock placeholder. Replaced with a dynamic
icon route (src/app/icon.tsx) that renders the sidebar mark —
emerald-500 rounded square with a white MessageSquare glyph.
Next.js auto-injects <link rel="icon"> pointing to the generated
PNG.
2) Template language default
Users hit Meta error #132001 "Template name does not exist in the
translation" because the template manager defaulted new templates'
language to "en" while Meta's WhatsApp templates are typically
approved under "en_US". Meta treats "en" and "en_US" as distinct
language codes — a template approved under one is rejected if sent
with the other.
Fixes:
- Default template form language to "en_US" (matches the DB
default on message_templates.language and the broadcast
sender's fallback).
- Swap the freeform <Input> for a datalist-backed input
autocompleting the ~17 most common Meta language codes.
- Inline help text: "Must match the exact language code the
template is approved under on Meta — e.g. en_US and en are
distinct."
For the user's existing welcome_message template (language="en"),
the fix is to edit it in Settings → Message Templates and change
Language to whatever Meta shows in the WhatsApp Manager
(typically en_US). One-time data fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The INSERT into broadcasts in use-broadcast-sending.ts never set
user_id, but broadcasts.user_id is NOT NULL + RLS-guarded. The
INSERT rejected (23502 / 42501), the hook threw, handleSend caught
with console.error only. UI showed nothing — the wizard stayed on
Step 4 and the flow appeared dead.
Fix:
- Resolve session.user at the top of createAndSendBroadcast and
include user_id on the broadcasts INSERT.
- handleSend in /broadcasts/new now toasts the error via sonner so
future failures are visible immediately instead of vanishing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stacked on PR B (#22). Merge A → B → C in order.
Step 2 — Audience
- Implement the "Custom Field" audience option (was a stub): field
picker + operator (is / is not / contains) + value input. Estimated
count query joins contact_custom_values with the operator mapped
to eq / neq / ilike. PostgREST ilike handles "contains" case-
insensitively.
- New "Exclude contacts with these tags" section — applies to every
audience type. Estimated count subtracts excluded contacts
client-side against the resolved base set.
- Switching audience type clears stale shape fields (tagIds /
customField / csvContacts) so old selections don't leak across.
Step 3 — Personalize
- Add "Custom Field" mapping option alongside static + contact field.
- Live Preview now pulls the most recent real contact (+ their custom
values) instead of hard-coded sample data. Falls back to sample if
the user has no contacts yet. Rendered in a WhatsApp-style bubble
so the user gets a realistic feel for how the recipient sees it.
Hook — use-broadcast-sending
- AudienceConfig gains customField + excludeTagIds.
- resolveAudience: custom-field branch + exclude-tag subtraction
applied after the base resolution (works for all / tags / custom
field).
- resolveVariables accepts a pre-built (fieldId → value) map per
contact. Before the send loop we bulk-fetch contact_custom_values
for all resolved contacts in one query (paged by 500 for PostgREST
limits) so we never N+1 per recipient.
new/page.tsx — passes customField + excludeTagIds into the hook;
variables state widens to allow type='custom_field'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UI polish for the Broadcasts feature. Stacked on PR A (correctness).
List page /broadcasts
- Replaces raw Sent/Delivered/Read/Failed columns with Delivery Rate +
Read Rate cells (percent + inline mini progress bar). Raw counts
still available on the detail page.
- Sending badge gets a live-looking pulsing dot.
- Indeterminate top-of-page progress bar appears while any broadcast
has status='sending'. Page polls the broadcasts table every 5 s
while that condition holds, and stops polling otherwise.
- Mobile: table wrapped in overflow-x-auto.
Detail page /broadcasts/[id]
- Adds a Replied stat card (was missing). Final grid: Total / Sent /
Delivered / Read / Replied / Failed — 6 cards on desktop, 2 on
mobile. Color spectrum matches the spec's funnel gradient.
- Pure-CSS horizontal funnel chart: Sent → Delivered → Read → Replied
with decreasing widths in emerald → teal → blue → indigo. Each bar
shows its absolute value and percent of Sent.
- Status filter dropdown above the recipients table. Filters
client-side; header reflects filtered / total.
- "Export CSV" downloads all recipients with status + every milestone
timestamp + error_message. RFC-4180 quoting.
- Recipients table gains Delivered / Read timestamp columns so the
CSV columns are also visible at a glance.
Wizard Step 4
- Save as Draft button next to Send Broadcast. Persists a broadcasts
row with status='draft' and zero recipients — enough for the user
to recognize the draft in the list. Full resume-into-wizard is a
future follow-up.
- Confirmation modal before send was already in place; kept as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three correctness bugs were making the existing broadcast flow wrong,
not just incomplete.
1. Per-recipient variables were broken
use-broadcast-sending.ts computed templateParams per contact but shipped
only templateParams[0] to the API, so every contact in a batch received
contact 0's personalization (name/email/etc).
Fix: extend POST /api/whatsapp/broadcast to accept a per-recipient
shape `{ recipients: [{ phone, params }], ... }`. Legacy
`{ phone_numbers, template_params }` still accepted. Hook sends per-
recipient payloads; API returns whatsapp_message_id per phone.
2. Webhook never updated broadcast_recipients
handleStatusUpdate in webhook/route.ts only mirrored Meta status onto
the `messages` table. broadcast_recipients had no correlation column,
so delivered_count / read_count / replied_count never advanced for
real broadcasts.
Fix (migration 003):
- broadcast_recipients.whatsapp_message_id (+ UNIQUE index) for
Meta-message-id correlation
- composite index (broadcast_id, status) for fast aggregate scans
- AFTER INSERT/UPDATE/DELETE trigger that recomputes parent
broadcasts counts via COUNT(*) FILTER — sender + webhook only
touch the recipient row, aggregates stay consistent
Webhook:
- handleStatusUpdate also updates broadcast_recipients with forward-
only status guard (read cannot regress to sent on a replay)
- processMessage flags the replied recipient row when an inbound
message arrives from a recent broadcast contact
3. Rate limiting did not match spec
Batch 50 / no delay → Batch 10 / 1s between batches, matching the spec
and Meta's per-phone-number rate ceiling.
Also: extract statusConfig from both page components into
src/lib/broadcast-status.ts (single source of truth for PR B). Add
whatsapp_message_id to BroadcastRecipient type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-ups to the Pipelines page that shipped in #18:
1. Analytics strip now sits ABOVE the Kanban board rather than below, so
top-line numbers read first and the board flows beneath.
2. Re-theme the board to match the rest of the dark app: slate-900 columns
with slate-800 borders, slate-800 deal cards with stage-colored accent
bar, emerald dollar values, and slate-500/slate-400 secondary text. The
empty-column "Drop a deal here" and drop-zone highlight stay but in
slate/emerald tones instead of white/light-gray.
3. Each analytics metric now has an info icon + tooltip explaining how it
is calculated in plain language (e.g. Weighted Value explains the
10% → 90% stage interpolation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebuild the Pipelines page to match spec.
Highlights:
- Drag-and-drop deal cards between stages via @dnd-kit (200ms drop animation,
emerald dashed drop zone, opacity fade while dragging)
- First-visit seed: auto-creates "Sales Pipeline" with spec-defined stages
(New Lead / Qualified / Proposal Sent / Negotiation / Won)
- Top bar: always-visible pipeline dropdown with "Manage Pipelines" option,
plus "Add Pipeline" and emerald "Add Deal" buttons
- Deal form converted from Dialog to right-side Sheet, adds assigned_to
(profile FK), "Link to Conversation" shortcut, "Mark as Won/Lost" status
buttons, and "Delete Deal"
- Pipeline settings modal: drag-to-reorder stages (@dnd-kit sortable),
per-stage color picker, "Create new pipeline" shortcut, archive
confirmation copy
- Analytics bar below board: total deals, pipeline value, avg deal size,
weighted pipeline value (probability interpolated across stages),
won/lost this month
- Contact detail sheet gains a "Deals" tab showing all deals for that
contact (value + stage badge + status)
- Migration 002: adds deals.assigned_to FK→profiles, normalizes status
CHECK to ('open','won','lost')
Note: spec called for light-gray Kanban columns (#f9fafb) + white cards
inside an otherwise-dark app. Followed spec literally; easy to retheme.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Selecting the already-active conversation called setMessages([]) but the
fetch effect in MessageThread only re-runs when conversationId changes,
so messages stayed empty until the user left the page and came back.
Bail out of handleSelectConversation when the clicked conversation is
already active.
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>
Incoming photos showed up as empty bubbles in the Inbox (only the
timestamp, no image). Investigation found three compounding bugs in
the media pipeline:
1. Swapped getMediaUrl args in webhook parseMessageContent
The helper signature is getMediaUrl(mediaId, accessToken), but every
media case (image/video/document/audio) called it as
getMediaUrl(accessToken, message.X.id). Meta got requests for
GET /v21.0/<ACCESS_TOKEN>
with Authorization: Bearer <MEDIA_ID>
Every verification failed → the catch block returned mediaUrl: null
→ the message was stored in the DB with no media_url → MessageBubble
had nothing to render.
Refactored to a single verifyAndBuildUrl(mediaId) helper called per
media type, with the arguments in the correct order.
2. No fallback UI when media_url is null
MessageBubble's image/video/audio/document cases all did
`{message.media_url && <render />}` with no else branch. So a media
message without a URL rendered an empty <div> — the user saw only
the timestamp.
Added a <MediaUnavailable label="..."> component that shows the icon
+ "Image/Video/Audio/Document unavailable" text when media_url is
null. Applied to all four media types.
3. Sticker type would fail the CHECK constraint on insert
WhatsApp sends image stickers as type: "sticker", but the
messages.content_type CHECK constraint only allows:
text, image, document, audio, video, location, template
Inserting content_type: 'sticker' silently failed.
processMessage now validates content_type against an allow-list
before insert. Stickers are mapped to 'image' (they ARE images).
Reactions and any other unknown type are mapped to 'text'.
Also added the sticker media handling to parseMessageContent so sticker
images actually render (with the verified proxy URL).
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>
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>
Three issues in the Inbox page:
1. "WhatsApp not connected" banner always showed even when connected.
The connection check queried .from("whatsapp_configs") (plural) but
the actual table is whatsapp_config (singular). Query always returned
no rows, so status was never "connected". Changed to the correct
table name and also switched .single() → .maybeSingle() so a missing
row returns null cleanly instead of throwing PGRST116.
2. The banner overlapped message content — it used absolute positioning
at top-14, left-60, which floated over the thread's header. Restructured
the page layout so the banner is part of the flex column (pushes panels
down) instead of overlapping them.
3. The inbox "kept refreshing every second" — an infinite loop between
MessageThread's fetch and the realtime subscription in the page:
MessageThread useEffect depended on a fetchMessages callback that had
[conversation, onMessagesLoaded] deps. After fetching messages, it
always issued a conversations.unread_count=0 UPDATE. That UPDATE fired
a postgres_changes event. The page's handleConversationEvent received
it and called setActiveConversation({...prev, ...conv}) — creating a
NEW OBJECT REFERENCE for the conversation prop. That changed the
conversation prop passed to MessageThread → fetchMessages changed →
useEffect re-fired → reset unread → fired another realtime event →
loop forever (visible as ~1-2 refreshes per second).
Fix:
- useEffect now depends on conversation?.id only (a stable string),
not the full object reference.
- onMessagesLoaded is read through a ref so it doesn't need to be
a dependency.
- unread_count reset only runs when the conversation actually has
unread messages (avoids UPDATE-thrashing realtime).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>