Files
pantry/docs/architecture/api.md
Pantry Lead Agent b1ef7e43be
Some checks failed
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
docs: restructure documentation into organized folders
Organized docs into logical subdirectories:

**New Structure:**
- docs/
  - README.md (index with quick links)
  - PROJECT_PLAN.md (root level - main roadmap)
  - development/
    - getting-started.md (5-min quickstart)
    - local-setup.md (detailed Docker Compose guide)
    - workflow.md (daily development)
    - git-workflow.md (branching strategy)
  - architecture/
    - overview.md (tech stack, design)
    - database.md (schema, RLS, migrations)
    - api.md (endpoints, functions)
  - deployment/
    - production.md (Docker, Coolify)
    - ci-cd.md (automated pipelines)

**Cleaned Up:**
- Moved DEV_SETUP.md → docs/development/local-setup.md
- Removed outdated SETUP.md (referenced old Coolify setup)
- Replaced with getting-started.md (current Docker Compose flow)
- Updated README.md links to new structure

All paths tested, no broken links.
2026-02-09 13:45:57 +00:00

14 KiB

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

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):

// Token is automatically included in requests
const { data } = await supabase
  .from('inventory_items')
  .select('*')

Edge Functions (manual):

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

const { data, error } = await supabase
  .from('inventory_items')
  .select(`
    *,
    product:products(*),
    unit:units(*),
    tags:item_tags(tag:tags(*))
  `)
  .order('created_at', { ascending: false })

Response:

{
  "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

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)

// 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

const { error } = await supabase
  .from('inventory_items')
  .delete()
  .eq('id', itemId)

Filter by Tags

const { data, error } = await supabase
  .from('inventory_items')
  .select(`
    *,
    item_tags!inner(tag:tags!inner(*))
  `)
  .eq('item_tags.tag.name', 'Fridge')

Search Items

const { data, error } = await supabase
  .from('inventory_items')
  .select('*')
  .or(`name.ilike.%${query}%,location.ilike.%${query}%`)

Tags

List Tags

const { data, error } = await supabase
  .from('tags')
  .select('*')
  .order('category', { ascending: true })
  .order('name', { ascending: true })

Create Tag

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

const { data, error } = await supabase
  .from('item_tags')
  .insert({
    item_id: 'item-uuid',
    tag_id: 'tag-uuid'
  })

Remove Tag from Item

const { error } = await supabase
  .from('item_tags')
  .delete()
  .eq('item_id', itemId)
  .eq('tag_id', tagId)

Units

List Units

const { data, error } = await supabase
  .from('units')
  .select('*, base_unit:units(name, abbreviation)')
  .order('unit_type', { ascending: true })
  .order('name', { ascending: true })

Convert Units

// 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

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)

const { data, error } = await supabase.rpc('search_products', {
  search_query: 'chocolate'
})

Response:

{
  "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:

{
  "barcode": "8000500310427"
}

Response (Cached):

{
  "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):

{
  "product": {
    "id": "new-uuid",
    "barcode": "8000500310427",
    "name": "Nutella",
    "brand": "Ferrero",
    "image_url": "https://...",
    ...
  },
  "cached": false,
  "source": "openfoodfacts"
}

Error Responses:

// 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:

// 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:

{
  "barcodes": ["8000500310427", "5449000000996", "123456789"]
}

Response:

{
  "results": [
    {
      "barcode": "8000500310427",
      "product": { ... },
      "cached": true
    },
    {
      "barcode": "5449000000996",
      "product": { ... },
      "cached": false
    },
    {
      "barcode": "123456789",
      "error": "PRODUCT_NOT_FOUND"
    }
  ]
}

🔄 Realtime Subscriptions

Subscribe to Inventory Changes

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

supabase
  .channel('tag-changes')
  .on(
    'postgres_changes',
    { event: '*', schema: 'public', table: 'tags' },
    refreshTags
  )
  .subscribe()

🔧 Custom SQL Functions (via RPC)

Consume Item (Atomic Decrement)

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:

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

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:

const { data, error } = await supabase.rpc('restock_item', {
  item_id: 'item-uuid',
  amount: 2
})

📊 Analytics Queries (Future)

Items by Tag

const { data, error } = await supabase.rpc('items_by_tag')
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

const { data, error } = await supabase.rpc('expiring_soon', {
  days: 7
})
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

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

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)

// 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