Add public profiles and cosmetic shop
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
-- Public profile details and in-game cosmetic purchases.
|
||||
alter table public.profiles
|
||||
add column bio text not null default '' check (char_length(bio) <= 280);
|
||||
|
||||
create table public.profile_cosmetics (
|
||||
id text primary key check (id in (
|
||||
'redstone-pulse',
|
||||
'ender-orbit',
|
||||
'nether-flame',
|
||||
'aurora-crown'
|
||||
)),
|
||||
name text not null,
|
||||
description text not null,
|
||||
price integer not null check (price > 0),
|
||||
rarity text not null check (rarity in ('common', 'rare', 'epic', 'legendary')),
|
||||
sort_order integer not null unique,
|
||||
active boolean not null default true,
|
||||
created_at timestamptz not null default clock_timestamp()
|
||||
);
|
||||
|
||||
insert into public.profile_cosmetics (id, name, description, price, rarity, sort_order)
|
||||
values
|
||||
('redstone-pulse', 'Redstone Pulse', 'A redstone signal ripples around your portrait.', 50, 'common', 1),
|
||||
('ender-orbit', 'Ender Orbit', 'Two ender sparks circle your portrait.', 90, 'rare', 2),
|
||||
('nether-flame', 'Nether Flame', 'A warm nether flame flickers along the frame.', 130, 'epic', 3),
|
||||
('aurora-crown', 'Aurora Crown', 'A living aurora sweeps around your portrait.', 180, 'legendary', 4);
|
||||
|
||||
alter table public.profiles
|
||||
add column profile_effect text references public.profile_cosmetics(id) on delete set null;
|
||||
|
||||
create table public.game_wallets (
|
||||
user_id uuid primary key references public.profiles(id) on delete cascade,
|
||||
balance integer not null default 0 check (balance >= 0),
|
||||
updated_at timestamptz not null default clock_timestamp()
|
||||
);
|
||||
|
||||
create table public.profile_cosmetic_ownership (
|
||||
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||
cosmetic_id text not null references public.profile_cosmetics(id) on delete restrict,
|
||||
purchased_at timestamptz not null default clock_timestamp(),
|
||||
primary key (user_id, cosmetic_id)
|
||||
);
|
||||
|
||||
create table public.game_wallet_transactions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||
amount integer not null check (amount <> 0),
|
||||
source text not null check (source in ('game_reward', 'cosmetic_purchase')),
|
||||
source_key text not null check (char_length(source_key) between 1 and 64),
|
||||
created_at timestamptz not null default clock_timestamp(),
|
||||
unique (user_id, source, source_key),
|
||||
check (
|
||||
(source = 'game_reward' and amount > 0)
|
||||
or (source = 'cosmetic_purchase' and amount < 0)
|
||||
)
|
||||
);
|
||||
|
||||
create index profile_cosmetic_ownership_user
|
||||
on public.profile_cosmetic_ownership(user_id, purchased_at desc);
|
||||
create index game_wallet_transactions_user_created
|
||||
on public.game_wallet_transactions(user_id, created_at desc);
|
||||
|
||||
alter table public.profile_cosmetics enable row level security;
|
||||
alter table public.game_wallets enable row level security;
|
||||
alter table public.profile_cosmetic_ownership enable row level security;
|
||||
alter table public.game_wallet_transactions enable row level security;
|
||||
|
||||
revoke all on public.profile_cosmetics,
|
||||
public.game_wallets,
|
||||
public.profile_cosmetic_ownership,
|
||||
public.game_wallet_transactions
|
||||
from public, anon, authenticated;
|
||||
grant select on public.profile_cosmetics to service_role;
|
||||
grant all on public.game_wallets,
|
||||
public.profile_cosmetic_ownership,
|
||||
public.game_wallet_transactions
|
||||
to service_role;
|
||||
|
||||
create function public.cosmetics_shop_state(p_user uuid)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_balance integer;
|
||||
v_owned jsonb;
|
||||
v_effect text;
|
||||
v_catalog jsonb;
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from public.profiles
|
||||
where id = p_user and account_status = 'active'
|
||||
) then
|
||||
raise exception using errcode = '42501', message = 'Active account required';
|
||||
end if;
|
||||
|
||||
insert into public.game_wallets(user_id)
|
||||
values (p_user)
|
||||
on conflict (user_id) do nothing;
|
||||
|
||||
select balance into v_balance
|
||||
from public.game_wallets
|
||||
where user_id = p_user;
|
||||
|
||||
select profile_effect into v_effect
|
||||
from public.profiles
|
||||
where id = p_user;
|
||||
|
||||
select coalesce(
|
||||
jsonb_agg(cosmetic_id order by purchased_at, cosmetic_id),
|
||||
'[]'::jsonb
|
||||
)
|
||||
into v_owned
|
||||
from public.profile_cosmetic_ownership
|
||||
where user_id = p_user;
|
||||
|
||||
select coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'description', description,
|
||||
'price', price,
|
||||
'rarity', rarity
|
||||
)
|
||||
order by sort_order
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
into v_catalog
|
||||
from public.profile_cosmetics
|
||||
where active;
|
||||
|
||||
return jsonb_build_object(
|
||||
'balance', v_balance,
|
||||
'owned', v_owned,
|
||||
'equipped', v_effect,
|
||||
'catalog', v_catalog
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create function public.buy_profile_cosmetic(p_user uuid, p_cosmetic_id text)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_price integer;
|
||||
v_balance integer;
|
||||
v_status text;
|
||||
begin
|
||||
select account_status into v_status
|
||||
from public.profiles
|
||||
where id = p_user
|
||||
for update;
|
||||
if v_status is distinct from 'active' then
|
||||
raise exception using errcode = '42501', message = 'Active account required';
|
||||
end if;
|
||||
|
||||
select price into v_price
|
||||
from public.profile_cosmetics
|
||||
where id = p_cosmetic_id and active;
|
||||
if not found then
|
||||
raise exception using errcode = '22023', message = 'Cosmetic is not available';
|
||||
end if;
|
||||
|
||||
insert into public.game_wallets(user_id)
|
||||
values (p_user)
|
||||
on conflict (user_id) do nothing;
|
||||
|
||||
select balance into v_balance
|
||||
from public.game_wallets
|
||||
where user_id = p_user
|
||||
for update;
|
||||
|
||||
if exists (
|
||||
select 1 from public.profile_cosmetic_ownership
|
||||
where user_id = p_user and cosmetic_id = p_cosmetic_id
|
||||
) then
|
||||
return public.cosmetics_shop_state(p_user);
|
||||
end if;
|
||||
|
||||
if v_balance < v_price then
|
||||
raise exception using errcode = 'P0001', message = 'Not enough GeoCoins';
|
||||
end if;
|
||||
|
||||
insert into public.profile_cosmetic_ownership(user_id, cosmetic_id)
|
||||
values (p_user, p_cosmetic_id);
|
||||
|
||||
insert into public.game_wallet_transactions(user_id, amount, source, source_key)
|
||||
values (p_user, -v_price, 'cosmetic_purchase', p_cosmetic_id);
|
||||
|
||||
update public.game_wallets
|
||||
set balance = balance - v_price,
|
||||
updated_at = clock_timestamp()
|
||||
where user_id = p_user;
|
||||
|
||||
update public.profiles
|
||||
set profile_effect = p_cosmetic_id
|
||||
where id = p_user;
|
||||
|
||||
return public.cosmetics_shop_state(p_user);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create function public.equip_profile_cosmetic(p_user uuid, p_cosmetic_id text)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_status text;
|
||||
begin
|
||||
select account_status into v_status
|
||||
from public.profiles
|
||||
where id = p_user
|
||||
for update;
|
||||
if v_status is distinct from 'active' then
|
||||
raise exception using errcode = '42501', message = 'Active account required';
|
||||
end if;
|
||||
|
||||
if p_cosmetic_id is not null and not exists (
|
||||
select 1 from public.profile_cosmetic_ownership
|
||||
where user_id = p_user and cosmetic_id = p_cosmetic_id
|
||||
) then
|
||||
raise exception using errcode = '42501', message = 'Cosmetic not owned';
|
||||
end if;
|
||||
|
||||
update public.profiles
|
||||
set profile_effect = p_cosmetic_id
|
||||
where id = p_user;
|
||||
|
||||
return public.cosmetics_shop_state(p_user);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create function public.reward_completed_game_cosmetic_currency()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_user uuid;
|
||||
v_status text;
|
||||
v_wallet_balance integer;
|
||||
v_score integer;
|
||||
v_earned_24h integer;
|
||||
v_reward integer;
|
||||
begin
|
||||
if old.result is not null or new.result is null or new.round_number <> 4 then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select s.owner_id into v_user
|
||||
from public.game_sessions s
|
||||
join public.revisions r on r.id = s.revision_id
|
||||
join public.datasets d on d.id = r.dataset_id
|
||||
where s.id = new.session_id
|
||||
and d.state = 'published';
|
||||
|
||||
if v_user is null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select account_status into v_status
|
||||
from public.profiles
|
||||
where id = v_user
|
||||
for update;
|
||||
if v_status is distinct from 'active' then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
insert into public.game_wallets(user_id)
|
||||
values (v_user)
|
||||
on conflict (user_id) do nothing;
|
||||
|
||||
select balance into v_wallet_balance
|
||||
from public.game_wallets
|
||||
where user_id = v_user
|
||||
for update;
|
||||
|
||||
select coalesce(sum(amount), 0)::integer into v_earned_24h
|
||||
from public.game_wallet_transactions
|
||||
where user_id = v_user
|
||||
and source = 'game_reward'
|
||||
and created_at >= clock_timestamp() - interval '24 hours';
|
||||
|
||||
select coalesce(sum((result->>'score')::integer), 0)::integer into v_score
|
||||
from public.game_rounds
|
||||
where session_id = new.session_id
|
||||
and result is not null;
|
||||
|
||||
v_reward := least(20, 10 + floor(v_score::numeric / 2500)::integer);
|
||||
v_reward := least(v_reward, greatest(0, 100 - v_earned_24h));
|
||||
if v_reward <= 0 then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
insert into public.game_wallet_transactions(user_id, amount, source, source_key)
|
||||
values (v_user, v_reward, 'game_reward', new.session_id::text)
|
||||
on conflict (user_id, source, source_key) do nothing;
|
||||
if not found then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
update public.game_wallets
|
||||
set balance = balance + v_reward,
|
||||
updated_at = clock_timestamp()
|
||||
where user_id = v_user;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger game_rounds_reward_cosmetic_currency
|
||||
after update of result on public.game_rounds
|
||||
for each row
|
||||
when (old.result is null and new.result is not null and new.round_number = 4)
|
||||
execute function public.reward_completed_game_cosmetic_currency();
|
||||
|
||||
revoke all on function public.cosmetics_shop_state(uuid) from public, anon, authenticated;
|
||||
revoke all on function public.buy_profile_cosmetic(uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.equip_profile_cosmetic(uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.reward_completed_game_cosmetic_currency() from public, anon, authenticated;
|
||||
grant execute on function public.cosmetics_shop_state(uuid) to service_role;
|
||||
grant execute on function public.buy_profile_cosmetic(uuid, text) to service_role;
|
||||
grant execute on function public.equip_profile_cosmetic(uuid, text) to service_role;
|
||||
grant execute on function public.reward_completed_game_cosmetic_currency() to service_role;
|
||||
Reference in New Issue
Block a user