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