# Pantry - API Reference **Version:** 1.0 **Last Updated:** 2026-02-08 --- ## 🌐 API Architecture Pantry uses a **hybrid approach:** 1. **Supabase Client SDK** — Direct database access from frontend (most operations) 2. **Edge Functions** — Custom logic for complex operations (product lookup, etc.) ### Why This Approach? - **Simple CRUD:** Supabase Client (auto-generated, realtime, RLS-protected) - **Complex logic:** Edge Functions (Open Food Facts integration, caching, etc.) --- ## 🔑 Authentication All API calls require authentication via Supabase Auth. ### Get Access Token ```typescript const { data, error } = await supabase.auth.signInWithPassword({ email: 'user@example.com', password: 'password' }) const token = data.session?.access_token ``` ### Using the Token **Supabase Client (automatic):** ```typescript // Token is automatically included in requests const { data } = await supabase .from('inventory_items') .select('*') ``` **Edge Functions (manual):** ```typescript fetch('https://your-project.supabase.co/functions/v1/product-lookup', { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ barcode: '123' }) }) ``` --- ## 📦 Supabase Client API ### Inventory Items #### List Items ```typescript const { data, error } = await supabase .from('inventory_items') .select(` *, product:products(*), unit:units(*), tags:item_tags(tag:tags(*)) `) .order('created_at', { ascending: false }) ``` **Response:** ```json { "data": [ { "id": "abc-123", "name": "Whole Milk", "quantity": 1.5, "expiry_date": "2026-02-15", "unit": { "abbreviation": "L", "name": "liter" }, "tags": [ { "tag": { "name": "Fridge", "icon": "🧊" } }, { "tag": { "name": "Dairy", "icon": "🥛" } } ], "product": { "barcode": "8000500310427", "brand": "Milka", "image_url": "https://..." } } ] } ``` #### Add Item ```typescript const { data, error } = await supabase .from('inventory_items') .insert({ product_id: 'product-uuid', // or null for custom items name: 'Whole Milk', quantity: 1, unit_id: 'unit-uuid', expiry_date: '2026-02-15', added_by: user.id }) .select() .single() ``` #### Update Item (Consume) ```typescript // Decrement quantity const { data, error } = await supabase .from('inventory_items') .update({ quantity: currentQuantity - 1 }) .eq('id', itemId) .select() .single() // Or use SQL function for atomic decrement: const { data, error } = await supabase.rpc('consume_item', { item_id: itemId, amount: 1 }) ``` #### Delete Item ```typescript const { error } = await supabase .from('inventory_items') .delete() .eq('id', itemId) ``` #### Filter by Tags ```typescript const { data, error } = await supabase .from('inventory_items') .select(` *, item_tags!inner(tag:tags!inner(*)) `) .eq('item_tags.tag.name', 'Fridge') ``` #### Search Items ```typescript const { data, error } = await supabase .from('inventory_items') .select('*') .or(`name.ilike.%${query}%,location.ilike.%${query}%`) ``` --- ### Tags #### List Tags ```typescript const { data, error } = await supabase .from('tags') .select('*') .order('category', { ascending: true }) .order('name', { ascending: true }) ``` #### Create Tag ```typescript const { data, error } = await supabase .from('tags') .insert({ name: 'Organic', category: 'dietary', icon: '🍃', color: '#84cc16', created_by: user.id }) .select() .single() ``` #### Add Tag to Item ```typescript const { data, error } = await supabase .from('item_tags') .insert({ item_id: 'item-uuid', tag_id: 'tag-uuid' }) ``` #### Remove Tag from Item ```typescript const { error } = await supabase .from('item_tags') .delete() .eq('item_id', itemId) .eq('tag_id', tagId) ``` --- ### Units #### List Units ```typescript const { data, error } = await supabase .from('units') .select('*, base_unit:units(name, abbreviation)') .order('unit_type', { ascending: true }) .order('name', { ascending: true }) ``` #### Convert Units ```typescript // Using SQL function const { data, error } = await supabase.rpc('convert_unit', { quantity: 500, from_unit_id: 'gram-uuid', to_unit_id: 'kg-uuid' }) // Returns: 0.5 ``` #### Create Custom Unit ```typescript const { data, error } = await supabase .from('units') .insert({ name: 'tablespoon', abbreviation: 'tbsp', unit_type: 'volume', base_unit_id: 'liter-uuid', conversion_factor: 0.015, // 15 mL is_default: false, created_by: user.id }) .select() .single() ``` --- ### Products #### Search Products (Cached) ```typescript const { data, error } = await supabase.rpc('search_products', { search_query: 'chocolate' }) ``` **Response:** ```json { "data": [ { "id": "product-123", "barcode": "8000500310427", "name": "Nutella", "brand": "Ferrero", "rank": 0.95 } ] } ``` --- ## ⚡ Edge Functions ### Product Lookup **Endpoint:** `POST /functions/v1/product-lookup` **Purpose:** Fetch product data from Open Food Facts (with caching) **Request:** ```json { "barcode": "8000500310427" } ``` **Response (Cached):** ```json { "product": { "id": "abc-123", "barcode": "8000500310427", "name": "Nutella", "brand": "Ferrero", "image_url": "https://...", "image_small_url": "https://...", "categories": ["spreads", "chocolate"], "default_unit_id": "gram-uuid", "default_quantity": 400 }, "cached": true, "source": "database" } ``` **Response (Not Cached - Fetched from Open Food Facts):** ```json { "product": { "id": "new-uuid", "barcode": "8000500310427", "name": "Nutella", "brand": "Ferrero", "image_url": "https://...", ... }, "cached": false, "source": "openfoodfacts" } ``` **Error Responses:** ```json // Product not found { "error": "PRODUCT_NOT_FOUND", "message": "Product not found in Open Food Facts", "barcode": "123invalid" } // Invalid barcode format { "error": "INVALID_BARCODE", "message": "Barcode must be 8-13 digits", "barcode": "abc" } ``` **Implementation:** ```typescript // supabase/functions/product-lookup/index.ts import { serve } from 'std/server' import { createClient } from '@supabase/supabase-js' serve(async (req) => { const { barcode } = await req.json() // Validate barcode if (!/^\d{8,13}$/.test(barcode)) { return new Response( JSON.stringify({ error: 'INVALID_BARCODE' }), { status: 400 } ) } const supabase = createClient(...) // Check cache const { data: cached } = await supabase .from('products') .select('*') .eq('barcode', barcode) .single() if (cached) { return new Response( JSON.stringify({ product: cached, cached: true }), { headers: { 'Content-Type': 'application/json' } } ) } // Fetch from Open Food Facts const res = await fetch( `https://world.openfoodfacts.org/api/v2/product/${barcode}.json` ) if (!res.ok) { return new Response( JSON.stringify({ error: 'PRODUCT_NOT_FOUND' }), { status: 404 } ) } const { product } = await res.json() // Cache product const { data: newProduct } = await supabase .from('products') .insert({ barcode, name: product.product_name, brand: product.brands, image_url: product.image_url, // ... more fields cached_at: new Date().toISOString() }) .select() .single() return new Response( JSON.stringify({ product: newProduct, cached: false }), { headers: { 'Content-Type': 'application/json' } } ) }) ``` --- ### Batch Product Lookup (Future) **Endpoint:** `POST /functions/v1/products/batch` **Purpose:** Lookup multiple products at once (for offline sync) **Request:** ```json { "barcodes": ["8000500310427", "5449000000996", "123456789"] } ``` **Response:** ```json { "results": [ { "barcode": "8000500310427", "product": { ... }, "cached": true }, { "barcode": "5449000000996", "product": { ... }, "cached": false }, { "barcode": "123456789", "error": "PRODUCT_NOT_FOUND" } ] } ``` --- ## 🔄 Realtime Subscriptions ### Subscribe to Inventory Changes ```typescript const subscription = supabase .channel('inventory-changes') .on( 'postgres_changes', { event: '*', // INSERT, UPDATE, DELETE schema: 'public', table: 'inventory_items' }, (payload) => { console.log('Change detected:', payload) if (payload.eventType === 'INSERT') { // New item added addItemToList(payload.new) } else if (payload.eventType === 'UPDATE') { // Item updated updateItemInList(payload.new) } else if (payload.eventType === 'DELETE') { // Item deleted removeItemFromList(payload.old.id) } } ) .subscribe() // Unsubscribe when done subscription.unsubscribe() ``` ### Subscribe to Tag Changes ```typescript supabase .channel('tag-changes') .on( 'postgres_changes', { event: '*', schema: 'public', table: 'tags' }, refreshTags ) .subscribe() ``` --- ## 🔧 Custom SQL Functions (via RPC) ### Consume Item (Atomic Decrement) ```sql CREATE OR REPLACE FUNCTION consume_item( item_id UUID, amount DECIMAL ) RETURNS inventory_items AS $$ DECLARE updated_item inventory_items; BEGIN UPDATE inventory_items SET quantity = GREATEST(quantity - amount, 0), updated_at = NOW() WHERE id = item_id RETURNING * INTO updated_item; -- Delete if quantity is 0 IF updated_item.quantity = 0 THEN DELETE FROM inventory_items WHERE id = item_id; updated_item.id := NULL; -- Signal deletion END IF; RETURN updated_item; END; $$ LANGUAGE plpgsql; ``` **Usage:** ```typescript const { data, error } = await supabase.rpc('consume_item', { item_id: 'item-uuid', amount: 1 }) if (data?.id === null) { // Item was deleted (quantity reached 0) } ``` ### Restock Item ```sql CREATE OR REPLACE FUNCTION restock_item( item_id UUID, amount DECIMAL ) RETURNS inventory_items AS $$ UPDATE inventory_items SET quantity = quantity + amount, updated_at = NOW() WHERE id = item_id RETURNING *; $$ LANGUAGE sql; ``` **Usage:** ```typescript const { data, error } = await supabase.rpc('restock_item', { item_id: 'item-uuid', amount: 2 }) ``` --- ## 📊 Analytics Queries (Future) ### Items by Tag ```typescript const { data, error } = await supabase.rpc('items_by_tag') ``` ```sql CREATE OR REPLACE FUNCTION items_by_tag() RETURNS TABLE (tag_name TEXT, item_count BIGINT) AS $$ SELECT t.name, COUNT(it.item_id) FROM tags t LEFT JOIN item_tags it ON t.id = it.tag_id GROUP BY t.name ORDER BY COUNT(it.item_id) DESC; $$ LANGUAGE sql; ``` ### Expiring Soon ```typescript const { data, error } = await supabase.rpc('expiring_soon', { days: 7 }) ``` ```sql CREATE OR REPLACE FUNCTION expiring_soon(days INTEGER) RETURNS SETOF inventory_items AS $$ SELECT * FROM inventory_items WHERE expiry_date IS NOT NULL AND expiry_date <= CURRENT_DATE + (days || ' days')::INTERVAL ORDER BY expiry_date ASC; $$ LANGUAGE sql; ``` --- ## 🚨 Error Handling ### Supabase Client Errors ```typescript const { data, error } = await supabase .from('inventory_items') .insert({ ... }) if (error) { // PostgreSQL error console.error(error.message) console.error(error.code) // e.g., "23505" (unique violation) console.error(error.details) } ``` ### Edge Function Errors ```typescript const res = await fetch('...', { ... }) if (!res.ok) { const error = await res.json() console.error(error.error) // Error code console.error(error.message) // Human-readable message } ``` --- ## 📚 Type Definitions (TypeScript) ```typescript // Generated by Supabase CLI: supabase gen types typescript export interface Database { public: { Tables: { inventory_items: { Row: { id: string product_id: string | null name: string quantity: number unit_id: string expiry_date: string | null location: string | null notes: string | null added_by: string created_at: string updated_at: string } Insert: { id?: string product_id?: string | null name: string quantity: number unit_id: string expiry_date?: string | null location?: string | null notes?: string | null added_by: string created_at?: string updated_at?: string } Update: { id?: string product_id?: string | null name?: string quantity?: number unit_id?: string expiry_date?: string | null location?: string | null notes?: string | null added_by?: string created_at?: string updated_at?: string } } // ... other tables } Functions: { convert_unit: { Args: { quantity: number; from_unit_id: string; to_unit_id: string } Returns: number } consume_item: { Args: { item_id: string; amount: number } Returns: Database['public']['Tables']['inventory_items']['Row'] } // ... other functions } } } ``` --- ## 🔐 Rate Limiting **Supabase Rate Limits (Free Tier):** - 500 requests/second per project - 2GB database size - 1GB file storage **Edge Functions:** - 500,000 invocations/month (free tier) - 2s timeout **Recommendations:** - Cache product lookups aggressively - Debounce search queries (300ms) - Use realtime subscriptions instead of polling --- **Next:** [Development Guide](./DEVELOPMENT.md)