Commit Graph

9 Commits

Author SHA1 Message Date
Arnas Donauskas
14cdbf457b
feat(ui): role-aware gating sweep + account-name in sidebar
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>
2026-05-31 19:02:14 +03:00
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