All blocks
CRM & sales
Parties
Companies and people an organization works with: lifecycle stages, owners, who works where with a role and a primary contact, domain and email dedupe, merges and keyset-paged search.
npx better-supabase sql add partiesimport { defineConfig } from "better-supabase/config";
export default defineConfig({
sql: {
modules: {
// Required by parties; sql add installs them too.
"updated-at": {},
tenant: {},
access: {},
// Modes: "managed", "custom". The default is "managed".
parties: { mode: "managed" },
},
},
});npx better-supabase sql add parties writes these files into your declarative schema with the default layout. See SQL modules for the migration step and the layout options.
supabase/schemas/900_better_supabase_01_updated_at.sqlschemaupdated-at100 linessupabase/better-supabase-data/900_better_supabase_01_updated_at.sqldataupdated-at12 linessupabase/schemas/900_better_supabase_04_tenant.sqlschematenant156 linessupabase/better-supabase-data/900_better_supabase_04_tenant.sqldatatenant12 linessupabase/schemas/900_better_supabase_19_access.sqlschemaaccess376 linessupabase/better-supabase-data/900_better_supabase_19_access.sqldataaccess12 linessupabase/schemas/900_better_supabase_93_parties.sqlschema609 linessupabase/better-supabase-data/900_better_supabase_93_parties.sqldata12 lines
supabase/schemas/900_better_supabase_93_parties.sql
-- better-supabase module: parties
-- @bs-module parties@1 managed
-- Companies and people an organization works with: lifecycle stages, owners, who works where with a role and a primary contact, domain and email dedupe, merges and keyset-paged search.
-- Managed by `better-supabase sql add`; re-running it overwrites this file.
-- Change it through `sql.modules` in better-supabase.config.ts and the module's SQL hooks.
create or replace function better_supabase.actor_may_write()
returns boolean
language sql
stable
security invoker
set search_path = ''
as $$
select case
when auth.jwt() is null then true
when coalesce(auth.jwt() -> 'act' ->> 'read_only', '') = 'false' then true
when auth.jwt() -> 'act' ->> 'kind' = 'agent'
then coalesce(auth.jwt() -> 'act' ->> 'read_only', '') <> 'true'
when auth.jwt() -> 'act' ->> 'kind' in ('support', 'impersonation') then false
when auth.jwt() -> 'act' ->> 'session_id' is not null
and auth.jwt() -> 'act' ->> 'kind' is null then false
else true
end
$$;
revoke execute on function better_supabase.actor_may_write() from public;
grant execute on function better_supabase.actor_may_write() to anon, authenticated, service_role;
create or replace function better_supabase.raise_actor_readonly()
returns void
language plpgsql
volatile
set search_path = ''
as $$
begin
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end;
$$;
revoke execute on function better_supabase.raise_actor_readonly() from public;
grant execute on function better_supabase.raise_actor_readonly() to anon, authenticated, service_role;
create schema if not exists better_supabase;
grant usage on schema better_supabase to anon, authenticated, service_role;
-- Companies and people an organization works with, and who works where.
create table if not exists "better_supabase"."companies" (
"id" uuid primary key default gen_random_uuid(),
"tenant_id" uuid not null,
"name" text not null check (length("name") between 1 and 300),
"domain" text check ("domain" = lower("domain") and "domain" ~ '^[a-z0-9.-]+\.[a-z]{2,}$'),
"website" text,
"vat_id" text,
"lifecycle" text not null,
"owner_user_id" uuid,
"address" jsonb check ("address" is null or jsonb_typeof("address") = 'object'),
"merged_into" uuid references "better_supabase"."companies" ("id"),
"archived_at" timestamptz,
"created_by" uuid,
"created_at" timestamptz not null default now(),
"updated_at" timestamptz not null default now()
);
create index if not exists companies_tenant_name_idx on "better_supabase"."companies" ("tenant_id", lower("name"), "id");
create unique index if not exists companies_domain_idx on "better_supabase"."companies" ("tenant_id", "domain")
where "domain" is not null and "archived_at" is null;
create index if not exists companies_owner_idx on "better_supabase"."companies" ("owner_user_id");
create index if not exists companies_merged_into_idx on "better_supabase"."companies" ("merged_into");
alter table "better_supabase"."companies" enable row level security;
revoke all on "better_supabase"."companies" from anon, authenticated;
grant select on "better_supabase"."companies" to authenticated;
grant all on "better_supabase"."companies" to service_role;
drop policy if exists companies_read on "better_supabase"."companies";
create policy companies_read on "better_supabase"."companies" for select to authenticated
using ("tenant_id" in (select better_supabase.tenant_ids_with('parties.read')));
create table if not exists "better_supabase"."people" (
"id" uuid primary key default gen_random_uuid(),
"tenant_id" uuid not null,
"name" text not null check (length("name") between 1 and 300),
"email" text check ("email" = lower("email") and "email" ~ '^[^@[:space:]]+@[^@[:space:]]+$'),
"phone" text,
"title" text,
"lifecycle" text not null,
"owner_user_id" uuid,
"user_id" uuid,
"merged_into" uuid references "better_supabase"."people" ("id"),
"archived_at" timestamptz,
"created_by" uuid,
"created_at" timestamptz not null default now(),
"updated_at" timestamptz not null default now()
);
create index if not exists people_tenant_name_idx on "better_supabase"."people" ("tenant_id", lower("name"), "id");
create unique index if not exists people_email_idx on "better_supabase"."people" ("tenant_id", "email")
where "email" is not null and "archived_at" is null;
create index if not exists people_owner_idx on "better_supabase"."people" ("owner_user_id");
create index if not exists people_user_idx on "better_supabase"."people" ("user_id");
create index if not exists people_merged_into_idx on "better_supabase"."people" ("merged_into");
alter table "better_supabase"."people" enable row level security;
revoke all on "better_supabase"."people" from anon, authenticated;
grant select on "better_supabase"."people" to authenticated;
grant all on "better_supabase"."people" to service_role;
drop policy if exists people_read on "better_supabase"."people";
create policy people_read on "better_supabase"."people" for select to authenticated
using ("tenant_id" in (select better_supabase.tenant_ids_with('parties.read')));
create table if not exists "better_supabase"."company_people" (
"tenant_id" uuid not null,
"company_id" uuid not null references "better_supabase"."companies" ("id") on delete cascade,
"person_id" uuid not null references "better_supabase"."people" ("id") on delete cascade,
"role" text,
"is_primary" boolean not null default false,
"created_at" timestamptz not null default now(),
primary key ("company_id", "person_id")
);
create index if not exists company_people_person_idx on "better_supabase"."company_people" ("person_id");
create unique index if not exists company_people_primary_idx on "better_supabase"."company_people" ("company_id") where "is_primary";
alter table "better_supabase"."company_people" enable row level security;
revoke all on "better_supabase"."company_people" from anon, authenticated;
grant select on "better_supabase"."company_people" to authenticated;
grant all on "better_supabase"."company_people" to service_role;
drop policy if exists company_people_read on "better_supabase"."company_people";
create policy company_people_read on "better_supabase"."company_people" for select to authenticated
using ("tenant_id" in (select better_supabase.tenant_ids_with('parties.read')));
-- Creates a company (company null) or updates the keys attrs names.
-- Returns its row. The owner watches it when notifications is installed.
create or replace function "better_supabase"."save_company"(tenant uuid, company uuid, attrs jsonb)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_id uuid := save_company.company;
v_created boolean := save_company.company is null;
v_row jsonb;
v_owner uuid;
v_previous_owner uuid;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', save_company.tenant, 'parties.write'), false)) then
raise exception 'not allowed to change companies and people' using errcode = '42501', hint = 'PARTY_FORBIDDEN';
end if;
if jsonb_typeof(coalesce(save_company.attrs, '{}')) <> 'object' then
raise exception 'attrs must be an object' using errcode = '22023', hint = 'PARTY_ATTRS_INVALID';
end if;
if save_company.attrs ? 'lifecycle' and not (save_company.attrs ->> 'lifecycle' = any ('{lead,prospect,customer,churned}'::text[])) then
raise exception 'unknown lifecycle stage' using errcode = '22023', hint = 'PARTY_LIFECYCLE_INVALID';
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."before_company_save"(uuid, uuid, jsonb)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::uuid, $3::jsonb)', v_hook::oid::regproc)
using save_company.tenant, save_company.company, save_company.attrs;
end if;
end;
begin
if v_created then
if nullif(trim(save_company.attrs ->> 'name'), '') is null then
raise exception 'a name is required' using errcode = '23502', hint = 'PARTY_NAME_REQUIRED';
end if;
insert into "better_supabase"."companies" ("tenant_id", "name", "domain", "website", "vat_id", "lifecycle", "owner_user_id", "address", "created_by")
values (save_company.tenant, nullif(trim(save_company.attrs ->> 'name'), ''), lower(nullif(trim(save_company.attrs ->> 'domain'), '')), nullif(trim(save_company.attrs ->> 'website'), ''), nullif(trim(save_company.attrs ->> 'vatId'), ''), coalesce(nullif(trim(save_company.attrs ->> 'lifecycle'), ''), ('{lead,prospect,customer,churned}'::text[])[1]), nullif(save_company.attrs ->> 'ownerId', '')::uuid, save_company.attrs -> 'address', auth.uid())
returning "id", "owner_user_id" into v_id, v_owner;
else
select x."owner_user_id" into v_previous_owner from "better_supabase"."companies" x
where x."id" = save_company.company and x."tenant_id" = save_company.tenant;
update "better_supabase"."companies" x set "name" = case when save_company.attrs ? 'name' then coalesce(nullif(trim(save_company.attrs ->> 'name'), ''), x."name") else x."name" end,
"domain" = case when save_company.attrs ? 'domain' then lower(nullif(trim(save_company.attrs ->> 'domain'), '')) else x."domain" end,
"website" = case when save_company.attrs ? 'website' then nullif(trim(save_company.attrs ->> 'website'), '') else x."website" end,
"vat_id" = case when save_company.attrs ? 'vatId' then nullif(trim(save_company.attrs ->> 'vatId'), '') else x."vat_id" end,
"lifecycle" = case when save_company.attrs ? 'lifecycle' then coalesce(nullif(trim(save_company.attrs ->> 'lifecycle'), ''), x."lifecycle") else x."lifecycle" end,
"owner_user_id" = case when save_company.attrs ? 'ownerId' then nullif(save_company.attrs ->> 'ownerId', '')::uuid else x."owner_user_id" end,
"address" = case when save_company.attrs ? 'address' then save_company.attrs -> 'address' else x."address" end,
"updated_at" = now()
where x."id" = save_company.company and x."tenant_id" = save_company.tenant and x."merged_into" is null
returning x."owner_user_id" into v_owner;
if not found then
raise exception 'company not found' using errcode = 'P0002', hint = 'PARTY_NOT_FOUND';
end if;
end if;
exception when unique_violation then
raise exception 'another company has this domain' using errcode = '23505', hint = 'PARTY_DOMAIN_TAKEN';
end;
select to_jsonb(x) into v_row from "better_supabase"."companies" x where x."id" = v_id;
if v_created then
null;
else
null;
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."after_company_save"(uuid, boolean)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::boolean)', v_hook::oid::regproc)
using v_id, v_created;
end if;
end;
return v_row;
end;
$$;
revoke execute on function "better_supabase"."save_company"(uuid, uuid, jsonb) from public, anon;
grant execute on function "better_supabase"."save_company"(uuid, uuid, jsonb) to authenticated, service_role;
-- Creates a person (person null) or updates the keys attrs names.
-- Returns its row. The owner watches it when notifications is installed.
create or replace function "better_supabase"."save_person"(tenant uuid, person uuid, attrs jsonb)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_id uuid := save_person.person;
v_created boolean := save_person.person is null;
v_row jsonb;
v_owner uuid;
v_previous_owner uuid;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', save_person.tenant, 'parties.write'), false)) then
raise exception 'not allowed to change companies and people' using errcode = '42501', hint = 'PARTY_FORBIDDEN';
end if;
if jsonb_typeof(coalesce(save_person.attrs, '{}')) <> 'object' then
raise exception 'attrs must be an object' using errcode = '22023', hint = 'PARTY_ATTRS_INVALID';
end if;
if save_person.attrs ? 'lifecycle' and not (save_person.attrs ->> 'lifecycle' = any ('{lead,mql,sql,opportunity,customer}'::text[])) then
raise exception 'unknown lifecycle stage' using errcode = '22023', hint = 'PARTY_LIFECYCLE_INVALID';
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."before_person_save"(uuid, uuid, jsonb)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::uuid, $3::jsonb)', v_hook::oid::regproc)
using save_person.tenant, save_person.person, save_person.attrs;
end if;
end;
begin
if v_created then
if nullif(trim(save_person.attrs ->> 'name'), '') is null then
raise exception 'a name is required' using errcode = '23502', hint = 'PARTY_NAME_REQUIRED';
end if;
insert into "better_supabase"."people" ("tenant_id", "name", "email", "phone", "title", "lifecycle", "owner_user_id", "user_id", "created_by")
values (save_person.tenant, nullif(trim(save_person.attrs ->> 'name'), ''), lower(nullif(trim(save_person.attrs ->> 'email'), '')), nullif(trim(save_person.attrs ->> 'phone'), ''), nullif(trim(save_person.attrs ->> 'title'), ''), coalesce(nullif(trim(save_person.attrs ->> 'lifecycle'), ''), ('{lead,mql,sql,opportunity,customer}'::text[])[1]), nullif(save_person.attrs ->> 'ownerId', '')::uuid, nullif(save_person.attrs ->> 'userId', '')::uuid, auth.uid())
returning "id", "owner_user_id" into v_id, v_owner;
else
select x."owner_user_id" into v_previous_owner from "better_supabase"."people" x
where x."id" = save_person.person and x."tenant_id" = save_person.tenant;
update "better_supabase"."people" x set "name" = case when save_person.attrs ? 'name' then coalesce(nullif(trim(save_person.attrs ->> 'name'), ''), x."name") else x."name" end,
"email" = case when save_person.attrs ? 'email' then lower(nullif(trim(save_person.attrs ->> 'email'), '')) else x."email" end,
"phone" = case when save_person.attrs ? 'phone' then nullif(trim(save_person.attrs ->> 'phone'), '') else x."phone" end,
"title" = case when save_person.attrs ? 'title' then nullif(trim(save_person.attrs ->> 'title'), '') else x."title" end,
"lifecycle" = case when save_person.attrs ? 'lifecycle' then coalesce(nullif(trim(save_person.attrs ->> 'lifecycle'), ''), x."lifecycle") else x."lifecycle" end,
"owner_user_id" = case when save_person.attrs ? 'ownerId' then nullif(save_person.attrs ->> 'ownerId', '')::uuid else x."owner_user_id" end,
"user_id" = case when save_person.attrs ? 'userId' then nullif(save_person.attrs ->> 'userId', '')::uuid else x."user_id" end,
"updated_at" = now()
where x."id" = save_person.person and x."tenant_id" = save_person.tenant and x."merged_into" is null
returning x."owner_user_id" into v_owner;
if not found then
raise exception 'person not found' using errcode = 'P0002', hint = 'PARTY_NOT_FOUND';
end if;
end if;
exception when unique_violation then
raise exception 'another person has this email' using errcode = '23505', hint = 'PARTY_EMAIL_TAKEN';
end;
select to_jsonb(x) into v_row from "better_supabase"."people" x where x."id" = v_id;
if v_created then
null;
else
null;
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."after_person_save"(uuid, boolean)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::boolean)', v_hook::oid::regproc)
using v_id, v_created;
end if;
end;
return v_row;
end;
$$;
revoke execute on function "better_supabase"."save_person"(uuid, uuid, jsonb) from public, anon;
grant execute on function "better_supabase"."save_person"(uuid, uuid, jsonb) to authenticated, service_role;
-- Archives a company (archived true) or restores it.
create or replace function "better_supabase"."archive_company"(id uuid, archived boolean)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant uuid;
v_name text;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
select x."tenant_id", x."name" into v_tenant, v_name from "better_supabase"."companies" x where x."id" = archive_company.id;
if v_tenant is null then
return false;
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'parties.write'), false)) then
raise exception 'not allowed to change companies and people' using errcode = '42501', hint = 'PARTY_FORBIDDEN';
end if;
begin
update "better_supabase"."companies" x set "archived_at" = case when archive_company.archived then coalesce(x."archived_at", now()) end, "updated_at" = now()
where x."id" = archive_company.id;
exception when unique_violation then
raise exception 'another company has this domain' using errcode = '23505', hint = 'PARTY_DOMAIN_TAKEN';
end;
if archive_company.archived then
null;
end if;
return true;
end;
$$;
revoke execute on function "better_supabase"."archive_company"(uuid, boolean) from public, anon;
grant execute on function "better_supabase"."archive_company"(uuid, boolean) to authenticated, service_role;
-- Archives a person (archived true) or restores it.
create or replace function "better_supabase"."archive_person"(id uuid, archived boolean)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant uuid;
v_name text;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
select x."tenant_id", x."name" into v_tenant, v_name from "better_supabase"."people" x where x."id" = archive_person.id;
if v_tenant is null then
return false;
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'parties.write'), false)) then
raise exception 'not allowed to change companies and people' using errcode = '42501', hint = 'PARTY_FORBIDDEN';
end if;
begin
update "better_supabase"."people" x set "archived_at" = case when archive_person.archived then coalesce(x."archived_at", now()) end, "updated_at" = now()
where x."id" = archive_person.id;
exception when unique_violation then
raise exception 'another person has this email' using errcode = '23505', hint = 'PARTY_EMAIL_TAKEN';
end;
if archive_person.archived then
null;
end if;
return true;
end;
$$;
revoke execute on function "better_supabase"."archive_person"(uuid, boolean) from public, anon;
grant execute on function "better_supabase"."archive_person"(uuid, boolean) to authenticated, service_role;
-- Links a person to a company with a role, or changes the link. A primary
-- link replaces the company's previous primary contact.
create or replace function "better_supabase"."set_company_person"(company uuid, person uuid, role text, is_primary boolean)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant uuid;
v_role text := nullif(trim(set_company_person.role), '');
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
select x."tenant_id" into v_tenant from "better_supabase"."companies" x
join "better_supabase"."people" p on p."id" = set_company_person.person and p."tenant_id" = x."tenant_id"
where x."id" = set_company_person.company;
if v_tenant is null then
raise exception 'company or person not found in one organization' using errcode = 'P0002', hint = 'PARTY_NOT_FOUND';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'parties.write'), false)) then
raise exception 'not allowed to change companies and people' using errcode = '42501', hint = 'PARTY_FORBIDDEN';
end if;
if coalesce(set_company_person.is_primary, false) then
update "better_supabase"."company_people" l set "is_primary" = false
where l."company_id" = set_company_person.company and l."person_id" <> set_company_person.person and l."is_primary";
end if;
insert into "better_supabase"."company_people" ("tenant_id", "company_id", "person_id", "role", "is_primary")
select x."tenant_id", set_company_person.company, set_company_person.person, v_role, coalesce(set_company_person.is_primary, false)
from "better_supabase"."companies" x where x."id" = set_company_person.company
on conflict ("company_id", "person_id") do update set "role" = excluded."role", "is_primary" = excluded."is_primary";
null;
return true;
end;
$$;
revoke execute on function "better_supabase"."set_company_person"(uuid, uuid, text, boolean) from public, anon;
grant execute on function "better_supabase"."set_company_person"(uuid, uuid, text, boolean) to authenticated, service_role;
create or replace function "better_supabase"."remove_company_person"(company uuid, person uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant uuid;
v_role text;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
select l."tenant_id", l."role" into v_tenant, v_role from "better_supabase"."company_people" l
where l."company_id" = remove_company_person.company and l."person_id" = remove_company_person.person;
if v_tenant is null then
return false;
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'parties.write'), false)) then
raise exception 'not allowed to change companies and people' using errcode = '42501', hint = 'PARTY_FORBIDDEN';
end if;
delete from "better_supabase"."company_people" l where l."company_id" = remove_company_person.company and l."person_id" = remove_company_person.person;
null;
return true;
end;
$$;
revoke execute on function "better_supabase"."remove_company_person"(uuid, uuid) from public, anon;
grant execute on function "better_supabase"."remove_company_person"(uuid, uuid) to authenticated, service_role;
-- Merges drop into keep: keep's empty fields take drop's values, drop's
-- links move to keep, and drop is archived with merged_into set. Apps move
-- their own references on the company.merged event.
create or replace function "better_supabase"."merge_companies"(keep uuid, drop uuid)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_keep "better_supabase"."companies";
v_drop "better_supabase"."companies";
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
select * into v_keep from "better_supabase"."companies" x where x."id" = merge_companies.keep for update;
select * into v_drop from "better_supabase"."companies" x where x."id" = merge_companies.drop for update;
if v_keep."id" is null or v_drop."id" is null then
raise exception 'company not found' using errcode = 'P0002', hint = 'PARTY_NOT_FOUND';
end if;
if v_keep."tenant_id" <> v_drop."tenant_id" or v_keep."id" = v_drop."id" or v_drop."merged_into" is not null then
raise exception 'merge two different, unmerged records of one organization' using errcode = '22023', hint = 'PARTY_MERGE_INVALID';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_keep."tenant_id", 'parties.manage'), false)) then
raise exception 'not allowed to change companies and people' using errcode = '42501', hint = 'PARTY_FORBIDDEN';
end if;
update "better_supabase"."companies" x set "domain" = null, "merged_into" = merge_companies.keep,
"archived_at" = coalesce(x."archived_at", now()), "updated_at" = now()
where x."id" = merge_companies.drop;
update "better_supabase"."companies" x set "domain" = coalesce(x."domain", v_drop."domain"), "website" = coalesce(x."website", v_drop."website"), "vat_id" = coalesce(x."vat_id", v_drop."vat_id"), "owner_user_id" = coalesce(x."owner_user_id", v_drop."owner_user_id"), "updated_at" = now()
where x."id" = merge_companies.keep;
insert into "better_supabase"."company_people" ("tenant_id", "company_id", "person_id", "role", "is_primary")
select l."tenant_id", merge_companies.keep, l."person_id", l."role", false
from "better_supabase"."company_people" l where l."company_id" = merge_companies.drop
on conflict do nothing;
delete from "better_supabase"."company_people" l where l."company_id" = merge_companies.drop;
null;
return (select to_jsonb(x) from "better_supabase"."companies" x where x."id" = merge_companies.keep);
end;
$$;
revoke execute on function "better_supabase"."merge_companies"(uuid, uuid) from public, anon;
grant execute on function "better_supabase"."merge_companies"(uuid, uuid) to authenticated, service_role;
-- Merges drop into keep: keep's empty fields take drop's values, drop's
-- links move to keep, and drop is archived with merged_into set. Apps move
-- their own references on the person.merged event.
create or replace function "better_supabase"."merge_people"(keep uuid, drop uuid)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_keep "better_supabase"."people";
v_drop "better_supabase"."people";
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
select * into v_keep from "better_supabase"."people" x where x."id" = merge_people.keep for update;
select * into v_drop from "better_supabase"."people" x where x."id" = merge_people.drop for update;
if v_keep."id" is null or v_drop."id" is null then
raise exception 'person not found' using errcode = 'P0002', hint = 'PARTY_NOT_FOUND';
end if;
if v_keep."tenant_id" <> v_drop."tenant_id" or v_keep."id" = v_drop."id" or v_drop."merged_into" is not null then
raise exception 'merge two different, unmerged records of one organization' using errcode = '22023', hint = 'PARTY_MERGE_INVALID';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_keep."tenant_id", 'parties.manage'), false)) then
raise exception 'not allowed to change companies and people' using errcode = '42501', hint = 'PARTY_FORBIDDEN';
end if;
update "better_supabase"."people" x set "email" = null, "merged_into" = merge_people.keep,
"archived_at" = coalesce(x."archived_at", now()), "updated_at" = now()
where x."id" = merge_people.drop;
update "better_supabase"."people" x set "email" = coalesce(x."email", v_drop."email"), "phone" = coalesce(x."phone", v_drop."phone"), "title" = coalesce(x."title", v_drop."title"), "owner_user_id" = coalesce(x."owner_user_id", v_drop."owner_user_id"), "user_id" = coalesce(x."user_id", v_drop."user_id"), "updated_at" = now()
where x."id" = merge_people.keep;
insert into "better_supabase"."company_people" ("tenant_id", "company_id", "person_id", "role", "is_primary")
select l."tenant_id", l."company_id", merge_people.keep, l."role", false
from "better_supabase"."company_people" l where l."person_id" = merge_people.drop
on conflict do nothing;
delete from "better_supabase"."company_people" l where l."person_id" = merge_people.drop;
null;
return (select to_jsonb(x) from "better_supabase"."people" x where x."id" = merge_people.keep);
end;
$$;
revoke execute on function "better_supabase"."merge_people"(uuid, uuid) from public, anon;
grant execute on function "better_supabase"."merge_people"(uuid, uuid) to authenticated, service_role;
-- A page of the organization's active companies, by name. search matches
-- the name and domain; after_name and after_id continue after the
-- last row of the previous page.
create or replace function "better_supabase"."list_companies"(tenant uuid, search text, lifecycle text, owner uuid, after_name text, after_id uuid, page_size integer)
returns jsonb
language plpgsql
stable
security invoker
set search_path = ''
as $$
declare
v_like text := '%' || replace(replace(replace(coalesce(list_companies.search, ''), '\', '\\'), '%', '\%'), '_', '\_') || '%';
begin
return coalesce((
select jsonb_agg(to_jsonb(p) order by lower(p."name"), p."id")
from (
select x.* from "better_supabase"."companies" x
where x."tenant_id" = list_companies.tenant
and x."archived_at" is null
and (list_companies.search is null or x."name" ilike v_like or x."domain" ilike v_like)
and (list_companies.lifecycle is null or x."lifecycle" = list_companies.lifecycle)
and (list_companies.owner is null or x."owner_user_id" = list_companies.owner)
and (list_companies.after_name is null or (lower(x."name"), x."id") > (lower(list_companies.after_name), list_companies.after_id))
order by lower(x."name"), x."id"
limit least(greatest(coalesce(list_companies.page_size, 50), 1), 200)
) p
), '[]'::jsonb);
end;
$$;
revoke execute on function "better_supabase"."list_companies"(uuid, text, text, uuid, text, uuid, integer) from public, anon;
grant execute on function "better_supabase"."list_companies"(uuid, text, text, uuid, text, uuid, integer) to authenticated, service_role;
-- A page of the organization's active people, by name. search matches
-- the name and email; after_name and after_id continue after the
-- last row of the previous page.
create or replace function "better_supabase"."list_people"(tenant uuid, search text, lifecycle text, owner uuid, after_name text, after_id uuid, page_size integer)
returns jsonb
language plpgsql
stable
security invoker
set search_path = ''
as $$
declare
v_like text := '%' || replace(replace(replace(coalesce(list_people.search, ''), '\', '\\'), '%', '\%'), '_', '\_') || '%';
begin
return coalesce((
select jsonb_agg(to_jsonb(p) order by lower(p."name"), p."id")
from (
select x.* from "better_supabase"."people" x
where x."tenant_id" = list_people.tenant
and x."archived_at" is null
and (list_people.search is null or x."name" ilike v_like or x."email" ilike v_like)
and (list_people.lifecycle is null or x."lifecycle" = list_people.lifecycle)
and (list_people.owner is null or x."owner_user_id" = list_people.owner)
and (list_people.after_name is null or (lower(x."name"), x."id") > (lower(list_people.after_name), list_people.after_id))
order by lower(x."name"), x."id"
limit least(greatest(coalesce(list_people.page_size, 50), 1), 200)
) p
), '[]'::jsonb);
end;
$$;
revoke execute on function "better_supabase"."list_people"(uuid, text, text, uuid, text, uuid, integer) from public, anon;
grant execute on function "better_supabase"."list_people"(uuid, text, text, uuid, text, uuid, integer) to authenticated, service_role;
-- The people of a company with their role, primary contact first.
create or replace function "better_supabase"."company_people_of"(company uuid)
returns jsonb
language sql
stable
security invoker
set search_path = ''
as $$
select coalesce(jsonb_agg(to_jsonb(p) || jsonb_build_object('role', l."role", 'is_primary', l."is_primary")
order by l."is_primary" desc, lower(p."name"), p."id"), '[]'::jsonb)
from "better_supabase"."company_people" l join "better_supabase"."people" p on p."id" = l."person_id"
where l."company_id" = company_people_of.company and p."archived_at" is null
$$;
revoke execute on function "better_supabase"."company_people_of"(uuid) from public, anon;
grant execute on function "better_supabase"."company_people_of"(uuid) to authenticated, service_role;
create schema if not exists better_supabase;
create table if not exists better_supabase.modules (
name text primary key,
version integer not null,
mode text not null,
installed_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table better_supabase.modules enable row level security;
revoke all on better_supabase.modules from anon, authenticated;
grant select on better_supabase.modules to service_role;
supabase/better-supabase-data/900_better_supabase_93_parties.sql
-- better-supabase module: parties
-- @bs-module-data parties
-- Rows and settings a schema diff doesn't capture. Run `better-supabase sql data`
-- after the schema migration to put them in a migration.
-- Managed by `better-supabase sql add`; re-running it overwrites this file.
-- Change it through `sql.modules` in better-supabase.config.ts and the module's SQL hooks.
insert into better_supabase.modules (name, version, mode)
values ('parties', 1, 'managed')
on conflict (name) do update
set version = excluded.version, mode = excluded.mode, updated_at = now();
This block has no React entry yet. Its TypeScript side is in better-supabase/blocks/parties, which runs on the server.
With the outbox installed, the module writes these events in the same transaction as the change.
- company.archived
- company.created
- company.merged
- company.person_linked
- company.person_unlinked
- company.updated
- person.archived
- person.created
- person.merged
- person.updated