Add public profiles and cosmetic shop
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s

This commit is contained in:
2026-10-15 17:30:10 +05:00
parent 44e62cff2f
commit c541cc09b4
28 changed files with 1359 additions and 45 deletions

View File

@@ -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;