All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s
334 lines
9.9 KiB
PL/PgSQL
334 lines
9.9 KiB
PL/PgSQL
-- 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;
|