diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..efa8bff223657ea756cb1792a90dcde0f3348017 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +banner.png filter=lfs diff=lfs merge=lfs -text +diagram.png filter=lfs diff=lfs merge=lfs -text +holo.png filter=lfs diff=lfs merge=lfs -text +mac.png filter=lfs diff=lfs merge=lfs -text +worldoscollage.mp4 filter=lfs diff=lfs merge=lfs -text diff --git a/20240414161707_basejump-setup.sql b/20240414161707_basejump-setup.sql new file mode 100644 index 0000000000000000000000000000000000000000..bbc2afdac405a7f309b3e210fe8cedb662d8985e --- /dev/null +++ b/20240414161707_basejump-setup.sql @@ -0,0 +1,186 @@ +/** + ____ _ + | _ \ (_) + | |_) | __ _ ___ ___ _ _ _ _ __ ___ _ __ + | _ < / _` / __|/ _ \ | | | | '_ ` _ \| '_ \ + | |_) | (_| \__ \ __/ | |_| | | | | | | |_) | + |____/ \__,_|___/\___| |\__,_|_| |_| |_| .__/ + _/ | | | + |__/ |_| + + Basejump is a starter kit for building SaaS products on top of Supabase. + Learn more at https://usebasejump.com + */ + + +/** + * ------------------------------------------------------- + * Section - Basejump schema setup and utility functions + * ------------------------------------------------------- + */ + +-- revoke execution by default from public +ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; +ALTER DEFAULT PRIVILEGES IN SCHEMA PUBLIC REVOKE EXECUTE ON FUNCTIONS FROM anon, authenticated; + +-- Create basejump schema +CREATE SCHEMA IF NOT EXISTS basejump; +GRANT USAGE ON SCHEMA basejump to authenticated; +GRANT USAGE ON SCHEMA basejump to service_role; + +/** + * ------------------------------------------------------- + * Section - Enums + * ------------------------------------------------------- + */ + +/** + * Invitation types are either email or link. Email invitations are sent to + * a single user and can only be claimed once. Link invitations can be used multiple times + * Both expire after 24 hours + */ +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 = 'invitation_type' + AND n.nspname = 'basejump') THEN + CREATE TYPE basejump.invitation_type AS ENUM ('one_time', '24_hour'); + end if; + end; +$$; + +/** + * ------------------------------------------------------- + * Section - Basejump settings + * ------------------------------------------------------- + */ + +CREATE TABLE IF NOT EXISTS basejump.config +( + enable_team_accounts boolean default true, + enable_personal_account_billing boolean default true, + enable_team_account_billing boolean default true, + billing_provider text default 'stripe' +); + +-- create config row +INSERT INTO basejump.config (enable_team_accounts, enable_personal_account_billing, enable_team_account_billing) +VALUES (true, true, true); + +-- enable select on the config table +GRANT SELECT ON basejump.config TO authenticated, service_role; + +-- enable RLS on config +ALTER TABLE basejump.config + ENABLE ROW LEVEL SECURITY; + +create policy "Basejump settings can be read by authenticated users" on basejump.config + for select + to authenticated + using ( + true + ); + +/** + * ------------------------------------------------------- + * Section - Basejump utility functions + * ------------------------------------------------------- + */ + +/** + basejump.get_config() + Get the full config object to check basejump settings + This is not accessible from the outside, so can only be used inside postgres functions + */ +CREATE OR REPLACE FUNCTION basejump.get_config() + RETURNS json AS +$$ +DECLARE + result RECORD; +BEGIN + SELECT * from basejump.config limit 1 into result; + return row_to_json(result); +END; +$$ LANGUAGE plpgsql; + +grant execute on function basejump.get_config() to authenticated, service_role; + + +/** + basejump.is_set("field_name") + Check a specific boolean config value + */ +CREATE OR REPLACE FUNCTION basejump.is_set(field_name text) + RETURNS boolean AS +$$ +DECLARE + result BOOLEAN; +BEGIN + execute format('select %I from basejump.config limit 1', field_name) into result; + return result; +END; +$$ LANGUAGE plpgsql; + +grant execute on function basejump.is_set(text) to authenticated; + + +/** + * Automatic handling for maintaining created_at and updated_at timestamps + * on tables + */ +CREATE OR REPLACE FUNCTION basejump.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; + + +/** + * Automatic handling for maintaining created_by and updated_by timestamps + * on tables + */ +CREATE OR REPLACE FUNCTION basejump.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; + +/** + basejump.generate_token(length) + Generates a secure token - used internally for invitation tokens + but could be used elsewhere. Check out the invitations table for more info on + how it's used + */ +CREATE OR REPLACE FUNCTION basejump.generate_token(length int) + RETURNS text AS +$$ +select regexp_replace(replace( + replace(replace(replace(encode(gen_random_bytes(length)::bytea, 'base64'), '/', ''), '+', + ''), '\', ''), + '=', + ''), E'[\\n\\r]+', '', 'g'); +$$ LANGUAGE sql; + +grant execute on function basejump.generate_token(int) to authenticated; \ No newline at end of file diff --git a/20240414161947_basejump-accounts.sql b/20240414161947_basejump-accounts.sql new file mode 100644 index 0000000000000000000000000000000000000000..c85c79b7ba92c347ccb85806dfdf527a12111c91 --- /dev/null +++ b/20240414161947_basejump-accounts.sql @@ -0,0 +1,708 @@ +/** + ____ _ + | _ \ (_) + | |_) | __ _ ___ ___ _ _ _ _ __ ___ _ __ + | _ < / _` / __|/ _ \ | | | | '_ ` _ \| '_ \ + | |_) | (_| \__ \ __/ | |_| | | | | | | |_) | + |____/ \__,_|___/\___| |\__,_|_| |_| |_| .__/ + _/ | | | + |__/ |_| + + 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; \ No newline at end of file diff --git a/20240414162100_basejump-invitations.sql b/20240414162100_basejump-invitations.sql new file mode 100644 index 0000000000000000000000000000000000000000..1b094fdc34ace744a79ae85bc660eb9c9e505245 --- /dev/null +++ b/20240414162100_basejump-invitations.sql @@ -0,0 +1,270 @@ +/** + * ------------------------------------------------------- + * Section - Invitations + * ------------------------------------------------------- + */ + +/** + * Invitations are sent to users to join a account + * They pre-define the role the user should have once they join + */ +create table if not exists basejump.invitations +( + -- the id of the invitation + id uuid unique not null default extensions.uuid_generate_v4(), + -- what role should invitation accepters be given in this account + account_role basejump.account_role not null, + -- the account the invitation is for + account_id uuid references basejump.accounts (id) on delete cascade not null, + -- unique token used to accept the invitation + token text unique not null default basejump.generate_token(30), + -- who created the invitation + invited_by_user_id uuid references auth.users not null, + -- account name. filled in by a trigger + account_name text, + -- when the invitation was last updated + updated_at timestamp with time zone, + -- when the invitation was created + created_at timestamp with time zone, + -- what type of invitation is this + invitation_type basejump.invitation_type not null, + primary key (id) +); + +-- Open up access to invitations +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE basejump.invitations TO authenticated, service_role; + +-- manage timestamps +CREATE TRIGGER basejump_set_invitations_timestamp + BEFORE INSERT OR UPDATE + ON basejump.invitations + FOR EACH ROW +EXECUTE FUNCTION basejump.trigger_set_timestamps(); + +/** + * This funciton fills in account info and inviting user email + * so that the recipient can get more info about the invitation prior to + * accepting. It allows us to avoid complex permissions on accounts + */ +CREATE OR REPLACE FUNCTION basejump.trigger_set_invitation_details() + RETURNS TRIGGER AS +$$ +BEGIN + NEW.invited_by_user_id = auth.uid(); + NEW.account_name = (select name from basejump.accounts where id = NEW.account_id); + RETURN NEW; +END +$$ LANGUAGE plpgsql; + +CREATE TRIGGER basejump_trigger_set_invitation_details + BEFORE INSERT + ON basejump.invitations + FOR EACH ROW +EXECUTE FUNCTION basejump.trigger_set_invitation_details(); + +-- enable RLS on invitations +alter table basejump.invitations + enable row level security; + +/** + * ------------------------- + * Section - RLS Policies + * ------------------------- + * This is where we define access to tables in the basejump schema + */ + + create policy "Invitations viewable by account owners" on basejump.invitations + for select + to authenticated + using ( + created_at > (now() - interval '24 hours') + and + basejump.has_role_on_account(account_id, 'owner') = true + ); + + +create policy "Invitations can be created by account owners" on basejump.invitations + for insert + to authenticated + with check ( + -- team accounts should be enabled + basejump.is_set('enable_team_accounts') = true + -- this should not be a personal account + and (SELECT personal_account + FROM basejump.accounts + WHERE id = account_id) = false + -- the inserting user should be an owner of the account + and + (basejump.has_role_on_account(account_id, 'owner') = true) + ); + +create policy "Invitations can be deleted by account owners" on basejump.invitations + for delete + to authenticated + using ( + basejump.has_role_on_account(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 a list of currently active invitations for a given account + */ + +create or replace function public.get_account_invitations(account_id uuid, results_limit integer default 25, + results_offset integer default 0) + returns json + language plpgsql +as +$$ +BEGIN + -- only account owners can access this function + if (select public.current_user_account_role(get_account_invitations.account_id) ->> 'account_role' <> 'owner') then + raise exception 'Only account owners can access this function'; + end if; + + return (select json_agg( + json_build_object( + 'account_role', i.account_role, + 'created_at', i.created_at, + 'invitation_type', i.invitation_type, + 'invitation_id', i.id + ) + ) + from basejump.invitations i + where i.account_id = get_account_invitations.account_id + and i.created_at > now() - interval '24 hours' + limit coalesce(get_account_invitations.results_limit, 25) offset coalesce(get_account_invitations.results_offset, 0)); +END; +$$; + +grant execute on function public.get_account_invitations(uuid, integer, integer) to authenticated; + + +/** + * Allows a user to accept an existing invitation and join a account + * This one exists in the public schema because we want it to be called + * using the supabase rpc method + */ +create or replace function public.accept_invitation(lookup_invitation_token text) + returns jsonb + language plpgsql + security definer set search_path = public, basejump +as +$$ +declare + lookup_account_id uuid; + declare new_member_role basejump.account_role; + lookup_account_slug text; +begin + select i.account_id, i.account_role, a.slug + into lookup_account_id, new_member_role, lookup_account_slug + from basejump.invitations i + join basejump.accounts a on a.id = i.account_id + where i.token = lookup_invitation_token + and i.created_at > now() - interval '24 hours'; + + if lookup_account_id IS NULL then + raise exception 'Invitation not found'; + end if; + + if lookup_account_id is not null then + -- we've validated the token is real, so grant the user access + insert into basejump.account_user (account_id, user_id, account_role) + values (lookup_account_id, auth.uid(), new_member_role); + -- email types of invitations are only good for one usage + delete from basejump.invitations where token = lookup_invitation_token and invitation_type = 'one_time'; + end if; + return json_build_object('account_id', lookup_account_id, 'account_role', new_member_role, 'slug', + lookup_account_slug); +EXCEPTION + WHEN unique_violation THEN + raise exception 'You are already a member of this account'; +end; +$$; + +grant execute on function public.accept_invitation(text) to authenticated; + + +/** + * Allows a user to lookup an existing invitation and join a account + * This one exists in the public schema because we want it to be called + * using the supabase rpc method + */ +create or replace function public.lookup_invitation(lookup_invitation_token text) + returns json + language plpgsql + security definer set search_path = public, basejump +as +$$ +declare + name text; + invitation_active boolean; +begin + select account_name, + case when id IS NOT NULL then true else false end as active + into name, invitation_active + from basejump.invitations + where token = lookup_invitation_token + and created_at > now() - interval '24 hours' + limit 1; + return json_build_object('active', coalesce(invitation_active, false), 'account_name', name); +end; +$$; + +grant execute on function public.lookup_invitation(text) to authenticated; + + +/** + Allows a user to create a new invitation if they are an owner of an account + */ +create or replace function public.create_invitation(account_id uuid, account_role basejump.account_role, + invitation_type basejump.invitation_type) + returns json + language plpgsql +as +$$ +declare + new_invitation basejump.invitations; +begin + insert into basejump.invitations (account_id, account_role, invitation_type, invited_by_user_id) + values (account_id, account_role, invitation_type, auth.uid()) + returning * into new_invitation; + + return json_build_object('token', new_invitation.token); +end +$$; + +grant execute on function public.create_invitation(uuid, basejump.account_role, basejump.invitation_type) to authenticated; + +/** + Allows an owner to delete an existing invitation + */ + +create or replace function public.delete_invitation(invitation_id uuid) + returns void + language plpgsql +as +$$ +begin + -- verify account owner for the invitation + if basejump.has_role_on_account( + (select account_id from basejump.invitations where id = delete_invitation.invitation_id), 'owner') <> + true then + raise exception 'Only account owners can delete invitations'; + end if; + + delete from basejump.invitations where id = delete_invitation.invitation_id; +end +$$; + +grant execute on function public.delete_invitation(uuid) to authenticated; \ No newline at end of file diff --git a/20240414162131_basejump-billing.sql b/20240414162131_basejump-billing.sql new file mode 100644 index 0000000000000000000000000000000000000000..19468fc7d828d98a9e44914520a6a2caea236eae --- /dev/null +++ b/20240414162131_basejump-billing.sql @@ -0,0 +1,236 @@ +/** + * ------------------------------------------------------- + * Section - Billing + * ------------------------------------------------------- + */ + +/** +* Subscription Status +* Tracks the current status of the account subscription +*/ +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'subscription_status' + AND n.nspname = 'basejump') THEN + create type basejump.subscription_status as enum ( + 'trialing', + 'active', + 'canceled', + 'incomplete', + 'incomplete_expired', + 'past_due', + 'unpaid' + ); + end if; + end; +$$; + + +/** + * Billing customer + * This is a private table that contains a mapping of user IDs to your billing providers IDs + */ +create table if not exists basejump.billing_customers +( + -- UUID from auth.users + account_id uuid references basejump.accounts (id) on delete cascade not null, + -- The user's customer ID in Stripe. User must not be able to update this. + id text primary key, + -- The email address the customer wants to use for invoicing + email text, + -- The active status of a customer + active boolean, + -- The billing provider the customer is using + provider text +); + +-- Open up access to billing_customers +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE basejump.billing_customers TO service_role; +GRANT SELECT ON TABLE basejump.billing_customers TO authenticated; + + +-- enable RLS for billing_customers +alter table + basejump.billing_customers + enable row level security; + +/** + * Billing subscriptions + * This is a private table that contains a mapping of account IDs to your billing providers subscription IDs + */ +create table if not exists basejump.billing_subscriptions +( + -- Subscription ID from Stripe, e.g. sub_1234. + id text primary key, + account_id uuid references basejump.accounts (id) on delete cascade not null, + billing_customer_id text references basejump.billing_customers (id) on delete cascade not null, + -- The status of the subscription object, one of subscription_status type above. + status basejump.subscription_status, + -- Set of key-value pairs, used to store additional information about the object in a structured format. + metadata jsonb, + -- ID of the price that created this subscription. + price_id text, + plan_name text, + -- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats. + quantity integer, + -- If true the subscription has been canceled by the user and will be deleted at the end of the billing period. + cancel_at_period_end boolean, + -- Time at which the subscription was created. + created timestamp with time zone default timezone('utc' :: text, now()) not null, + -- Start of the current period that the subscription has been invoiced for. + current_period_start timestamp with time zone default timezone('utc' :: text, now()) not null, + -- End of the current period that the subscription has been invoiced for. At the end of this period, a new invoice will be created. + current_period_end timestamp with time zone default timezone('utc' :: text, now()) not null, + -- If the subscription has ended, the timestamp of the date the subscription ended. + ended_at timestamp with time zone default timezone('utc' :: text, now()), + -- A date in the future at which the subscription will automatically get canceled. + cancel_at timestamp with time zone default timezone('utc' :: text, now()), + -- If the subscription has been canceled, the date of that cancellation. If the subscription was canceled with `cancel_at_period_end`, `canceled_at` will still reflect the date of the initial cancellation request, not the end of the subscription period when the subscription is automatically moved to a canceled state. + canceled_at timestamp with time zone default timezone('utc' :: text, now()), + -- If the subscription has a trial, the beginning of that trial. + trial_start timestamp with time zone default timezone('utc' :: text, now()), + -- If the subscription has a trial, the end of that trial. + trial_end timestamp with time zone default timezone('utc' :: text, now()), + provider text +); + +-- Open up access to billing_subscriptions +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE basejump.billing_subscriptions TO service_role; +GRANT SELECT ON TABLE basejump.billing_subscriptions TO authenticated; + +-- enable RLS for billing_subscriptions +alter table + basejump.billing_subscriptions + enable row level security; + +/** + * ------------------------- + * Section - RLS Policies + * ------------------------- + * This is where we define access to tables in the basejump schema + */ + +create policy "Can only view own billing customer data." on basejump.billing_customers for + select + using ( + basejump.has_role_on_account(account_id) = true + ); + + +create policy "Can only view own billing subscription data." on basejump.billing_subscriptions for + select + using ( + basejump.has_role_on_account(account_id) = 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 current billing status for an account + */ +CREATE OR REPLACE FUNCTION public.get_account_billing_status(account_id uuid) + RETURNS jsonb + security definer + set search_path = public, basejump +AS +$$ +DECLARE + result jsonb; + role_result jsonb; +BEGIN + select public.current_user_account_role(get_account_billing_status.account_id) into role_result; + + select jsonb_build_object( + 'account_id', get_account_billing_status.account_id, + 'billing_subscription_id', s.id, + 'billing_enabled', case + when a.personal_account = true then config.enable_personal_account_billing + else config.enable_team_account_billing end, + 'billing_status', s.status, + 'billing_customer_id', c.id, + 'billing_provider', config.billing_provider, + 'billing_email', + coalesce(c.email, u.email) -- if we don't have a customer email, use the user's email as a fallback + ) + into result + from basejump.accounts a + join auth.users u on u.id = a.primary_owner_user_id + left join basejump.billing_subscriptions s on s.account_id = a.id + left join basejump.billing_customers c on c.account_id = coalesce(s.account_id, a.id) + join basejump.config config on true + where a.id = get_account_billing_status.account_id + order by s.created desc + limit 1; + + return result || role_result; +END; +$$ LANGUAGE plpgsql; + +grant execute on function public.get_account_billing_status(uuid) to authenticated; + +/** + * Allow service accounts to upsert the billing data for an account + */ +CREATE OR REPLACE FUNCTION public.service_role_upsert_customer_subscription(account_id uuid, + customer jsonb default null, + subscription jsonb default null) + RETURNS void AS +$$ +BEGIN + -- if the customer is not null, upsert the data into billing_customers, only upsert fields that are present in the jsonb object + if customer is not null then + insert into basejump.billing_customers (id, account_id, email, provider) + values (customer ->> 'id', service_role_upsert_customer_subscription.account_id, customer ->> 'billing_email', + (customer ->> 'provider')) + on conflict (id) do update + set email = customer ->> 'billing_email'; + end if; + + -- if the subscription is not null, upsert the data into billing_subscriptions, only upsert fields that are present in the jsonb object + if subscription is not null then + insert into basejump.billing_subscriptions (id, account_id, billing_customer_id, status, metadata, price_id, + quantity, cancel_at_period_end, created, current_period_start, + current_period_end, ended_at, cancel_at, canceled_at, trial_start, + trial_end, plan_name, provider) + values (subscription ->> 'id', service_role_upsert_customer_subscription.account_id, + subscription ->> 'billing_customer_id', (subscription ->> 'status')::basejump.subscription_status, + subscription -> 'metadata', + subscription ->> 'price_id', (subscription ->> 'quantity')::int, + (subscription ->> 'cancel_at_period_end')::boolean, + (subscription ->> 'created')::timestamptz, (subscription ->> 'current_period_start')::timestamptz, + (subscription ->> 'current_period_end')::timestamptz, (subscription ->> 'ended_at')::timestamptz, + (subscription ->> 'cancel_at')::timestamptz, + (subscription ->> 'canceled_at')::timestamptz, (subscription ->> 'trial_start')::timestamptz, + (subscription ->> 'trial_end')::timestamptz, + subscription ->> 'plan_name', (subscription ->> 'provider')) + on conflict (id) do update + set billing_customer_id = subscription ->> 'billing_customer_id', + status = (subscription ->> 'status')::basejump.subscription_status, + metadata = subscription -> 'metadata', + price_id = subscription ->> 'price_id', + quantity = (subscription ->> 'quantity')::int, + cancel_at_period_end = (subscription ->> 'cancel_at_period_end')::boolean, + current_period_start = (subscription ->> 'current_period_start')::timestamptz, + current_period_end = (subscription ->> 'current_period_end')::timestamptz, + ended_at = (subscription ->> 'ended_at')::timestamptz, + cancel_at = (subscription ->> 'cancel_at')::timestamptz, + canceled_at = (subscription ->> 'canceled_at')::timestamptz, + trial_start = (subscription ->> 'trial_start')::timestamptz, + trial_end = (subscription ->> 'trial_end')::timestamptz, + plan_name = subscription ->> 'plan_name'; + end if; +end; +$$ LANGUAGE plpgsql; + +GRANT EXECUTE ON FUNCTION public.service_role_upsert_customer_subscription(uuid, jsonb, jsonb) TO service_role; \ No newline at end of file diff --git a/20250409211903_basejump-configure.sql b/20250409211903_basejump-configure.sql new file mode 100644 index 0000000000000000000000000000000000000000..fe198d5a11add3986fd194d83da794fdac754143 --- /dev/null +++ b/20250409211903_basejump-configure.sql @@ -0,0 +1,3 @@ +UPDATE basejump.config SET enable_team_accounts = TRUE; +UPDATE basejump.config SET enable_personal_account_billing = TRUE; +UPDATE basejump.config SET enable_team_account_billing = TRUE; diff --git a/20250409212058_initial.sql b/20250409212058_initial.sql new file mode 100644 index 0000000000000000000000000000000000000000..37559db6837ffe094ba98d9ff37248c6814bcc25 --- /dev/null +++ b/20250409212058_initial.sql @@ -0,0 +1,189 @@ +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Create devices table first +CREATE TABLE public.devices ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL, + name TEXT, + last_seen TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + is_online BOOLEAN DEFAULT FALSE, + CONSTRAINT fk_account FOREIGN KEY (account_id) REFERENCES basejump.accounts(id) ON DELETE CASCADE +); + +-- Create recordings table +CREATE TABLE public.recordings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL, + device_id UUID NOT NULL, + preprocessed_file_path TEXT, + meta JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + name TEXT, + ui_annotated BOOLEAN DEFAULT FALSE, + a11y_file_path TEXT, + audio_file_path TEXT, + action_annotated BOOLEAN DEFAULT FALSE, + raw_data_file_path TEXT, + metadata_file_path TEXT, + action_training_file_path TEXT, + CONSTRAINT fk_account FOREIGN KEY (account_id) REFERENCES basejump.accounts(id) ON DELETE CASCADE, + CONSTRAINT fk_device FOREIGN KEY (device_id) REFERENCES public.devices(id) ON DELETE CASCADE +); + +-- Create indexes for foreign keys +CREATE INDEX idx_recordings_account_id ON public.recordings(account_id); +CREATE INDEX idx_recordings_device_id ON public.recordings(device_id); +CREATE INDEX idx_devices_account_id ON public.devices(account_id); + +-- Add RLS policies (optional, can be customized as needed) +ALTER TABLE public.recordings ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.devices ENABLE ROW LEVEL SECURITY; + +-- Create RLS policies for devices +CREATE POLICY "Account members can delete their own devices" + ON public.devices FOR DELETE + USING (basejump.has_role_on_account(account_id)); + +CREATE POLICY "Account members can insert their own devices" + ON public.devices FOR INSERT + WITH CHECK (basejump.has_role_on_account(account_id)); + +CREATE POLICY "Account members can only access their own devices" + ON public.devices FOR ALL + USING (basejump.has_role_on_account(account_id)); + +CREATE POLICY "Account members can update their own devices" + ON public.devices FOR UPDATE + USING (basejump.has_role_on_account(account_id)); + +CREATE POLICY "Account members can view their own devices" + ON public.devices FOR SELECT + USING (basejump.has_role_on_account(account_id)); + +-- Create RLS policies for recordings +CREATE POLICY "Account members can delete their own recordings" + ON public.recordings FOR DELETE + USING (basejump.has_role_on_account(account_id)); + +CREATE POLICY "Account members can insert their own recordings" + ON public.recordings FOR INSERT + WITH CHECK (basejump.has_role_on_account(account_id)); + +CREATE POLICY "Account members can only access their own recordings" + ON public.recordings FOR ALL + USING (basejump.has_role_on_account(account_id)); + +CREATE POLICY "Account members can update their own recordings" + ON public.recordings FOR UPDATE + USING (basejump.has_role_on_account(account_id)); + +CREATE POLICY "Account members can view their own recordings" + ON public.recordings FOR SELECT + USING (basejump.has_role_on_account(account_id)); + +-- Note: For threads and messages, you might want different RLS policies +-- depending on your application's requirements + + +-- Also drop the old function signature +DROP FUNCTION IF EXISTS transfer_device(UUID, UUID, TEXT); + + +CREATE OR REPLACE FUNCTION transfer_device( + device_id UUID, -- Parameter remains UUID + new_account_id UUID, -- Changed parameter name and implies new ownership target + device_name TEXT DEFAULT NULL +) +RETURNS SETOF devices AS $$ +DECLARE + device_exists BOOLEAN; + updated_device devices; +BEGIN + -- Check if a device with the specified UUID exists + SELECT EXISTS ( + SELECT 1 FROM devices WHERE id = device_id + ) INTO device_exists; + + IF device_exists THEN + -- Device exists: update its account ownership and last_seen timestamp + UPDATE devices + SET + account_id = new_account_id, -- Update account_id instead of user_id + name = COALESCE(device_name, name), + last_seen = NOW() + WHERE id = device_id + RETURNING * INTO updated_device; + + RETURN NEXT updated_device; + ELSE + -- Device doesn't exist; return nothing so the caller can handle creation + RETURN; + END IF; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Grant execute permission so that authenticated users can call this function +-- Updated function signature +GRANT EXECUTE ON FUNCTION transfer_device(UUID, UUID, TEXT) TO authenticated; + + + + +-- Create the ui_grounding bucket +INSERT INTO storage.buckets (id, name, public) +VALUES ('ui_grounding', 'ui_grounding', false) +ON CONFLICT (id) DO NOTHING; -- Avoid error if bucket already exists + +-- Create the ui_grounding_trajs bucket +INSERT INTO storage.buckets (id, name, public) +VALUES ('ui_grounding_trajs', 'ui_grounding_trajs', false) +ON CONFLICT (id) DO NOTHING; -- Avoid error if bucket already exists + +-- Create the recordings bucket +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ('recordings', 'recordings', false, null, null) -- Set file size limit and mime types as needed +ON CONFLICT (id) DO NOTHING; -- Avoid error if bucket already exists + + +-- RLS policies for the 'recordings' bucket +-- Allow members to view files in accounts they belong to +CREATE POLICY "Account members can select recording files" + ON storage.objects FOR SELECT + TO authenticated + USING ( + bucket_id = 'recordings' AND + (storage.foldername(name))[1]::uuid IN (SELECT basejump.get_accounts_with_role()) + ); + +-- Allow members to insert files into accounts they belong to +CREATE POLICY "Account members can insert recording files" + ON storage.objects FOR INSERT + TO authenticated + WITH CHECK ( + bucket_id = 'recordings' AND + (storage.foldername(name))[1]::uuid IN (SELECT basejump.get_accounts_with_role()) + ); + +-- Allow members to update files in accounts they belong to +CREATE POLICY "Account members can update recording files" + ON storage.objects FOR UPDATE + TO authenticated + USING ( + bucket_id = 'recordings' AND + (storage.foldername(name))[1]::uuid IN (SELECT basejump.get_accounts_with_role()) + ); + +-- Allow members to delete files from accounts they belong to +-- Consider restricting this further, e.g., to 'owner' role if needed: +-- (storage.foldername(name))[1]::uuid IN (SELECT basejump.get_accounts_with_role('owner')) +CREATE POLICY "Account members can delete recording files" + ON storage.objects FOR DELETE + TO authenticated + USING ( + bucket_id = 'recordings' AND + (storage.foldername(name))[1]::uuid IN (SELECT basejump.get_accounts_with_role()) + ); diff --git a/20250416133920_agentpress_schema.sql b/20250416133920_agentpress_schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..b6a905ae45a354d65edc8a8beb679569722272ee --- /dev/null +++ b/20250416133920_agentpress_schema.sql @@ -0,0 +1,382 @@ +-- AGENTPRESS SCHEMA: +-- Create projects table +CREATE TABLE projects ( + project_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + account_id UUID NOT NULL REFERENCES basejump.accounts(id) ON DELETE CASCADE, + sandbox JSONB DEFAULT '{}'::jsonb, + is_public BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL +); + +-- Create threads table +CREATE TABLE threads ( + thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID REFERENCES basejump.accounts(id) ON DELETE CASCADE, + project_id UUID REFERENCES projects(project_id) ON DELETE CASCADE, + is_public BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL +); + +-- Create messages table +CREATE TABLE messages ( + message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + thread_id UUID NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE, + type TEXT NOT NULL, + is_llm_message BOOLEAN NOT NULL DEFAULT TRUE, + content JSONB NOT NULL, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL +); + +-- Create agent_runs table +CREATE TABLE agent_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + thread_id UUID NOT NULL REFERENCES threads(thread_id), + status TEXT NOT NULL DEFAULT 'running', + started_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL, + completed_at TIMESTAMP WITH TIME ZONE, + responses JSONB NOT NULL DEFAULT '[]'::jsonb, -- TO BE REMOVED, NOT USED + error TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL +); + +-- Create updated_at trigger function +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = TIMEZONE('utc'::text, NOW()); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create triggers for updated_at +CREATE TRIGGER update_threads_updated_at + BEFORE UPDATE ON threads + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_messages_updated_at + BEFORE UPDATE ON messages + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_agent_runs_updated_at + BEFORE UPDATE ON agent_runs + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_projects_updated_at + BEFORE UPDATE ON projects + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Create indexes for better query performance +CREATE INDEX idx_threads_created_at ON threads(created_at); +CREATE INDEX idx_threads_account_id ON threads(account_id); +CREATE INDEX idx_threads_project_id ON threads(project_id); +CREATE INDEX idx_agent_runs_thread_id ON agent_runs(thread_id); +CREATE INDEX idx_agent_runs_status ON agent_runs(status); +CREATE INDEX idx_agent_runs_created_at ON agent_runs(created_at); +CREATE INDEX idx_projects_account_id ON projects(account_id); +CREATE INDEX idx_projects_created_at ON projects(created_at); +CREATE INDEX idx_messages_thread_id ON messages(thread_id); +CREATE INDEX idx_messages_created_at ON messages(created_at); + +-- Enable Row Level Security +ALTER TABLE threads ENABLE ROW LEVEL SECURITY; +ALTER TABLE messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE agent_runs ENABLE ROW LEVEL SECURITY; +ALTER TABLE projects ENABLE ROW LEVEL SECURITY; + +-- Project policies +CREATE POLICY project_select_policy ON projects + FOR SELECT + USING ( + is_public = TRUE OR + basejump.has_role_on_account(account_id) = true + ); + +CREATE POLICY project_insert_policy ON projects + FOR INSERT + WITH CHECK (basejump.has_role_on_account(account_id) = true); + +CREATE POLICY project_update_policy ON projects + FOR UPDATE + USING (basejump.has_role_on_account(account_id) = true); + +CREATE POLICY project_delete_policy ON projects + FOR DELETE + USING (basejump.has_role_on_account(account_id) = true); + +-- Thread policies based on project and account ownership +CREATE POLICY thread_select_policy ON threads + FOR SELECT + USING ( + basejump.has_role_on_account(account_id) = true OR + EXISTS ( + SELECT 1 FROM projects + WHERE projects.project_id = threads.project_id + AND ( + projects.is_public = TRUE OR + basejump.has_role_on_account(projects.account_id) = true + ) + ) + ); + +CREATE POLICY thread_insert_policy ON threads + FOR INSERT + WITH CHECK ( + basejump.has_role_on_account(account_id) = true OR + EXISTS ( + SELECT 1 FROM projects + WHERE projects.project_id = threads.project_id + AND basejump.has_role_on_account(projects.account_id) = true + ) + ); + +CREATE POLICY thread_update_policy ON threads + FOR UPDATE + USING ( + basejump.has_role_on_account(account_id) = true OR + EXISTS ( + SELECT 1 FROM projects + WHERE projects.project_id = threads.project_id + AND basejump.has_role_on_account(projects.account_id) = true + ) + ); + +CREATE POLICY thread_delete_policy ON threads + FOR DELETE + USING ( + basejump.has_role_on_account(account_id) = true OR + EXISTS ( + SELECT 1 FROM projects + WHERE projects.project_id = threads.project_id + AND basejump.has_role_on_account(projects.account_id) = true + ) + ); + +-- Create policies for agent_runs based on thread ownership +CREATE POLICY agent_run_select_policy ON agent_runs + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM threads + LEFT JOIN projects ON threads.project_id = projects.project_id + WHERE threads.thread_id = agent_runs.thread_id + AND ( + projects.is_public = TRUE OR + basejump.has_role_on_account(threads.account_id) = true OR + basejump.has_role_on_account(projects.account_id) = true + ) + ) + ); + +CREATE POLICY agent_run_insert_policy ON agent_runs + FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM threads + LEFT JOIN projects ON threads.project_id = projects.project_id + WHERE threads.thread_id = agent_runs.thread_id + AND ( + basejump.has_role_on_account(threads.account_id) = true OR + basejump.has_role_on_account(projects.account_id) = true + ) + ) + ); + +CREATE POLICY agent_run_update_policy ON agent_runs + FOR UPDATE + USING ( + EXISTS ( + SELECT 1 FROM threads + LEFT JOIN projects ON threads.project_id = projects.project_id + WHERE threads.thread_id = agent_runs.thread_id + AND ( + basejump.has_role_on_account(threads.account_id) = true OR + basejump.has_role_on_account(projects.account_id) = true + ) + ) + ); + +CREATE POLICY agent_run_delete_policy ON agent_runs + FOR DELETE + USING ( + EXISTS ( + SELECT 1 FROM threads + LEFT JOIN projects ON threads.project_id = projects.project_id + WHERE threads.thread_id = agent_runs.thread_id + AND ( + basejump.has_role_on_account(threads.account_id) = true OR + basejump.has_role_on_account(projects.account_id) = true + ) + ) + ); + +-- Create message policies based on thread ownership +CREATE POLICY message_select_policy ON messages + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM threads + LEFT JOIN projects ON threads.project_id = projects.project_id + WHERE threads.thread_id = messages.thread_id + AND ( + projects.is_public = TRUE OR + basejump.has_role_on_account(threads.account_id) = true OR + basejump.has_role_on_account(projects.account_id) = true + ) + ) + ); + +CREATE POLICY message_insert_policy ON messages + FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM threads + LEFT JOIN projects ON threads.project_id = projects.project_id + WHERE threads.thread_id = messages.thread_id + AND ( + basejump.has_role_on_account(threads.account_id) = true OR + basejump.has_role_on_account(projects.account_id) = true + ) + ) + ); + +CREATE POLICY message_update_policy ON messages + FOR UPDATE + USING ( + EXISTS ( + SELECT 1 FROM threads + LEFT JOIN projects ON threads.project_id = projects.project_id + WHERE threads.thread_id = messages.thread_id + AND ( + basejump.has_role_on_account(threads.account_id) = true OR + basejump.has_role_on_account(projects.account_id) = true + ) + ) + ); + +CREATE POLICY message_delete_policy ON messages + FOR DELETE + USING ( + EXISTS ( + SELECT 1 FROM threads + LEFT JOIN projects ON threads.project_id = projects.project_id + WHERE threads.thread_id = messages.thread_id + AND ( + basejump.has_role_on_account(threads.account_id) = true OR + basejump.has_role_on_account(projects.account_id) = true + ) + ) + ); + +-- Grant permissions to roles +GRANT ALL PRIVILEGES ON TABLE projects TO authenticated, service_role; +GRANT SELECT ON TABLE projects TO anon; +GRANT SELECT ON TABLE threads TO authenticated, anon, service_role; +GRANT SELECT ON TABLE messages TO authenticated, anon, service_role; +GRANT ALL PRIVILEGES ON TABLE agent_runs TO authenticated, service_role; + +-- Create a function that matches the Python get_messages behavior +CREATE OR REPLACE FUNCTION get_llm_formatted_messages(p_thread_id UUID) +RETURNS JSONB +SECURITY DEFINER -- Changed to SECURITY DEFINER to allow service role access +LANGUAGE plpgsql +AS $$ +DECLARE + messages_array JSONB := '[]'::JSONB; + has_access BOOLEAN; + current_role TEXT; + latest_summary_id UUID; + latest_summary_time TIMESTAMP WITH TIME ZONE; + is_project_public BOOLEAN; +BEGIN + -- Get current role + SELECT current_user INTO current_role; + + -- Check if associated project is public + SELECT p.is_public INTO is_project_public + FROM threads t + LEFT JOIN projects p ON t.project_id = p.project_id + WHERE t.thread_id = p_thread_id; + + -- Skip access check for service_role or public projects + IF current_role = 'authenticated' AND NOT is_project_public THEN + -- Check if thread exists and user has access + SELECT EXISTS ( + SELECT 1 FROM threads t + LEFT JOIN projects p ON t.project_id = p.project_id + WHERE t.thread_id = p_thread_id + AND ( + basejump.has_role_on_account(t.account_id) = true OR + basejump.has_role_on_account(p.account_id) = true + ) + ) INTO has_access; + + IF NOT has_access THEN + RAISE EXCEPTION 'Thread not found or access denied'; + END IF; + END IF; + + -- Find the latest summary message if it exists + SELECT message_id, created_at + INTO latest_summary_id, latest_summary_time + FROM messages + WHERE thread_id = p_thread_id + AND type = 'summary' + AND is_llm_message = TRUE + ORDER BY created_at DESC + LIMIT 1; + + -- Log whether a summary was found (helpful for debugging) + IF latest_summary_id IS NOT NULL THEN + RAISE NOTICE 'Found latest summary message: id=%, time=%', latest_summary_id, latest_summary_time; + ELSE + RAISE NOTICE 'No summary message found for thread %', p_thread_id; + END IF; + + -- Parse content if it's stored as a string and return proper JSON objects + WITH parsed_messages AS ( + SELECT + message_id, + CASE + WHEN jsonb_typeof(content) = 'string' THEN content::text::jsonb + ELSE content + END AS parsed_content, + created_at, + type + FROM messages + WHERE thread_id = p_thread_id + AND is_llm_message = TRUE + AND ( + -- Include the latest summary and all messages after it, + -- or all messages if no summary exists + latest_summary_id IS NULL + OR message_id = latest_summary_id + OR created_at > latest_summary_time + ) + ORDER BY created_at + ) + SELECT JSONB_AGG(parsed_content) + INTO messages_array + FROM parsed_messages; + + -- Handle the case when no messages are found + IF messages_array IS NULL THEN + RETURN '[]'::JSONB; + END IF; + + RETURN messages_array; +END; +$$; + +-- Grant execute permissions +GRANT EXECUTE ON FUNCTION get_llm_formatted_messages TO authenticated, anon, service_role; \ No newline at end of file diff --git a/ActiveJobsProvider.py b/ActiveJobsProvider.py new file mode 100644 index 0000000000000000000000000000000000000000..0b09aae1784ee6c6ed41dd3c18fa7fcddd50dc1a --- /dev/null +++ b/ActiveJobsProvider.py @@ -0,0 +1,57 @@ +from typing import Dict + +from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema + + +class ActiveJobsProvider(RapidDataProviderBase): + def __init__(self): + endpoints: Dict[str, EndpointSchema] = { + "active_jobs": { + "route": "/active-ats-7d", + "method": "GET", + "name": "Active Jobs Search", + "description": "Get active job listings with various filter options.", + "payload": { + "limit": "Optional. Number of jobs per API call (10-100). Default is 100.", + "offset": "Optional. Offset for pagination. Default is 0.", + "title_filter": "Optional. Search terms for job title.", + "advanced_title_filter": "Optional. Advanced title filter with operators (can't be used with title_filter).", + "location_filter": "Optional. Filter by location(s). Use full names like 'United States' not 'US'.", + "description_filter": "Optional. Filter on job description content.", + "organization_filter": "Optional. Filter by company name(s).", + "description_type": "Optional. Return format for description: 'text' or 'html'. Leave empty to exclude descriptions.", + "source": "Optional. Filter by ATS source.", + "date_filter": "Optional. Filter by posting date (greater than).", + "ai_employment_type_filter": "Optional. Filter by employment type (FULL_TIME, PART_TIME, etc).", + "ai_work_arrangement_filter": "Optional. Filter by work arrangement (On-site, Hybrid, Remote OK, Remote Solely).", + "ai_experience_level_filter": "Optional. Filter by experience level (0-2, 2-5, 5-10, 10+).", + "li_organization_slug_filter": "Optional. Filter by LinkedIn company slug.", + "li_organization_slug_exclusion_filter": "Optional. Exclude LinkedIn company slugs.", + "li_industry_filter": "Optional. Filter by LinkedIn industry.", + "li_organization_specialties_filter": "Optional. Filter by LinkedIn company specialties.", + "li_organization_description_filter": "Optional. Filter by LinkedIn company description." + } + } + } + + base_url = "https://active-jobs-db.p.rapidapi.com" + super().__init__(base_url, endpoints) + + +if __name__ == "__main__": + from dotenv import load_dotenv + load_dotenv() + tool = ActiveJobsProvider() + + # Example for searching active jobs + jobs = tool.call_endpoint( + route="active_jobs", + payload={ + "limit": "10", + "offset": "0", + "title_filter": "\"Data Engineer\"", + "location_filter": "\"United States\" OR \"United Kingdom\"", + "description_type": "text" + } + ) + print("Active Jobs:", jobs) \ No newline at end of file diff --git a/AmazonProvider.py b/AmazonProvider.py new file mode 100644 index 0000000000000000000000000000000000000000..5ecea89e52bed279e554189d377937e2edcd89d7 --- /dev/null +++ b/AmazonProvider.py @@ -0,0 +1,191 @@ +from typing import Dict, Optional + +from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema + + +class AmazonProvider(RapidDataProviderBase): + def __init__(self): + endpoints: Dict[str, EndpointSchema] = { + "search": { + "route": "/search", + "method": "GET", + "name": "Amazon Product Search", + "description": "Search for products on Amazon with various filters and parameters.", + "payload": { + "query": "Search query (supports both free-form text queries or a product asin)", + "page": "Results page to return (default: 1)", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "sort_by": "Return the results in a specific sort order (RELEVANCE, LOWEST_PRICE, HIGHEST_PRICE, REVIEWS, NEWEST, BEST_SELLERS)", + "product_condition": "Return products in a specific condition (ALL, NEW, USED, RENEWED, COLLECTIBLE)", + "is_prime": "Only return prime products (boolean)", + "deals_and_discounts": "Return deals and discounts in a specific condition (NONE, ALL_DISCOUNTS, TODAYS_DEALS)", + "category_id": "Find products in a specific category / department (optional)", + "category": "Filter by specific numeric Amazon category (optional)", + "min_price": "Only return product offers with price greater than a certain value (optional)", + "max_price": "Only return product offers with price lower than a certain value (optional)", + "brand": "Find products with a specific brand (optional)", + "seller_id": "Find products sold by specific seller (optional)", + "four_stars_and_up": "Return product listings with ratings of 4 stars & up (optional)", + "additional_filters": "Any filters available on the Amazon page but not part of this endpoint's parameters (optional)" + } + }, + "product-details": { + "route": "/product-details", + "method": "GET", + "name": "Amazon Product Details", + "description": "Get detailed information about specific Amazon products by ASIN.", + "payload": { + "asin": "Product ASIN for which to get details. Supports batching of up to 10 ASINs in a single request, separated by comma.", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "more_info_query": "A query to search and get more info about the product as part of Product Information, Customer Q&As, and Customer Reviews (optional)", + "fields": "A comma separated list of product fields to include in the response (field projection). By default all fields are returned. (optional)" + } + }, + "products-by-category": { + "route": "/products-by-category", + "method": "GET", + "name": "Amazon Products by Category", + "description": "Get products from a specific Amazon category.", + "payload": { + "category_id": "The Amazon category for which to return results. Multiple category values can be separated by comma.", + "page": "Page to return (default: 1)", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "sort_by": "Return the results in a specific sort order (RELEVANCE, LOWEST_PRICE, HIGHEST_PRICE, REVIEWS, NEWEST, BEST_SELLERS)", + "min_price": "Only return product offers with price greater than a certain value (optional)", + "max_price": "Only return product offers with price lower than a certain value (optional)", + "product_condition": "Return products in a specific condition (ALL, NEW, USED, RENEWED, COLLECTIBLE)", + "brand": "Only return products of a specific brand. Multiple brands can be specified as a comma separated list (optional)", + "is_prime": "Only return prime products (boolean)", + "deals_and_discounts": "Return deals and discounts in a specific condition (NONE, ALL_DISCOUNTS, TODAYS_DEALS)", + "four_stars_and_up": "Return product listings with ratings of 4 stars & up (optional)", + "additional_filters": "Any filters available on the Amazon page but not part of this endpoint's parameters (optional)" + } + }, + "product-reviews": { + "route": "/product-reviews", + "method": "GET", + "name": "Amazon Product Reviews", + "description": "Get customer reviews for a specific Amazon product by ASIN.", + "payload": { + "asin": "Product asin for which to get reviews.", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "page": "Results page to return (default: 1)", + "sort_by": "Return reviews in a specific sort order (TOP_REVIEWS, MOST_RECENT)", + "star_rating": "Only return reviews with a specific star rating (ALL, 5_STARS, 4_STARS, 3_STARS, 2_STARS, 1_STARS, POSITIVE, CRITICAL)", + "verified_purchases_only": "Only return reviews by reviewers who made a verified purchase (boolean)", + "images_or_videos_only": "Only return reviews containing images and / or videos (boolean)", + "current_format_only": "Only return reviews of the current format (product variant - e.g. Color) (boolean)" + } + }, + "seller-profile": { + "route": "/seller-profile", + "method": "GET", + "name": "Amazon Seller Profile", + "description": "Get detailed information about a specific Amazon seller by Seller ID.", + "payload": { + "seller_id": "The Amazon Seller ID for which to get seller profile details", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "fields": "A comma separated list of seller profile fields to include in the response (field projection). By default all fields are returned. (optional)" + } + }, + "seller-reviews": { + "route": "/seller-reviews", + "method": "GET", + "name": "Amazon Seller Reviews", + "description": "Get customer reviews for a specific Amazon seller by Seller ID.", + "payload": { + "seller_id": "The Amazon Seller ID for which to get seller reviews", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "star_rating": "Only return reviews with a specific star rating or positive / negative sentiment (ALL, 5_STARS, 4_STARS, 3_STARS, 2_STARS, 1_STARS, POSITIVE, CRITICAL)", + "page": "The page of seller feedback results to retrieve (default: 1)", + "fields": "A comma separated list of seller review fields to include in the response (field projection). By default all fields are returned. (optional)" + } + } + } + base_url = "https://real-time-amazon-data.p.rapidapi.com" + super().__init__(base_url, endpoints) + + +if __name__ == "__main__": + from dotenv import load_dotenv + load_dotenv() + tool = AmazonProvider() + + # Example for product search + search_result = tool.call_endpoint( + route="search", + payload={ + "query": "Phone", + "page": 1, + "country": "US", + "sort_by": "RELEVANCE", + "product_condition": "ALL", + "is_prime": False, + "deals_and_discounts": "NONE" + } + ) + print("Search Result:", search_result) + + # Example for product details + details_result = tool.call_endpoint( + route="product-details", + payload={ + "asin": "B07ZPKBL9V", + "country": "US" + } + ) + print("Product Details:", details_result) + + # Example for products by category + category_result = tool.call_endpoint( + route="products-by-category", + payload={ + "category_id": "2478868012", + "page": 1, + "country": "US", + "sort_by": "RELEVANCE", + "product_condition": "ALL", + "is_prime": False, + "deals_and_discounts": "NONE" + } + ) + print("Category Products:", category_result) + + # Example for product reviews + reviews_result = tool.call_endpoint( + route="product-reviews", + payload={ + "asin": "B07ZPKN6YR", + "country": "US", + "page": 1, + "sort_by": "TOP_REVIEWS", + "star_rating": "ALL", + "verified_purchases_only": False, + "images_or_videos_only": False, + "current_format_only": False + } + ) + print("Product Reviews:", reviews_result) + + # Example for seller profile + seller_result = tool.call_endpoint( + route="seller-profile", + payload={ + "seller_id": "A02211013Q5HP3OMSZC7W", + "country": "US" + } + ) + print("Seller Profile:", seller_result) + + # Example for seller reviews + seller_reviews_result = tool.call_endpoint( + route="seller-reviews", + payload={ + "seller_id": "A02211013Q5HP3OMSZC7W", + "country": "US", + "star_rating": "ALL", + "page": 1 + } + ) + print("Seller Reviews:", seller_reviews_result) + diff --git a/AuthProvider.tsx b/AuthProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..84c45045eb114766dc8c867d328093d1387f788a --- /dev/null +++ b/AuthProvider.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { createClient } from '@/lib/supabase/client'; +import { User, Session } from '@supabase/supabase-js'; +import { SupabaseClient } from '@supabase/supabase-js'; + +type AuthContextType = { + supabase: SupabaseClient; + session: Session | null; + user: User | null; + isLoading: boolean; + signOut: () => Promise; +}; + +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const supabase = createClient(); + const [session, setSession] = useState(null); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const getInitialSession = async () => { + const { data: { session: currentSession } } = await supabase.auth.getSession(); + setSession(currentSession); + setUser(currentSession?.user ?? null); + setIsLoading(false); + }; + + getInitialSession(); + + const { data: authListener } = supabase.auth.onAuthStateChange( + (_event, newSession) => { + setSession(newSession); + setUser(newSession?.user ?? null); + // No need to set loading state here as initial load is done + // and subsequent changes shouldn't show a loading state for the whole app + if (isLoading) setIsLoading(false); + } + ); + + return () => { + authListener?.subscription.unsubscribe(); + }; + }, [supabase, isLoading]); // Added isLoading to dependencies to ensure it runs once after initial load completes + + const signOut = async () => { + await supabase.auth.signOut(); + // State updates will be handled by onAuthStateChange + }; + + const value = { + supabase, + session, + user, + isLoading, + signOut, + }; + + return ( + + {children} + + ); +}; + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; \ No newline at end of file diff --git a/BrowserToolView.tsx b/BrowserToolView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4f4c80a149ae0fd5d90c03c50b9b25469d5ca50b --- /dev/null +++ b/BrowserToolView.tsx @@ -0,0 +1,195 @@ +import React, { useMemo } from "react"; +import { Globe, MonitorPlay, ExternalLink, CheckCircle, AlertTriangle, CircleDashed } from "lucide-react"; +import { ToolViewProps } from "./types"; +import { extractBrowserUrl, extractBrowserOperation, formatTimestamp, getToolTitle } from "./utils"; +import { ApiMessageType } from '@/components/thread/types'; +import { safeJsonParse } from '@/components/thread/utils'; +import { cn } from "@/lib/utils"; + +export function BrowserToolView({ + name = "browser-operation", + assistantContent, + toolContent, + assistantTimestamp, + toolTimestamp, + isSuccess = true, + isStreaming = false, + project, + agentStatus = 'idle', + messages = [], + currentIndex = 0, + totalCalls = 1 +}: ToolViewProps) { + const url = extractBrowserUrl(assistantContent); + const operation = extractBrowserOperation(name); + const toolTitle = getToolTitle(name); + + // --- message_id Extraction Logic --- + let browserStateMessageId: string | undefined; + + try { + // 1. Parse the top-level JSON + const topLevelParsed = safeJsonParse<{ content?: string }>(toolContent, {}); + const innerContentString = topLevelParsed?.content; + + if (innerContentString && typeof innerContentString === 'string') { + // 2. Extract the output='...' string using regex + const outputMatch = innerContentString.match(/\boutput='(.*?)'(?=\s*\))/); + const outputString = outputMatch ? outputMatch[1] : null; + + if (outputString) { + // 3. Unescape the JSON string (basic unescaping for \n and \") + const unescapedOutput = outputString.replace(/\\n/g, '\n').replace(/\\"/g, '"'); + + // 4. Parse the unescaped JSON to get message_id + const finalParsedOutput = safeJsonParse<{ message_id?: string }>(unescapedOutput, {}); + browserStateMessageId = finalParsedOutput?.message_id; + } + } + } catch (error) { + console.error("[BrowserToolView] Error parsing tool content for message_id:", error); + } + + // Find the browser_state message and extract the screenshot + let screenshotBase64: string | null = null; + if (browserStateMessageId && messages.length > 0) { + const browserStateMessage = messages.find(msg => + (msg.type as string) === 'browser_state' && + msg.message_id === browserStateMessageId + ); + + if (browserStateMessage) { + const browserStateContent = safeJsonParse<{ screenshot_base64?: string }>(browserStateMessage.content, {}); + screenshotBase64 = browserStateContent?.screenshot_base64 || null; + } + } + + // Check if we have a VNC preview URL from the project + const vncPreviewUrl = project?.sandbox?.vnc_preview ? + `${project.sandbox.vnc_preview}/vnc_lite.html?password=${project?.sandbox?.pass}&autoconnect=true&scale=local&width=1024&height=768` : + undefined; + + const isRunning = isStreaming || agentStatus === 'running'; + const isLastToolCall = currentIndex === (totalCalls - 1); + + // Memoize the VNC iframe to prevent reconnections on re-renders + const vncIframe = useMemo(() => { + if (!vncPreviewUrl) return null; + + console.log("[BrowserToolView] Creating memoized VNC iframe with URL:", vncPreviewUrl); + + return ( + + + + + )} + + + ); +} diff --git a/hero-video-section.tsx b/hero-video-section.tsx new file mode 100644 index 0000000000000000000000000000000000000000..40e40afccec7eecc38f9ff75c4eabd947a56c128 --- /dev/null +++ b/hero-video-section.tsx @@ -0,0 +1,24 @@ +import { HeroVideoDialog } from "@/components/home/ui/hero-video-dialog"; + +export function HeroVideoSection() { + return ( +
+
+ + +
+
+ ); +} diff --git a/holo.png b/holo.png new file mode 100644 index 0000000000000000000000000000000000000000..422287bc9a043def300f6ee1275d10c0ce639c6e --- /dev/null +++ b/holo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77490be8481a5279921dfe972c4d9d0bf0a0b9eab07e9d4534c8ff9917610e1b +size 8296643 diff --git a/home.tsx b/home.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fcc2c81f339874b3c97a31ef972f50240b80dade --- /dev/null +++ b/home.tsx @@ -0,0 +1,1287 @@ +import { FirstBentoAnimation } from "@/components/home/first-bento-animation"; +import { FourthBentoAnimation } from "@/components/home/fourth-bento-animation"; +import { SecondBentoAnimation } from "@/components/home/second-bento-animation"; +import { ThirdBentoAnimation } from "@/components/home/third-bento-animation"; +import { FlickeringGrid } from "@/components/home/ui/flickering-grid"; +import { Globe } from "@/components/home/ui/globe"; +import { cn } from "@/lib/utils"; +import { motion } from "motion/react"; + +export const Highlight = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( + + {children} + + ); +}; + +export const BLUR_FADE_DELAY = 0.15; + +export const siteConfig = { + name: "Kortix Suna", + description: "The Generalist AI Agent that can act on your behalf.", + cta: "Hire Suna", + url: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000", + keywords: [ + "AI Agent", + "Generalist AI", + "Open Source AI", + "Autonomous Agent", + ], + links: { + email: "support@kortix.ai", + twitter: "https://x.com/kortixai", + discord: "https://discord.gg/kortixai", + github: "https://github.com/Kortix-ai/Suna", + instagram: "https://instagram.com/kortixai", + }, + nav: { + links: [ + { id: 1, name: "Home", href: "#hero" }, + { id: 2, name: "Use Cases", href: "#use-cases" }, + { id: 3, name: "Open Source", href: "#open-source" }, + { id: 4, name: "Pricing", href: "#pricing" }, + ], + }, + hero: { + badgeIcon: ( + + + + + + ), + badge: "100% OPEN SOURCE", + githubUrl: "https://github.com/kortix-ai/suna", + title: "Suna, the AI Employee.", + description: "Suna by Kortix – is a generalist AI Agent that acts on your behalf.", + inputPlaceholder: "Ask Suna to...", + }, + cloudPricingItems: [ + { + name: "Free", + price: "$0", + description: "For individual use and exploration", + buttonText: "Hire Suna", + buttonColor: "bg-secondary text-white", + isPopular: false, + hours: "10 min", + features: [ + "10 minutes", + // "Community support", + // "Single user", + // "Standard response time", + ], + stripePriceId: 'price_1RGJ9GG6l1KZGqIroxSqgphC', + }, + { + name: "Pro", + price: "$29", + description: "For professionals and small teams", + buttonText: "Hire Suna", + buttonColor: "bg-primary text-white dark:text-black", + isPopular: true, + hours: "4 hours", + features: [ + "4 hours usage per month", + // "Priority support", + // "Advanced features", + // "5 team members", + // "Custom integrations", + ], + stripePriceId: 'price_1RGJ9LG6l1KZGqIrd9pwzeNW', + }, + { + name: "Enterprise", + price: "$199", + description: "For organizations with complex needs", + buttonText: "Hire Suna", + buttonColor: "bg-secondary text-white", + isPopular: false, + hours: "40 hours", + features: [ + "40 hours usage per month", + // "Dedicated support", + // "SSO & advanced security", + // "Unlimited team members", + // "Service level agreement", + // "Custom AI model training", + ], + showContactSales: true, + stripePriceId: 'price_1RGJ9JG6l1KZGqIrVUU4ZRv6', + }, + ], + companyShowcase: { + companyLogos: [ + { + id: 1, + name: "Company 1", + logo: ( + + + + + + + + + + ), + }, + { + id: 2, + name: "Company 2", + logo: ( + + + + + + + + + + + + ), + }, + { + id: 3, + name: "Company 3", + logo: ( + + + + ), + }, + { + id: 4, + name: "Company 4", + logo: ( + + + + ), + }, + { + id: 5, + name: "Company 5", + logo: ( + + + + + + + + ), + }, + { + id: 6, + name: "Company 6", + logo: ( + + + + + + + + + + + + + ), + }, + { + id: 7, + name: "Company 7", + logo: ( + + + + + + + + + + + + + + + + ), + }, + { + id: 8, + name: "Company 8", + logo: ( + + + + ), + }, + ], + }, + featureSection: { + title: "How Kortix Suna Works", + description: + "Discover how Kortix Suna transforms your commands into action in four easy steps", + items: [ + { + id: 1, + title: "Request an Action", + content: + "Speak or type your command—let Kortix Suna capture your intent. Your request instantly sets the process in motion.", + image: + "https://images.unsplash.com/photo-1720371300677-ba4838fa0678?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + }, + { + id: 2, + title: "AI Understanding & Planning", + content: + "Suna analyzes your request, understands the context, and develops a structured plan to complete the task efficiently.", + image: + "https://images.unsplash.com/photo-1686170287433-c95faf6d3608?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHwzfHx8ZW58MHx8fHx8fA%3D%3D", + }, + { + id: 3, + title: "Autonomous Execution", + content: + "Using its capabilities and integrations, Suna executes the task independently, handling any complexities along the way.", + image: + "https://images.unsplash.com/photo-1720378042271-60aff1e1c538?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHwxMHx8fGVufDB8fHx8fA%3D%3D", + }, + { + id: 4, + title: "Results & Learning", + content: + "Suna delivers results and learns from each interaction, continuously improving its performance to better serve your needs.", + image: + "https://images.unsplash.com/photo-1666882990322-e7f3b8df4f75?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1yZWxhdGVkfDF8fHxlbnwwfHx8fHw%3D", + }, + ], + }, + bentoSection: { + title: "Empower Your Workflow with Kortix Suna", + description: + "Let Kortix Suna act on your behalf with advanced AI capabilities, seamless integrations, and autonomous task execution.", + items: [ + { + id: 1, + content: , + title: "Autonomous Task Execution", + description: + "Experience true automation with Suna. Ask your AI Agent to complete tasks, research information, and handle complex workflows with minimal supervision.", + }, + { + id: 2, + content: , + title: "Seamless Integrations", + description: + "Connect Suna to your existing tools for a unified workflow. Boost productivity through AI-powered interconnected systems.", + }, + { + id: 3, + content: ( + + ), + title: "Intelligent Data Analysis", + description: + "Transform raw data into actionable insights in seconds. Make better decisions with Suna's real-time, adaptive intelligence.", + }, + { + id: 4, + content: , + title: "Complete Customization", + description: + "Tailor Suna to your specific needs. As an open source solution, you have full control over its capabilities, integrations, and implementation.", + }, + ], + }, + benefits: [ + { + id: 1, + text: "Automate everyday tasks with Suna's powerful AI capabilities.", + image: "/Device-6.png", + }, + { + id: 2, + text: "Increase productivity with autonomous task completion.", + image: "/Device-7.png", + }, + { + id: 3, + text: "Improve focus on high-value work as Suna handles the routine.", + image: "/Device-8.png", + }, + { + id: 4, + text: "Access cutting-edge AI as an open source, transparent solution.", + image: "/Device-1.png", + }, + ], + growthSection: { + title: "Open Source & Secure", + description: + "Where advanced security meets complete transparency—designed to protect your data while providing full access to the code.", + items: [ + { + id: 1, + content: ( +
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ ), + + title: "Open Source Security", + description: + "Benefit from the security of open source code that thousands of eyes can review, audit, and improve.", + }, + { + id: 2, + content: ( +
+ +
+ ), + + title: "Community Powered", + description: + "Join a thriving community of developers and users continuously enhancing and expanding Suna's capabilities.", + }, + ], + }, + quoteSection: { + quote: + "Kortix Suna has transformed how we approach everyday tasks. The level of automation it provides, combined with its open source nature, makes it an invaluable tool for our entire organization.", + author: { + name: "Alex Johnson", + role: "CTO, Innovatech", + image: "https://randomuser.me/api/portraits/men/91.jpg", + }, + }, + pricing: { + title: "Open Source & Free Forever", + description: + "Kortix Suna is 100% open source and free to use. No hidden fees, no premium features locked behind paywalls.", + pricingItems: [ + { + name: "Community", + href: "#", + price: "Free", + period: "forever", + yearlyPrice: "Free", + features: [ + "Full agent capabilities", + "Unlimited usage", + "Full source code access", + "Community support", + ], + description: "Perfect for individual users and developers", + buttonText: "Hire Suna", + buttonColor: "bg-accent text-primary", + isPopular: false, + }, + { + name: "Self-Hosted", + href: "#", + price: "Free", + period: "forever", + yearlyPrice: "Free", + features: [ + "Full agent capabilities", + "Unlimited usage", + "Full source code access", + "Custom deployment", + "Local data storage", + "Integration with your tools", + "Full customization", + "Community support", + ], + description: "Ideal for organizations with specific requirements", + buttonText: "View Docs", + buttonColor: "bg-secondary text-white", + isPopular: true, + }, + { + name: "Enterprise", + href: "#", + price: "Custom", + period: "", + yearlyPrice: "Custom", + features: [ + "Everything in Self-Hosted", + "Priority support", + "Custom development", + "Dedicated hosting", + "SLA guarantees", + ], + description: "For large teams needing custom implementations", + buttonText: "Contact Us", + buttonColor: "bg-primary text-primary-foreground", + isPopular: false, + }, + ], + }, + testimonials: [ + { + id: "1", + name: "Alex Rivera", + role: "CTO at InnovateTech", + img: "https://randomuser.me/api/portraits/men/91.jpg", + description: ( +

+ The AI-driven analytics from #QuantumInsights have revolutionized our + product development cycle. + + Insights are now more accurate and faster than ever. + {" "} + A game-changer for tech companies. +

+ ), + }, + { + id: "2", + name: "Samantha Lee", + role: "Marketing Director at NextGen Solutions", + img: "https://randomuser.me/api/portraits/women/12.jpg", + description: ( +

+ Implementing #AIStream's customer prediction model has + drastically improved our targeting strategy. + Seeing a 50% increase in conversion rates!{" "} + Highly recommend their solutions. +

+ ), + }, + { + id: "3", + name: "Raj Patel", + role: "Founder & CEO at StartUp Grid", + img: "https://randomuser.me/api/portraits/men/45.jpg", + description: ( +

+ As a startup, we need to move fast and stay ahead. #CodeAI's + automated coding assistant helps us do just that. + Our development speed has doubled. Essential + tool for any startup. +

+ ), + }, + { + id: "4", + name: "Emily Chen", + role: "Product Manager at Digital Wave", + img: "https://randomuser.me/api/portraits/women/83.jpg", + description: ( +

+ #VoiceGen's AI-driven voice synthesis has made creating global + products a breeze. + Localization is now seamless and efficient. A + must-have for global product teams. +

+ ), + }, + { + id: "5", + name: "Michael Brown", + role: "Data Scientist at FinTech Innovations", + img: "https://randomuser.me/api/portraits/men/1.jpg", + description: ( +

+ Leveraging #DataCrunch's AI for our financial models has given us + an edge in predictive accuracy. + + Our investment strategies are now powered by real-time data + analytics. + {" "} + Transformative for the finance industry. +

+ ), + }, + { + id: "6", + name: "Linda Wu", + role: "VP of Operations at LogiChain Solutions", + img: "https://randomuser.me/api/portraits/women/5.jpg", + description: ( +

+ #LogiTech's supply chain optimization tools have drastically + reduced our operational costs. + + Efficiency and accuracy in logistics have never been better. + {" "} +

+ ), + }, + { + id: "7", + name: "Carlos Gomez", + role: "Head of R&D at EcoInnovate", + img: "https://randomuser.me/api/portraits/men/14.jpg", + description: ( +

+ By integrating #GreenTech's sustainable energy solutions, + we've seen a significant reduction in carbon footprint. + + Leading the way in eco-friendly business practices. + {" "} + Pioneering change in the industry. +

+ ), + }, + { + id: "8", + name: "Aisha Khan", + role: "Chief Marketing Officer at Fashion Forward", + img: "https://randomuser.me/api/portraits/women/56.jpg", + description: ( +

+ #TrendSetter's market analysis AI has transformed how we approach + fashion trends. + + Our campaigns are now data-driven with higher customer engagement. + {" "} + Revolutionizing fashion marketing. +

+ ), + }, + { + id: "9", + name: "Tom Chen", + role: "Director of IT at HealthTech Solutions", + img: "https://randomuser.me/api/portraits/men/18.jpg", + description: ( +

+ Implementing #MediCareAI in our patient care systems has improved + patient outcomes significantly. + + Technology and healthcare working hand in hand for better health. + {" "} + A milestone in medical technology. +

+ ), + }, + { + id: "10", + name: "Sofia Patel", + role: "CEO at EduTech Innovations", + img: "https://randomuser.me/api/portraits/women/73.jpg", + description: ( +

+ #LearnSmart's AI-driven personalized learning plans have doubled + student performance metrics. + + Education tailored to every learner's needs. + {" "} + Transforming the educational landscape. +

+ ), + }, + { + id: "11", + name: "Jake Morrison", + role: "CTO at SecureNet Tech", + img: "https://randomuser.me/api/portraits/men/25.jpg", + description: ( +

+ With #CyberShield's AI-powered security systems, our data + protection levels are unmatched. + + Ensuring safety and trust in digital spaces. + {" "} + Redefining cybersecurity standards. +

+ ), + }, + { + id: "12", + name: "Nadia Ali", + role: "Product Manager at Creative Solutions", + img: "https://randomuser.me/api/portraits/women/78.jpg", + description: ( +

+ #DesignPro's AI has streamlined our creative process, enhancing + productivity and innovation. + Bringing creativity and technology together. A + game-changer for creative industries. +

+ ), + }, + { + id: "13", + name: "Omar Farooq", + role: "Founder at Startup Hub", + img: "https://randomuser.me/api/portraits/men/54.jpg", + description: ( +

+ #VentureAI's insights into startup ecosystems have been + invaluable for our growth and funding strategies. + + Empowering startups with data-driven decisions. + {" "} + A catalyst for startup success. +

+ ), + }, + ], + faqSection: { + title: "Frequently Asked Questions", + description: + "Answers to common questions about Kortix Suna and its capabilities. If you have any other questions, please don't hesitate to contact us.", + faQitems: [ + { + id: 1, + question: "What is an AI Agent?", + answer: + "An AI Agent is an intelligent software program that can perform tasks autonomously, learn from interactions, and make decisions to help achieve specific goals. It combines artificial intelligence and machine learning to provide personalized assistance and automation.", + }, + { + id: 2, + question: "How does Kortix Suna work?", + answer: + "Kortix Suna works by analyzing your requirements, leveraging advanced AI algorithms to understand context, and executing tasks based on your instructions. It can integrate with your workflow, learn from feedback, and continuously improve its performance.", + }, + { + id: 3, + question: "Is Kortix Suna really free?", + answer: + "Yes, Kortix Suna is completely free and open source. We believe in democratizing AI technology and making it accessible to everyone. You can use it, modify it, and contribute to its development without any cost.", + }, + { + id: 4, + question: "Can I integrate Suna with my existing tools?", + answer: + "Yes, Kortix Suna is designed to be highly compatible with popular tools and platforms. We offer APIs and pre-built integrations for seamless connection with your existing workflow tools and systems.", + }, + { + id: 5, + question: "How can I contribute to Kortix Suna?", + answer: + "You can contribute to Kortix Suna by submitting pull requests on GitHub, reporting bugs, suggesting new features, or helping with documentation. Join our Discord community to connect with other contributors and Hire Suna.", + }, + { + id: 6, + question: "How does Kortix Suna save me time?", + answer: + "Kortix Suna automates repetitive tasks, streamlines workflows, and provides quick solutions to common challenges. This automation and efficiency can save hours of manual work, allowing you to focus on more strategic activities.", + }, + ], + }, + ctaSection: { + id: "cta", + title: "Start Using Kortix Suna Today", + backgroundImage: "/holo.png", + button: { + text: "Hire Suna today", + href: "/auth", + }, + subtext: "The generalist AI Agent that acts on your behalf", + }, + footerLinks: [ + { + title: "Kortix", + links: [ + { id: 1, title: "About", url: "https://kortix.ai" }, + { id: 3, title: "Contact", url: "mailto:hey@kortix.ai" }, + { id: 4, title: "Careers", url: "https://kortix.ai/careers" }, + ], + }, + { + title: "Resources", + links: [ + { id: 5, title: "Documentation", url: "https://github.com/Kortix-ai/Suna" }, + { id: 7, title: "Discord", url: "https://discord.gg/Py6pCBUUPw" }, + { id: 8, title: "GitHub", url: "https://github.com/Kortix-ai/Suna" }, + ], + }, + { + title: "Legal", + links: [ + { id: 9, title: "Privacy Policy", url: "https://suna.so/legal?tab=privacy" }, + { id: 10, title: "Terms of Service", url: "https://suna.so/legal?tab=terms" }, + { id: 11, title: "License Apache 2.0", url: "https://github.com/Kortix-ai/Suna/blob/main/LICENSE" }, + ], + }, + ], + useCases: [ + { + id: "competitor-analysis", + title: "Competitor Analysis", + description: "Analyze the market for my next company in the healthcare industry, located in the UK. Give me the major players, their market size, strengths, and weaknesses, and add their website URLs. Once done, generate a PDF report.", + category: "research", + featured: true, + icon: ( + + + + + + ), + image: "https://images.unsplash.com/photo-1576091160550-2173dba999ef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/5ee791ac-e19c-4986-a61c-6d0659d0e5bc" + }, + { + id: "vc-list", + title: "VC List", + description: "Give me the list of the most important VC Funds in the United States based on Assets Under Management. Give me website URLs, and if possible an email to reach them out.", + category: "finance", + featured: true, + icon: ( + + + + + ), + image: "https://images.unsplash.com/photo-1444653614773-995cb1ef9efa?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/804d20a3-cf1c-4adb-83bb-0e77cc6adeac" + }, + { + id: "candidate-search", + title: "Looking for Candidates", + description: "Go on LinkedIn, and find me 10 profiles available - they are not working right now - for a junior software engineer position, who are located in Munich, Germany. They should have at least one bachelor's degree in Computer Science or anything related to it, and 1-year of experience in any field/role.", + category: "recruitment", + featured: true, + icon: ( + + + + + + ), + image: "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/3ae581b0-2db8-4c63-b324-3b8d29762e74" + }, + { + id: "company-trip", + title: "Planning Company Trip", + description: "Generate me a route plan for my company. We should go to California. We'll be in 8 people. Compose the trip from the departure (Paris, France) to the activities we can do considering that the trip will be 7 days long - departure on the 21st of Apr 2025.", + category: "travel", + featured: true, + icon: ( + + + + + + + ), + image: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/725e64a0-f1e2-4bb6-8a1f-703c2833fd72" + }, + { + id: "excel-spreadsheet", + title: "Working on Excel", + description: "My company asked me to set up an Excel spreadsheet with all the information about Italian lottery games (Lotto, 10eLotto, and Million Day). Based on that, generate and send me a spreadsheet with all the basic information (public ones).", + category: "data", + featured: true, + icon: ( + + + + + + ), + image: "https://images.unsplash.com/photo-1532153975070-2e9ab71f1b14?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/128f23a4-51cd-42a6-97a0-0b458b32010e" + }, + { + id: "speaker-prospecting", + title: "Automate Event Speaker Prospecting", + description: "Find 20 AI ethics speakers from Europe who've spoken at conferences in the past year. Scrapes conference sites, cross-references LinkedIn and YouTube, and outputs contact info + talk summaries.", + category: "research", + featured: true, + icon: ( + + + + + + ), + image: "https://images.unsplash.com/photo-1523580494863-6f3031224c94?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/7a7592ea-ed44-4c69-bcb5-5f9bb88c188c" + }, + { + id: "scientific-papers", + title: "Summarize and Cross-Reference Scientific Papers", + description: "Research and compare scientific papers talking about Alcohol effects on our bodies during the last 5 years. Generate a report about the most important scientific papers talking about the topic I wrote before.", + category: "research", + featured: true, + icon: ( + + + + + + ), + image: "https://images.unsplash.com/photo-1532153975070-2e9ab71f1b14?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/c2081b3c-786e-4e7c-9bf4-46e9b23bb662" + }, + { + id: "lead-generation", + title: "Research + First Contact Draft", + description: "Research my potential customers (B2B) on LinkedIn. They should be in the clean tech industry. Find their websites and their email addresses. After that, based on the company profile, generate a personalized first contact email.", + category: "sales", + featured: true, + icon: ( + + + + + + ), + image: "https://images.unsplash.com/photo-1552581234-26160f608093?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/6b6296a6-8683-49e5-9ad0-a32952d12c44" + }, + { + id: "seo-analysis", + title: "SEO Analysis", + description: "Based on my website suna.so, generate an SEO report analysis, find top-ranking pages by keyword clusters, and identify topics I'm missing.", + category: "marketing", + featured: true, + icon: ( + + + + + + + ), + image: "https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/43491cb0-cd6c-45f0-880c-66ddc8c4b842" + }, + { + id: "personal-trip", + title: "Generate a Personal Trip", + description: "Generate a personal trip to London, with departure from Bangkok on the 1st of May. The trip will last 10 days. Find an accommodation in the center of London, with a rating on Google reviews of at least 4.5.", + category: "travel", + featured: true, + icon: ( + + + + + + + ), + image: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/37b31907-8349-4f63-b0e5-27ca597ed02a" + }, + { + id: "funded-startups", + title: "Recently Funded Startups", + description: "Go on Crunchbase, Dealroom, and TechCrunch, filter by Series A funding rounds in the SaaS Finance Space, and build a report with company data, founders, and contact info for outbound sales.", + category: "finance", + featured: true, + icon: ( + + + + + ), + image: "https://images.unsplash.com/photo-1444653614773-995cb1ef9efa?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/8b2a897e-985a-4d5e-867b-15239274f764" + }, + { + id: "scrape-forums", + title: "Scrape Forum Discussions", + description: "I need to find the best beauty centers in Rome, but I want to find them by using open forums that speak about this topic. Go on Google, and scrape the forums by looking for beauty center discussions located in Rome.", + category: "research", + featured: true, + icon: ( + + + + + + ), + image: "https://images.unsplash.com/photo-1523580494863-6f3031224c94?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2400&q=80", + url: "https://www.suna.so/share/7d7a5d93-a20d-48b0-82cc-e9a876e9fd04" + } + ], +}; + +export type SiteConfig = typeof siteConfig; diff --git a/html-renderer.tsx b/html-renderer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b482d9d68a4da2f4d10299a08180c413dc7c911e --- /dev/null +++ b/html-renderer.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React, { useState } from "react"; +import { CodeRenderer } from "./code-renderer"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Monitor, Code, ExternalLink } from "lucide-react"; + +interface HtmlRendererProps { + content: string; + previewUrl: string; + className?: string; +} + +export function HtmlRenderer({ content, previewUrl, className }: HtmlRendererProps) { + // Always default to 'preview' mode + const [viewMode, setViewMode] = useState<'preview' | 'code'>('preview'); + + return ( +
+ {/* Content area */} +
+ {/* View mode toggle */} +
+ + + +
+ + {viewMode === 'preview' ? ( +
+