wacrm/CHANGELOG.md
Arnas Donauskas 0ed07ee6c9
feat(webhook,engines): account-scoped routing
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>
2026-05-31 18:45:48 +03:00

17 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 of auth.uid() = user_id. The user_id columns 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 by UNIQUE(account_id).
  • flow_runs idempotency 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 as owner.

Fixed

  • Inbound WhatsApp messages now land in the shared inbox. The webhook + automations + flows engines used to route inbound events by user_id, which after the 017 migration only matched the WhatsApp config owner's automations / flows — teammates' rules never fired. PR 8 of the multi-user series flips every lookup to account_id so any member of the account sees the inbound message and any teammate's automation or flow can react to it. Also fixes incipient NOT NULL violations on automation_logs, automation_pending_executions, flow_runs, and deals — those tables gained account_id NOT NULL in 017 but the engines hadn't yet been updated to populate it.

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 the accounts and account_invitations tables plus an account_role_enum type; adds account_id to 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 role owner and every existing row of theirs is linked to that account.
  • supabase/migrations/018_account_member_rpcs.sql — adds three SECURITY DEFINER RPCs (set_member_role, remove_account_member, transfer_account_ownership) that back the member-management API. They self-check the caller's role and raise SQLSTATE 42501 / 22023 on forbidden / bad input so the API layer can map cleanly to 403 / 400. Idempotent.
  • supabase/migrations/019_invitation_rpcs.sql — adds two SECURITY DEFINER RPCs: peek_invitation (anonymous read by token hash, returns a fixed-shape JSON envelope) and redeem_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_media flow 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 new flow-media Supabase 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 as send_message. (#156)

Migration required

Apply against your Supabase project before deploying this version:

  • supabase/migrations/016_flow_media.sql — does two things:
    1. Adds 'send_media' to the flow_nodes.node_type CHECK constraint. Without this the send_media node fails to save with a constraint violation.
    2. Creates the public flow-media Supabase 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 the avatars bucket from migration 008 — the bucket is public so Meta can fetch the URL without credentials.

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 errors PGRST116 for 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/config now returns 409 when another user has already claimed the number, the webhook lookup distinguishes 0 rows from ≥2 rows and logs the conflicting user_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 — adds UNIQUE(phone_number_id) to whatsapp_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, DELETE the 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_events tables with partial unique indexes that enforce one active run per contact. Widened messages.content_type CHECK to accept 'interactive'; added interactive_reply_id column so the inbox can render button/list taps. (#112)
  • Runner engine. dispatchInboundToFlows parses 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's message_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) as timed_out so abandoned conversations free up the contact for new triggers. Reuses AUTOMATION_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 via dataset.theme, persisted to localStorage. Inline boot script in layout.tsx replays 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 with primary tokens 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/row reply_id identifiers hidden behind a per-node "Show advanced" disclosure. (#118)
    • send_list nodes 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 + beforeunload reload guard. (#122)
    • New-flow dialog actually widens to fit the 3 template cards (was capped at 384px by a baked-in sm:max-w-sm from shadcn). (#129, #131)
    • Validation panel pinned to the viewport bottom so activate-readiness follows the user as they scroll through nodes. (#130)

Engine reliability

  • Atomic execution_count increment 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; loadActiveRun switched to defensive .limit(1) so a migration glitch producing duplicates can't crash dispatch. (#126)

Security

  • PII redacted from reply_received event payload — customer text is no longer persisted to flow_run_events.payload; only the length is. A collect_input prompt 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 the x-cron-secret header check. (#127)

Fixed

  • /flows no longer spuriously redirects to /dashboard when navigating in. Root cause: useAuth flipped loading: false before the profile fetch resolved. use-auth now exposes a separate profileLoading boolean. (#128)

Migration required

Apply, in order, against your Supabase project:

  1. supabase/migrations/010_flows.sql — Flows core tables, indexes, RLS policies, and the messages schema widening.
  2. supabase/migrations/011_profile_beta_features.sql — adds the profiles.beta_features column. Surviving for future betas; Flows no longer reads it.
  3. supabase/migrations/012_flows_increment_counter.sql — atomic counter RPC. Without this the engine still runs but flows.execution_count is 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; the profiles.beta_features column 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.sql to your Supabase project. It adds messages.reply_to_message_id and the new message_reactions table (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_reactions instead, 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.