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