Commit Graph

8 Commits

Author SHA1 Message Date
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
Arnas Donauskas
b013861160
feat(api): invitation create + peek + redeem endpoints
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>
2026-05-31 17:32:46 +03:00
Arnas Donauskas
0a1cf97d6b
feat(api): account + members management endpoints
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>
2026-05-31 15:00:27 +03:00
Arnas Donauskas
2f0334b0de
feat(db): account-sharing schema foundation
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>
2026-05-31 12:59:07 +03:00
Arnas Donauskas
78e0f25911
docs(changelog): 0.2.2 — send_media flow node + migration 016
New users / self-hosters pulling this version need to apply migration
016 before deploying — the entry flags it under "Migration required"
and explains exactly what it does (CHECK constraint + storage bucket
+ RLS policies) so the operator knows why each piece exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:22:22 +02:00
Arnas Donauskas
ec91cc3382
docs: changelog for 0.2.1 (whatsapp_config single-tenant fix)
PATCH bump for the bug fix in #143 (closes #136). Surfaces the
required migration (013_whatsapp_config_phone_number_id_unique.sql)
and the diagnostic SQL self-hosters should run before applying it,
plus a note that wacrm is single-tenant per WhatsApp number by
design.
2026-05-26 11:26:17 +02:00
Arnas Donauskas
f40c6becde
docs: changelog for 0.2.0 (Flows + color themes + soft-GA)
Versions the prior `## Unreleased` chat-actions block as 0.1.1 (it
shipped with migration 009 a few days back) and adds 0.2.0 covering
the Flows feature, color themes, and the various engine + UX work
that landed alongside.

Also bumps package.json: 0.1.0 → 0.2.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:23:09 +03:00
Arnas Donauskas
faa128f0fd
docs: add CHANGELOG with migration-required note
Self-hosters pulling updates have no signal today that a new release
needs a Supabase migration applied — they only find out when /react
starts returning 500s in prod. CHANGELOG.md fixes that for v-current
(chat actions, requires migration 009) and gives future entries a home.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:39:16 +03:00