All blocks
Foundation
Teams
Tenant teams and their members. Inbox assignment uses these rows instead of its own team tables.
npx better-supabase sql add teamsimport { defineConfig } from "better-supabase/config";
export default defineConfig({
sql: {
modules: {
// Required by teams; sql add installs them too.
"updated-at": {},
tenant: {},
access: {},
// Modes: "managed", "adopt", "custom". The default is "managed".
teams: { mode: "managed" },
},
},
});npx better-supabase sql add teams 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.sqlschemaaccess375 linessupabase/better-supabase-data/900_better_supabase_19_access.sqldataaccess12 linessupabase/schemas/900_better_supabase_58_teams.sqlschema326 linessupabase/better-supabase-data/900_better_supabase_58_teams.sqldata12 lines
supabase/schemas/900_better_supabase_58_teams.sql
-- better-supabase module: teams
-- @bs-module teams@1 managed
-- Tenant teams and their members. Inbox assignment uses these rows instead of its own team tables.
-- 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;
-- Teams belong to a tenant. Inbox assignment uses these rows when inbox is installed.
create table if not exists "better_supabase"."teams" (
"id" uuid primary key default gen_random_uuid(),
"tenant_id" uuid not null,
"name" text not null check (length("name") between 1 and 200),
"slug" text not null check ("slug" ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
"description" text,
"status" text not null default 'active' check ("status" in ('active', 'archived')),
"created_by" uuid,
"created_at" timestamptz not null default now(),
"updated_at" timestamptz not null default now(),
unique ("tenant_id", "slug")
);
alter table "better_supabase"."teams" enable row level security;
revoke all on "better_supabase"."teams" from anon, authenticated;
grant select on "better_supabase"."teams" to authenticated;
grant all on "better_supabase"."teams" to service_role;
drop policy if exists teams_read on "better_supabase"."teams";
create policy teams_read on "better_supabase"."teams" for select to authenticated
using ("tenant_id" in (select better_supabase.tenant_ids_with('teams.read')));
create table if not exists "better_supabase"."team_members" (
"team_id" uuid not null references "better_supabase"."teams" ("id") on delete cascade,
"user_id" uuid not null references auth.users (id) on delete cascade,
"tenant_id" uuid not null,
"created_at" timestamptz not null default now(),
primary key ("team_id", "user_id")
);
create index if not exists team_members_user_idx on "better_supabase"."team_members" ("user_id");
create index if not exists team_members_membership_idx on "better_supabase"."team_members" ("tenant_id", "user_id");
alter table "better_supabase"."team_members" enable row level security;
revoke all on "better_supabase"."team_members" from anon, authenticated;
grant select on "better_supabase"."team_members" to authenticated;
grant all on "better_supabase"."team_members" to service_role;
drop policy if exists team_members_read on "better_supabase"."team_members";
create policy team_members_read on "better_supabase"."team_members" for select to authenticated
using ("tenant_id" in (select better_supabase.tenant_ids_with('teams.read')));
alter table "better_supabase"."team_members" drop constraint if exists team_members_membership_fkey;
alter table "better_supabase"."team_members"
add constraint team_members_membership_fkey
foreign key ("tenant_id", "user_id")
references "better_supabase"."memberships" ("organization_id", "user_id");
create or replace function "better_supabase"."team_ids_of"(member uuid)
returns setof uuid
language sql
stable
security definer
set search_path = ''
as $$
select "team_id" from "better_supabase"."team_members" where "user_id" = team_ids_of.member
$$;
revoke execute on function "better_supabase"."team_ids_of"(uuid) from public, anon;
grant execute on function "better_supabase"."team_ids_of"(uuid) to authenticated, service_role;
create or replace function "better_supabase"."create_team"(tenant uuid, name text, description text)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_id uuid;
v_slug text := trim(both '-' from regexp_replace(lower(create_team.name), '[^a-z0-9]+', '-', 'g'));
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', create_team.tenant, 'teams.manage'), false)) then
raise exception 'not allowed to manage teams' using errcode = '42501', hint = 'TEAM_FORBIDDEN';
end if;
if v_slug is null or v_slug = '' then
raise exception 'team name needs a slug' using errcode = '22023', hint = 'TEAM_SLUG_INVALID';
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."before_team_create"(uuid, text)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::text)', v_hook::oid::regproc)
using create_team.tenant, v_slug;
end if;
end;
insert into "better_supabase"."teams" ("tenant_id", "name", "slug", "description", "created_by")
values (create_team.tenant, create_team.name, v_slug, create_team.description, auth.uid())
returning "id" into v_id;
null;
declare
v_hook regprocedure := to_regprocedure('"public"."after_team_create"(uuid, uuid)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::uuid)', v_hook::oid::regproc)
using v_id, create_team.tenant;
end if;
end;
return jsonb_build_object('id', v_id, 'slug', v_slug);
end;
$$;
revoke execute on function "better_supabase"."create_team"(uuid, text, text) from public, anon;
grant execute on function "better_supabase"."create_team"(uuid, text, text) to authenticated, service_role;
create or replace function "better_supabase"."update_team"(team uuid, name text, description text)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant 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;
select "tenant_id" into v_tenant from "better_supabase"."teams" where "id" = update_team.team;
if not found then
raise exception 'team not found' using errcode = 'P0002', hint = 'TEAM_NOT_FOUND';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'teams.manage'), false)) then
raise exception 'not allowed to manage teams' using errcode = '42501', hint = 'TEAM_FORBIDDEN';
end if;
update "better_supabase"."teams" as t
set "name" = coalesce(update_team.name, t."name"),
"description" = coalesce(update_team.description, t."description"),
"updated_at" = now()
where t."id" = update_team.team;
null;
return jsonb_build_object('id', update_team.team);
end;
$$;
revoke execute on function "better_supabase"."update_team"(uuid, text, text) from public, anon;
grant execute on function "better_supabase"."update_team"(uuid, text, text) to authenticated, service_role;
create or replace function "better_supabase"."archive_team"(team uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant 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;
select "tenant_id" into v_tenant from "better_supabase"."teams" where "id" = archive_team.team;
if not found then
raise exception 'team not found' using errcode = 'P0002', hint = 'TEAM_NOT_FOUND';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'teams.manage'), false)) then
raise exception 'not allowed to manage teams' using errcode = '42501', hint = 'TEAM_FORBIDDEN';
end if;
update "better_supabase"."teams" set "status" = 'archived', "updated_at" = now() where "id" = archive_team.team;
null;
return true;
end;
$$;
revoke execute on function "better_supabase"."archive_team"(uuid) from public, anon;
grant execute on function "better_supabase"."archive_team"(uuid) to authenticated, service_role;
create or replace function "better_supabase"."delete_team"(team uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant 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;
select "tenant_id" into v_tenant from "better_supabase"."teams" where "id" = delete_team.team;
if not found then
raise exception 'team not found' using errcode = 'P0002', hint = 'TEAM_NOT_FOUND';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'teams.manage'), false)) then
raise exception 'not allowed to manage teams' using errcode = '42501', hint = 'TEAM_FORBIDDEN';
end if;
delete from "better_supabase"."teams" where "id" = delete_team.team;
null;
return true;
end;
$$;
revoke execute on function "better_supabase"."delete_team"(uuid) from public, anon;
grant execute on function "better_supabase"."delete_team"(uuid) to authenticated, service_role;
create or replace function "better_supabase"."add_team_member"(team uuid, member uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant 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;
select "tenant_id" into v_tenant from "better_supabase"."teams" where "id" = add_team_member.team;
if not found then
raise exception 'team not found' using errcode = 'P0002', hint = 'TEAM_NOT_FOUND';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'teams.manage'), false)) then
raise exception 'not allowed to manage teams' using errcode = '42501', hint = 'TEAM_FORBIDDEN';
end if;
if not exists (
select 1 from "better_supabase"."memberships" m
where m."organization_id" = v_tenant
and m."user_id" = add_team_member.member
) then
raise exception 'user is not a member of the tenant' using errcode = '22023', hint = 'TEAM_MEMBER_INVALID';
end if;
insert into "better_supabase"."team_members" ("team_id", "user_id", "tenant_id")
values (add_team_member.team, add_team_member.member, v_tenant)
on conflict do nothing;
null;
return true;
end;
$$;
revoke execute on function "better_supabase"."add_team_member"(uuid, uuid) from public, anon;
grant execute on function "better_supabase"."add_team_member"(uuid, uuid) to authenticated, service_role;
create or replace function "better_supabase"."remove_team_member"(team uuid, member uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
declare
v_tenant 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;
select "tenant_id" into v_tenant from "better_supabase"."teams" where "id" = remove_team_member.team;
if not found then
raise exception 'team not found' using errcode = 'P0002', hint = 'TEAM_NOT_FOUND';
end if;
if not (auth.role() = 'service_role' or coalesce(better_supabase.can('tenant', v_tenant, 'teams.manage'), false)) then
raise exception 'not allowed to manage teams' using errcode = '42501', hint = 'TEAM_FORBIDDEN';
end if;
delete from "better_supabase"."team_members"
where "team_id" = remove_team_member.team and "user_id" = remove_team_member.member;
null;
return found;
end;
$$;
revoke execute on function "better_supabase"."remove_team_member"(uuid, uuid) from public, anon;
grant execute on function "better_supabase"."remove_team_member"(uuid, uuid) to authenticated, service_role;
create or replace function "better_supabase"."list_teams"(tenant uuid)
returns jsonb
language sql
stable
security definer
set search_path = ''
as $$
select coalesce(jsonb_agg(jsonb_build_object(
'id', t."id",
'name', t."name",
'slug', t."slug",
'status', t."status"
)), '[]'::jsonb)
from "better_supabase"."teams" t
where t."tenant_id" = list_teams.tenant
and (
auth.role() = 'service_role'
or coalesce(better_supabase.can('tenant', list_teams.tenant, 'teams.read'), false)
)
$$;
revoke execute on function "better_supabase"."list_teams"(uuid) from public, anon;
grant execute on function "better_supabase"."list_teams"(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_58_teams.sql
-- better-supabase module: teams
-- @bs-module-data teams
-- 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 ('teams', 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/teams, which runs on the server.
With the outbox installed, the module writes these events in the same transaction as the change.
- team.archived
- team.created
- team.deleted
- team.updated
- team_member.added
- team_member.removed