Spaces:
Sleeping
Sleeping
| /** | |
| ____ _ | |
| | _ \ (_) | |
| | |_) | __ _ ___ ___ _ _ _ _ __ ___ _ __ | |
| | _ < / _` / __|/ _ \ | | | | '_ ` _ \| '_ \ | |
| | |_) | (_| \__ \ __/ | |_| | | | | | | |_) | | |
| |____/ \__,_|___/\___| |\__,_|_| |_| |_| .__/ | |
| _/ | | | | |
| |__/ |_| | |
| Basejump is a starter kit for building SaaS products on top of Supabase. | |
| Learn more at https://usebasejump.com | |
| */ | |
| /** | |
| * ------------------------------------------------------- | |
| * Section - Accounts | |
| * ------------------------------------------------------- | |
| */ | |
| /** | |
| * Account roles allow you to provide permission levels to users | |
| * when they're acting on an account. By default, we provide | |
| * "owner" and "member". The only distinction is that owners can | |
| * also manage billing and invite/remove account members. | |
| */ | |
| DO | |
| $$ | |
| BEGIN | |
| -- check it account_role already exists on basejump schema | |
| IF NOT EXISTS(SELECT 1 | |
| FROM pg_type t | |
| JOIN pg_namespace n ON n.oid = t.typnamespace | |
| WHERE t.typname = 'account_role' | |
| AND n.nspname = 'basejump') THEN | |
| CREATE TYPE basejump.account_role AS ENUM ('owner', 'member'); | |
| end if; | |
| end; | |
| $$; | |
| /** | |
| * Accounts are the primary grouping for most objects within | |
| * the system. They have many users, and all billing is connected to | |
| * an account. | |
| */ | |
| CREATE TABLE IF NOT EXISTS basejump.accounts | |
| ( | |
| id uuid unique NOT NULL DEFAULT extensions.uuid_generate_v4(), | |
| -- defaults to the user who creates the account | |
| -- this user cannot be removed from an account without changing | |
| -- the primary owner first | |
| primary_owner_user_id uuid references auth.users not null default auth.uid(), | |
| -- Account name | |
| name text, | |
| slug text unique, | |
| 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, | |
| private_metadata jsonb default '{}'::jsonb, | |
| public_metadata jsonb default '{}'::jsonb, | |
| PRIMARY KEY (id) | |
| ); | |
| -- constraint that conditionally allows nulls on the slug ONLY if personal_account is true | |
| -- remove this if you want to ignore accounts slugs entirely | |
| ALTER TABLE basejump.accounts | |
| ADD CONSTRAINT basejump_accounts_slug_null_if_personal_account_true CHECK ( | |
| (personal_account = true AND slug is null) | |
| OR (personal_account = false AND slug is not null) | |
| ); | |
| -- Open up access to accounts | |
| GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE basejump.accounts TO authenticated, service_role; | |
| /** | |
| * We want to protect some fields on accounts from being updated | |
| * Specifically the primary owner user id and account id. | |
| * primary_owner_user_id should be updated using the dedicated function | |
| */ | |
| CREATE OR REPLACE FUNCTION basejump.protect_account_fields() | |
| RETURNS TRIGGER AS | |
| $$ | |
| BEGIN | |
| IF current_user IN ('authenticated', 'anon') THEN | |
| -- these are protected fields that users are not allowed to update themselves | |
| -- platform admins should be VERY careful about updating them as well. | |
| if NEW.id <> OLD.id | |
| OR NEW.personal_account <> OLD.personal_account | |
| OR NEW.primary_owner_user_id <> OLD.primary_owner_user_id | |
| 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 basejump_protect_account_fields | |
| BEFORE UPDATE | |
| ON basejump.accounts | |
| FOR EACH ROW | |
| EXECUTE FUNCTION basejump.protect_account_fields(); | |
| -- convert any character in the slug that's not a letter, number, or dash to a dash on insert/update for accounts | |
| CREATE OR REPLACE FUNCTION basejump.slugify_account_slug() | |
| RETURNS TRIGGER AS | |
| $$ | |
| BEGIN | |
| if NEW.slug is not null then | |
| NEW.slug = lower(regexp_replace(NEW.slug, '[^a-zA-Z0-9-]+', '-', 'g')); | |
| end if; | |
| RETURN NEW; | |
| END | |
| $$ LANGUAGE plpgsql; | |
| -- trigger to slugify the account slug | |
| CREATE TRIGGER basejump_slugify_account_slug | |
| BEFORE INSERT OR UPDATE | |
| ON basejump.accounts | |
| FOR EACH ROW | |
| EXECUTE FUNCTION basejump.slugify_account_slug(); | |
| -- enable RLS for accounts | |
| alter table basejump.accounts | |
| enable row level security; | |
| -- protect the timestamps | |
| CREATE TRIGGER basejump_set_accounts_timestamp | |
| BEFORE INSERT OR UPDATE | |
| ON basejump.accounts | |
| FOR EACH ROW | |
| EXECUTE PROCEDURE basejump.trigger_set_timestamps(); | |
| -- set the user tracking | |
| CREATE TRIGGER basejump_set_accounts_user_tracking | |
| BEFORE INSERT OR UPDATE | |
| ON basejump.accounts | |
| FOR EACH ROW | |
| EXECUTE PROCEDURE basejump.trigger_set_user_tracking(); | |
| /** | |
| * Account users are the users that are associated with an account. | |
| * They can be invited to join the account, and can have different roles. | |
| * The system does not enforce any permissions for roles, other than restricting | |
| * billing and account membership to only owners | |
| */ | |
| create table if not exists basejump.account_user | |
| ( | |
| -- id of the user in the account | |
| user_id uuid references auth.users on delete cascade not null, | |
| -- id of the account the user is in | |
| account_id uuid references basejump.accounts on delete cascade not null, | |
| -- role of the user in the account | |
| account_role basejump.account_role not null, | |
| constraint account_user_pkey primary key (user_id, account_id) | |
| ); | |
| GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE basejump.account_user TO authenticated, service_role; | |
| -- enable RLS for account_user | |
| alter table basejump.account_user | |
| enable row level security; | |
| /** | |
| * When an account gets created, we want to insert the current user as the first | |
| * owner | |
| */ | |
| create or replace function basejump.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 basejump.account_user (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 basejump_add_current_user_to_new_account | |
| AFTER INSERT | |
| ON basejump.accounts | |
| FOR EACH ROW | |
| EXECUTE FUNCTION basejump.add_current_user_to_new_account(); | |
| /** | |
| * When a user signs up, we need to create a personal account for them | |
| * and add them to the account_user table so they can act on it | |
| */ | |
| create or replace function basejump.run_new_user_setup() | |
| returns trigger | |
| language plpgsql | |
| security definer | |
| set search_path = public | |
| as | |
| $$ | |
| declare | |
| first_account_id uuid; | |
| generated_user_name text; | |
| begin | |
| -- first we setup the user profile | |
| -- TODO: see if we can get the user's name from the auth.users table once we learn how oauth works | |
| if new.email IS NOT NULL then | |
| generated_user_name := split_part(new.email, '@', 1); | |
| end if; | |
| -- create the new users's personal account | |
| insert into basejump.accounts (name, primary_owner_user_id, personal_account, id) | |
| values (generated_user_name, NEW.id, true, NEW.id) | |
| returning id into first_account_id; | |
| -- add them to the account_user table so they can act on it | |
| insert into basejump.account_user (account_id, user_id, account_role) | |
| values (first_account_id, NEW.id, 'owner'); | |
| 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 basejump.run_new_user_setup(); | |
| /** | |
| * ------------------------------------------------------- | |
| * Section - Account permission utility functions | |
| * ------------------------------------------------------- | |
| * These functions are stored on the basejump schema, and useful for things like | |
| * generating RLS policies | |
| */ | |
| /** | |
| * Returns true if the current user has the pass in role on the passed in account | |
| * If no role is sent, will return true if the user is a member of the account | |
| * NOTE: This is an inefficient function when used on large query sets. You should reach for the get_accounts_with_role and lookup | |
| * the account ID in those cases. | |
| */ | |
| create or replace function basejump.has_role_on_account(account_id uuid, account_role basejump.account_role default null) | |
| returns boolean | |
| language sql | |
| security definer | |
| set search_path = public | |
| as | |
| $$ | |
| select exists( | |
| select 1 | |
| from basejump.account_user wu | |
| where wu.user_id = auth.uid() | |
| and wu.account_id = has_role_on_account.account_id | |
| and ( | |
| wu.account_role = has_role_on_account.account_role | |
| or has_role_on_account.account_role is null | |
| ) | |
| ); | |
| $$; | |
| grant execute on function basejump.has_role_on_account(uuid, basejump.account_role) to authenticated, anon, public, service_role; | |
| /** | |
| * Returns account_ids that the current user is a member of. If you pass in a role, | |
| * it'll only return accounts that the user is a member of with that role. | |
| */ | |
| create or replace function basejump.get_accounts_with_role(passed_in_role basejump.account_role default null) | |
| returns setof uuid | |
| language sql | |
| security definer | |
| set search_path = public | |
| as | |
| $$ | |
| select account_id | |
| from basejump.account_user wu | |
| where wu.user_id = auth.uid() | |
| and ( | |
| wu.account_role = passed_in_role | |
| or passed_in_role is null | |
| ); | |
| $$; | |
| grant execute on function basejump.get_accounts_with_role(basejump.account_role) to authenticated; | |
| /** | |
| * ------------------------- | |
| * Section - RLS Policies | |
| * ------------------------- | |
| * This is where we define access to tables in the basejump schema | |
| */ | |
| create policy "users can view their own account_users" on basejump.account_user | |
| for select | |
| to authenticated | |
| using ( | |
| user_id = auth.uid() | |
| ); | |
| create policy "users can view their teammates" on basejump.account_user | |
| for select | |
| to authenticated | |
| using ( | |
| basejump.has_role_on_account(account_id) = true | |
| ); | |
| create policy "Account users can be deleted by owners except primary account owner" on basejump.account_user | |
| for delete | |
| to authenticated | |
| using ( | |
| (basejump.has_role_on_account(account_id, 'owner') = true) | |
| AND | |
| user_id != (select primary_owner_user_id | |
| from basejump.accounts | |
| where account_id = accounts.id) | |
| ); | |
| create policy "Accounts are viewable by members" on basejump.accounts | |
| for select | |
| to authenticated | |
| using ( | |
| basejump.has_role_on_account(id) = true | |
| ); | |
| -- Primary owner should always have access to the account | |
| create policy "Accounts are viewable by primary owner" on basejump.accounts | |
| for select | |
| to authenticated | |
| using ( | |
| primary_owner_user_id = auth.uid() | |
| ); | |
| create policy "Team accounts can be created by any user" on basejump.accounts | |
| for insert | |
| to authenticated | |
| with check ( | |
| basejump.is_set('enable_team_accounts') = true | |
| and personal_account = false | |
| ); | |
| create policy "Accounts can be edited by owners" on basejump.accounts | |
| for update | |
| to authenticated | |
| using ( | |
| basejump.has_role_on_account(id, 'owner') = true | |
| ); | |
| /** | |
| * ------------------------------------------------------- | |
| * Section - Public functions | |
| * ------------------------------------------------------- | |
| * Each of these functions exists in the public name space because they are accessible | |
| * via the API. it is the primary way developers can interact with Basejump accounts | |
| */ | |
| /** | |
| * Returns the account_id for a given account slug | |
| */ | |
| create or replace function public.get_account_id(slug text) | |
| returns uuid | |
| language sql | |
| as | |
| $$ | |
| select id | |
| from basejump.accounts | |
| where slug = get_account_id.slug; | |
| $$; | |
| grant execute on function public.get_account_id(text) to authenticated, service_role; | |
| /** | |
| * Returns the current user's role within a given account_id | |
| */ | |
| create or replace function public.current_user_account_role(account_id uuid) | |
| returns jsonb | |
| language plpgsql | |
| as | |
| $$ | |
| DECLARE | |
| response jsonb; | |
| BEGIN | |
| select jsonb_build_object( | |
| 'account_role', wu.account_role, | |
| 'is_primary_owner', a.primary_owner_user_id = auth.uid(), | |
| 'is_personal_account', a.personal_account | |
| ) | |
| into response | |
| from basejump.account_user wu | |
| join basejump.accounts a on a.id = wu.account_id | |
| where wu.user_id = auth.uid() | |
| and wu.account_id = current_user_account_role.account_id; | |
| -- if the user is not a member of the account, throw an error | |
| if response ->> 'account_role' IS NULL then | |
| raise exception 'Not found'; | |
| end if; | |
| return response; | |
| END | |
| $$; | |
| grant execute on function public.current_user_account_role(uuid) to authenticated; | |
| /** | |
| * Let's you update a users role within an account if you are an owner of that account | |
| **/ | |
| create or replace function public.update_account_user_role(account_id uuid, user_id uuid, | |
| new_account_role basejump.account_role, | |
| make_primary_owner boolean default false) | |
| returns void | |
| security definer | |
| set search_path = public | |
| language plpgsql | |
| as | |
| $$ | |
| declare | |
| is_account_owner boolean; | |
| is_account_primary_owner boolean; | |
| changing_primary_owner boolean; | |
| begin | |
| -- check if the user is an owner, and if they are, allow them to update the role | |
| select basejump.has_role_on_account(update_account_user_role.account_id, 'owner') into is_account_owner; | |
| if not is_account_owner then | |
| raise exception 'You must be an owner of the account to update a users role'; | |
| end if; | |
| -- check if the user being changed is the primary owner, if so its not allowed | |
| select primary_owner_user_id = auth.uid(), primary_owner_user_id = update_account_user_role.user_id | |
| into is_account_primary_owner, changing_primary_owner | |
| from basejump.accounts | |
| where id = update_account_user_role.account_id; | |
| if changing_primary_owner = true and is_account_primary_owner = false then | |
| raise exception 'You must be the primary owner of the account to change the primary owner'; | |
| end if; | |
| update basejump.account_user au | |
| set account_role = new_account_role | |
| where au.account_id = update_account_user_role.account_id | |
| and au.user_id = update_account_user_role.user_id; | |
| if make_primary_owner = true then | |
| -- first we see if the current user is the owner, only they can do this | |
| if is_account_primary_owner = false then | |
| raise exception 'You must be the primary owner of the account to change the primary owner'; | |
| end if; | |
| update basejump.accounts | |
| set primary_owner_user_id = update_account_user_role.user_id | |
| where id = update_account_user_role.account_id; | |
| end if; | |
| end; | |
| $$; | |
| grant execute on function public.update_account_user_role(uuid, uuid, basejump.account_role, boolean) to authenticated; | |
| /** | |
| Returns the current user's accounts | |
| */ | |
| create or replace function public.get_accounts() | |
| returns json | |
| language sql | |
| as | |
| $$ | |
| select coalesce(json_agg( | |
| json_build_object( | |
| 'account_id', wu.account_id, | |
| 'account_role', wu.account_role, | |
| 'is_primary_owner', a.primary_owner_user_id = auth.uid(), | |
| 'name', a.name, | |
| 'slug', a.slug, | |
| 'personal_account', a.personal_account, | |
| 'created_at', a.created_at, | |
| 'updated_at', a.updated_at | |
| ) | |
| ), '[]'::json) | |
| from basejump.account_user wu | |
| join basejump.accounts a on a.id = wu.account_id | |
| where wu.user_id = auth.uid(); | |
| $$; | |
| grant execute on function public.get_accounts() to authenticated; | |
| /** | |
| Returns a specific account that the current user has access to | |
| */ | |
| create or replace function public.get_account(account_id uuid) | |
| returns json | |
| language plpgsql | |
| as | |
| $$ | |
| BEGIN | |
| -- check if the user is a member of the account or a service_role user | |
| if current_user IN ('anon', 'authenticated') and | |
| (select current_user_account_role(get_account.account_id) ->> 'account_role' IS NULL) then | |
| raise exception 'You must be a member of an account to access it'; | |
| end if; | |
| return (select json_build_object( | |
| 'account_id', a.id, | |
| 'account_role', wu.account_role, | |
| 'is_primary_owner', a.primary_owner_user_id = auth.uid(), | |
| 'name', a.name, | |
| 'slug', a.slug, | |
| 'personal_account', a.personal_account, | |
| 'billing_enabled', case | |
| when a.personal_account = true then | |
| config.enable_personal_account_billing | |
| else | |
| config.enable_team_account_billing | |
| end, | |
| 'billing_status', bs.status, | |
| 'created_at', a.created_at, | |
| 'updated_at', a.updated_at, | |
| 'metadata', a.public_metadata | |
| ) | |
| from basejump.accounts a | |
| left join basejump.account_user wu on a.id = wu.account_id and wu.user_id = auth.uid() | |
| join basejump.config config on true | |
| left join (select bs.account_id, status | |
| from basejump.billing_subscriptions bs | |
| where bs.account_id = get_account.account_id | |
| order by created desc | |
| limit 1) bs on bs.account_id = a.id | |
| where a.id = get_account.account_id); | |
| END; | |
| $$; | |
| grant execute on function public.get_account(uuid) to authenticated, service_role; | |
| /** | |
| Returns a specific account that the current user has access to | |
| */ | |
| create or replace function public.get_account_by_slug(slug text) | |
| returns json | |
| language plpgsql | |
| as | |
| $$ | |
| DECLARE | |
| internal_account_id uuid; | |
| BEGIN | |
| select a.id | |
| into internal_account_id | |
| from basejump.accounts a | |
| where a.slug IS NOT NULL | |
| and a.slug = get_account_by_slug.slug; | |
| return public.get_account(internal_account_id); | |
| END; | |
| $$; | |
| grant execute on function public.get_account_by_slug(text) to authenticated; | |
| /** | |
| Returns the personal account for the current user | |
| */ | |
| create or replace function public.get_personal_account() | |
| returns json | |
| language plpgsql | |
| as | |
| $$ | |
| BEGIN | |
| return public.get_account(auth.uid()); | |
| END; | |
| $$; | |
| grant execute on function public.get_personal_account() to authenticated; | |
| /** | |
| * Create an account | |
| */ | |
| create or replace function public.create_account(slug text default null, name text default null) | |
| returns json | |
| language plpgsql | |
| as | |
| $$ | |
| DECLARE | |
| new_account_id uuid; | |
| BEGIN | |
| insert into basejump.accounts (slug, name) | |
| values (create_account.slug, create_account.name) | |
| returning id into new_account_id; | |
| return public.get_account(new_account_id); | |
| EXCEPTION | |
| WHEN unique_violation THEN | |
| raise exception 'An account with that unique ID already exists'; | |
| END; | |
| $$; | |
| grant execute on function public.create_account(slug text, name text) to authenticated; | |
| /** | |
| Update an account with passed in info. None of the info is required except for account ID. | |
| If you don't pass in a value for a field, it will not be updated. | |
| If you set replace_meta to true, the metadata will be replaced with the passed in metadata. | |
| If you set replace_meta to false, the metadata will be merged with the passed in metadata. | |
| */ | |
| create or replace function public.update_account(account_id uuid, slug text default null, name text default null, | |
| public_metadata jsonb default null, | |
| replace_metadata boolean default false) | |
| returns json | |
| language plpgsql | |
| as | |
| $$ | |
| BEGIN | |
| -- check if postgres role is service_role | |
| if current_user IN ('anon', 'authenticated') and | |
| not (select current_user_account_role(update_account.account_id) ->> 'account_role' = 'owner') then | |
| raise exception 'Only account owners can update an account'; | |
| end if; | |
| update basejump.accounts accounts | |
| set slug = coalesce(update_account.slug, accounts.slug), | |
| name = coalesce(update_account.name, accounts.name), | |
| public_metadata = case | |
| when update_account.public_metadata is null then accounts.public_metadata -- do nothing | |
| when accounts.public_metadata IS NULL then update_account.public_metadata -- set metadata | |
| when update_account.replace_metadata | |
| then update_account.public_metadata -- replace metadata | |
| else accounts.public_metadata || update_account.public_metadata end -- merge metadata | |
| where accounts.id = update_account.account_id; | |
| return public.get_account(account_id); | |
| END; | |
| $$; | |
| grant execute on function public.update_account(uuid, text, text, jsonb, boolean) to authenticated, service_role; | |
| /** | |
| Returns a list of current account members. Only account owners can access this function. | |
| It's a security definer because it requries us to lookup personal_accounts for existing members so we can | |
| get their names. | |
| */ | |
| create or replace function public.get_account_members(account_id uuid, results_limit integer default 50, | |
| results_offset integer default 0) | |
| returns json | |
| language plpgsql | |
| security definer | |
| set search_path = basejump | |
| as | |
| $$ | |
| BEGIN | |
| -- only account owners can access this function | |
| if (select public.current_user_account_role(get_account_members.account_id) ->> 'account_role' <> 'owner') then | |
| raise exception 'Only account owners can access this function'; | |
| end if; | |
| return (select json_agg( | |
| json_build_object( | |
| 'user_id', wu.user_id, | |
| 'account_role', wu.account_role, | |
| 'name', p.name, | |
| 'email', u.email, | |
| 'is_primary_owner', a.primary_owner_user_id = wu.user_id | |
| ) | |
| ) | |
| from basejump.account_user wu | |
| join basejump.accounts a on a.id = wu.account_id | |
| join basejump.accounts p on p.primary_owner_user_id = wu.user_id and p.personal_account = true | |
| join auth.users u on u.id = wu.user_id | |
| where wu.account_id = get_account_members.account_id | |
| limit coalesce(get_account_members.results_limit, 50) offset coalesce(get_account_members.results_offset, 0)); | |
| END; | |
| $$; | |
| grant execute on function public.get_account_members(uuid, integer, integer) to authenticated; | |
| /** | |
| Allows an owner of the account to remove any member other than the primary owner | |
| */ | |
| create or replace function public.remove_account_member(account_id uuid, user_id uuid) | |
| returns void | |
| language plpgsql | |
| as | |
| $$ | |
| BEGIN | |
| -- only account owners can access this function | |
| if basejump.has_role_on_account(remove_account_member.account_id, 'owner') <> true then | |
| raise exception 'Only account owners can access this function'; | |
| end if; | |
| delete | |
| from basejump.account_user wu | |
| where wu.account_id = remove_account_member.account_id | |
| and wu.user_id = remove_account_member.user_id; | |
| END; | |
| $$; | |
| grant execute on function public.remove_account_member(uuid, uuid) to authenticated; |