The same phone could become multiple contacts: only the WhatsApp
webhook de-duped, while manual create and CSV import inserted raw
un-normalized phones, and the DB had no uniqueness. That fragmented
conversations, deals, and tags across duplicates.
- Migration 022: generated `contacts.phone_normalized` (digits-only),
a one-shot `merge_duplicate_contacts()` that collapses existing
dupes into the oldest row (re-pointing conversations, deals, notes,
tags, custom values, broadcast recipients — no data loss), then a
UNIQUE (account_id, phone_normalized) index as the authoritative
guarantee for every write path.
- Shared `src/lib/contacts/dedupe.ts`: findExistingContact (suffix
prefilter + phonesMatch), isExactMatch, isUniqueViolation,
dedupeByPhone. The webhook now reuses findExistingContact (and
recovers from a unique-violation race instead of dropping the
message).
- Contact form: on-blur duplicate check — hard-block + "view existing"
link on an exact match, soft warning on a trunk-variant near-match;
unique-violation backstop on submit.
- CSV import: de-dupes within the file and against existing contacts,
reports "imported / skipped / failed", treats 23505 as skipped.
- Contact type gains account_id (was missing) + phone_normalized.
- Unit tests for the dedupe helpers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wacrm hardcoded USD across every deal-value formatter, the new-deal
form, and automation-created deals, making deal tracking unhelpful for
non-US self-hosted users. Add a per-account default currency.
- Migration 021: accounts.default_currency TEXT NOT NULL DEFAULT 'USD'
with a 3-letter-code CHECK. The existing admin-only accounts_update
RLS policy already gates writes.
- New src/lib/currency.ts: single source of truth (CURRENCIES list,
formatCurrency, formatCurrencyShort), replacing ~6 duplicated
USD-hardcoded helpers.
- useAuth exposes defaultCurrency (joined from accounts), falling back
to USD while loading / on pre-021 schemas.
- Settings → Deals tab to pick the currency (admin-only, read-only for
others to match RLS).
- Deal form defaults new deals to the account currency and sources its
dropdown from CURRENCIES; existing deals keep their saved currency.
- Pipeline board/analytics, dashboard "Open Deals Value", and the
pipeline donut format totals in the account currency. Individual
deal cards still show each deal's own currency.
- Automation create_deal sets the new deal's currency from the
account default instead of the static DB default.
One currency per account (no FX conversion) per the issue's
recommended first pass.
Co-Authored-By: Claude Opus 4.8 (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 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>
`is_account_member` is a `LANGUAGE sql` function, so Postgres parses
and validates its body at CREATE time — references to `profiles.account_id`
and `profiles.account_role` must already exist in the schema. The
original ordering created the helper before the ALTER TABLE that
added those columns, causing Supabase to abort with
ERROR: column p.account_id does not exist (SQLSTATE 42703)
on every fresh apply. Reordering: the profile column-add now runs
immediately after the `accounts` / `account_invitations` tables and
before the helper. No semantic change elsewhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Turns every wacrm install multi-tenant on the database side. A single
user's signup now creates an `accounts` row, links the profile to it
as `owner`, and every domain row is scoped via a new `account_id`
column instead of `user_id`.
Why now: the next handful of PRs add the invite/members user surface
(gated by the `account_sharing` beta flag); that surface needs RLS to
already speak "membership" rather than "ownership". Doing the schema
flip in isolation keeps the diff reviewable and lets the follow-up
PRs stay focused on TS/UI.
What the migration does:
- Introduces `account_role_enum` + `accounts` + `account_invitations`.
- Adds `account_id` to every parent tenant table (16 tables incl.
profiles) and `account_role` to profiles.
- Backfills one account per existing user, then sets NOT NULL.
- Replaces every `auth.uid() = user_id` RLS policy with a membership
check via a SECURITY DEFINER `is_account_member(account_id, min)`
helper. Three tiers: viewers read, agents write operational data,
admins write settings-class tables (whatsapp_config, templates,
pipelines, tags, custom_fields).
- Swaps `whatsapp_config.UNIQUE(user_id)` → `UNIQUE(account_id)` and
`flow_runs` active-run idempotency key to `(account_id, contact_id)`.
- Replaces `handle_new_user` so new signups bootstrap their own
account atomically.
Backwards compatibility: every existing user is the sole `owner` of
their freshly-created account, every existing row is linked to it,
and the legacy `user_id` columns stay (still used for assignment /
audit, no longer for isolation). Net behavioural change on its own:
zero.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets flows attach an image, video, or document (e.g. PDF invoice /
receipt) to a customer message — the most-requested gap in v1 was that
nodes were text-only. Single node with a `media_type` discriminator
rather than three node types: Meta's wire payload differs only in the
top-level key, so three types would triple the surface area (forms,
engine cases, menu entries) for no behavioural difference.
Files are uploaded directly from the builder to a new public
`flow-media` Supabase Storage bucket (per-user RLS, 16 MB cap, MIME
allowlist) and the resulting public URL is what Meta fetches at send
time. Same trade-off as the existing avatars bucket.
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>
Postgres rejects CHECK constraints that contain subqueries with
SQLSTATE 0A000 — `NOT EXISTS (SELECT 1 FROM jsonb_array_elements(...))`
inside CHECK is illegal even though it parses.
Move per-element validation entirely to the API layer (the upcoming
template-validators in PR 3). The DB CHECK keeps the cheap shape
guard: NULL or an array of length <= 10. Per-element validation
needs specific error messages anyway ("button #3 has no `text` field")
which API-layer validators do much better than a CHECK violation.
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.
startNewRun read flows.execution_count from a cached row and wrote
`cached + 1`. Two concurrent webhooks starting runs on the same flow
for different contacts both read N and both write N+1 — one count
permanently lost.
Mirrors the automations engine's solution (migration 007): a tiny
SECURITY DEFINER function that does the increment server-side, locked
to the service role. The engine's call becomes a single RPC.
The counter is best-effort: if the RPC fails we log + continue rather
than failing the run for a stat error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for the upcoming WhatsApp chatbot flow builder. No runner,
no UI yet — just the plumbing that lets the codebase SEND interactive
messages (button replies + lists) and RECEIVE the taps as structured
message rows. Plan: ~/.claude/plans/understood-research-such-functionality-binary-lake.md
## What's in this PR
### Migration 010_flows.sql
- New tables: flows, flow_nodes, flow_runs, flow_run_events
- Linchpin partial unique index on flow_runs(user_id, contact_id) WHERE status='active' — at most one active run per contact, makes concurrent webhook deliveries idempotent at the DB layer
- Widens messages.content_type CHECK to allow 'interactive'
- New messages.interactive_reply_id column for the tapped button/row id
- RLS on every table; flow_runs added to supabase_realtime publication
- Idempotent throughout — matches the 009_message_actions.sql template
### Interactive Meta senders (src/lib/whatsapp/meta-api.ts)
- sendInteractiveButtons: type='interactive', interactive.type='button', up to 3 reply buttons
- sendInteractiveList: type='interactive', interactive.type='list', up to 10 rows across sections
- Limits exported as INTERACTIVE_LIMITS so flow validation can share the constants
- All Meta caps validated BEFORE the network call (button title ≤20 chars, list row title ≤24 chars, ≤10 total rows, no duplicate row ids, body ≤1024 chars, etc.) — misconfigured flows fail at save time instead of as a customer-facing 400 mid-conversation
### Webhook (src/app/api/whatsapp/webhook/route.ts)
- Extends WhatsAppMessage interface with `interactive?: { type, button_reply?, list_reply? }`
- parseMessageContent returns a new `interactiveReplyId` field; refactored to use a shared `empty` base so every return site picks up the new field without 9 explicit copies
- New `case 'interactive'`: uses the tapped option's TITLE as content_text (so the inbox bubble reads "Existing customer", not a UUID) and stashes the id separately
- 'interactive' added to ALLOWED_CONTENT_TYPES so the messages insert no longer coerces it to 'text'
### Inbox bubble (src/components/inbox/message-bubble.tsx)
- New `case "interactive"` in MessageContent renders the tap with a small "↩ Button reply" affordance so agents reading the inbox can tell at a glance that this was a tap rather than the customer typing the same words
### Types (src/types/index.ts)
- ContentType union extended with 'interactive'
- Message.interactive_reply_id field
### Engine helpers (src/lib/flows/)
- admin-client.ts: thin service-role client wrapper, mirrors automations/admin-client.ts
- meta-send.ts: engineSendInteractiveButtons / engineSendInteractiveList — adapts the Meta senders for service-role use, persists messages with sender_type='bot' content_type='interactive', uses the same phone-variant retry as automations/meta-send.ts. Unused in PR #1; ready for the runner in PR #2
### Tests
- 13 new vitest cases in meta-api.test.ts covering Meta's limit validation + payload-shape assertions for both interactive senders
## Validation
- npm test → 102/102 (was 89; 13 new)
- npm run lint → 0 errors
- npm run typecheck → clean
- npm run build → clean
## What's NOT in this PR
- The runner (dispatchInboundToFlows) — PR #2
- Builder UI / API surface — PR #3
- collect_input / condition / set_tag nodes + templates — PR #4
- react-flow canvas + http_fetch — v2 (PR #5)
## Risks tracked from the plan
- messages.content_type CHECK widening: grep'd every consumer; message-bubble.tsx, reply-quote.tsx, send/route.ts, automations/meta-send.ts all handle the new variant correctly (or accept it as a defaulted fallthrough)
- engineSendText / engineSendInteractive share ~80% of code; deliberately not pre-factored yet — let both stabilize, extract a shared base in PR #2 once the shape settles
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scaffolding for the upcoming WhatsApp Flows feature. The whole Flows
stack (interactive button/list send + parse, server-side runner, no-
code builder, templates) will land across the next ~4 PRs *behind*
this gate so the owner can dogfood the feature on their own account
before exposing it to teammates or downstream pullers of the OSS.
What this lands:
1. `profiles.beta_features TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]`
— generic opt-in column, lives on the existing profiles table.
Empty by default for every existing row.
2. `src/lib/flows/feature-flag.ts` — `isFlowsEnabled(profile)`
predicate + `BETA_FEATURE_FLOWS` constant. Single source of
truth for the gate; later PRs import this in every guarded
surface (sidebar nav, /flows page, /api/flows routes, webhook
dispatcher).
3. `Profile.beta_features` added to the shared TS type and the
auth hook's local copy. Auth hook loads the column and
defaults it to `[]` defensively if the DB read returns null.
4. 11 unit tests covering null/undefined/empty/mismatched cases,
including case sensitivity (only lowercase `'flows'` opts in)
and defensive guards against malformed input.
How the owner enables it for their own account, once merged:
UPDATE profiles
SET beta_features = array_append(beta_features, 'flows')
WHERE user_id = '<their-auth-uid>';
How we ship it to everyone, when ready: grep-and-delete the
`isFlowsEnabled()` callsites in one cleanup PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three WhatsApp-standard affordances to the inbox: per-message
emoji reactions, reply-with-quote, and copy-text. Bubbles stay pure
presenters — actions layer on by composition (MessageActions wraps
MessageBubble). Hover toolbar on desktop, long-press (onContextMenu)
on touch.
Data model (migration 009):
- messages.reply_to_message_id (self-FK, ON DELETE SET NULL) so replies
survive a deleted parent
- message_reactions table — one row per (message, actor) with a unique
constraint, conversation_id denormalised so Supabase Realtime can
filter without a join
- RLS scoped via conversation ownership; service-role bypass for the
webhook
API:
- /api/whatsapp/send accepts reply_to_message_id, resolves the parent's
Meta message_id and forwards it as context.message_id
- /api/whatsapp/react (new) sends Meta reaction payloads and mirrors
the upsert/delete into message_reactions
- Webhook stops storing inbound reactions as fake text messages and
instead upserts/deletes message_reactions; inbound replies (context.id)
resolve to internal UUID and populate reply_to_message_id
UI:
- MessageActions hover toolbar with inline 6-emoji Popover (static set;
no emoji-picker library)
- ReplyQuote shared between the in-bubble quote and the composer chip
- MessageReactions pill row with click-to-toggle
- Thread subscribes to message_reactions filtered by conversation_id;
optimistic upsert with rollback on failure
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 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>
The engine did a client-side read-modify-write on
automations.execution_count, so two concurrent dispatches of the
same automation (e.g. fired for two different contacts within the
same second) could both read N and both write N+1, permanently
losing one count.
- Migration 007 adds increment_automation_execution_count(p_automation_id)
as a SECURITY DEFINER sql function. Only service_role can EXECUTE
it — an authenticated user can't juice someone else's counter by
calling the RPC directly.
- Engine now calls this RPC instead of reading automation.execution_count
and adding 1. Atomic in one statement; concurrent dispatches both
land correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Root cause (error 23503):
broadcast_recipients.contact_id and deals.contact_id were declared
NOT NULL REFERENCES contacts(id) with no ON DELETE clause, so
Postgres defaulted to NO ACTION. Any contact who'd ever received a
broadcast or been attached to a deal became un-deletable with
"violates foreign key constraint broadcast_recipients_contact_id_fkey".
Fix (migration 004):
Relax both FKs to ON DELETE SET NULL + drop NOT NULL on both
columns. History rows survive with contact_id = NULL. The UI is
already null-safe:
- broadcast detail: contact?.name ?? 'Unknown'
- deal card: contact?.name || contact?.phone || 'No contact'
- CSV export: contact?.name ?? ''
Why not CASCADE:
CASCADE would wipe broadcast recipient rows and trigger the
aggregate recount on broadcasts (migration 003), retroactively
changing sent_count / delivered_count / read_count. It would also
delete deal history. SET NULL preserves both counts and history.
Type/code:
- Deal.contact_id + BroadcastRecipient.contact_id widen to
string | null
- DealForm: setContactId(deal.contact_id ?? "") — the contact
picker uses "" to mean unselected
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>
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>
The Supabase GitHub integration re-runs migrations on every push.
The original 001_initial_schema.sql used plain CREATE TABLE without
IF NOT EXISTS, so it failed with "relation 'profiles' already exists"
after the first successful run.
Changes:
- CREATE TABLE -> CREATE TABLE IF NOT EXISTS (all 16 tables)
- CREATE INDEX -> CREATE INDEX IF NOT EXISTS
- DROP POLICY IF EXISTS before each CREATE POLICY
(Postgres has no CREATE POLICY IF NOT EXISTS)
- DROP TRIGGER IF EXISTS before each CREATE TRIGGER
- Wrapped ALTER PUBLICATION in DO block that checks pg_publication_tables
- handle_new_user() trigger now uses SECURITY DEFINER, SET search_path,
EXCEPTION handler, and owner=postgres to properly bypass RLS on the
profiles INSERT during signup (fixes "Database error saving new user")
The migration is now safe to re-run any number of times.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>