Skip to main content

Overview

The inventory system tracks all items won by users from case openings. Items can be sold for balance, withdrawn, or kept in inventory. Each item has a status that determines its current state in the system.

Database Schema

Items Table

Master catalog of all possible items:
create table public.items (
  id uuid default uuid_generate_v4() primary key,
  name text not null,
  image_url text,
  rarity text not null,        -- common, rare, epic, legendary
  price numeric not null,      -- Base value in ARS
  created_at timestamp with time zone default now() not null
)

User Items Table

Tracks ownership and status:
create table public.user_items (
  id uuid default uuid_generate_v4() primary key,
  user_id uuid references public.users not null,
  item_id uuid references public.items not null,
  status text default 'in_inventory',  -- Item lifecycle status
  created_at timestamp with time zone default now() not null
)

Case Items Table

Defines which items can be won from which cases:
create table public.case_items (
  id uuid default uuid_generate_v4() primary key,
  case_id uuid references public.cases not null,
  name text not null,          -- Item name (denormalized)
  value number not null,       -- Item value
  image_url text not null,
  probability number not null, -- Drop chance percentage
  created_at timestamp with time zone default now() not null
)
The current schema has case_items with denormalized data (name, value, image_url). This allows case items to have different values than the base item.

Item Status Lifecycle

in_inventory

Item is freshly won and sitting in user’s inventory. Can be sold or withdrawn.

sold

Item has been sold for balance. No longer available to user.

withdrawn

Item has been withdrawn/shipped to user. Permanent state.

Adding Items to Inventory

When a user wins an item from a case:
app/api/cases/open/route.ts
// After determining winner from provably fair calculation
const winnerItem = getWinningItem(mappedItems, rollValue)

// Add to user's inventory
await supabase.from('user_items').insert({
  user_id: user.id,
  item_id: winnerItem.id,
  status: 'in_inventory'
})

// Alternative: If using denormalized case_items
// You might need to create the item record first
const { data: itemRecord } = await supabase
  .from('items')
  .select('*')
  .eq('name', winnerItem.name)
  .eq('price', winnerItem.value)
  .single()

if (!itemRecord) {
  // Create item if doesn't exist
  const { data: newItem } = await supabase
    .from('items')
    .insert({
      name: winnerItem.name,
      image_url: winnerItem.image_url,
      rarity: winnerItem.rarity,
      price: winnerItem.value
    })
    .select()
    .single()
  
  await supabase.from('user_items').insert({
    user_id: user.id,
    item_id: newItem.id,
    status: 'in_inventory'
  })
}

Fetching Inventory

Retrieve all items in a user’s inventory:
const { data: inventory } = await supabase
  .from('user_items')
  .select(`
    id,
    status,
    created_at,
    items (
      id,
      name,
      image_url,
      rarity,
      price
    )
  `)
  .eq('user_id', user.id)
  .eq('status', 'in_inventory')
  .order('created_at', { ascending: false })

// Returns:
// [
//   {
//     id: "user-item-uuid",
//     status: "in_inventory",
//     created_at: "2026-03-04T10:30:00Z",
//     items: {
//       id: "item-uuid",
//       name: "Dragon Lore AWP",
//       image_url: "https://...",
//       rarity: "legendary",
//       price: 15000
//     }
//   }
// ]
Supabase automatically joins the items table when you use the nested select syntax.

Selling Items

Convert inventory items to balance:
app/api/inventory/sell/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'

export async function POST(request: Request) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  
  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
  
  const { userItemId } = await request.json()
  
  // 1. Fetch the item with ownership verification
  const { data: userItem, error: fetchError } = await supabase
    .from('user_items')
    .select('*, items(*)')
    .eq('id', userItemId)
    .eq('user_id', user.id)  // Ensure ownership
    .eq('status', 'in_inventory')  // Can only sell items in inventory
    .single()
  
  if (fetchError || !userItem) {
    return NextResponse.json(
      { error: 'Item not found or already sold' },
      { status: 404 }
    )
  }
  
  const itemValue = userItem.items.price
  
  // 2. Update item status to 'sold'
  const { error: updateError } = await supabase
    .from('user_items')
    .update({ status: 'sold' })
    .eq('id', userItemId)
  
  if (updateError) {
    return NextResponse.json(
      { error: 'Failed to update item' },
      { status: 500 }
    )
  }
  
  // 3. Add value to user balance
  const { data: userData } = await supabase
    .from('users')
    .select('balance')
    .eq('id', user.id)
    .single()
  
  await supabase
    .from('users')
    .update({ balance: userData.balance + itemValue })
    .eq('id', user.id)
  
  // 4. Record transaction
  await supabase.from('transactions').insert({
    user_id: user.id,
    amount: itemValue,
    type: 'item_sell',
    reference_id: userItem.item_id
  })
  
  return NextResponse.json({
    success: true,
    item_name: userItem.items.name,
    amount_received: itemValue,
    new_balance: userData.balance + itemValue
  })
}

Bulk Sell

Sell multiple items at once:
export async function POST(request: Request) {
  const { userItemIds } = await request.json()  // Array of IDs
  
  // Fetch all items
  const { data: items } = await supabase
    .from('user_items')
    .select('*, items(*)')
    .in('id', userItemIds)
    .eq('user_id', user.id)
    .eq('status', 'in_inventory')
  
  if (!items || items.length === 0) {
    return NextResponse.json(
      { error: 'No valid items to sell' },
      { status: 400 }
    )
  }
  
  // Calculate total value
  const totalValue = items.reduce(
    (sum, item) => sum + item.items.price,
    0
  )
  
  // Update all items to sold
  await supabase
    .from('user_items')
    .update({ status: 'sold' })
    .in('id', userItemIds)
  
  // Update balance
  const { data: userData } = await supabase
    .from('users')
    .select('balance')
    .eq('id', user.id)
    .single()
  
  await supabase
    .from('users')
    .update({ balance: userData.balance + totalValue })
    .eq('id', user.id)
  
  // Record transaction
  await supabase.from('transactions').insert({
    user_id: user.id,
    amount: totalValue,
    type: 'item_sell',
    reference_id: null  // Multiple items
  })
  
  return NextResponse.json({
    success: true,
    items_sold: items.length,
    total_value: totalValue
  })
}

Inventory Filters

Filter inventory by rarity or value:
// By rarity
const { data: legendaryItems } = await supabase
  .from('user_items')
  .select('*, items!inner(*)')  // !inner makes it an INNER JOIN
  .eq('user_id', user.id)
  .eq('status', 'in_inventory')
  .eq('items.rarity', 'legendary')

// By minimum value
const { data: highValueItems } = await supabase
  .from('user_items')
  .select('*, items!inner(*)')
  .eq('user_id', user.id)
  .eq('status', 'in_inventory')
  .gte('items.price', 5000)  // Items worth 5000+ ARS

Inventory Statistics

Calculate inventory metrics:
const { data: inventory } = await supabase
  .from('user_items')
  .select('*, items(*)')
  .eq('user_id', user.id)
  .eq('status', 'in_inventory')

const stats = {
  total_items: inventory.length,
  total_value: inventory.reduce(
    (sum, item) => sum + item.items.price, 
    0
  ),
  by_rarity: inventory.reduce((acc, item) => {
    const rarity = item.items.rarity
    acc[rarity] = (acc[rarity] || 0) + 1
    return acc
  }, {} as Record<string, number>),
  highest_value: Math.max(
    ...inventory.map(item => item.items.price)
  )
}

// Returns:
// {
//   total_items: 23,
//   total_value: 45000,
//   by_rarity: {
//     common: 15,
//     rare: 5,
//     epic: 2,
//     legendary: 1
//   },
//   highest_value: 15000
// }

Item Withdrawal

For physical/digital item delivery:
app/api/inventory/withdraw/route.ts
export async function POST(request: Request) {
  const { userItemId, deliveryDetails } = await request.json()
  
  // Fetch item
  const { data: userItem } = await supabase
    .from('user_items')
    .select('*, items(*)')
    .eq('id', userItemId)
    .eq('user_id', user.id)
    .eq('status', 'in_inventory')
    .single()
  
  if (!userItem) {
    return NextResponse.json(
      { error: 'Item not found' },
      { status: 404 }
    )
  }
  
  // Check minimum withdrawal value
  const MIN_WITHDRAWAL_VALUE = 10000
  if (userItem.items.price < MIN_WITHDRAWAL_VALUE) {
    return NextResponse.json(
      { 
        error: `Items must be worth at least ${MIN_WITHDRAWAL_VALUE} ARS to withdraw` 
      },
      { status: 400 }
    )
  }
  
  // Update status
  await supabase
    .from('user_items')
    .update({ status: 'withdrawn' })
    .eq('id', userItemId)
  
  // Create withdrawal request (new table needed)
  await supabase.from('withdrawal_requests').insert({
    user_id: user.id,
    user_item_id: userItemId,
    delivery_details: deliveryDetails,
    status: 'pending'
  })
  
  return NextResponse.json({
    success: true,
    message: 'Withdrawal request created'
  })
}

UI Component Example

components/inventory-grid.tsx
'use client'

import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { formatCurrency } from '@/lib/utils'

interface InventoryItem {
  id: string
  status: string
  created_at: string
  items: {
    id: string
    name: string
    image_url: string
    rarity: string
    price: number
  }
}

export function InventoryGrid() {
  const [items, setItems] = useState<InventoryItem[]>([])
  const [loading, setLoading] = useState(true)
  const supabase = createClient()
  
  useEffect(() => {
    async function fetchInventory() {
      const { data: { user } } = await supabase.auth.getUser()
      if (!user) return
      
      const { data } = await supabase
        .from('user_items')
        .select('*, items(*)')
        .eq('user_id', user.id)
        .eq('status', 'in_inventory')
        .order('created_at', { ascending: false })
      
      setItems(data || [])
      setLoading(false)
    }
    
    fetchInventory()
  }, [])
  
  const handleSell = async (userItemId: string) => {
    const response = await fetch('/api/inventory/sell', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userItemId })
    })
    
    if (response.ok) {
      // Remove from UI
      setItems(items.filter(item => item.id !== userItemId))
    }
  }
  
  if (loading) return <div>Loading...</div>
  if (items.length === 0) return <div>No items in inventory</div>
  
  return (
    <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
      {items.map(item => (
        <div 
          key={item.id}
          className="bg-[#1a1d26] border border-white/5 rounded-lg p-4"
        >
          <img 
            src={item.items.image_url} 
            alt={item.items.name}
            className="w-full h-40 object-contain"
          />
          <h3 className="text-white font-bold mt-2">
            {item.items.name}
          </h3>
          <p className="text-primary font-bold">
            {formatCurrency(item.items.price)}
          </p>
          <button
            onClick={() => handleSell(item.id)}
            className="btn-primary w-full mt-2"
          >
            Sell
          </button>
        </div>
      ))}
    </div>
  )
}

Row Level Security

Ensure users can only see their own items:
-- Users can view their own items
create policy "Users can view own items"
  on public.user_items for select
  using (auth.uid() = user_id);

-- Users cannot directly insert items (only via API)
create policy "No direct insert"
  on public.user_items for insert
  with check (false);

-- Users cannot update items directly
create policy "No direct update"
  on public.user_items for update
  using (false);
Disable direct INSERT/UPDATE from client to prevent exploitation. Only allow server-side API routes to modify inventory.

Anti-Duplication Measures

Consider adding constraints to prevent duplicate item awards:
-- Prevent same item from being added twice in same second
CREATE UNIQUE INDEX unique_user_item_per_second 
ON user_items (user_id, item_id, 
  date_trunc('second', created_at));
Use idempotency keys when adding items:
const idempotencyKey = `${user.id}-${caseId}-${nonce}`

// Check if already processed
const { data: existing } = await supabase
  .from('game_rolls')
  .select('*')
  .eq('idempotency_key', idempotencyKey)
  .single()

if (existing) {
  return NextResponse.json({ 
    error: 'Game already processed' 
  }, { status: 409 })
}

Performance Optimization

1

Index user_id + status

CREATE INDEX idx_user_items_user_status 
ON user_items (user_id, status);
2

Pagination for large inventories

const { data, count } = await supabase
  .from('user_items')
  .select('*, items(*)', { count: 'exact' })
  .eq('user_id', user.id)
  .range(0, 19)  // First 20 items
3

Cache inventory count

Store count in users table, update with triggers

Future Features

Item Trading

Allow users to trade items with each other

Item Upgrading

Combine multiple items to create higher rarity items

Item Marketplace

Let users list items for sale to other users

Item History

Track when item was won, from which case, etc.

Wallet System

Learn how selling items adds to balance

Case Opening

See how items are won from cases

Build docs developers (and LLMs) love