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

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:
Pantry Lead Agent
2026-02-09 13:45:57 +00:00
parent 12bda4c08f
commit b1ef7e43be
12 changed files with 280 additions and 220 deletions

746
docs/architecture/api.md Normal file
View 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)