Files
myeasycms-v2/supabase/migrations/20221215192558_schema.sql
giancarlo 13308194ec Update admin and marketing layouts, add new admin components
Refined both admin and marketing layouts for a clearer design. Newly added components for the admin page include admin-account-page, admin-members-table and admin-memberships-table. Also included in this update are route renaming, minor text edits and corrections in the code.
2024-04-08 20:00:52 +08:00

2050 lines
60 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'
);
/*
Payment Status
- We create the payment status for the Supabase MakerKit. These statuses are used to manage the status of the payments
*/
create type public.payment_status as ENUM(
'pending',
'succeeded',
'failed'
);
/*
* 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'
);
/*
* Subscription Item Type
- We create the subscription item type for the Supabase MakerKit. These types are used to manage the type of the subscription items
- The types are 'flat', 'per_seat', and 'metered'.
- You can add more types as needed.
*/
create type public.subscription_item_type as ENUM(
'flat',
'per_seat',
'metered'
);
/*
* -------------------------------------------------------
* Section: App Configuration
* We create the configuration for the Supabase MakerKit to enable or disable features
* -------------------------------------------------------
*/
create table if not exists public.config(
enable_team_accounts boolean default true not null,
enable_account_billing boolean default true not null,
enable_team_account_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_team_accounts is 'Enable team accounts';
comment on column public.config.enable_account_billing is 'Enable billing for individual accounts';
comment on column public.config.enable_team_account_billing is 'Enable billing for team accounts';
comment on column public.config.billing_provider is 'The billing provider to use';
alter table public.config enable row level security;
-- create config row
insert into public.config(
enable_team_accounts,
enable_account_billing,
enable_team_account_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(),
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,
unique(name, account_id),
primary key (name)
);
grant select on table public.roles to authenticated, service_role;
-- define the system role uuid as a static UUID to be used as a default
-- account_id for system roles when the account_id is null. Useful for constraints.
create or replace function kit.get_system_role_uuid()
returns uuid
as $$
begin
return 'fd4f287c-762e-42b7-8207-b1252f799670';
end; $$ language plpgsql immutable;
grant execute on function kit.get_system_role_uuid() to authenticated, service_role;
create unique index idx_unique_hierarchy_per_account
on public.roles (hierarchy_level, coalesce(account_id, kit.get_system_role_uuid()));
create unique index idx_unique_name_per_account
on public.roles (name, coalesce(account_id, kit.get_system_role_uuid()));
create or replace function kit.check_non_personal_account_roles()
returns trigger
as $$
begin
if new.account_id is not null and(
select
is_personal_account
from
public.accounts
where
id = new.account_id) then
raise exception 'Roles cannot be created for personal accounts';
end if;
return new;
end; $$ language plpgsql;
create constraint trigger tr_check_non_personal_account_roles
after insert or update on public.roles
for each row
execute procedure kit.check_non_personal_account_roles();
-- 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
alter table public.roles enable row level security;
/*
* -------------------------------------------------------
* 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();
-- Functions
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;
-- Function to check if a user is a team member of an account or not
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;
-- SELECT(roles): authenticated users can query roles if the role is public
-- or the user has a role on the account the role is for
create policy roles_read on public.roles
for select to authenticated
using (
account_id is null
or public.has_role_on_account(account_id)
);
-- 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 uuid
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;
return target_account_id;
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,
unique (account_id, customer_id, provider)
);
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(
id text not null primary key,
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,
status public.subscription_status not null,
active bool not null,
billing_provider public.billing_provider not null,
cancel_at_period_end bool not null,
currency varchar(3) not null,
created_at timestamptz not null default current_timestamp,
updated_at timestamptz not null default current_timestamp,
period_starts_at timestamptz not null,
period_ends_at timestamptz not null,
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.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.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) and public.is_set('enable_team_account_billing'))
or (account_id = auth.uid() and public.is_set('enable_account_billing'))
);
-- Functions
create or replace function
public.upsert_subscription(target_account_id uuid,
target_customer_id varchar(255), target_subscription_id text,
active bool, status public.subscription_status, billing_provider
public.billing_provider, cancel_at_period_end bool, currency
varchar(3), period_starts_at timestamptz, period_ends_at
timestamptz, line_items jsonb, trial_starts_at timestamptz
default null, trial_ends_at timestamptz default null)
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 (
target_account_id,
billing_provider,
target_customer_id)
on conflict (
account_id,
provider,
customer_id)
do update set
provider = excluded.provider
returning
id into new_billing_customer_id;
insert into public.subscriptions(
account_id,
billing_customer_id,
id,
active,
status,
billing_provider,
cancel_at_period_end,
currency,
period_starts_at,
period_ends_at,
trial_starts_at,
trial_ends_at)
values (
target_account_id,
new_billing_customer_id,
target_subscription_id,
active,
status,
billing_provider,
cancel_at_period_end,
currency,
period_starts_at,
period_ends_at,
trial_starts_at,
trial_ends_at)
on conflict (
id)
do update set
active = excluded.active,
status = excluded.status,
cancel_at_period_end = excluded.cancel_at_period_end,
currency = excluded.currency,
period_starts_at = excluded.period_starts_at,
period_ends_at = excluded.period_ends_at,
trial_starts_at = excluded.trial_starts_at,
trial_ends_at = excluded.trial_ends_at
returning
* into new_subscription;
-- Upsert subscription items
with item_data as (
select
(line_item ->> 'product_id')::varchar as prod_id,
(line_item ->> 'variant_id')::varchar as var_id,
(line_item ->> 'type')::public.subscription_item_type as type,
(line_item ->> 'price_amount')::numeric as price_amt,
(line_item ->> 'quantity')::integer as qty,
(line_item ->> 'interval')::varchar as intv,
(line_item ->> 'interval_count')::integer as intv_count
from
jsonb_array_elements(line_items) as line_item)
insert into public.subscription_items(
subscription_id,
product_id,
variant_id,
type,
price_amount,
quantity,
interval,
interval_count)
select
target_subscription_id,
prod_id,
var_id,
type,
price_amt,
qty,
intv,
intv_count
from
item_data
on conflict (subscription_id,
product_id,
variant_id)
do update set
price_amount = excluded.price_amount,
quantity = excluded.quantity,
interval = excluded.interval,
interval_count = excluded.interval_count;
return new_subscription;
end;
$$
language plpgsql;
grant execute on function public.upsert_subscription(uuid, varchar,
text, bool, public.subscription_status, public.billing_provider,
bool, varchar, timestamptz, timestamptz, jsonb, timestamptz,
timestamptz) to service_role;
/* -------------------------------------------------------
* Section: Subscription Items
* We create the schema for the subscription items. Subscription items are the items in a subscription.
* For example, a subscription might have a subscription item with the product ID 'prod_123' and the variant ID 'var_123'.
* -------------------------------------------------------
*/
create table if not exists public.subscription_items(
subscription_id text references public.subscriptions(id) on
delete cascade not null,
product_id varchar(255) not null,
variant_id varchar(255) not null,
type public.subscription_item_type not null,
price_amount numeric,
quantity integer not null default 1,
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,
unique (subscription_id, product_id, variant_id)
);
comment on table public.subscription_items is 'The items in a subscription';
comment on column public.subscription_items.subscription_id is 'The subscription the item is for';
comment on column public.subscription_items.product_id is 'The product ID for the item';
comment on column public.subscription_items.variant_id is 'The variant ID for the item';
comment on column public.subscription_items.price_amount is 'The price amount for the item';
comment on column public.subscription_items.quantity is 'The quantity of the item';
comment on column public.subscription_items.interval is 'The interval for the item';
comment on column public.subscription_items.interval_count is 'The interval count for the item';
comment on column public.subscription_items.created_at is 'The creation date of the item';
comment on column public.subscription_items.updated_at is 'The last update date of the item';
-- Open up access to subscription_items table for authenticated users
-- and service_role
grant select on table public.subscription_items to authenticated,
service_role;
grant insert, update, delete on table public.subscription_items to
service_role;
-- RLS
alter table public.subscription_items enable row level security;
-- SELECT: Users can read subscription items on a subscription they are
-- a member of
create policy subscription_items_read_self on public.subscription_items
for select to authenticated
using (exists (
select
1
from
public.subscriptions
where
id = subscription_id and (account_id = auth.uid() or
has_role_on_account(account_id))));
/**
* -------------------------------------------------------
* Section: Orders
* We create the schema for the subscription items. Subscription items are the items in a subscription.
* For example, a subscription might have a subscription item with the product ID 'prod_123' and the variant ID 'var_123'.
* -------------------------------------------------------
*/
create table if not exists public.orders(
id text not null primary key,
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,
status public.payment_status not null,
billing_provider public.billing_provider not null,
total_amount numeric not null,
currency varchar(3) not null,
created_at timestamptz not null default current_timestamp,
updated_at timestamptz not null default current_timestamp
);
-- Open up access to subscription_items table for authenticated users
-- and service_role
grant select on table public.orders to authenticated;
grant select, insert, update, delete on table public.orders to service_role;
-- RLS
alter table public.orders enable row level security;
-- SELECT
-- Users can read orders on an account they are a member of or the
-- account is their own
create policy orders_read_self on public.orders
for select to authenticated
using ((account_id = auth.uid() and public.is_set('enable_account_billing'))
or (has_role_on_account(account_id) and public.is_set('enable_team_account_billing')));
/**
* -------------------------------------------------------
* Section: Order Items
* We create the schema for the order items. Order items are the items in an order.
* -------------------------------------------------------
*/
create table if not exists public.order_items(
order_id text references public.orders(id) on delete cascade not null,
product_id text not null,
variant_id text not null,
price_amount numeric,
quantity integer not null default 1,
created_at timestamptz not null default current_timestamp,
updated_at timestamptz not null default current_timestamp,
unique (order_id, product_id, variant_id)
);
-- Open up access to order_items table for authenticated users and
-- service_role
grant select on table public.order_items to authenticated, service_role;
-- RLS
alter table public.order_items enable row level security;
-- SELECT
-- Users can read order items on an order they are a member of
create policy order_items_read_self on public.order_items
for select to authenticated
using (exists (
select
1
from
public.orders
where
id = order_id and (account_id = auth.uid() or
has_role_on_account(account_id))));
-- Functions
create or replace function public.upsert_order(target_account_id
uuid, target_customer_id varchar(255), target_order_id text,
status public.payment_status, billing_provider
public.billing_provider, total_amount numeric, currency
varchar(3), line_items jsonb)
returns public.orders
as $$
declare
new_order public.orders;
new_billing_customer_id int;
begin
insert into public.billing_customers(
account_id,
provider,
customer_id)
values (
target_account_id,
billing_provider,
target_customer_id)
on conflict (
account_id,
provider,
customer_id)
do update set
provider = excluded.provider
returning
id into new_billing_customer_id;
insert into public.orders(
account_id,
billing_customer_id,
id,
status,
billing_provider,
total_amount,
currency)
values (
target_account_id,
new_billing_customer_id,
target_order_id,
status,
billing_provider,
total_amount,
currency)
on conflict (
id)
do update set
status = excluded.status,
total_amount = excluded.total_amount,
currency = excluded.currency
returning
* into new_order;
insert into public.order_items(
order_id,
product_id,
variant_id,
price_amount,
quantity)
select
target_order_id,
(line_item ->> 'product_id')::varchar,
(line_item ->> 'variant_id')::varchar,
(line_item ->> 'price_amount')::numeric,
(line_item ->> 'quantity')::integer
from
jsonb_array_elements(line_items) as line_item
on conflict (order_id,
product_id,
variant_id)
do update set
price_amount = excluded.price_amount,
quantity = excluded.quantity;
return new_order;
end;
$$
language plpgsql;
grant execute on function public.upsert_order(uuid, varchar, text,
public.payment_status, public.billing_provider, numeric, varchar,
jsonb) 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_team_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'));