docs: restructure documentation into organized folders
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
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
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.
This commit is contained in:
746
docs/architecture/api.md
Normal file
746
docs/architecture/api.md
Normal file
@@ -0,0 +1,746 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user