From f02208c0ea936bbcad50f542adfdf990f96f0fbe Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 24 May 2026 04:02:56 -0600 Subject: [PATCH] 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. --- .../20260525100000_shop_catalog_upsert.sql | 43 ++++ ...20260525110000_shop_purchase_and_equip.sql | 186 ++++++++++++++++++ supabase/seed.sql | 4 +- 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260525100000_shop_catalog_upsert.sql create mode 100644 supabase/migrations/20260525110000_shop_purchase_and_equip.sql diff --git a/supabase/migrations/20260525100000_shop_catalog_upsert.sql b/supabase/migrations/20260525100000_shop_catalog_upsert.sql new file mode 100644 index 0000000..7490df7 --- /dev/null +++ b/supabase/migrations/20260525100000_shop_catalog_upsert.sql @@ -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; diff --git a/supabase/migrations/20260525110000_shop_purchase_and_equip.sql b/supabase/migrations/20260525110000_shop_purchase_and_equip.sql new file mode 100644 index 0000000..83443f0 --- /dev/null +++ b/supabase/migrations/20260525110000_shop_purchase_and_equip.sql @@ -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; diff --git a/supabase/seed.sql b/supabase/seed.sql index 2fd7e6a..a9eb64b 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,13 +1,15 @@ -- Character layer styles sold in the shop (id = variant id, type = layer id). 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 where id in ( 'hat_blue', 'glasses_round', 'room_sunset', + 'room-space', + 'room-neon', 'pants-1', 'pants-2', 'shoes-1',