mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
017 claimed to be idempotent but only dropped the *legacy* policy names before recreating; the new policies (contacts_select, …) were created unguarded. A second run therefore failed with `42710: policy "contacts_select" for table "contacts" already exists`. Drop every policy on the tables 017 manages (it owns all RLS on them — no later migration adds others) in a DO block before the CREATEs, so the rewrite re-establishes the full set on every run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
690 lines
36 KiB
PL/PgSQL
690 lines
36 KiB
PL/PgSQL
-- ============================================================
|
|
-- 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;
|
|
|
|
-- ============================================================
|
|
-- 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).
|
|
--
|
|
-- Added BEFORE the is_account_member helper below because LANGUAGE
|
|
-- sql functions resolve column references at CREATE time (unlike
|
|
-- plpgsql, which defers to call time).
|
|
-- ============================================================
|
|
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);
|
|
|
|
-- ============================================================
|
|
-- 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;
|
|
|
|
-- ============================================================
|
|
-- 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:
|
|
-- 0. Heal orphaned auth.users that never got a profile row.
|
|
-- 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
|
|
-- (0) Heal orphaned users. The pre-017 signup trigger (migration
|
|
-- 001) inserted the profile inside an `EXCEPTION WHEN OTHERS ...
|
|
-- RAISE WARNING; RETURN NEW` block, so a signup could leave an
|
|
-- auth.users row with no matching profiles row. Those orphans would
|
|
-- be skipped by step (1) below, get no account, and — if they own
|
|
-- any domain rows (pre-017 RLS only required auth.uid() = user_id,
|
|
-- not a profile) — leave account_id NULL and abort the SET NOT NULL
|
|
-- step. Backfilling the missing profile first keys the whole backfill
|
|
-- off auth.users instead of profiles, so every authenticated user is
|
|
-- migrated and no domain row can be left without an account.
|
|
-- full_name / email are NOT NULL on profiles, hence the COALESCE.
|
|
INSERT INTO public.profiles (user_id, full_name, email)
|
|
SELECT u.id,
|
|
COALESCE(u.raw_user_meta_data->>'full_name', ''),
|
|
COALESCE(u.email, '')
|
|
FROM auth.users u
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM public.profiles p WHERE p.user_id = u.id
|
|
);
|
|
|
|
-- (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.
|
|
-- ============================================================
|
|
|
|
-- Make the RLS rewrite re-runnable. CREATE POLICY has no IF NOT EXISTS
|
|
-- form, and the DROP statements below only name the *legacy* policies —
|
|
-- the new ones (contacts_select, …) would error with 42710 "policy
|
|
-- already exists" on a second run. 017 owns every policy on these tables
|
|
-- (no later migration adds others), so drop them all first, then the
|
|
-- CREATEs below re-establish the full set.
|
|
DO $$
|
|
DECLARE
|
|
pol RECORD;
|
|
BEGIN
|
|
FOR pol IN
|
|
SELECT policyname, tablename
|
|
FROM pg_policies
|
|
WHERE schemaname = 'public'
|
|
AND tablename = ANY (ARRAY[
|
|
'contacts', 'tags', 'custom_fields', 'contact_notes',
|
|
'conversations', 'whatsapp_config', 'message_templates',
|
|
'pipelines', 'deals', 'broadcasts', 'automations',
|
|
'automation_logs', 'flows', 'flow_runs', 'contact_tags',
|
|
'contact_custom_values', 'messages', 'pipeline_stages',
|
|
'broadcast_recipients', 'automation_steps', 'flow_nodes',
|
|
'flow_run_events', 'message_reactions', 'profiles',
|
|
'accounts', 'account_invitations'
|
|
])
|
|
LOOP
|
|
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', pol.policyname, pol.tablename);
|
|
END LOOP;
|
|
END $$;
|
|
|
|
-- ---- 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();
|