Add shop catalog migration and atomic purchase/equip RPCs.
Upsert shop_items on deploy so the shop is never empty, and move buy/equip logic into Postgres for transactional coin deduction.
This commit is contained in:
43
supabase/migrations/20260525100000_shop_catalog_upsert.sql
Normal file
43
supabase/migrations/20260525100000_shop_catalog_upsert.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- Idempotent shop catalog. Fixes empty shop when seed did not run.
|
||||||
|
|
||||||
|
delete from public.user_items
|
||||||
|
where item_id in ('hat_blue', 'glasses_round', 'room_sunset', 'room-space', 'room-neon');
|
||||||
|
|
||||||
|
delete from public.shop_items
|
||||||
|
where id in (
|
||||||
|
'hat_blue',
|
||||||
|
'glasses_round',
|
||||||
|
'room_sunset',
|
||||||
|
'room-space',
|
||||||
|
'room-neon',
|
||||||
|
'pants-1',
|
||||||
|
'pants-2',
|
||||||
|
'shoes-1',
|
||||||
|
'shoes-2',
|
||||||
|
'torso-1',
|
||||||
|
'torso-2',
|
||||||
|
'eyes-1',
|
||||||
|
'eyes-2',
|
||||||
|
'head-1',
|
||||||
|
'head-2'
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.shop_items (id, name, type, price, image_path)
|
||||||
|
values
|
||||||
|
('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'),
|
||||||
|
('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'),
|
||||||
|
('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'),
|
||||||
|
('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.png'),
|
||||||
|
('head-3', 'Head 3', 'head', 40, '/character/head/head-3.png'),
|
||||||
|
('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'),
|
||||||
|
('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'),
|
||||||
|
('head-6', 'Head 6', 'head', 55, '/character/head/head-6.png'),
|
||||||
|
('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png'),
|
||||||
|
('room-night', 'Starlit Night', 'room', 40, ''),
|
||||||
|
('room-forest', 'Mystic Forest', 'room', 50, '')
|
||||||
|
on conflict (id) do update
|
||||||
|
set
|
||||||
|
name = excluded.name,
|
||||||
|
type = excluded.type,
|
||||||
|
price = excluded.price,
|
||||||
|
image_path = excluded.image_path;
|
||||||
186
supabase/migrations/20260525110000_shop_purchase_and_equip.sql
Normal file
186
supabase/migrations/20260525110000_shop_purchase_and_equip.sql
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
-- Atomic shop purchase and equip operations.
|
||||||
|
|
||||||
|
create or replace function public.purchase_shop_item(p_item_id text)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user_id uuid := auth.uid();
|
||||||
|
v_item public.shop_items%rowtype;
|
||||||
|
v_coins int;
|
||||||
|
begin
|
||||||
|
if v_user_id is null then
|
||||||
|
raise exception 'You must be signed in to purchase items.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into v_item from public.shop_items where id = p_item_id;
|
||||||
|
if not found then
|
||||||
|
raise exception 'That shop item is no longer available.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if exists (
|
||||||
|
select 1 from public.user_items
|
||||||
|
where user_id = v_user_id and item_id = p_item_id
|
||||||
|
) then
|
||||||
|
raise exception 'You already own this item.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select coins into v_coins
|
||||||
|
from public.avatar_state
|
||||||
|
where user_id = v_user_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
insert into public.avatar_state (user_id, coins) values (v_user_id, 0);
|
||||||
|
v_coins := 0;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_coins < v_item.price then
|
||||||
|
raise exception 'Not enough points for this purchase.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.avatar_state
|
||||||
|
set coins = v_coins - v_item.price, updated_at = now()
|
||||||
|
where user_id = v_user_id;
|
||||||
|
|
||||||
|
insert into public.user_items (user_id, item_id)
|
||||||
|
values (v_user_id, p_item_id);
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'item_id', v_item.id,
|
||||||
|
'name', v_item.name,
|
||||||
|
'type', v_item.type,
|
||||||
|
'price', v_item.price,
|
||||||
|
'image_path', v_item.image_path,
|
||||||
|
'coins', v_coins - v_item.price
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.equip_shop_item(p_item_id text)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user_id uuid := auth.uid();
|
||||||
|
v_item public.shop_items%rowtype;
|
||||||
|
v_equipped text[];
|
||||||
|
v_layer text;
|
||||||
|
begin
|
||||||
|
if v_user_id is null then
|
||||||
|
raise exception 'You must be signed in to equip items.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (
|
||||||
|
select 1 from public.user_items
|
||||||
|
where user_id = v_user_id and item_id = p_item_id
|
||||||
|
) then
|
||||||
|
raise exception 'Purchase this item before equipping it.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into v_item from public.shop_items where id = p_item_id;
|
||||||
|
if not found then
|
||||||
|
raise exception 'Could not find that shop item.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_item.type = 'room' then
|
||||||
|
update public.avatar_state
|
||||||
|
set room_background = p_item_id, updated_at = now()
|
||||||
|
where user_id = v_user_id;
|
||||||
|
|
||||||
|
return jsonb_build_object('equipped_room', p_item_id);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select equipped_items into v_equipped
|
||||||
|
from public.avatar_state
|
||||||
|
where user_id = v_user_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
v_equipped := coalesce(v_equipped, '{}');
|
||||||
|
|
||||||
|
v_equipped := array(
|
||||||
|
select e from unnest(v_equipped) as e
|
||||||
|
where e = p_item_id
|
||||||
|
or not (e like v_item.type || '-%')
|
||||||
|
);
|
||||||
|
|
||||||
|
v_equipped := array_append(v_equipped, p_item_id);
|
||||||
|
v_layer := v_item.type;
|
||||||
|
|
||||||
|
update public.avatar_state
|
||||||
|
set
|
||||||
|
equipped_items = v_equipped,
|
||||||
|
equipped_item = p_item_id,
|
||||||
|
head_style = case when v_layer = 'head' then p_item_id else head_style end,
|
||||||
|
torso_style = case when v_layer = 'torso' then p_item_id else torso_style end,
|
||||||
|
pants_style = case when v_layer = 'pants' then p_item_id else pants_style end,
|
||||||
|
shoe_style = case when v_layer = 'shoes' then p_item_id else shoe_style end,
|
||||||
|
eye_type = case when v_layer = 'eyes' then p_item_id else eye_type end,
|
||||||
|
updated_at = now()
|
||||||
|
where user_id = v_user_id;
|
||||||
|
|
||||||
|
return jsonb_build_object('equipped_items', to_jsonb(v_equipped));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.unequip_shop_item(p_item_id text)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user_id uuid := auth.uid();
|
||||||
|
v_item public.shop_items%rowtype;
|
||||||
|
v_equipped text[];
|
||||||
|
v_layer text;
|
||||||
|
begin
|
||||||
|
if v_user_id is null then
|
||||||
|
raise exception 'You must be signed in to unequip items.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into v_item from public.shop_items where id = p_item_id;
|
||||||
|
if not found then
|
||||||
|
raise exception 'Could not find that shop item.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_item.type = 'room' then
|
||||||
|
raise exception 'Room backgrounds cannot be unequipped.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select equipped_items into v_equipped
|
||||||
|
from public.avatar_state
|
||||||
|
where user_id = v_user_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
v_equipped := coalesce(v_equipped, '{}');
|
||||||
|
v_equipped := array(select e from unnest(v_equipped) as e where e <> p_item_id);
|
||||||
|
v_layer := v_item.type;
|
||||||
|
|
||||||
|
update public.avatar_state
|
||||||
|
set
|
||||||
|
equipped_items = v_equipped,
|
||||||
|
equipped_item = coalesce(v_equipped[1], 'none'),
|
||||||
|
head_style = case when v_layer = 'head' then 'none' else head_style end,
|
||||||
|
torso_style = case when v_layer = 'torso' then 'none' else torso_style end,
|
||||||
|
pants_style = case when v_layer = 'pants' then 'none' else pants_style end,
|
||||||
|
shoe_style = case when v_layer = 'shoes' then 'none' else shoe_style end,
|
||||||
|
eye_type = case when v_layer = 'eyes' then 'none' else eye_type end,
|
||||||
|
updated_at = now()
|
||||||
|
where user_id = v_user_id;
|
||||||
|
|
||||||
|
return jsonb_build_object('equipped_items', to_jsonb(v_equipped));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.purchase_shop_item(text) from public;
|
||||||
|
revoke all on function public.equip_shop_item(text) from public;
|
||||||
|
revoke all on function public.unequip_shop_item(text) from public;
|
||||||
|
|
||||||
|
grant execute on function public.purchase_shop_item(text) to authenticated;
|
||||||
|
grant execute on function public.equip_shop_item(text) to authenticated;
|
||||||
|
grant execute on function public.unequip_shop_item(text) to authenticated;
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
-- Character layer styles sold in the shop (id = variant id, type = layer id).
|
-- Character layer styles sold in the shop (id = variant id, type = layer id).
|
||||||
|
|
||||||
delete from public.user_items
|
delete from public.user_items
|
||||||
where item_id in ('hat_blue', 'glasses_round', 'room_sunset');
|
where item_id in ('hat_blue', 'glasses_round', 'room_sunset', 'room-space', 'room-neon');
|
||||||
|
|
||||||
delete from public.shop_items
|
delete from public.shop_items
|
||||||
where id in (
|
where id in (
|
||||||
'hat_blue',
|
'hat_blue',
|
||||||
'glasses_round',
|
'glasses_round',
|
||||||
'room_sunset',
|
'room_sunset',
|
||||||
|
'room-space',
|
||||||
|
'room-neon',
|
||||||
'pants-1',
|
'pants-1',
|
||||||
'pants-2',
|
'pants-2',
|
||||||
'shoes-1',
|
'shoes-1',
|
||||||
|
|||||||
Reference in New Issue
Block a user