mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
The pre-017 signup trigger (001) inserted the profiles row inside an `EXCEPTION WHEN OTHERS ... RAISE WARNING; RETURN NEW` block, so a signup could leave an auth.users row with no matching profiles row. The 017 account-sharing backfill keyed accounts off `profiles`, so such an orphaned user got no account and was never linked. If that user owned any domain rows (pre-017 RLS only required `auth.uid() = user_id`, not a profile), their account_id stayed NULL and the subsequent `ALTER TABLE ... SET NOT NULL` aborted the whole migration — exactly the upgrade failure reported in #274. Add a step (0) to the backfill DO block that creates the missing profile for every auth.users row lacking one, before accounts are created. This keys the backfill off auth.users instead of profiles, so every authenticated user is migrated and no domain row can be left without an account. Idempotent and safe to re-run, matching the rest of 017. Fixes #274 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
661 lines
34 KiB
PL/PgSQL
661 lines
34 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.
|
|
-- ============================================================
|
|
|
|
-- ---- 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();
|