From 2f0334b0de9b9599c0db68fe7ba3bf7d410b2749 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Sun, 31 May 2026 12:59:07 +0300 Subject: [PATCH] feat(db): account-sharing schema foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 42 ++ supabase/migrations/017_account_sharing.sql | 635 ++++++++++++++++++++ 2 files changed, 677 insertions(+) create mode 100644 supabase/migrations/017_account_sharing.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2f360..2fbd074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,48 @@ Versions follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 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`. + +### 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. + ## [0.2.2] — 2026-05-29 Flow nodes can now send media. Closes the most-requested gap from user diff --git a/supabase/migrations/017_account_sharing.sql b/supabase/migrations/017_account_sharing.sql new file mode 100644 index 0000000..5a5e4e2 --- /dev/null +++ b/supabase/migrations/017_account_sharing.sql @@ -0,0 +1,635 @@ +-- ============================================================ +-- 017_account_sharing.sql — Multi-user accounts (foundation) +-- +-- Turns wacrm from single-tenant-per-user into multi-tenant-per- +-- account. Every existing user becomes the sole `owner` of a +-- freshly-created account; every existing row is backfilled with +-- that account's id. Post-apply behaviour is identical to before +-- *until* a teammate is invited (which lands in later PRs). +-- +-- What this migration does +-- 1. Introduces `account_role_enum` and tables `accounts` / +-- `account_invitations`. +-- 2. Adds an `is_account_member(account_id, min_role)` SECURITY +-- DEFINER helper used by every policy below. +-- 3. Adds `account_id` (+ `account_role` on `profiles`) to every +-- table that previously carried a `user_id` FK to auth.users. +-- 4. Backfills one account per existing user and propagates +-- `account_id` to every domain row. +-- 5. Drops the old `auth.uid() = user_id` policies and replaces +-- them with membership-checked equivalents. Viewers may read; +-- agents+ may write to operational data; admins+ may write to +-- settings-class tables. +-- 6. Swaps `whatsapp_config.UNIQUE(user_id)` for +-- `UNIQUE(account_id)` — one WhatsApp number per account. +-- 7. Swaps the `flow_runs` "one active run per (user_id, contact)" +-- unique index for `(account_id, contact_id)`. +-- 8. Replaces `handle_new_user` so new signups receive a freshly- +-- created personal account *and* the `owner` role atomically. +-- +-- What this migration does NOT touch +-- - `profiles.role TEXT` (legacy, unused) stays. Flag for removal +-- in a later cleanup. +-- - The `user_id` columns on domain tables stay too — they still +-- identify "the agent who owns this row" (assignment, audit). +-- They are *no longer* used for tenancy isolation. +-- - Storage buckets (avatars, flow-media) stay user-scoped. A +-- later migration will rescope flow-media to account paths. +-- - No user-facing UI changes — those are gated separately on +-- `profiles.beta_features` containing 'account_sharing' in the +-- follow-up PRs. +-- +-- Idempotent — safe to run multiple times. New columns use +-- IF NOT EXISTS; policies / triggers / indexes are dropped before +-- recreate (Postgres has no CREATE POLICY IF NOT EXISTS). +-- ============================================================ + +-- ============================================================ +-- TYPES +-- ============================================================ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'account_role_enum') THEN + CREATE TYPE account_role_enum AS ENUM ('owner', 'admin', 'agent', 'viewer'); + END IF; +END $$; + +-- ============================================================ +-- ACCOUNTS +-- ============================================================ +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + -- owner_user_id is denormalised for fast "is this user the owner of + -- their account" reads and for the one-account-per-user invariant + -- below. The source of truth for membership is profiles.account_id. + owner_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- One account per user (the locked design decision — single +-- membership). Drops automatically if we ever relax to many-to-many. +CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_one_per_owner + ON accounts(owner_user_id); + +ALTER TABLE accounts ENABLE ROW LEVEL SECURITY; + +DROP TRIGGER IF EXISTS set_updated_at ON accounts; +CREATE TRIGGER set_updated_at BEFORE UPDATE ON accounts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================================ +-- ACCOUNT_INVITATIONS +-- +-- One row per outstanding invite link. We store `token_hash` (SHA- +-- 256) rather than the raw token so a leaked DB snapshot doesn't +-- yield a usable invite. The plaintext token is returned exactly +-- once by the POST endpoint at creation time and never persisted. +-- ============================================================ +CREATE TABLE IF NOT EXISTS account_invitations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + role account_role_enum NOT NULL CHECK (role <> 'owner'), + created_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + label TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ, + accepted_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_account_invitations_account_pending + ON account_invitations(account_id, expires_at) + WHERE accepted_at IS NULL; + +ALTER TABLE account_invitations ENABLE ROW LEVEL SECURITY; + +-- ============================================================ +-- MEMBERSHIP HELPER +-- +-- SECURITY DEFINER so the policy body can read `profiles` without +-- recursive RLS evaluation. Returns true iff `auth.uid()` is a +-- member of `target_account_id` with at least `min_role`. +-- +-- Role hierarchy: owner > admin > agent > viewer. +-- ============================================================ +CREATE OR REPLACE FUNCTION is_account_member( + target_account_id UUID, + min_role account_role_enum DEFAULT 'viewer' +) RETURNS BOOLEAN +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM profiles p + WHERE p.user_id = auth.uid() + AND p.account_id = target_account_id + AND CASE p.account_role + WHEN 'owner' THEN 4 + WHEN 'admin' THEN 3 + WHEN 'agent' THEN 2 + WHEN 'viewer' THEN 1 + END + >= + CASE min_role + WHEN 'owner' THEN 4 + WHEN 'admin' THEN 3 + WHEN 'agent' THEN 2 + WHEN 'viewer' THEN 1 + END + ); +$$; + +ALTER FUNCTION is_account_member(UUID, account_role_enum) OWNER TO postgres; +GRANT EXECUTE ON FUNCTION is_account_member(UUID, account_role_enum) TO authenticated, service_role; + +-- ============================================================ +-- PROFILE EXTENSION +-- +-- account_role lives on profiles (not a separate memberships table) +-- because the design is one-account-per-user; this keeps reads cheap +-- (one row, already loaded by the auth hook). +-- ============================================================ +ALTER TABLE profiles + ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS account_role account_role_enum; + +CREATE INDEX IF NOT EXISTS idx_profiles_account_role + ON profiles(account_id, account_role); + +-- ============================================================ +-- ADD account_id TO EVERY PARENT TENANT TABLE +-- +-- Nullable for now — backfill runs below, then NOT NULL applied at +-- the end. Indexes too: every "list mine" query becomes "list my +-- account's", so account_id is the new hot lookup key. +-- ============================================================ +ALTER TABLE contacts ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE tags ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE contact_notes ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE conversations ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE whatsapp_config ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE message_templates ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE pipelines ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE deals ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE broadcasts ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE automations ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE automation_logs ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE automation_pending_executions ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE flows ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE flow_runs ADD COLUMN IF NOT EXISTS account_id UUID REFERENCES accounts(id) ON DELETE CASCADE; + +-- ============================================================ +-- BACKFILL +-- +-- Order is load-bearing: +-- 1. Create one account per existing profile (the existing user +-- is the owner). +-- 2. Stamp profile.account_id / account_role from the row above. +-- 3. Propagate account_id to every domain table via the profile. +-- 4. Apply NOT NULL on every account_id column. +-- +-- Wrapped in a DO block so a partially-applied migration (e.g. +-- accounts already exist but propagation didn't finish) re-converges +-- on re-run rather than duplicating accounts. +-- ============================================================ +DO $$ +DECLARE + v_table TEXT; + v_tables TEXT[] := ARRAY[ + 'contacts', 'tags', 'custom_fields', 'contact_notes', + 'conversations', 'whatsapp_config', 'message_templates', + 'pipelines', 'deals', 'broadcasts', + 'automations', 'automation_logs', 'automation_pending_executions', + 'flows', 'flow_runs' + ]; +BEGIN + -- (1) Create one account per existing profile whose user does not + -- yet own one. Idempotent: skips users that already have an account. + INSERT INTO accounts (name, owner_user_id) + SELECT COALESCE(NULLIF(p.full_name, ''), p.email, 'My account'), + p.user_id + FROM profiles p + WHERE NOT EXISTS ( + SELECT 1 FROM accounts a WHERE a.owner_user_id = p.user_id + ); + + -- (2) Stamp profile.account_id / account_role for every profile that + -- hasn't been linked yet. + UPDATE profiles p + SET account_id = a.id, + account_role = 'owner' + FROM accounts a + WHERE a.owner_user_id = p.user_id + AND p.account_id IS NULL; + + -- (3) Propagate account_id to every domain table. Uses the row's + -- existing user_id → profiles.user_id → profiles.account_id chain. + -- Only updates rows where account_id IS NULL so a re-run is cheap. + FOREACH v_table IN ARRAY v_tables LOOP + EXECUTE format($f$ + UPDATE %I t + SET account_id = p.account_id + FROM profiles p + WHERE t.user_id = p.user_id + AND t.account_id IS NULL + $f$, v_table); + END LOOP; +END $$; + +-- (4) NOT NULL — split out from the DO block so DDL changes happen +-- at the top transactional level. Idempotent: NOT NULL on an +-- already-NOT NULL column is a no-op error-free. +ALTER TABLE profiles ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE profiles ALTER COLUMN account_role SET NOT NULL; +ALTER TABLE contacts ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE tags ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE custom_fields ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE contact_notes ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE conversations ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE whatsapp_config ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE message_templates ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE pipelines ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE deals ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE broadcasts ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE automations ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE automation_logs ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE automation_pending_executions ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE flows ALTER COLUMN account_id SET NOT NULL; +ALTER TABLE flow_runs ALTER COLUMN account_id SET NOT NULL; + +-- ============================================================ +-- INDEXES ON account_id (every parent — these are the new hot keys) +-- ============================================================ +CREATE INDEX IF NOT EXISTS idx_contacts_account ON contacts(account_id); +CREATE INDEX IF NOT EXISTS idx_tags_account ON tags(account_id); +CREATE INDEX IF NOT EXISTS idx_custom_fields_account ON custom_fields(account_id); +CREATE INDEX IF NOT EXISTS idx_contact_notes_account ON contact_notes(account_id); +CREATE INDEX IF NOT EXISTS idx_conversations_account ON conversations(account_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_config_account ON whatsapp_config(account_id); +CREATE INDEX IF NOT EXISTS idx_message_templates_account ON message_templates(account_id); +CREATE INDEX IF NOT EXISTS idx_pipelines_account ON pipelines(account_id); +CREATE INDEX IF NOT EXISTS idx_deals_account ON deals(account_id); +CREATE INDEX IF NOT EXISTS idx_broadcasts_account ON broadcasts(account_id); +CREATE INDEX IF NOT EXISTS idx_automations_account ON automations(account_id); +CREATE INDEX IF NOT EXISTS idx_automation_logs_account ON automation_logs(account_id); +CREATE INDEX IF NOT EXISTS idx_automation_pending_account ON automation_pending_executions(account_id); +CREATE INDEX IF NOT EXISTS idx_flows_account ON flows(account_id); +CREATE INDEX IF NOT EXISTS idx_flow_runs_account ON flow_runs(account_id); + +-- ============================================================ +-- whatsapp_config: one WhatsApp number per ACCOUNT +-- +-- Was UNIQUE(user_id). Same number cannot be configured by two +-- accounts; same account cannot register two numbers. If multi- +-- number-per-account is ever wanted, drop the unique and add a +-- "primary" boolean. +-- ============================================================ +ALTER TABLE whatsapp_config DROP CONSTRAINT IF EXISTS whatsapp_config_user_id_key; +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'whatsapp_config_account_id_key' + ) THEN + ALTER TABLE whatsapp_config ADD CONSTRAINT whatsapp_config_account_id_key UNIQUE (account_id); + END IF; +END $$; + +-- ============================================================ +-- flow_runs: idempotency key swaps to (account_id, contact_id) +-- +-- The "at most one active run per contact" invariant is per-account +-- now — two accounts that happen to share a contact phone number +-- must be able to run their own flows independently. +-- ============================================================ +DROP INDEX IF EXISTS idx_one_active_run_per_contact; +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_active_run_per_contact + ON flow_runs(account_id, contact_id) + WHERE status = 'active'; + +-- ============================================================ +-- RLS REWRITE — PARENT TABLES +-- +-- Replaces every `auth.uid() = user_id` policy with the membership +-- check. Three policy tiers: +-- - viewer : SELECT (read-only) +-- - agent+ : SELECT + INSERT/UPDATE/DELETE (operational data) +-- - admin+ : same + write paths on settings-class tables +-- +-- The legacy `user_id` column stays on every row (still useful for +-- assignment + audit) but is no longer consulted for isolation. +-- ============================================================ + +-- ---- contacts --------------------------------------------------- +DROP POLICY IF EXISTS "Users can manage own contacts" ON contacts; +CREATE POLICY contacts_select ON contacts FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY contacts_insert ON contacts FOR INSERT WITH CHECK (is_account_member(account_id, 'agent')); +CREATE POLICY contacts_update ON contacts FOR UPDATE USING (is_account_member(account_id, 'agent')); +CREATE POLICY contacts_delete ON contacts FOR DELETE USING (is_account_member(account_id, 'agent')); + +-- ---- tags (settings-class) ------------------------------------- +DROP POLICY IF EXISTS "Users can manage own tags" ON tags; +CREATE POLICY tags_select ON tags FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY tags_insert ON tags FOR INSERT WITH CHECK (is_account_member(account_id, 'admin')); +CREATE POLICY tags_update ON tags FOR UPDATE USING (is_account_member(account_id, 'admin')); +CREATE POLICY tags_delete ON tags FOR DELETE USING (is_account_member(account_id, 'admin')); + +-- ---- custom_fields (settings-class) ---------------------------- +DROP POLICY IF EXISTS "Users can manage own custom fields" ON custom_fields; +CREATE POLICY custom_fields_select ON custom_fields FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY custom_fields_insert ON custom_fields FOR INSERT WITH CHECK (is_account_member(account_id, 'admin')); +CREATE POLICY custom_fields_update ON custom_fields FOR UPDATE USING (is_account_member(account_id, 'admin')); +CREATE POLICY custom_fields_delete ON custom_fields FOR DELETE USING (is_account_member(account_id, 'admin')); + +-- ---- contact_notes --------------------------------------------- +DROP POLICY IF EXISTS "Users can manage own notes" ON contact_notes; +CREATE POLICY contact_notes_select ON contact_notes FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY contact_notes_insert ON contact_notes FOR INSERT WITH CHECK (is_account_member(account_id, 'agent')); +CREATE POLICY contact_notes_update ON contact_notes FOR UPDATE USING (is_account_member(account_id, 'agent')); +CREATE POLICY contact_notes_delete ON contact_notes FOR DELETE USING (is_account_member(account_id, 'agent')); + +-- ---- conversations --------------------------------------------- +DROP POLICY IF EXISTS "Users can manage own conversations" ON conversations; +CREATE POLICY conversations_select ON conversations FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY conversations_insert ON conversations FOR INSERT WITH CHECK (is_account_member(account_id, 'agent')); +CREATE POLICY conversations_update ON conversations FOR UPDATE USING (is_account_member(account_id, 'agent')); +CREATE POLICY conversations_delete ON conversations FOR DELETE USING (is_account_member(account_id, 'agent')); + +-- ---- whatsapp_config (settings-class) -------------------------- +DROP POLICY IF EXISTS "Users can manage own config" ON whatsapp_config; +CREATE POLICY whatsapp_config_select ON whatsapp_config FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY whatsapp_config_insert ON whatsapp_config FOR INSERT WITH CHECK (is_account_member(account_id, 'admin')); +CREATE POLICY whatsapp_config_update ON whatsapp_config FOR UPDATE USING (is_account_member(account_id, 'admin')); +CREATE POLICY whatsapp_config_delete ON whatsapp_config FOR DELETE USING (is_account_member(account_id, 'admin')); + +-- ---- message_templates (settings-class) ------------------------ +DROP POLICY IF EXISTS "Users can manage own templates" ON message_templates; +CREATE POLICY message_templates_select ON message_templates FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY message_templates_insert ON message_templates FOR INSERT WITH CHECK (is_account_member(account_id, 'admin')); +CREATE POLICY message_templates_update ON message_templates FOR UPDATE USING (is_account_member(account_id, 'admin')); +CREATE POLICY message_templates_delete ON message_templates FOR DELETE USING (is_account_member(account_id, 'admin')); + +-- ---- pipelines (settings-class) -------------------------------- +DROP POLICY IF EXISTS "Users can manage own pipelines" ON pipelines; +CREATE POLICY pipelines_select ON pipelines FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY pipelines_insert ON pipelines FOR INSERT WITH CHECK (is_account_member(account_id, 'admin')); +CREATE POLICY pipelines_update ON pipelines FOR UPDATE USING (is_account_member(account_id, 'admin')); +CREATE POLICY pipelines_delete ON pipelines FOR DELETE USING (is_account_member(account_id, 'admin')); + +-- ---- deals ------------------------------------------------------ +DROP POLICY IF EXISTS "Users can manage own deals" ON deals; +CREATE POLICY deals_select ON deals FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY deals_insert ON deals FOR INSERT WITH CHECK (is_account_member(account_id, 'agent')); +CREATE POLICY deals_update ON deals FOR UPDATE USING (is_account_member(account_id, 'agent')); +CREATE POLICY deals_delete ON deals FOR DELETE USING (is_account_member(account_id, 'agent')); + +-- ---- broadcasts ------------------------------------------------- +DROP POLICY IF EXISTS "Users can manage own broadcasts" ON broadcasts; +CREATE POLICY broadcasts_select ON broadcasts FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY broadcasts_insert ON broadcasts FOR INSERT WITH CHECK (is_account_member(account_id, 'agent')); +CREATE POLICY broadcasts_update ON broadcasts FOR UPDATE USING (is_account_member(account_id, 'agent')); +CREATE POLICY broadcasts_delete ON broadcasts FOR DELETE USING (is_account_member(account_id, 'agent')); + +-- ---- automations ------------------------------------------------ +DROP POLICY IF EXISTS "Users can manage own automations" ON automations; +CREATE POLICY automations_select ON automations FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY automations_insert ON automations FOR INSERT WITH CHECK (is_account_member(account_id, 'agent')); +CREATE POLICY automations_update ON automations FOR UPDATE USING (is_account_member(account_id, 'agent')); +CREATE POLICY automations_delete ON automations FOR DELETE USING (is_account_member(account_id, 'agent')); + +-- ---- automation_logs ------------------------------------------- +DROP POLICY IF EXISTS "Users can view own automation logs" ON automation_logs; +CREATE POLICY automation_logs_select ON automation_logs FOR SELECT USING (is_account_member(account_id)); +-- Service role inserts logs; no INSERT/UPDATE/DELETE policy for clients. + +-- ---- automation_pending_executions ----------------------------- +-- Service-role only (no client policies). Account_id is on the row +-- for consistency and so the cron can route account-scoped queries. + +-- ---- flows ------------------------------------------------------ +DROP POLICY IF EXISTS "Users can manage own flows" ON flows; +CREATE POLICY flows_select ON flows FOR SELECT USING (is_account_member(account_id)); +CREATE POLICY flows_insert ON flows FOR INSERT WITH CHECK (is_account_member(account_id, 'agent')); +CREATE POLICY flows_update ON flows FOR UPDATE USING (is_account_member(account_id, 'agent')); +CREATE POLICY flows_delete ON flows FOR DELETE USING (is_account_member(account_id, 'agent')); + +-- ---- flow_runs -------------------------------------------------- +DROP POLICY IF EXISTS "Users see own flow runs" ON flow_runs; +CREATE POLICY flow_runs_select ON flow_runs FOR SELECT USING (is_account_member(account_id)); +-- Service-role driven; no client INSERT/UPDATE/DELETE. + +-- ============================================================ +-- RLS REWRITE — CHILD TABLES (parent-join semantics) +-- ============================================================ + +-- ---- contact_tags ---------------------------------------------- +DROP POLICY IF EXISTS "Users can manage contact tags" ON contact_tags; +CREATE POLICY contact_tags_select ON contact_tags FOR SELECT USING ( + EXISTS (SELECT 1 FROM contacts c WHERE c.id = contact_tags.contact_id AND is_account_member(c.account_id)) +); +CREATE POLICY contact_tags_modify ON contact_tags FOR ALL USING ( + EXISTS (SELECT 1 FROM contacts c WHERE c.id = contact_tags.contact_id AND is_account_member(c.account_id, 'agent')) +) WITH CHECK ( + EXISTS (SELECT 1 FROM contacts c WHERE c.id = contact_tags.contact_id AND is_account_member(c.account_id, 'agent')) +); + +-- ---- contact_custom_values ------------------------------------- +DROP POLICY IF EXISTS "Users can manage custom values" ON contact_custom_values; +CREATE POLICY contact_custom_values_select ON contact_custom_values FOR SELECT USING ( + EXISTS (SELECT 1 FROM contacts c WHERE c.id = contact_custom_values.contact_id AND is_account_member(c.account_id)) +); +CREATE POLICY contact_custom_values_modify ON contact_custom_values FOR ALL USING ( + EXISTS (SELECT 1 FROM contacts c WHERE c.id = contact_custom_values.contact_id AND is_account_member(c.account_id, 'agent')) +) WITH CHECK ( + EXISTS (SELECT 1 FROM contacts c WHERE c.id = contact_custom_values.contact_id AND is_account_member(c.account_id, 'agent')) +); + +-- ---- messages -------------------------------------------------- +DROP POLICY IF EXISTS "Users can view own messages" ON messages; +DROP POLICY IF EXISTS "Service role can insert messages" ON messages; +CREATE POLICY messages_select ON messages FOR SELECT USING ( + EXISTS (SELECT 1 FROM conversations c WHERE c.id = messages.conversation_id AND is_account_member(c.account_id)) +); +CREATE POLICY messages_modify ON messages FOR ALL USING ( + EXISTS (SELECT 1 FROM conversations c WHERE c.id = messages.conversation_id AND is_account_member(c.account_id, 'agent')) +) WITH CHECK ( + EXISTS (SELECT 1 FROM conversations c WHERE c.id = messages.conversation_id AND is_account_member(c.account_id, 'agent')) +); +-- Service-role webhook inserts (Meta deliveries) bypass RLS as before. + +-- ---- pipeline_stages ------------------------------------------- +DROP POLICY IF EXISTS "Users can manage pipeline stages" ON pipeline_stages; +CREATE POLICY pipeline_stages_select ON pipeline_stages FOR SELECT USING ( + EXISTS (SELECT 1 FROM pipelines p WHERE p.id = pipeline_stages.pipeline_id AND is_account_member(p.account_id)) +); +CREATE POLICY pipeline_stages_modify ON pipeline_stages FOR ALL USING ( + EXISTS (SELECT 1 FROM pipelines p WHERE p.id = pipeline_stages.pipeline_id AND is_account_member(p.account_id, 'admin')) +) WITH CHECK ( + EXISTS (SELECT 1 FROM pipelines p WHERE p.id = pipeline_stages.pipeline_id AND is_account_member(p.account_id, 'admin')) +); + +-- ---- broadcast_recipients -------------------------------------- +DROP POLICY IF EXISTS "Users can manage broadcast recipients" ON broadcast_recipients; +CREATE POLICY broadcast_recipients_select ON broadcast_recipients FOR SELECT USING ( + EXISTS (SELECT 1 FROM broadcasts b WHERE b.id = broadcast_recipients.broadcast_id AND is_account_member(b.account_id)) +); +CREATE POLICY broadcast_recipients_modify ON broadcast_recipients FOR ALL USING ( + EXISTS (SELECT 1 FROM broadcasts b WHERE b.id = broadcast_recipients.broadcast_id AND is_account_member(b.account_id, 'agent')) +) WITH CHECK ( + EXISTS (SELECT 1 FROM broadcasts b WHERE b.id = broadcast_recipients.broadcast_id AND is_account_member(b.account_id, 'agent')) +); + +-- ---- automation_steps ------------------------------------------ +DROP POLICY IF EXISTS "Users can manage steps of own automations" ON automation_steps; +CREATE POLICY automation_steps_select ON automation_steps FOR SELECT USING ( + EXISTS (SELECT 1 FROM automations a WHERE a.id = automation_steps.automation_id AND is_account_member(a.account_id)) +); +CREATE POLICY automation_steps_modify ON automation_steps FOR ALL USING ( + EXISTS (SELECT 1 FROM automations a WHERE a.id = automation_steps.automation_id AND is_account_member(a.account_id, 'agent')) +) WITH CHECK ( + EXISTS (SELECT 1 FROM automations a WHERE a.id = automation_steps.automation_id AND is_account_member(a.account_id, 'agent')) +); + +-- ---- flow_nodes ------------------------------------------------ +DROP POLICY IF EXISTS "Users manage nodes on their flows" ON flow_nodes; +CREATE POLICY flow_nodes_select ON flow_nodes FOR SELECT USING ( + EXISTS (SELECT 1 FROM flows f WHERE f.id = flow_nodes.flow_id AND is_account_member(f.account_id)) +); +CREATE POLICY flow_nodes_modify ON flow_nodes FOR ALL USING ( + EXISTS (SELECT 1 FROM flows f WHERE f.id = flow_nodes.flow_id AND is_account_member(f.account_id, 'agent')) +) WITH CHECK ( + EXISTS (SELECT 1 FROM flows f WHERE f.id = flow_nodes.flow_id AND is_account_member(f.account_id, 'agent')) +); + +-- ---- flow_run_events ------------------------------------------- +DROP POLICY IF EXISTS "Users see events on their runs" ON flow_run_events; +CREATE POLICY flow_run_events_select ON flow_run_events FOR SELECT USING ( + EXISTS (SELECT 1 FROM flow_runs r WHERE r.id = flow_run_events.flow_run_id AND is_account_member(r.account_id)) +); + +-- ---- message_reactions ----------------------------------------- +DROP POLICY IF EXISTS "Users see reactions on their conversations" ON message_reactions; +DROP POLICY IF EXISTS "Users insert reactions on their conversations" ON message_reactions; +DROP POLICY IF EXISTS "Users delete their own agent reactions" ON message_reactions; +DROP POLICY IF EXISTS "Users update their own agent reactions" ON message_reactions; +CREATE POLICY message_reactions_select ON message_reactions FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM messages m + JOIN conversations c ON c.id = m.conversation_id + WHERE m.id = message_reactions.message_id + AND is_account_member(c.account_id) + ) +); +CREATE POLICY message_reactions_modify ON message_reactions FOR ALL USING ( + EXISTS ( + SELECT 1 FROM messages m + JOIN conversations c ON c.id = m.conversation_id + WHERE m.id = message_reactions.message_id + AND is_account_member(c.account_id, 'agent') + ) +) WITH CHECK ( + EXISTS ( + SELECT 1 FROM messages m + JOIN conversations c ON c.id = m.conversation_id + WHERE m.id = message_reactions.message_id + AND is_account_member(c.account_id, 'agent') + ) +); + +-- ============================================================ +-- RLS — PROFILES (revised) +-- +-- A profile row is readable by every member of its account so the +-- Members tab can render. It is only writable by the row's own +-- user (so an admin cannot edit a teammate's name/avatar — that's +-- the teammate's own settings). Role changes happen via the +-- separate /api/account/members endpoint (admin-only, server-side). +-- ============================================================ +DROP POLICY IF EXISTS "Users can view own profile" ON profiles; +DROP POLICY IF EXISTS "Users can update own profile" ON profiles; +DROP POLICY IF EXISTS "Users can insert own profile" ON profiles; +CREATE POLICY profiles_select ON profiles FOR SELECT + USING (auth.uid() = user_id OR is_account_member(account_id)); +CREATE POLICY profiles_update ON profiles FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +CREATE POLICY profiles_insert ON profiles FOR INSERT + WITH CHECK (auth.uid() = user_id); + +-- ============================================================ +-- RLS — ACCOUNTS & ACCOUNT_INVITATIONS +-- +-- accounts: members read; admins+ update; nobody inserts via +-- client (the signup trigger / redeem RPC own creation). +-- invitations: admins+ full control; everyone else has no +-- visibility. The /api/invitations/[token]/peek endpoint uses the +-- service role to look up by token_hash anonymously. +-- ============================================================ +DROP POLICY IF EXISTS accounts_select ON accounts; +DROP POLICY IF EXISTS accounts_update ON accounts; +CREATE POLICY accounts_select ON accounts FOR SELECT + USING (is_account_member(id)); +CREATE POLICY accounts_update ON accounts FOR UPDATE + USING (is_account_member(id, 'admin')) + WITH CHECK (is_account_member(id, 'admin')); + +DROP POLICY IF EXISTS account_invitations_select ON account_invitations; +DROP POLICY IF EXISTS account_invitations_modify ON account_invitations; +CREATE POLICY account_invitations_select ON account_invitations FOR SELECT + USING (is_account_member(account_id, 'admin')); +CREATE POLICY account_invitations_modify ON account_invitations FOR ALL + USING (is_account_member(account_id, 'admin')) + WITH CHECK (is_account_member(account_id, 'admin')); + +-- ============================================================ +-- SIGNUP TRIGGER — replace to also create a personal account +-- +-- Every new auth.users row now produces: +-- - a fresh `accounts` row owned by them +-- - a `profiles` row linked to that account with role = 'owner' +-- +-- The invite-redemption RPC (later PR) will reassign profile.account_id +-- to the inviter's account and delete the orphan personal account if +-- it's still empty. +-- ============================================================ +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +DROP FUNCTION IF EXISTS public.handle_new_user(); + +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_full_name TEXT; + v_account_id UUID; +BEGIN + v_full_name := COALESCE(NEW.raw_user_meta_data->>'full_name', ''); + + INSERT INTO public.accounts (name, owner_user_id) + VALUES (COALESCE(NULLIF(v_full_name, ''), NEW.email, 'My account'), NEW.id) + RETURNING id INTO v_account_id; + + INSERT INTO public.profiles (user_id, full_name, email, account_id, account_role) + VALUES (NEW.id, v_full_name, NEW.email, v_account_id, 'owner'); + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'Failed to bootstrap account/profile for user %: %', NEW.id, SQLERRM; + RETURN NEW; +END; +$$; + +ALTER FUNCTION public.handle_new_user() OWNER TO postgres; + +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();