Files
myeasycms-v2/supabase/migrations/20221215192558_schema.sql
giancarlo af908ae685 Refactor team accounts feature and improve form validation
Updated several components within the team accounts feature to use more specific schema definitions and provide clearer form validation. Several schema files were renamed to better reflect their usage and additional properties were included. The commit also introduces handling for a new accountId property, which provides more accurate user account tracking. Autocomplete was turned off on deletion actions for better security. Changes related to account ownership transfer were also made, including transaction logging and error handling improvements.
2024-03-29 17:39:35 +08:00

1555 lines
46 KiB
PL/PgSQL

/*
* -------------------------------------------------------
* Supabase SaaS Starter Kit Schema
* This is the schema for the Supabase SaaS Starter Kit.
* It includes the schema for accounts, account roles, role permissions, memberships, invitations, subscriptions, and more.
* -------------------------------------------------------
*/
/*
* -------------------------------------------------------
* Section: Revoke default privileges from public schema
* We will revoke all default privileges from public schema on functions to prevent public access to them
* -------------------------------------------------------
*/
create extension if not exists "unaccent";
-- Create a private Makerkit schema
create schema if not exists kit;
grant USAGE on schema kit to authenticated,
authenticated;
-- We remove all default privileges from public schema on functions to prevent public access to them
alter default privileges
revoke
execute on functions
from
public;
revoke all on schema public
from
public;
revoke all PRIVILEGES on database "postgres"
from
"anon";
revoke all PRIVILEGES on schema "public"
from
"anon";
revoke all PRIVILEGES on schema "storage"
from
"anon";
revoke all PRIVILEGES on all SEQUENCES in schema "public"
from
"anon";
revoke all PRIVILEGES on all SEQUENCES in schema "storage"
from
"anon";
revoke all PRIVILEGES on all FUNCTIONS in schema "public"
from
"anon";
revoke all PRIVILEGES on all FUNCTIONS in schema "storage"
from
"anon";
revoke all PRIVILEGES on all TABLES in schema "public"
from
"anon";
revoke all PRIVILEGES on all TABLES in schema "storage"
from
"anon";
-- We remove all default privileges from public schema on functions to prevent public access to them by default
alter default privileges in schema public
revoke
execute on functions
from
anon,
authenticated;
-- we allow the authenticated role to execute functions in the public schema
grant usage on schema public to authenticated;
-- we allow the service_role role to execute functions in the public schema
grant usage on schema public to service_role;
/*
* -------------------------------------------------------
* Section: Enums
* We create the enums for the schema
* -------------------------------------------------------
*/
/*
* Permissions
- We create the permissions for the Supabase MakerKit. These permissions are used to manage the permissions for the roles
- The permissions are 'roles.manage', 'billing.manage', 'settings.manage', 'members.manage', and 'invites.manage'.
- You can add more permissions as needed.
*/
create type public.app_permissions as enum(
'roles.manage',
'billing.manage',
'settings.manage',
'members.manage',
'invites.manage'
);
/*
* Subscription Status
- We create the subscription status for the Supabase MakerKit. These statuses are used to manage the status of the subscriptions
- The statuses are 'active', 'trialing', 'past_due', 'canceled', 'unpaid', 'incomplete', 'incomplete_expired', and 'paused'.
- You can add more statuses as needed.
*/
create type public.subscription_status as ENUM(
'active',
'trialing',
'past_due',
'canceled',
'unpaid',
'incomplete',
'incomplete_expired',
'paused'
);
/*
* Billing Provider
- We create the billing provider for the Supabase MakerKit. These providers are used to manage the billing provider for the accounts and organizations
- The providers are 'stripe', 'lemon-squeezy', and 'paddle'.
- You can add more providers as needed.
*/
create type public.billing_provider as ENUM('stripe', 'lemon-squeezy', 'paddle');
/*
* -------------------------------------------------------
* Section: App Configuration
* We create the configuration for the Supabase MakerKit to enable or disable features
* -------------------------------------------------------
*/
create table if not exists
public.config (
enable_organization_accounts boolean default true not null,
enable_account_billing boolean default true not null,
enable_organization_billing boolean default true not null,
billing_provider public.billing_provider default 'stripe' not null
);
comment on table public.config is 'Configuration for the Supabase MakerKit.';
comment on column public.config.enable_account_billing is 'Enable billing for individual accounts';
comment on column public.config.enable_organization_accounts is 'Enable organization accounts';
comment on column public.config.enable_organization_billing is 'Enable billing for organizations';
comment on column public.config.billing_provider is 'The billing provider to use for accounts and organizations';
alter table public.config enable row level security;
-- create config row
insert into
public.config (
enable_organization_accounts,
enable_account_billing,
enable_organization_billing
)
values
(true, true, true);
-- Open up access to config table for authenticated users and service_role
grant
select
on public.config to authenticated,
service_role;
-- RLS on the config table
-- Authenticated users can read the config
create policy "public config can be read by authenticated users" on public.config for
select
to authenticated using (true);
create
or replace function public.get_config () returns json as $$
declare
result record;
begin
select
*
from
public.config
limit 1 into result;
return row_to_json(result);
end;
$$ language plpgsql;
-- Automatically set timestamps on tables when a row is inserted or updated
create
or replace function public.trigger_set_timestamps () returns trigger as $$
begin
if TG_OP = 'INSERT' then
new.created_at = now();
new.updated_at = now();
else
new.updated_at = now();
new.created_at = old.created_at;
end if;
return NEW;
end
$$ language plpgsql;
-- Automatically set user tracking on tables when a row is inserted or updated
create
or replace function public.trigger_set_user_tracking () returns trigger as $$
begin
if TG_OP = 'INSERT' then
new.created_by = auth.uid();
new.updated_by = auth.uid();
else
new.updated_by = auth.uid();
new.created_by = old.created_by;
end if;
return NEW;
end
$$ language plpgsql;
grant
execute on function public.get_config () to authenticated,
service_role;
create
or replace function public.is_set (field_name text) returns boolean as $$
declare
result boolean;
begin
execute format('select %I from public.config limit 1', field_name) into result;
return result;
end;
$$ language plpgsql;
grant
execute on function public.is_set (text) to authenticated;
/*
* -------------------------------------------------------
* Section: Accounts
* We create the schema for the accounts. Accounts are the top level entity in the Supabase MakerKit. They can be organizations or personal accounts.
* -------------------------------------------------------
*/
-- Accounts table
create table if not exists
public.accounts (
id uuid unique not null default extensions.uuid_generate_v4 (),
primary_owner_user_id uuid references auth.users on delete cascade not null default auth.uid (), -- Auth ID in Supabase Auth
name varchar(255) not null,
slug text unique,
email varchar(320) unique,
is_personal_account boolean default false not null,
updated_at timestamp with time zone,
created_at timestamp with time zone,
created_by uuid references auth.users,
updated_by uuid references auth.users,
picture_url varchar(1000),
primary key (id)
);
comment on table public.accounts is 'Accounts are the top level entity in the Supabase MakerKit. They can be organizations or personal accounts.';
comment on column public.accounts.is_personal_account is 'Whether the account is a personal account or not';
comment on column public.accounts.name is 'The name of the account';
comment on column public.accounts.slug is 'The slug of the account';
comment on column public.accounts.primary_owner_user_id is 'The primary owner of the account';
comment on column public.accounts.email is 'The email of the account. For organizations, this is the email of the organization (if any)';
-- Enable RLS on the accounts table
alter table "public"."accounts" enable row level security;
-- Open up access to accounts
grant
select
,
insert,
update,
delete on table public.accounts to authenticated,
service_role;
-- constraint that conditionally allows nulls on the slug ONLY if personal_account is true
alter table public.accounts
add constraint accounts_slug_null_if_personal_account_true check (
(
is_personal_account = true
and slug is null
)
or (
is_personal_account = false
and slug is not null
)
);
-- constraint to ensure that the primary_owner_user_id is unique for personal accounts
create unique index unique_personal_account on public.accounts (primary_owner_user_id)
where
is_personal_account = true;
-- RLS on the accounts table
-- SELECT: Users can read their own accounts
create policy accounts_read_self on public.accounts for
select
to authenticated using (auth.uid () = primary_owner_user_id);
-- UPDATE: Team owners can update their accounts
create policy accounts_self_update on public.accounts
for update
to authenticated using (auth.uid () = primary_owner_user_id)
with
check (auth.uid () = primary_owner_user_id);
-- Functions
-- Function to transfer team account ownership to another user
create or replace function public.transfer_team_account_ownership (target_account_id uuid, new_owner_id uuid) returns void as $$
begin
if current_user not in('service_role') then
raise exception 'You do not have permission to transfer account ownership';
end if;
-- update the primary owner of the account
update public.accounts
set primary_owner_user_id = new_owner_id
where id = target_account_id and is_personal_account = false;
-- update membership assigning it the hierarchy role
update public.accounts_memberships
set account_role = (
select
name
from
public.roles
where
hierarchy_level = 1
)
where target_account_id = account_id and user_id = new_owner_id;
end;
$$ language plpgsql;
grant execute on function public.transfer_team_account_ownership (uuid, uuid) to service_role;
create function public.is_account_owner (account_id uuid) returns boolean as $$
select
exists(
select
1
from
public.accounts
where
id = is_account_owner.account_id
and primary_owner_user_id = auth.uid());
$$ language sql;
grant execute on function public.is_account_owner (uuid) to authenticated, service_role;
create
or replace function kit.protect_account_fields () returns trigger as $$
begin
if current_user in('authenticated', 'anon') then
if new.id <> old.id
or new.is_personal_account <> old.is_personal_account
or new.primary_owner_user_id <> old.primary_owner_user_id
or new.email <> old.email then
raise exception 'You do not have permission to update this field';
end if;
end if;
return NEW;
end
$$ language plpgsql;
-- trigger to protect account fields
create trigger protect_account_fields before
update on public.accounts for each row
execute function kit.protect_account_fields ();
create
or replace function kit.add_current_user_to_new_account () returns trigger language plpgsql security definer
set
search_path = public as $$
begin
if new.primary_owner_user_id = auth.uid() then
insert into public.accounts_memberships(
account_id,
user_id,
account_role)
values(
new.id,
auth.uid(),
'owner');
end if;
return NEW;
end;
$$;
-- trigger the function whenever a new account is created
create trigger "add_current_user_to_new_account"
after insert on public.accounts for each row
execute function kit.add_current_user_to_new_account ();
-- create a trigger to update the account email when the primary owner email is updated
create
or replace function kit.handle_update_user_email () returns trigger language plpgsql security definer
set
search_path = public as $$
begin
update public.accounts set email = new.email where primary_owner_user_id = new.id and is_personal_account = true;
return new;
end;
$$;
-- trigger the function every time a user email is updated
-- only if the user is the primary owner of the account and the account is personal account
create trigger "on_auth_user_updated"
after
update of email on auth.users for each row
execute procedure kit.handle_update_user_email ();
/*
* -------------------------------------------------------
* Section: Roles
* We create the schema for the roles. Roles are the roles for an account. For example, an account might have the roles 'owner', 'admin', and 'member'.
* -------------------------------------------------------
*/
-- Account Memberships table
create table if not exists public.roles (
name varchar(50) not null,
hierarchy_level int not null,
account_id uuid references public.accounts (id) on delete cascade,
is_custom boolean not null default false,
unique (hierarchy_level, account_id, is_custom),
primary key (name)
);
-- Seed the roles table with default roles 'owner' and 'member'
insert into public.roles (name, hierarchy_level) values ('owner', 1);
insert into public.roles (name, hierarchy_level) values ('member', 2);
-- RLS
-- SELECT: authenticated users can query roles
create policy roles_read on public.roles for
select
to authenticated
using (true);
/*
* -------------------------------------------------------
* Section: Memberships
* We create the schema for the memberships. Memberships are the memberships for an account. For example, a user might be a member of an account with the role 'owner'.
* -------------------------------------------------------
*/
-- Account Memberships table
create table if not exists
public.accounts_memberships (
user_id uuid references auth.users on delete cascade not null,
account_id uuid references public.accounts (id) on delete cascade not null,
account_role varchar(50) references public.roles (name) not null,
created_at timestamptz default current_timestamp not null,
updated_at timestamptz default current_timestamp not null,
created_by uuid references auth.users,
updated_by uuid references auth.users,
primary key (user_id, account_id)
);
comment on table public.accounts_memberships is 'The memberships for an account';
comment on column public.accounts_memberships.account_id is 'The account the membership is for';
comment on column public.accounts_memberships.account_role is 'The role for the membership';
-- Open up access to accounts_memberships table for authenticated users and service_role
grant
select
,
insert,
update,
delete on table public.accounts_memberships to service_role;
-- Enable RLS on the accounts_memberships table
alter table public.accounts_memberships enable row level security;
-- Trigger to prevent a primary owner from being removed from an account
create
or replace function kit.prevent_account_owner_membership_delete () returns trigger as $$
begin
if exists (select 1 from public.accounts where id = old.account_id and primary_owner_user_id = old.user_id) then
raise exception 'The primary account owner cannot be removed from the account membership list';
end if;
return old;
end;
$$ language plpgsql;
create or replace trigger prevent_account_owner_membership_delete_check before delete
on public.accounts_memberships for each row
execute function kit.prevent_account_owner_membership_delete ();
create
or replace function public.has_role_on_account (
account_id uuid,
account_role varchar(50) default null
) returns boolean language sql security definer
set
search_path = public as $$
select
exists(
select
1
from
public.accounts_memberships membership
where
membership.user_id = auth.uid()
and membership.account_id = has_role_on_account.account_id
and(membership.account_role = has_role_on_account.account_role
or has_role_on_account.account_role is null));
$$;
grant
execute on function public.has_role_on_account (uuid, varchar) to authenticated;
create
or replace function public.is_team_member (account_id uuid, user_id uuid) returns boolean language sql security definer
set
search_path = public as $$
select
exists(
select
1
from
public.accounts_memberships membership
where
public.has_role_on_account(account_id) and
membership.user_id = is_team_member.user_id and
membership.account_id = is_team_member.account_id);
$$;
grant
execute on function public.is_team_member (uuid, uuid) to authenticated;
-- Functions
-- Function to check if a user can remove a member from an account
create or replace function kit.can_remove_account_member (target_team_account_id uuid, user_id uuid) returns boolean as $$
declare
permission_granted boolean;
target_user_hierarchy_level int;
current_user_hierarchy_level int;
begin
-- validate the auth user has the required permission on the account
-- to manage members of the account
select public.has_permission (auth.uid (), target_team_account_id, 'members.manage'::app_permissions) into permission_granted;
if not permission_granted then
raise exception 'You do not have permission to remove a member from this account';
end if;
-- users cannot remove themselves from the account with this function
if can_remove_account_member.user_id = auth.uid () then
raise exception 'You cannot remove yourself from the account';
end if;
select hierarchy_level into target_user_hierarchy_level from public.roles where name = target_user_role;
select hierarchy_level into current_user_hierarchy_level from public.roles where name = (select account_role from public.accounts_memberships where account_id = target_team_account_id and user_id = auth.uid());
-- check if the current user has a higher hierarchy level than the target user
if current_user_hierarchy_level <= target_user_hierarchy_level then
raise exception 'You do not have permission to remove this user from the account';
end if;
return true;
end;
$$ language plpgsql;
grant execute on function kit.can_remove_account_member (uuid, uuid) to authenticated, service_role;
-- RLS
-- SELECT: Users can read their team members account memberships
create policy accounts_memberships_read_self on public.accounts_memberships for
select
to authenticated using (user_id = auth.uid ());
-- SELECT: Users can read their team members account memberships
create policy accounts_memberships_team_read on public.accounts_memberships for
select
to authenticated using (is_team_member (account_id, user_id));
-- RLS on the accounts table
-- SELECT: Users can read the team accounts they are a member of
create policy accounts_read_team on public.accounts for
select
to authenticated using (
has_role_on_account (id)
);
-- DELETE: Users can remove themselves from an account
create policy accounts_memberships_delete_self on public.accounts_memberships for
delete
to authenticated using (user_id = auth.uid ());
-- DELETE: Users with the required role can remove members from an account
create policy accounts_memberships_delete on public.accounts_memberships for
delete
to authenticated using (kit.can_remove_account_member (account_id, user_id));
-- SELECT (public.accounts): Team members can read accounts of the team they are a member of
create policy accounts_team_read ON public.accounts
for select
to authenticated
using (
exists (
select 1 from public.accounts_memberships as membership
where public.is_team_member(membership.account_id, id)
)
);
/*
* -------------------------------------------------------
* Section: Account Roles
* We create the schema for the account roles. Account roles are the roles for an account.
* -------------------------------------------------------
*/
-- Account Roles table
create table
public.account_roles (
id bigint generated by default as identity primary key,
account_id uuid references public.accounts (id) on delete cascade not null,
role varchar(50) references public.roles(name) not null,
unique (account_id, role)
);
comment on table public.account_roles is 'The roles for an account';
comment on column public.account_roles.account_id is 'The account the role is for';
comment on column public.account_roles.role is 'The role for the account';
-- Open up access to account roles
grant
select
,
insert,
update,
delete on table public.account_roles to authenticated,
service_role;
-- Enable RLS on the account_roles table
alter table public.account_roles enable row level security;
-- RLS
-- SELECT: Users can read account roles of an account they are a member of
create policy account_roles_read_self on public.account_roles for
select
to authenticated using (has_role_on_account (account_id));
/*
* -------------------------------------------------------
* Section: Role Permissions
* We create the schema for the role permissions. Role permissions are the permissions for a role.
* For example, the 'owner' role might have the 'roles.manage' permission.
* -------------------------------------------------------
*/
-- Create table for roles permissions
create table if not exists
public.role_permissions (
id bigint generated by default as identity primary key,
role varchar(50) references public.roles(name) not null,
permission app_permissions not null,
unique (role, permission)
);
comment on table public.role_permissions is 'The permissions for a role';
comment on column public.role_permissions.role is 'The role the permission is for';
comment on column public.role_permissions.permission is 'The permission for the role';
-- Open up access to accounts
grant
select
,
insert,
update,
delete on table public.role_permissions to authenticated,
service_role;
-- Create a function to check if a user has a permission
create function public.has_permission (
user_id uuid,
account_id uuid,
permission_name app_permissions
) returns boolean as $$
begin
return exists(
select
1
from
public.accounts_memberships
join public.role_permissions on accounts_memberships.account_role = role_permissions.role
where
accounts_memberships.user_id = has_permission.user_id
and accounts_memberships.account_id = has_permission.account_id
and role_permissions.permission = has_permission.permission_name);
end;
$$ language plpgsql;
grant execute on function public.has_permission (uuid, uuid, public.app_permissions) to authenticated, service_role;
create or replace function public.has_more_elevated_role (
target_user_id uuid,
target_account_id uuid,
role_name varchar
) returns boolean as $$
declare
declare is_primary_owner boolean;
user_role_hierarchy_level int;
target_role_hierarchy_level int;
begin
select exists (select 1 from public.accounts where id = target_account_id and primary_owner_user_id = target_user_id) into is_primary_owner;
-- If the user is the primary owner, they have the highest role and can perform any action
if is_primary_owner then
return true;
end if;
select hierarchy_level into user_role_hierarchy_level from public.roles where name = (select account_role from public.accounts_memberships where account_id = target_account_id and target_user_id = user_id);
select hierarchy_level into target_role_hierarchy_level from public.roles where name = role_name;
-- If the user's role is higher than the target role, they can perform the action
return user_role_hierarchy_level < target_role_hierarchy_level;
end;
$$ language plpgsql;
grant execute on function public.has_more_elevated_role (uuid, uuid, varchar) to authenticated, service_role;
-- Enable RLS on the role_permissions table
alter table public.role_permissions enable row level security;
-- RLS
-- Authenticated Users can read their permissions
create policy role_permissions_read on public.role_permissions for
select
to authenticated using (true);
/*
* -------------------------------------------------------
* Section: Invitations
* We create the schema for the invitations. Invitations are the invitations for an account sent to a user to join the account.
* -------------------------------------------------------
*/
create table if not exists
public.invitations (
id serial primary key,
email varchar(255) not null,
account_id uuid references public.accounts (id) on delete cascade not null,
invited_by uuid references auth.users on delete cascade not null,
role varchar(50) references public.roles (name) not null,
invite_token varchar(255) unique not null,
created_at timestamptz default current_timestamp not null,
updated_at timestamptz default current_timestamp not null,
expires_at timestamptz default current_timestamp + interval '7 days' not null
);
comment on table public.invitations is 'The invitations for an account';
comment on column public.invitations.account_id is 'The account the invitation is for';
comment on column public.invitations.invited_by is 'The user who invited the user';
comment on column public.invitations.role is 'The role for the invitation';
comment on column public.invitations.invite_token is 'The token for the invitation';
-- Open up access to invitations table for authenticated users and service_role
grant
select
,
insert,
update,
delete on table public.invitations to service_role;
-- Enable RLS on the invitations table
alter table public.invitations enable row level security;
create
or replace function check_organization_account () returns trigger as $$
begin
if(
select
is_personal_account
from
public.accounts
where
id = new.account_id) then
raise exception 'Account must be an organization account';
end if;
return NEW;
end;
$$ language plpgsql;
create trigger only_organization_accounts_check before insert
or
update on public.invitations for each row
execute procedure check_organization_account ();
-- RLS
-- SELECT: Users can read invitations to users of an account they are a member of
create policy invitations_read_self on public.invitations for
select
to authenticated using (has_role_on_account (account_id));
-- INSERT: Users can create invitations to users of an account they are a member of
-- and have the 'invites.manage' permission AND the target role is not higher than the user's role
create policy invitations_create_self on public.invitations for
insert
to authenticated with check (
public.has_permission (auth.uid (), account_id, 'invites.manage'::app_permissions) and
public.has_more_elevated_role (auth.uid (), account_id, role)
);
-- UPDATE: Users can update invitations to users of an account they are a member of
-- and have the 'invites.manage' permission AND the target role is not higher than the user's role
create policy invitations_update on public.invitations for
update
to authenticated using (
public.has_permission (auth.uid (), account_id, 'invites.manage'::app_permissions)
and public.has_more_elevated_role (auth.uid (), account_id, role)
) with check (
public.has_permission (auth.uid (), account_id, 'invites.manage'::app_permissions)
and public.has_more_elevated_role (auth.uid (), account_id, role)
);
-- DELETE: Users can delete invitations to users of an account they are a member of
-- and have the 'invites.manage' permission
create policy invitations_delete on public.invitations for
delete
to authenticated using (
has_role_on_account (account_id)
and public.has_permission (auth.uid (), account_id, 'invites.manage'::app_permissions)
);
-- Functions
-- Function to accept an invitation to an account
create or replace function accept_invitation(token text, user_id uuid) returns void as $$
declare
target_account_id uuid;
target_role varchar(50);
begin
select
account_id,
role
into
target_account_id,
target_role
from
public.invitations
where
invite_token = token
and expires_at > now();
if not found then
raise exception 'Invalid or expired invitation token';
end if;
insert into
public.accounts_memberships(
user_id,
account_id,
account_role)
values
(accept_invitation.user_id, target_account_id, target_role);
delete from
public.invitations
where
invite_token = token;
end;
$$ language plpgsql;
grant execute on function accept_invitation (text, uuid) to service_role;
/*
* -------------------------------------------------------
* Section: Billing Customers
* We create the schema for the billing customers. Billing customers are the customers for an account in the billing provider. For example, a user might have a customer in the billing provider with the customer ID 'cus_123'.
* -------------------------------------------------------
*/
-- Account Subscriptions table
create table
public.billing_customers (
account_id uuid references public.accounts (id) on delete cascade not null,
id serial primary key,
email text,
provider public.billing_provider not null,
customer_id text not null
);
comment on table public.billing_customers is 'The billing customers for an account';
comment on column public.billing_customers.account_id is 'The account the billing customer is for';
comment on column public.billing_customers.provider is 'The provider of the billing customer';
comment on column public.billing_customers.customer_id is 'The customer ID for the billing customer';
-- Open up access to billing_customers table for authenticated users and service_role
grant
select
,
insert,
update,
delete on table public.billing_customers to service_role;
-- Enable RLS on billing_customers table
alter table public.billing_customers enable row level security;
grant
select
on table public.billing_customers to authenticated;
-- RLS
-- SELECT: Users can read account subscriptions on an account they are a member of
create policy billing_customers_read_self on public.billing_customers for
select
to authenticated using (account_id = auth.uid() or has_role_on_account (account_id));
/*
* -------------------------------------------------------
* Section: Subscriptions
* We create the schema for the subscriptions. Subscriptions are the subscriptions for an account to a product. For example, a user might have a subscription to a product with the status 'active'.
* -------------------------------------------------------
*/
-- Subscriptions table
create table if not exists
public.subscriptions (
account_id uuid references public.accounts (id) on delete cascade not null,
billing_customer_id int references public.billing_customers on delete cascade not null,
id text not null primary key,
status public.subscription_status not null,
active bool not null,
billing_provider public.billing_provider not null,
product_id varchar(255) not null,
variant_id varchar(255) not null,
price_amount numeric,
cancel_at_period_end bool not null,
currency varchar(3) not null,
interval varchar(255) not null,
interval_count integer not null check (interval_count > 0),
created_at timestamptz not null default current_timestamp,
updated_at timestamptz not null default current_timestamp,
period_starts_at timestamptz,
period_ends_at timestamptz,
trial_starts_at timestamptz,
trial_ends_at timestamptz
);
comment on table public.subscriptions is 'The subscriptions for an account';
comment on column public.subscriptions.account_id is 'The account the subscription is for';
comment on column public.subscriptions.billing_provider is 'The provider of the subscription';
comment on column public.subscriptions.product_id is 'The product ID for the subscription';
comment on column public.subscriptions.variant_id is 'The variant ID for the subscription';
comment on column public.subscriptions.price_amount is 'The price amount for the subscription';
comment on column public.subscriptions.cancel_at_period_end is 'Whether the subscription will be canceled at the end of the period';
comment on column public.subscriptions.currency is 'The currency for the subscription';
comment on column public.subscriptions.interval is 'The interval for the subscription';
comment on column public.subscriptions.interval_count is 'The interval count for the subscription';
comment on column public.subscriptions.status is 'The status of the subscription';
comment on column public.subscriptions.period_starts_at is 'The start of the current period for the subscription';
comment on column public.subscriptions.period_ends_at is 'The end of the current period for the subscription';
comment on column public.subscriptions.trial_starts_at is 'The start of the trial period for the subscription';
comment on column public.subscriptions.trial_ends_at is 'The end of the trial period for the subscription';
-- Open up access to subscriptions table for authenticated users and service_role
grant
select
,
insert,
update,
delete on table public.subscriptions to service_role;
grant select on table public.subscriptions to authenticated;
-- Enable RLS on subscriptions table
alter table public.subscriptions enable row level security;
-- RLS
-- SELECT: Users can read account subscriptions on an account they are a member of
create policy subscriptions_read_self on public.subscriptions for
select
to authenticated using (has_role_on_account (account_id) or account_id = auth.uid ());
-- Functions
create or replace function public.add_subscription (
account_id uuid,
subscription_id text,
active bool,
status public.subscription_status,
billing_provider public.billing_provider,
product_id varchar(255),
variant_id varchar(255),
price_amount numeric,
cancel_at_period_end bool,
currency varchar(3),
"interval" varchar(255),
interval_count integer,
period_starts_at timestamptz,
period_ends_at timestamptz,
trial_starts_at timestamptz,
trial_ends_at timestamptz,
customer_id varchar(255)
) returns public.subscriptions as $$
declare
new_subscription public.subscriptions;
new_billing_customer_id int;
begin
insert into public.billing_customers(
account_id,
provider,
customer_id)
values (
account_id,
billing_provider,
customer_id)
returning
id into new_billing_customer_id;
insert into public.subscriptions(
account_id,
billing_customer_id,
id,
active,
status,
billing_provider,
product_id,
variant_id,
price_amount,
cancel_at_period_end,
currency,
interval,
interval_count,
period_starts_at,
period_ends_at,
trial_starts_at,
trial_ends_at)
values (
account_id,
new_billing_customer_id,
subscription_id,
active,
status,
billing_provider,
product_id,
variant_id,
price_amount,
cancel_at_period_end,
currency,
interval,
interval_count,
period_starts_at,
period_ends_at,
trial_starts_at,
trial_ends_at)
returning
* into new_subscription;
return new_subscription;
end;
$$ language plpgsql;
grant execute on function public.add_subscription (
uuid,
text,
boolean,
public.subscription_status,
public.billing_provider,
varchar,
varchar,
numeric,
boolean,
varchar,
varchar,
integer,
timestamptz,
timestamptz,
timestamptz,
timestamptz,
varchar
) to service_role;
/*
* -------------------------------------------------------
* Section: Functions
* -------------------------------------------------------
*/
-- Create a function to slugify a string
create
or replace function kit.slugify ("value" text) returns text as $$
-- removes accents (diacritic signs) from a given string --
with "unaccented" as(
select
unaccent("value") as "value"
),
-- lowercases the string
"lowercase" as(
select
lower("value") as "value"
from
"unaccented"
),
-- remove single and double quotes
"removed_quotes" as(
select
regexp_replace("value", '[''"]+', '', 'gi') as "value"
from
"lowercase"
),
-- replaces anything that's not a letter, number, hyphen('-'), or underscore('_') with a hyphen('-')
"hyphenated" as(
select
regexp_replace("value", '[^a-z0-9\\-_]+', '-', 'gi') as "value"
from
"removed_quotes"
),
-- trims hyphens('-') if they exist on the head or tail of the string
"trimmed" as(
select
regexp_replace(regexp_replace("value", '\-+$', ''), '^\-', '') as
"value" from "hyphenated"
)
select
"value"
from
"trimmed";
$$ language SQL strict immutable;
grant
execute on function kit.slugify (text) to service_role,
authenticated;
create function kit.set_slug_from_account_name () returns trigger language plpgsql as $$
declare
sql_string varchar;
tmp_slug varchar;
increment integer;
tmp_row record;
tmp_row_count integer;
begin
tmp_row_count = 1;
increment = 0;
while tmp_row_count > 0 loop
if increment > 0 then
tmp_slug = kit.slugify(new.name || ' ' || increment::varchar);
else
tmp_slug = kit.slugify(new.name);
end if;
sql_string = format('select count(1) cnt from accounts where slug = ''' || tmp_slug || '''; ');
for tmp_row in execute (sql_string)
loop
raise notice '%', tmp_row;
tmp_row_count = tmp_row.cnt;
end loop;
increment = increment +1;
end loop;
new.slug := tmp_slug;
return NEW;
end
$$;
-- Create a trigger to set the slug from the account name
create trigger "set_slug_from_account_name" before insert on public.accounts for each row when (
NEW.name is not null
and NEW.slug is null
and NEW.is_personal_account = false
)
execute procedure kit.set_slug_from_account_name ();
-- Create a trigger when a name is updated to update the slug
create trigger "update_slug_from_account_name" before
update on public.accounts for each row when (
NEW.name is not null
and NEW.name <> OLD.name
and NEW.is_personal_account = false
)
execute procedure kit.set_slug_from_account_name ();
-- Create a function to setup a new user with a personal account
create function kit.setup_new_user () returns trigger language plpgsql security definer
set
search_path = public as $$
declare
user_name text;
begin
if new.raw_user_meta_data ->> 'display_name' is not null then
user_name := new.raw_user_meta_data ->> 'display_name';
end if;
if user_name is null and new.email is not null then
user_name := split_part(new.email, '@', 1);
end if;
if user_name is null then
user_name := '';
end if;
insert into public.accounts(
id,
primary_owner_user_id,
name,
is_personal_account,
email)
values (
new.id,
new.id,
user_name,
true,
new.email);
return new;
end;
$$;
-- trigger the function every time a user is created
create trigger on_auth_user_created
after insert on auth.users for each row
execute procedure kit.setup_new_user ();
create
or replace function public.create_account (account_name text) returns public.accounts as $$
declare
new_account public.accounts;
begin
insert into public.accounts(
name,
is_personal_account)
values (
account_name,
false)
returning
* into new_account;
return new_account;
end;
$$ language plpgsql;
grant
execute on function public.create_account (text) to authenticated, service_role;
-- RLS
-- Authenticated users can create organization accounts
create policy create_org_account on public.accounts for insert to authenticated
with
check (
public.is_set ('enable_organization_accounts')
and public.accounts.is_personal_account = false
);
create
or replace function public.create_invitation (
account_id uuid,
email text,
role varchar(50)
) returns public.invitations as $$
declare
new_invitation public.invitations;
invite_token text;
begin
invite_token := extensions.uuid_generate_v4();
insert into public.invitations(
email,
account_id,
invited_by,
role,
invite_token)
values (
email,
account_id,
auth.uid(),
role,
invite_token)
returning
* into new_invitation;
return new_invitation;
end;
$$ language plpgsql;
create
or replace function public.get_user_accounts () returns setof public.accounts as $$
begin
select
id,
name,
picture_url
from
public.accounts
join public.accounts_memberships on accounts.id = accounts_memberships.account_id
where
accounts_memberships.user_id = auth.uid();
end;
$$ language plpgsql;
-- we create a view to load the general app data for the authenticated user
-- which includes the user's accounts, memberships, and roles, and relative subscription status
create or replace view
public.user_account_workspace as
select
accounts.id as id,
accounts.name as name,
accounts.picture_url as picture_url,
subscriptions.status as subscription_status
from
public.accounts
left join public.subscriptions on accounts.id = subscriptions.account_id
where
primary_owner_user_id = auth.uid ()
and accounts.is_personal_account = true;
grant
select
on public.user_account_workspace to authenticated,
service_role;
create or replace view
public.user_accounts as
select
accounts.id as id,
accounts.name as name,
accounts.picture_url as picture_url,
accounts.slug as slug,
accounts_memberships.account_role as role
from
public.accounts
join public.accounts_memberships on accounts.id = accounts_memberships.account_id
where
accounts_memberships.user_id = auth.uid ()
and accounts.is_personal_account = false;
grant
select
on public.user_accounts to authenticated,
service_role;
create
or replace function public.organization_account_workspace (account_slug text) returns table (
id uuid,
name varchar(255),
picture_url varchar(1000),
slug text,
role varchar(50),
role_hierarchy_level int,
primary_owner_user_id uuid,
subscription_status public.subscription_status,
permissions public.app_permissions[]
) as $$
begin
return QUERY
select
accounts.id,
accounts.name,
accounts.picture_url,
accounts.slug,
accounts_memberships.account_role,
roles.hierarchy_level,
accounts.primary_owner_user_id,
subscriptions.status,
array_agg(role_permissions.permission)
from
public.accounts
join public.accounts_memberships on accounts.id = accounts_memberships.account_id
left join public.subscriptions on accounts.id = subscriptions.account_id
left join public.role_permissions on accounts_memberships.account_role =
role_permissions.role
left join public.roles on accounts_memberships.account_role = roles.name
where
accounts.slug = account_slug
and public.accounts_memberships.user_id = auth.uid()
group by
accounts.id,
accounts_memberships.account_role,
subscriptions.status,
roles.hierarchy_level;
end;
$$ language plpgsql;
grant
execute on function public.organization_account_workspace (text) to authenticated,
service_role;
CREATE
OR REPLACE FUNCTION public.get_account_members (account_slug text) RETURNS TABLE (
id uuid,
user_id uuid,
account_id uuid,
role varchar(50),
role_hierarchy_level int,
primary_owner_user_id uuid,
name varchar,
email varchar,
picture_url varchar,
created_at timestamptz,
updated_at timestamptz
) LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY
SELECT acc.id, am.user_id, am.account_id, am.account_role, r.hierarchy_level, a.primary_owner_user_id, acc.name, acc.email, acc.picture_url, am.created_at, am.updated_at
FROM public.accounts_memberships am
JOIN public.accounts a ON a.id = am.account_id
JOIN public.accounts acc on acc.id = am.user_id
JOIN public.roles r ON r.name = am.account_role
WHERE a.slug = account_slug;
END;
$$;
grant
execute on function public.get_account_members (text) to authenticated,
service_role;
create or replace function public.get_account_invitations(account_slug text) returns table (
id integer,
email varchar(255),
account_id uuid,
invited_by uuid,
role varchar(50),
created_at timestamptz,
updated_at timestamptz,
expires_at timestamptz,
inviter_name varchar,
inviter_email varchar
) as $$
begin
return query
select
invitation.id,
invitation.email,
invitation.account_id,
invitation.invited_by,
invitation.role,
invitation.created_at,
invitation.updated_at,
invitation.expires_at,
account.name,
account.email
from
public.invitations as invitation
join public.accounts as account on invitation.account_id = account.id
where
account.slug = account_slug;
end;
$$ language plpgsql;
grant execute on function public.get_account_invitations (text) to authenticated, service_role;
CREATE TYPE kit.invitation AS (
email text,
role varchar(50)
);
-- Then, modify your function to use this type
CREATE OR REPLACE FUNCTION public.add_invitations_to_account(account_slug text, invitations kit.invitation[])
RETURNS public.invitations[] AS $$
DECLARE
new_invitation public.invitations;
all_invitations public.invitations[] := ARRAY[]::public.invitations[];
invite_token text;
email text;
role varchar(50);
BEGIN
FOREACH email, role IN ARRAY invitations
LOOP
invite_token := extensions.uuid_generate_v4();
INSERT INTO public.invitations(
email,
account_id,
invited_by,
role,
invite_token)
VALUES (
email,
(SELECT id FROM public.accounts WHERE slug = account_slug),
auth.uid(),
role,
invite_token)
RETURNING *
INTO new_invitation;
all_invitations := array_append(all_invitations, new_invitation);
END LOOP;
RETURN all_invitations;
END;
$$ LANGUAGE plpgsql;
grant execute on function public.add_invitations_to_account (text, kit.invitation[]) to authenticated, service_role;
-- Storage
-- Account Image
insert into
storage.buckets (id, name, PUBLIC)
values
('account_image', 'account_image', true);
create or replace function kit.get_storage_filename_as_uuid (name text) returns uuid as $$
begin
return replace(
storage.filename (name),
concat('.', storage.extension (name)),
''
)::uuid;
end;
$$ language plpgsql;
grant execute on function kit.get_storage_filename_as_uuid (text) to authenticated, service_role;
-- RLS policies for storage
create policy account_image on storage.objects for all using (
bucket_id = 'account_image'
and kit.get_storage_filename_as_uuid(name) = auth.uid () or
public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
)
with
check (
bucket_id = 'account_image'
and kit.get_storage_filename_as_uuid(name) = auth.uid () or
public.has_permission(auth.uid(), kit.get_storage_filename_as_uuid(name), 'settings.manage')
);