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>
16 KiB
Changelog
User-visible changes in wacrm. Self-hosters: when pulling an update,
check this file for any migration required notes and apply the
matching SQL files from supabase/migrations/ against your Supabase
project before restarting the app.
Versions follow Keep a Changelog.
Pre-1.0, MINOR bumps cover new modules; PATCH bumps cover bug fixes
and polish.
[Unreleased]
Foundation for multi-user accounts. Every wacrm install becomes
multi-tenant on the database side: a single user's signup creates a
fresh "account", and every row is scoped to that account rather than
to the user directly. The user-visible invite / members surface lands
in follow-up PRs gated by the 'account_sharing' beta feature flag —
this release is wiring with no behaviour change on its own. Existing
self-hosted instances keep working: every existing user is backfilled
as the sole owner of their own account and sees identical data.
Changed
- Tenancy moves from per-user to per-account. RLS on every
domain table (contacts, conversations, messages, broadcasts,
automations, flows, pipelines, templates, tags, …) now checks
account membership via a new SECURITY DEFINER helper
is_account_member(account_id, min_role)instead ofauth.uid() = user_id. Theuser_idcolumns stay on every row for assignment / audit but no longer enforce isolation. - WhatsApp config is one-per-account, not one-per-user. The
whatsapp_config.UNIQUE(user_id)constraint is replaced byUNIQUE(account_id). flow_runsidempotency key swaps to(account_id, contact_id)so two accounts sharing a contact phone number can each run their own flows independently.- The signup trigger (
handle_new_user) now also creates a personal account and links the new profile to it asowner.
Added
- Account & member management API — server-side endpoints
for the upcoming Members tab UI. All routes are role-gated and
return Supabase-RLS-scoped data.
GET /api/account— caller's account + role. Any member.PATCH /api/account— rename the account. Admin+.GET /api/account/members— list members. Email visible to admin+ only; agents/viewers see name + avatar + role + joined date.PATCH /api/account/members/[userId]— change a member's role. Admin+. Owner promotion/demotion goes through the transfer endpoint instead.DELETE /api/account/members/[userId]— remove a member. Admin+. The removed user keeps their login and is moved to a freshly-created personal account (mirror of the signup flow).POST /api/account/transfer-ownership— owner only. Atomic swap with the named member.
- Invitation API + redeem flow — the no-email, link-only
invite path. Backend is complete; the Members tab UI that
drives it lands in a follow-up.
GET /api/account/invitations— list outstanding (admin+).POST /api/account/invitations— create an invite, returns the plaintext token + share URL exactly once (we store only the SHA-256 hash on the row). Body{ role, expiresInDays?, label? }. Admin+.DELETE /api/account/invitations/[id]— revoke (admin+).GET /api/invitations/[token]/peek— public, per-IP rate-limited. Returns{ ok, account_name, role, expires_at }or{ ok: false, reason }so the join page can render "You're being invited to as ".POST /api/invitations/[token]/redeem— authenticated. Atomically moves the caller's profile to the inviter's account and cleans up the orphan personal account. Refuses with 409 if the caller's current account already contains domain data (no silent data loss).
Migration required
Apply against your Supabase project before deploying this version:
supabase/migrations/017_account_sharing.sql— introduces theaccountsandaccount_invitationstables plus anaccount_role_enumtype; addsaccount_idto every user-scoped table and backfills it; rewrites every RLS policy; replaces the new-user trigger. Idempotent. No data loss — every existing user is mapped to a freshly-created account with roleownerand every existing row of theirs is linked to that account.supabase/migrations/018_account_member_rpcs.sql— adds threeSECURITY DEFINERRPCs (set_member_role,remove_account_member,transfer_account_ownership) that back the member-management API. They self-check the caller's role and raise SQLSTATE42501/22023on forbidden / bad input so the API layer can map cleanly to 403 / 400. Idempotent.supabase/migrations/019_invitation_rpcs.sql— adds twoSECURITY DEFINERRPCs:peek_invitation(anonymous read by token hash, returns a fixed-shape JSON envelope) andredeem_invitation(authenticated atomic move + orphan cleanup, with a domain-data safety check). Both bypass the RLS that would otherwise block their reads/writes. Idempotent.
[0.2.2] — 2026-05-29
Flow nodes can now send media. Closes the most-requested gap from user feedback after the v0.2.0 Flows launch — flows were text-only and couldn't deliver an invoice, receipt, product photo, or short demo video mid-conversation.
Added
send_mediaflow node. Send an image (PNG / JPEG / WebP), video (MP4 / 3GP), or document (PDF, Word, Excel, PowerPoint, TXT) to the customer from any point in a flow. Pick a file in the builder, it uploads to the newflow-mediaSupabase Storage bucket, and Meta fetches the public URL at send time. Optional caption (1024 char cap, supports{{vars.X}}interpolation); documents also take an optional filename shown in the recipient's chat. Auto-advances after send — same suspend semantics assend_message. (#156)
Migration required
Apply against your Supabase project before deploying this version:
supabase/migrations/016_flow_media.sql— does two things:- Adds
'send_media'to theflow_nodes.node_typeCHECK constraint. Without this thesend_medianode fails to save with a constraint violation. - Creates the public
flow-mediaSupabase Storage bucket (16 MB file-size cap, image / video / document MIME allowlist) plus per-user RLS policies (path prefix =auth.uid()). Without this the builder's file picker fails on upload. Same shape as theavatarsbucket from migration 008 — the bucket is public so Meta can fetch the URL without credentials.
- Adds
The migration is idempotent and safe to re-run.
[0.2.1] — 2026-05-26
Bug-fix release. Plugs a silent inbound-message drop that triggered
when two users on the same instance saved the same WhatsApp
phone_number_id.
Fixed
- Inbound WhatsApp messages no longer silently disappear when two
users have claimed the same
phone_number_id. Previously the webhook used.single()to look up the owning config, which errorsPGRST116for both 0 rows and ≥2 rows — the second user's save put the DB into the ≥2-row state and every inbound message was dropped while the log misleadingly reported "No config found for phone_number_id". Three layers of fix:POST /api/whatsapp/confignow returns 409 when another user has already claimed the number, the webhook lookup distinguishes 0 rows from ≥2 rows and logs the conflictinguser_ids, and a new DB constraint (UNIQUE(phone_number_id)) prevents the bad state at the storage layer. Reported in #136, fixed in #143.
Migration required
Apply against your Supabase project before deploying this version:
-
supabase/migrations/013_whatsapp_config_phone_number_id_unique.sql— addsUNIQUE(phone_number_id)towhatsapp_config. Fails loudly with a copy-pasteable resolution hint if duplicate rows already exist; auto-deduping would destroy encrypted tokens, so the operator picks which row keeps the number. To check first:SELECT phone_number_id, array_agg(user_id) AS owners, count(*) AS n FROM whatsapp_config GROUP BY phone_number_id HAVING count(*) > 1;If that returns rows,
DELETEthe duplicate row(s) you want to drop, then re-run the migration.
Note on multi-user setups
wacrm is intentionally single-tenant per WhatsApp number. RLS on
conversations/messages is auth.uid() = user_id, so a second
user physically cannot read messages routed to a different owner —
two users sharing one number was never supported. If you need
multiple humans handling the same inbox, run them under one shared
account.
[0.2.0] — 2026-05-22
The Flows release. Adds a no-code, branching, button-driven WhatsApp conversation engine that runs alongside Automations. Also ships a 5-theme color picker in Settings and opens Flows to all users.
Added
Flows — branching chatbot conversations
- Module + schema. New
flows,flow_nodes,flow_runs,flow_run_eventstables with partial unique indexes that enforce one active run per contact. Widenedmessages.content_typeCHECK to accept'interactive'; addedinteractive_reply_idcolumn so the inbox can render button/list taps. (#112) - Runner engine.
dispatchInboundToFlowsparses every inbound webhook, decides whether the message is a reply on an active run or a fresh trigger, advances the state machine, and reports back to the webhook so consumed messages don't also fire automations. Idempotent on Meta'smessage_id. (#114) - No-code builder UI at
/flows. Linear-list editor with per-node config forms, live validator, draft/active/archived status, and a 5-route REST API (GET/POST /api/flows,GET/PUT/DELETE /api/flows/[id],POST /api/flows/[id]/activate,GET /api/flows/[id]/runs,GET /api/flows/templates). (#115) - Templates + v1.5 node types. Three starter templates
(Welcome menu, FAQ bot, Lead capture) cloneable from the New-flow
dialog. Three new node types:
collect_input(capture customer text into a variable),condition(branch on var / tag / contact field),set_tag(add or remove a tag).{{vars.X}}interpolation in send_message + collect_input prompts. Per-flow run-history viewer at/flows/[id]/runs. (#117) - Stale-run sweep cron at
GET /api/flows/cron— marks runs past their configured timeout (default 24h) astimed_outso abandoned conversations free up the contact for new triggers. ReusesAUTOMATION_CRON_SECRET. (#114)
Color themes
- 5 color themes (Violet default, Emerald, Cobalt, Amber, Rose)
selectable from a new Appearance tab in Settings. CSS variables
scoped under
html[data-theme="..."], applied at runtime viadataset.theme, persisted tolocalStorage. Inline boot script inlayout.tsxreplays the choice before first paint so there's no flash of the default. (#132) - Theme tokenization sweep — every previously hard-coded
violet-*Tailwind class replaced withprimarytokens across ~49 files. Picking a non-violet theme now themes the whole app, not just the chrome. (#133)
Changed
Flows — soft-GA
- Flows is now available to every authenticated user. The per-account beta gate is gone; the sidebar entry + page header carry a small "Beta" chip as the only remaining signal. (#134)
- Editor UX:
- Internal
node_key+ per-button/rowreply_ididentifiers hidden behind a per-node "Show advanced" disclosure. (#118) send_listnodes can have multiple sections. (#119)- Collapsed node cards show a 1-line content preview per node type (text excerpt, button titles, condition summary, etc.). (#120)
- Validation issues are clickable: jump to + flash the offending node. (#121)
- Unsaved-changes "● Edited" indicator +
beforeunloadreload guard. (#122) - New-flow dialog actually widens to fit the 3 template cards
(was capped at 384px by a baked-in
sm:max-w-smfrom shadcn). (#129, #131) - Validation panel pinned to the viewport bottom so activate-readiness follows the user as they scroll through nodes. (#130)
- Internal
Engine reliability
- Atomic
execution_countincrement via SECURITY DEFINER RPC — prevents lost counts when two webhooks start runs concurrently. Mirrors the automations engine pattern. (#124) - Preload all flow_nodes once per dispatch — one SELECT per inbound instead of one per advance-loop iteration. A 5-node auto-advance chain now costs 1 round trip, not 5. (#125)
- Wasted re-read dropped after reprompt reset;
loadActiveRunswitched to defensive.limit(1)so a migration glitch producing duplicates can't crash dispatch. (#126)
Security
- PII redacted from
reply_receivedevent payload — customer text is no longer persisted toflow_run_events.payload; only the length is. Acollect_inputprompt asking "what's your card number?" used to leave the PAN sitting in the events table. (#123) - Constant-time cron-secret compare on
/api/flows/cron(crypto.timingSafeEqual) to close a theoretical timing-side-channel on thex-cron-secretheader check. (#127)
Fixed
/flowsno longer spuriously redirects to/dashboardwhen navigating in. Root cause:useAuthflippedloading: falsebefore the profile fetch resolved.use-authnow exposes a separateprofileLoadingboolean. (#128)
Migration required
Apply, in order, against your Supabase project:
supabase/migrations/010_flows.sql— Flows core tables, indexes, RLS policies, and themessagesschema widening.supabase/migrations/011_profile_beta_features.sql— adds theprofiles.beta_featurescolumn. Surviving for future betas; Flows no longer reads it.supabase/migrations/012_flows_increment_counter.sql— atomic counter RPC. Without this the engine still runs butflows.execution_countis racy.
Each migration is idempotent — safe to re-run if you're not sure whether you applied a previous one.
Removed
src/lib/flows/feature-flag.ts+ its tests. Flows is open to all users; theprofiles.beta_featurescolumn itself survives for future beta gates. (#134)
[0.1.1] — 2026-05-19
Added
- Chat actions in the inbox: emoji reactions, reply-with-quote, and copy-text on individual messages. Hover on desktop, long-press on touch. Outbound reactions and replies forward to WhatsApp via the Cloud API; inbound reactions and swipe-replies from customers arrive through the webhook and appear in real time.
Migration required
- Apply
supabase/migrations/009_message_actions.sqlto your Supabase project. It addsmessages.reply_to_message_idand the newmessage_reactionstable (with RLS and realtime). The migration is idempotent — safe to re-run.
Changed
- The webhook no longer stores inbound customer reactions as fake
text messages. They are written to
message_reactionsinstead, so any custom queries that counted reactions as messages will need updating.
[0.1.0]
Initial template release. Core CRM: inbox, contacts, pipelines, broadcasts, automations (with a Wait-step cron drain), WhatsApp Cloud API integration, Supabase auth + RLS.