wacrm/supabase/migrations/017_account_sharing.sql
Arnas Donauskas 79c60eeaea
fix(migrations): make 017 RLS rewrite re-runnable
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>
2026-06-22 09:48:47 +03:00

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();