docs: Add comprehensive technical documentation
Complete documentation suite: - DATABASE.md: Full schema, RLS policies, functions, queries - API.md: Supabase client API, Edge functions, realtime - DEVELOPMENT.md: Setup, workflow, conventions, testing - DEPLOYMENT.md: Docker Compose, Coolify, monitoring, backups Ready for development to begin.
This commit is contained in:
746
docs/API.md
Normal file
746
docs/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)
|
||||||
651
docs/DATABASE.md
Normal file
651
docs/DATABASE.md
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
# Pantry - Database Schema
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Last Updated:** 2026-02-08
|
||||||
|
**PostgreSQL:** 15+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Schema Overview
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
| Table | Purpose | Rows (Est.) |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `inventory_items` | Current inventory (in your kitchen) | 100-500 |
|
||||||
|
| `products` | Master data cache (from Open Food Facts) | 500-2000 |
|
||||||
|
| `tags` | Organization labels (position, type, custom) | 20-50 |
|
||||||
|
| `item_tags` | Many-to-many item ↔ tag | 200-1000 |
|
||||||
|
| `units` | Measurement units + conversions | 30-50 |
|
||||||
|
| `users` | User accounts (Supabase Auth manages) | 2-10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗃️ Table Definitions
|
||||||
|
|
||||||
|
### `inventory_items`
|
||||||
|
|
||||||
|
**Purpose:** Actual items in your kitchen right now
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE inventory_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Product reference (nullable for custom items)
|
||||||
|
product_id UUID REFERENCES products(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Core data
|
||||||
|
name TEXT NOT NULL, -- Product name or custom name
|
||||||
|
quantity DECIMAL(10,2) NOT NULL CHECK (quantity >= 0),
|
||||||
|
unit_id UUID NOT NULL REFERENCES units(id),
|
||||||
|
|
||||||
|
-- Optional metadata
|
||||||
|
expiry_date DATE,
|
||||||
|
location TEXT, -- Free text: "top shelf", "door", etc.
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Audit trail
|
||||||
|
added_by UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_items_product ON inventory_items(product_id);
|
||||||
|
CREATE INDEX idx_items_added_by ON inventory_items(added_by);
|
||||||
|
CREATE INDEX idx_items_expiry ON inventory_items(expiry_date) WHERE expiry_date IS NOT NULL;
|
||||||
|
|
||||||
|
-- Auto-update timestamp
|
||||||
|
CREATE TRIGGER update_items_updated_at
|
||||||
|
BEFORE UPDATE ON inventory_items
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sample Data:**
|
||||||
|
```sql
|
||||||
|
INSERT INTO inventory_items (product_id, name, quantity, unit_id, expiry_date, added_by)
|
||||||
|
VALUES
|
||||||
|
('abc-123', 'Whole Milk', 1.5, 'unit-liter', '2026-02-15', 'user-123'),
|
||||||
|
(NULL, 'Homemade Jam', 300, 'unit-gram', '2026-06-01', 'user-123');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `products`
|
||||||
|
|
||||||
|
**Purpose:** Cached product data from Open Food Facts
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE products (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Open Food Facts data
|
||||||
|
barcode TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
brand TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
image_small_url TEXT, -- Thumbnail
|
||||||
|
|
||||||
|
-- Categories from Open Food Facts
|
||||||
|
categories TEXT[], -- Array: ['dairy', 'milk']
|
||||||
|
|
||||||
|
-- Nutrition (optional, for future features)
|
||||||
|
nutrition JSONB, -- Full nutrition data
|
||||||
|
|
||||||
|
-- Defaults
|
||||||
|
default_unit_id UUID REFERENCES units(id),
|
||||||
|
default_quantity DECIMAL(10,2), -- E.g., 1L bottle
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
source TEXT DEFAULT 'openfoodfacts',
|
||||||
|
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_fetched TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Quality score (from Open Food Facts)
|
||||||
|
completeness_score INTEGER CHECK (completeness_score BETWEEN 0 AND 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE UNIQUE INDEX idx_products_barcode ON products(barcode);
|
||||||
|
CREATE INDEX idx_products_name ON products USING GIN (to_tsvector('english', name));
|
||||||
|
|
||||||
|
-- Full-text search
|
||||||
|
CREATE INDEX idx_products_search ON products
|
||||||
|
USING GIN (to_tsvector('english', name || ' ' || COALESCE(brand, '')));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sample Data:**
|
||||||
|
```sql
|
||||||
|
INSERT INTO products (barcode, name, brand, image_url, default_unit_id, cached_at)
|
||||||
|
VALUES
|
||||||
|
('8000500310427', 'Nutella', 'Ferrero', 'https://...', 'unit-gram', NOW()),
|
||||||
|
('5449000000996', 'Coca-Cola', 'Coca-Cola', 'https://...', 'unit-liter', NOW());
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `tags`
|
||||||
|
|
||||||
|
**Purpose:** Flexible labeling system
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TYPE tag_category AS ENUM (
|
||||||
|
'position', -- Location: fridge, freezer, pantry
|
||||||
|
'type', -- Food type: dairy, meat, vegan
|
||||||
|
'dietary', -- Diet: gluten-free, vegan, organic
|
||||||
|
'custom' -- User-defined
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Core data
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category tag_category NOT NULL DEFAULT 'custom',
|
||||||
|
|
||||||
|
-- Visual
|
||||||
|
icon TEXT, -- Emoji or icon name: "🧊", "cheese"
|
||||||
|
color TEXT, -- Hex color: "#3b82f6"
|
||||||
|
|
||||||
|
-- Ownership
|
||||||
|
created_by UUID REFERENCES auth.users(id), -- NULL = system tag
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_tag_name UNIQUE (name, created_by)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_tags_category ON tags(category);
|
||||||
|
CREATE INDEX idx_tags_created_by ON tags(created_by);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sample Data (Seed):**
|
||||||
|
```sql
|
||||||
|
-- System tags (created_by = NULL)
|
||||||
|
INSERT INTO tags (name, category, icon, color, created_by) VALUES
|
||||||
|
-- Position
|
||||||
|
('Fridge', 'position', '🧊', '#3b82f6', NULL),
|
||||||
|
('Freezer', 'position', '❄️', '#0ea5e9', NULL),
|
||||||
|
('Pantry', 'position', '📦', '#f59e0b', NULL),
|
||||||
|
('Spices', 'position', '🌶️', '#ef4444', NULL),
|
||||||
|
|
||||||
|
-- Type
|
||||||
|
('Dairy', 'type', '🥛', '#fbbf24', NULL),
|
||||||
|
('Cheese', 'type', '🧀', '#fcd34d', NULL),
|
||||||
|
('Meat', 'type', '🥩', '#dc2626', NULL),
|
||||||
|
('Fish', 'type', '🐟', '#06b6d4', NULL),
|
||||||
|
('Vegetables', 'type', '🥬', '#10b981', NULL),
|
||||||
|
('Fruits', 'type', '🍎', '#f87171', NULL),
|
||||||
|
('Bakery', 'type', '🍞', '#d97706', NULL),
|
||||||
|
('Snacks', 'type', '🍫', '#7c3aed', NULL),
|
||||||
|
|
||||||
|
-- Dietary
|
||||||
|
('Vegan', 'dietary', '🌱', '#22c55e', NULL),
|
||||||
|
('Gluten-Free', 'dietary', '🌾', '#eab308', NULL),
|
||||||
|
('Organic', 'dietary', '🍃', '#84cc16', NULL);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `item_tags`
|
||||||
|
|
||||||
|
**Purpose:** Many-to-many relationship between items and tags
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE item_tags (
|
||||||
|
item_id UUID NOT NULL REFERENCES inventory_items(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
PRIMARY KEY (item_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_item_tags_tag ON item_tags(tag_id);
|
||||||
|
CREATE INDEX idx_item_tags_item ON item_tags(item_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sample Data:**
|
||||||
|
```sql
|
||||||
|
-- Milk in fridge + dairy tag
|
||||||
|
INSERT INTO item_tags (item_id, tag_id) VALUES
|
||||||
|
('item-milk', 'tag-fridge'),
|
||||||
|
('item-milk', 'tag-dairy');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `units`
|
||||||
|
|
||||||
|
**Purpose:** Measurement units with conversion support
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TYPE unit_type AS ENUM (
|
||||||
|
'weight', -- kg, g, lb, oz
|
||||||
|
'volume', -- L, mL, cup, tbsp
|
||||||
|
'count', -- pcs, items (no conversion)
|
||||||
|
'custom' -- can, jar, bottle (user-defined)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE units (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Core data
|
||||||
|
name TEXT NOT NULL, -- "kilogram", "liter", "piece"
|
||||||
|
abbreviation TEXT NOT NULL, -- "kg", "L", "pcs"
|
||||||
|
unit_type unit_type NOT NULL,
|
||||||
|
|
||||||
|
-- Conversion system
|
||||||
|
base_unit_id UUID REFERENCES units(id), -- NULL = this is a base unit
|
||||||
|
conversion_factor DECIMAL(20,10), -- Factor to convert to base unit
|
||||||
|
|
||||||
|
-- E.g., for grams: base_unit = kg, factor = 0.001
|
||||||
|
-- To convert: value_in_g * 0.001 = value_in_kg
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
is_default BOOLEAN DEFAULT false, -- Shipped with app
|
||||||
|
created_by UUID REFERENCES auth.users(id), -- NULL = system unit
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_unit_abbr UNIQUE (abbreviation, created_by)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_units_type ON units(unit_type);
|
||||||
|
CREATE INDEX idx_units_base ON units(base_unit_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sample Data (Seed):**
|
||||||
|
```sql
|
||||||
|
-- Weight (metric)
|
||||||
|
INSERT INTO units (name, abbreviation, unit_type, base_unit_id, conversion_factor, is_default, created_by) VALUES
|
||||||
|
('kilogram', 'kg', 'weight', NULL, 1.0, true, NULL), -- Base unit
|
||||||
|
('gram', 'g', 'weight', (SELECT id FROM units WHERE abbreviation = 'kg'), 0.001, true, NULL),
|
||||||
|
('milligram', 'mg', 'weight', (SELECT id FROM units WHERE abbreviation = 'kg'), 0.000001, true, NULL),
|
||||||
|
|
||||||
|
-- Volume (metric)
|
||||||
|
('liter', 'L', 'volume', NULL, 1.0, true, NULL), -- Base unit
|
||||||
|
('milliliter', 'mL', 'volume', (SELECT id FROM units WHERE abbreviation = 'L'), 0.001, true, NULL),
|
||||||
|
|
||||||
|
-- Count
|
||||||
|
('piece', 'pcs', 'count', NULL, 1.0, true, NULL), -- No conversion
|
||||||
|
('item', 'item', 'count', NULL, 1.0, true, NULL),
|
||||||
|
|
||||||
|
-- Custom (common containers)
|
||||||
|
('can', 'can', 'custom', NULL, NULL, true, NULL), -- User defines conversion
|
||||||
|
('jar', 'jar', 'custom', NULL, NULL, true, NULL),
|
||||||
|
('bottle', 'bottle', 'custom', NULL, NULL, true, NULL),
|
||||||
|
('package', 'pkg', 'custom', NULL, NULL, true, NULL);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `users` (Supabase Auth)
|
||||||
|
|
||||||
|
**Purpose:** User accounts (managed by Supabase Auth)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- This table is managed by Supabase Auth (auth.users)
|
||||||
|
-- We only reference it via foreign keys
|
||||||
|
|
||||||
|
-- Additional user metadata (if needed)
|
||||||
|
CREATE TABLE user_profiles (
|
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
display_name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
|
||||||
|
-- Preferences
|
||||||
|
default_unit_system TEXT DEFAULT 'metric', -- 'metric' or 'imperial'
|
||||||
|
theme TEXT DEFAULT 'auto', -- 'light', 'dark', 'auto'
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Row Level Security (RLS)
|
||||||
|
|
||||||
|
### Enable RLS
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE inventory_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE item_tags ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE units ENABLE ROW LEVEL SECURITY;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Policies
|
||||||
|
|
||||||
|
**inventory_items:**
|
||||||
|
```sql
|
||||||
|
-- Everyone can read (shared inventory)
|
||||||
|
CREATE POLICY "items_select_all" ON inventory_items
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Authenticated users can insert
|
||||||
|
CREATE POLICY "items_insert_auth" ON inventory_items
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
|
||||||
|
|
||||||
|
-- Authenticated users can update
|
||||||
|
CREATE POLICY "items_update_auth" ON inventory_items
|
||||||
|
FOR UPDATE USING (auth.uid() IS NOT NULL);
|
||||||
|
|
||||||
|
-- Authenticated users can delete
|
||||||
|
CREATE POLICY "items_delete_auth" ON inventory_items
|
||||||
|
FOR DELETE USING (auth.uid() IS NOT NULL);
|
||||||
|
```
|
||||||
|
|
||||||
|
**products:**
|
||||||
|
```sql
|
||||||
|
-- Everyone can read cached products
|
||||||
|
CREATE POLICY "products_select_all" ON products
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Only service role can write (via Edge Functions)
|
||||||
|
-- (No user-level INSERT/UPDATE policy)
|
||||||
|
```
|
||||||
|
|
||||||
|
**tags:**
|
||||||
|
```sql
|
||||||
|
-- Everyone can read all tags
|
||||||
|
CREATE POLICY "tags_select_all" ON tags
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Authenticated users can create tags
|
||||||
|
CREATE POLICY "tags_insert_auth" ON tags
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
|
||||||
|
|
||||||
|
-- Users can only update their own tags (or system tags if admin)
|
||||||
|
CREATE POLICY "tags_update_own" ON tags
|
||||||
|
FOR UPDATE USING (
|
||||||
|
created_by = auth.uid() OR created_by IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can only delete their own tags
|
||||||
|
CREATE POLICY "tags_delete_own" ON tags
|
||||||
|
FOR DELETE USING (created_by = auth.uid());
|
||||||
|
```
|
||||||
|
|
||||||
|
**item_tags:**
|
||||||
|
```sql
|
||||||
|
-- Everyone can read
|
||||||
|
CREATE POLICY "item_tags_select_all" ON item_tags
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Authenticated users can add tags to items
|
||||||
|
CREATE POLICY "item_tags_insert_auth" ON item_tags
|
||||||
|
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
|
||||||
|
|
||||||
|
-- Authenticated users can remove tags
|
||||||
|
CREATE POLICY "item_tags_delete_auth" ON item_tags
|
||||||
|
FOR DELETE USING (auth.uid() IS NOT NULL);
|
||||||
|
```
|
||||||
|
|
||||||
|
**units:**
|
||||||
|
```sql
|
||||||
|
-- Everyone can read
|
||||||
|
CREATE POLICY "units_select_all" ON units
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Authenticated users can create custom units
|
||||||
|
CREATE POLICY "units_insert_auth" ON units
|
||||||
|
FOR INSERT WITH CHECK (
|
||||||
|
auth.uid() IS NOT NULL AND is_default = false
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can update their own custom units
|
||||||
|
CREATE POLICY "units_update_own" ON units
|
||||||
|
FOR UPDATE USING (
|
||||||
|
created_by = auth.uid() AND is_default = false
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Functions & Triggers
|
||||||
|
|
||||||
|
### Update Timestamp Trigger
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Apply to tables with updated_at
|
||||||
|
CREATE TRIGGER update_items_updated_at
|
||||||
|
BEFORE UPDATE ON inventory_items
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_profiles_updated_at
|
||||||
|
BEFORE UPDATE ON user_profiles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unit Conversion Function
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION convert_unit(
|
||||||
|
quantity DECIMAL,
|
||||||
|
from_unit_id UUID,
|
||||||
|
to_unit_id UUID
|
||||||
|
)
|
||||||
|
RETURNS DECIMAL AS $$
|
||||||
|
DECLARE
|
||||||
|
from_factor DECIMAL;
|
||||||
|
to_factor DECIMAL;
|
||||||
|
from_type unit_type;
|
||||||
|
to_type unit_type;
|
||||||
|
base_quantity DECIMAL;
|
||||||
|
BEGIN
|
||||||
|
-- Get unit types and conversion factors
|
||||||
|
SELECT unit_type,
|
||||||
|
COALESCE(conversion_factor, 1.0) INTO from_type, from_factor
|
||||||
|
FROM units WHERE id = from_unit_id;
|
||||||
|
|
||||||
|
SELECT unit_type,
|
||||||
|
COALESCE(conversion_factor, 1.0) INTO to_type, to_factor
|
||||||
|
FROM units WHERE id = to_unit_id;
|
||||||
|
|
||||||
|
-- Check if units are compatible
|
||||||
|
IF from_type != to_type THEN
|
||||||
|
RAISE EXCEPTION 'Cannot convert between % and %', from_type, to_type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Convert to base unit, then to target unit
|
||||||
|
base_quantity := quantity * from_factor;
|
||||||
|
RETURN base_quantity / to_factor;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
|
-- Usage:
|
||||||
|
-- SELECT convert_unit(500, 'gram-id', 'kg-id'); -> 0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full-Text Search Function
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION search_products(search_query TEXT)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id UUID,
|
||||||
|
barcode TEXT,
|
||||||
|
name TEXT,
|
||||||
|
brand TEXT,
|
||||||
|
rank REAL
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.barcode,
|
||||||
|
p.name,
|
||||||
|
p.brand,
|
||||||
|
ts_rank(to_tsvector('english', p.name || ' ' || COALESCE(p.brand, '')),
|
||||||
|
plainto_tsquery('english', search_query)) AS rank
|
||||||
|
FROM products p
|
||||||
|
WHERE to_tsvector('english', p.name || ' ' || COALESCE(p.brand, ''))
|
||||||
|
@@ plainto_tsquery('english', search_query)
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT 20;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Usage:
|
||||||
|
-- SELECT * FROM search_products('chocolate');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Example Queries
|
||||||
|
|
||||||
|
### List all inventory items with tags and units
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
i.name,
|
||||||
|
i.quantity,
|
||||||
|
u.abbreviation AS unit,
|
||||||
|
i.expiry_date,
|
||||||
|
ARRAY_AGG(t.name) AS tags,
|
||||||
|
p.brand,
|
||||||
|
p.image_url
|
||||||
|
FROM inventory_items i
|
||||||
|
LEFT JOIN units u ON i.unit_id = u.id
|
||||||
|
LEFT JOIN products p ON i.product_id = p.id
|
||||||
|
LEFT JOIN item_tags it ON i.id = it.item_id
|
||||||
|
LEFT JOIN tags t ON it.tag_id = t.id
|
||||||
|
GROUP BY i.id, u.abbreviation, p.brand, p.image_url
|
||||||
|
ORDER BY i.created_at DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Find items in fridge expiring soon
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
i.name,
|
||||||
|
i.quantity,
|
||||||
|
u.abbreviation,
|
||||||
|
i.expiry_date,
|
||||||
|
i.expiry_date - CURRENT_DATE AS days_left
|
||||||
|
FROM inventory_items i
|
||||||
|
JOIN units u ON i.unit_id = u.id
|
||||||
|
JOIN item_tags it ON i.id = it.item_id
|
||||||
|
JOIN tags t ON it.tag_id = t.id
|
||||||
|
WHERE
|
||||||
|
t.name = 'Fridge'
|
||||||
|
AND i.expiry_date IS NOT NULL
|
||||||
|
AND i.expiry_date <= CURRENT_DATE + INTERVAL '7 days'
|
||||||
|
ORDER BY i.expiry_date ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Convert all items to base units
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
i.name,
|
||||||
|
i.quantity,
|
||||||
|
u.abbreviation AS original_unit,
|
||||||
|
convert_unit(i.quantity, i.unit_id, bu.id) AS base_quantity,
|
||||||
|
bu.abbreviation AS base_unit
|
||||||
|
FROM inventory_items i
|
||||||
|
JOIN units u ON i.unit_id = u.id
|
||||||
|
LEFT JOIN units bu ON u.base_unit_id = bu.id OR (u.base_unit_id IS NULL AND u.id = bu.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Maintenance
|
||||||
|
|
||||||
|
### Vacuum & Analyze
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Regular maintenance (run weekly)
|
||||||
|
VACUUM ANALYZE inventory_items;
|
||||||
|
VACUUM ANALYZE products;
|
||||||
|
VACUUM ANALYZE tags;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean old cached products
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Delete products not referenced by any items and older than 30 days
|
||||||
|
DELETE FROM products
|
||||||
|
WHERE id NOT IN (SELECT DISTINCT product_id FROM inventory_items WHERE product_id IS NOT NULL)
|
||||||
|
AND cached_at < NOW() - INTERVAL '30 days';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### Table sizes
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
schemaname,
|
||||||
|
tablename,
|
||||||
|
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Index usage
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
schemaname,
|
||||||
|
tablename,
|
||||||
|
indexname,
|
||||||
|
idx_scan AS scans,
|
||||||
|
pg_size_pretty(pg_relation_size(indexrelid)) AS size
|
||||||
|
FROM pg_stat_user_indexes
|
||||||
|
ORDER BY idx_scan DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Migration Strategy
|
||||||
|
|
||||||
|
### Version 1 (Initial Schema)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- migrations/001_initial_schema.sql
|
||||||
|
CREATE TABLE inventory_items (...);
|
||||||
|
CREATE TABLE products (...);
|
||||||
|
CREATE TABLE tags (...);
|
||||||
|
CREATE TABLE item_tags (...);
|
||||||
|
CREATE TABLE units (...);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version 2 (Seed Data)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- migrations/002_seed_defaults.sql
|
||||||
|
INSERT INTO units (...) VALUES (...);
|
||||||
|
INSERT INTO tags (...) VALUES (...);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version 3 (RLS Policies)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- migrations/003_rls_policies.sql
|
||||||
|
ALTER TABLE inventory_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY ...;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next:** [API Reference](./API.md)
|
||||||
642
docs/DEPLOYMENT.md
Normal file
642
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
# Pantry - Deployment Guide
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Last Updated:** 2026-02-08
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Deployment Options
|
||||||
|
|
||||||
|
### 1. Docker Compose (Recommended)
|
||||||
|
- Single-server deployment
|
||||||
|
- All services in one stack
|
||||||
|
- Easy to self-host
|
||||||
|
|
||||||
|
### 2. Coolify
|
||||||
|
- UI-based deployment
|
||||||
|
- Automatic SSL via Traefik
|
||||||
|
- One-click updates
|
||||||
|
|
||||||
|
### 3. Manual (Advanced)
|
||||||
|
- Custom infrastructure
|
||||||
|
- Kubernetes, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Docker Compose Deployment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker 24+ & Docker Compose
|
||||||
|
- Domain name (for SSL)
|
||||||
|
- Minimum 2GB RAM
|
||||||
|
- 10GB disk space
|
||||||
|
|
||||||
|
### Quick Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://gitea.jeanlucmakiola.de/pantry-app/pantry.git
|
||||||
|
cd pantry
|
||||||
|
|
||||||
|
# Copy production config
|
||||||
|
cp .env.example .env.production
|
||||||
|
nano .env.production # Edit variables
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
docker-compose -f docker/docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Configuration
|
||||||
|
|
||||||
|
**docker/docker-compose.prod.yml:**
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL (Supabase)
|
||||||
|
postgres:
|
||||||
|
image: supabase/postgres:15
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
POSTGRES_DB: pantry
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- pantry-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Supabase Auth (GoTrue)
|
||||||
|
auth:
|
||||||
|
image: supabase/gotrue:v2.151.0
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
GOTRUE_DB_DRIVER: postgres
|
||||||
|
GOTRUE_DB_DATABASE_URL: postgres://postgres:${DB_PASSWORD}@postgres:5432/pantry
|
||||||
|
GOTRUE_SITE_URL: ${PUBLIC_APP_URL}
|
||||||
|
GOTRUE_URI_ALLOW_LIST: ${PUBLIC_APP_URL}
|
||||||
|
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
GOTRUE_JWT_EXP: 3600
|
||||||
|
# Email/Password
|
||||||
|
GOTRUE_EXTERNAL_EMAIL_ENABLED: true
|
||||||
|
# OIDC (optional)
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${AUTH_GOOGLE_ENABLED:-false}
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${AUTH_GOOGLE_CLIENT_ID}
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_SECRET: ${AUTH_GOOGLE_SECRET}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- pantry-network
|
||||||
|
|
||||||
|
# Supabase Realtime
|
||||||
|
realtime:
|
||||||
|
image: supabase/realtime:v2.30.23
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_NAME: pantry
|
||||||
|
DB_USER: postgres
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_PORT: 5432
|
||||||
|
SECRET_KEY_BASE: ${REALTIME_SECRET}
|
||||||
|
REPLICATION_MODE: RLS
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- pantry-network
|
||||||
|
|
||||||
|
# Supabase Storage (optional - for product images)
|
||||||
|
storage:
|
||||||
|
image: supabase/storage-api:v1.0.6
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGREST_URL: http://rest:3000
|
||||||
|
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
DATABASE_URL: postgres://postgres:${DB_PASSWORD}@postgres:5432/pantry
|
||||||
|
STORAGE_BACKEND: file
|
||||||
|
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
|
||||||
|
volumes:
|
||||||
|
- storage_data:/var/lib/storage
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- pantry-network
|
||||||
|
|
||||||
|
# PostgREST (Supabase API)
|
||||||
|
rest:
|
||||||
|
image: postgrest/postgrest:v12.0.2
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PGRST_DB_URI: postgres://postgres:${DB_PASSWORD}@postgres:5432/pantry
|
||||||
|
PGRST_DB_SCHEMAS: public,storage
|
||||||
|
PGRST_DB_ANON_ROLE: anon
|
||||||
|
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
PGRST_DB_USE_LEGACY_GUCS: false
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- pantry-network
|
||||||
|
|
||||||
|
# Pantry App (Nuxt)
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ../app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SUPABASE_URL: http://rest:3000
|
||||||
|
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
|
||||||
|
PUBLIC_APP_URL: ${PUBLIC_APP_URL}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- rest
|
||||||
|
- auth
|
||||||
|
- realtime
|
||||||
|
networks:
|
||||||
|
- pantry-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# Reverse Proxy (Caddy with auto-SSL)
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
networks:
|
||||||
|
- pantry-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
storage_data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
pantry-network:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
**.env.production:**
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
DB_PASSWORD=<generate-strong-password>
|
||||||
|
|
||||||
|
# JWT (generate: openssl rand -hex 32)
|
||||||
|
JWT_SECRET=<generate-strong-jwt-secret>
|
||||||
|
|
||||||
|
# Realtime
|
||||||
|
REALTIME_SECRET=<generate-strong-secret>
|
||||||
|
|
||||||
|
# Supabase Keys (generate via supabase CLI or manually)
|
||||||
|
SUPABASE_ANON_KEY=<your-anon-key>
|
||||||
|
SUPABASE_SERVICE_KEY=<your-service-key>
|
||||||
|
|
||||||
|
# App
|
||||||
|
PUBLIC_APP_URL=https://pantry.yourdomain.com
|
||||||
|
|
||||||
|
# OIDC (optional)
|
||||||
|
AUTH_GOOGLE_ENABLED=false
|
||||||
|
AUTH_GOOGLE_CLIENT_ID=
|
||||||
|
AUTH_GOOGLE_SECRET=
|
||||||
|
|
||||||
|
# Open Food Facts
|
||||||
|
OPENFOODFACTS_API_URL=https://world.openfoodfacts.org
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# DB Password
|
||||||
|
openssl rand -hex 32
|
||||||
|
|
||||||
|
# JWT Secret (needs to be the same for all Supabase services)
|
||||||
|
openssl rand -hex 32
|
||||||
|
|
||||||
|
# Supabase Keys (use supabase CLI)
|
||||||
|
supabase init
|
||||||
|
supabase start
|
||||||
|
# Copy anon key and service key from output
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caddy Configuration
|
||||||
|
|
||||||
|
**docker/Caddyfile:**
|
||||||
|
```
|
||||||
|
{
|
||||||
|
email your-email@example.com
|
||||||
|
}
|
||||||
|
|
||||||
|
pantry.yourdomain.com {
|
||||||
|
reverse_proxy app:3000
|
||||||
|
|
||||||
|
# WebSocket support (for Supabase Realtime)
|
||||||
|
@websockets {
|
||||||
|
header Connection *Upgrade*
|
||||||
|
header Upgrade websocket
|
||||||
|
}
|
||||||
|
reverse_proxy @websockets realtime:4000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply migrations on first deploy
|
||||||
|
docker-compose -f docker/docker-compose.prod.yml exec postgres \
|
||||||
|
psql -U postgres -d pantry -f /migrations/001_schema.sql
|
||||||
|
|
||||||
|
# Or use Supabase CLI
|
||||||
|
supabase db push --db-url postgres://postgres:${DB_PASSWORD}@localhost:5432/pantry
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSL Certificates
|
||||||
|
|
||||||
|
**Caddy (automatic):**
|
||||||
|
- Caddy automatically requests Let's Encrypt certificates
|
||||||
|
- Renews certificates automatically
|
||||||
|
|
||||||
|
**Manual (Certbot):**
|
||||||
|
```bash
|
||||||
|
# Install certbot
|
||||||
|
sudo apt-get install certbot
|
||||||
|
|
||||||
|
# Request certificate
|
||||||
|
sudo certbot certonly --standalone -d pantry.yourdomain.com
|
||||||
|
|
||||||
|
# Add to Nginx/Caddy config
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ☁️ Coolify Deployment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Coolify instance running
|
||||||
|
- Domain name
|
||||||
|
- Git repository access
|
||||||
|
|
||||||
|
### Deploy Steps
|
||||||
|
|
||||||
|
**1. Add Resource in Coolify:**
|
||||||
|
- Type: Docker Compose
|
||||||
|
- Source: Git Repository
|
||||||
|
- Repository: `https://gitea.jeanlucmakiola.de/pantry-app/pantry.git`
|
||||||
|
- Branch: `main`
|
||||||
|
- Compose File: `docker/docker-compose.prod.yml`
|
||||||
|
|
||||||
|
**2. Configure Environment:**
|
||||||
|
- Add environment variables from `.env.production`
|
||||||
|
- Generate secrets via Coolify UI
|
||||||
|
|
||||||
|
**3. Set Domain:**
|
||||||
|
- Domain: `pantry.yourdomain.com`
|
||||||
|
- SSL: Enable (Let's Encrypt automatic)
|
||||||
|
|
||||||
|
**4. Deploy:**
|
||||||
|
- Click "Deploy"
|
||||||
|
- Coolify builds and starts services
|
||||||
|
- Traefik automatically handles SSL
|
||||||
|
|
||||||
|
**5. Verify:**
|
||||||
|
- Visit `https://pantry.yourdomain.com`
|
||||||
|
- Check logs in Coolify UI
|
||||||
|
|
||||||
|
### Coolify-Specific Config
|
||||||
|
|
||||||
|
**docker/docker-compose.coolify.yml:**
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ... (same as prod, but with Coolify labels)
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ../app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SUPABASE_URL: http://rest:3000
|
||||||
|
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
|
||||||
|
labels:
|
||||||
|
- "coolify.managed=true"
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.pantry.rule=Host(`${DOMAIN}`)"
|
||||||
|
- "traefik.http.services.pantry.loadbalancer.server.port=3000"
|
||||||
|
networks:
|
||||||
|
- pantry-network
|
||||||
|
- coolify # Coolify proxy network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
pantry-network:
|
||||||
|
driver: bridge
|
||||||
|
coolify:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 App Dockerfile
|
||||||
|
|
||||||
|
**app/Dockerfile:**
|
||||||
|
```dockerfile
|
||||||
|
FROM oven/bun:1 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package.json bun.lockb ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build app
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Production image
|
||||||
|
FROM oven/bun:1-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built app
|
||||||
|
COPY --from=builder /app/.output /app/.output
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start app
|
||||||
|
CMD ["bun", "run", ".output/server/index.mjs"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Post-Deployment
|
||||||
|
|
||||||
|
### 1. Apply Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via Supabase CLI
|
||||||
|
supabase db push --db-url postgres://postgres:PASSWORD@your-server:5432/pantry
|
||||||
|
|
||||||
|
# Or manually via psql
|
||||||
|
psql -h your-server -U postgres -d pantry -f supabase/migrations/001_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Seed Default Data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply seed migrations
|
||||||
|
psql -h your-server -U postgres -d pantry -f supabase/migrations/002_seed_units.sql
|
||||||
|
psql -h your-server -U postgres -d pantry -f supabase/migrations/003_seed_tags.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create First User
|
||||||
|
|
||||||
|
**Via Supabase Studio:**
|
||||||
|
- Access at `http://your-server:54323` (if exposed)
|
||||||
|
- Go to "Authentication" → "Users"
|
||||||
|
- Add user manually
|
||||||
|
|
||||||
|
**Via API:**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://pantry.yourdomain.com/auth/v1/signup \
|
||||||
|
-H "apikey: YOUR_ANON_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email": "admin@example.com", "password": "secure-password"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
**App:**
|
||||||
|
```bash
|
||||||
|
curl https://pantry.yourdomain.com/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Supabase:**
|
||||||
|
```bash
|
||||||
|
curl http://your-server:54321/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
**Docker Compose:**
|
||||||
|
```bash
|
||||||
|
# All services
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Specific service
|
||||||
|
docker-compose logs -f app
|
||||||
|
docker-compose logs -f postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
**Coolify:**
|
||||||
|
- View logs in Coolify UI
|
||||||
|
- Real-time log streaming
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
**Disk Usage:**
|
||||||
|
```bash
|
||||||
|
docker system df
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database Size:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
pg_size_pretty(pg_database_size('pantry')) AS db_size;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Container Stats:**
|
||||||
|
```bash
|
||||||
|
docker stats
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Updates
|
||||||
|
|
||||||
|
### Pull Latest Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Pull latest code
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# Rebuild app
|
||||||
|
docker-compose build app
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Apply new migrations
|
||||||
|
supabase db push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coolify Auto-Updates
|
||||||
|
|
||||||
|
**Enable in Coolify UI:**
|
||||||
|
- Resource Settings → Auto Deploy
|
||||||
|
- Trigger: Git push to `main`
|
||||||
|
- Coolify rebuilds and redeploys automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Security Checklist
|
||||||
|
|
||||||
|
- [ ] Strong passwords for all services
|
||||||
|
- [ ] JWT secret rotated and secured
|
||||||
|
- [ ] SSL certificates valid
|
||||||
|
- [ ] Firewall rules configured (only 80/443 exposed)
|
||||||
|
- [ ] Database backups enabled
|
||||||
|
- [ ] Environment variables not committed to git
|
||||||
|
- [ ] Supabase service key kept secret (not exposed to frontend)
|
||||||
|
- [ ] Rate limiting configured (if needed)
|
||||||
|
- [ ] CORS configured properly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Backup & Restore
|
||||||
|
|
||||||
|
### Backup Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full backup
|
||||||
|
docker-compose exec postgres pg_dump -U postgres pantry > backup.sql
|
||||||
|
|
||||||
|
# Compressed
|
||||||
|
docker-compose exec postgres pg_dump -U postgres pantry | gzip > backup.sql.gz
|
||||||
|
|
||||||
|
# Scheduled backup (cron)
|
||||||
|
0 2 * * * docker-compose -f /path/to/docker-compose.prod.yml exec postgres pg_dump -U postgres pantry | gzip > /backups/pantry-$(date +\%Y\%m\%d).sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From backup
|
||||||
|
docker-compose exec -T postgres psql -U postgres pantry < backup.sql
|
||||||
|
|
||||||
|
# From compressed
|
||||||
|
gunzip -c backup.sql.gz | docker-compose exec -T postgres psql -U postgres pantry
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup Volumes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup postgres data volume
|
||||||
|
docker run --rm \
|
||||||
|
-v pantry_postgres_data:/data \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
alpine tar czf /backup/postgres-data.tar.gz /data
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
docker run --rm \
|
||||||
|
-v pantry_postgres_data:/data \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
alpine tar xzf /backup/postgres-data.tar.gz -C /
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Troubleshooting
|
||||||
|
|
||||||
|
### App won't start
|
||||||
|
|
||||||
|
**Check logs:**
|
||||||
|
```bash
|
||||||
|
docker-compose logs app
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
- Environment variables missing
|
||||||
|
- Can't connect to Supabase
|
||||||
|
- Port 3000 already in use
|
||||||
|
|
||||||
|
### Database connection failed
|
||||||
|
|
||||||
|
**Check PostgreSQL:**
|
||||||
|
```bash
|
||||||
|
docker-compose ps postgres
|
||||||
|
docker-compose logs postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test connection:**
|
||||||
|
```bash
|
||||||
|
docker-compose exec postgres psql -U postgres -d pantry -c "SELECT 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSL not working
|
||||||
|
|
||||||
|
**Caddy:**
|
||||||
|
```bash
|
||||||
|
# Check Caddy logs
|
||||||
|
docker-compose logs caddy
|
||||||
|
|
||||||
|
# Verify DNS points to server
|
||||||
|
dig pantry.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Coolify:**
|
||||||
|
- Check Traefik logs in Coolify
|
||||||
|
- Verify domain configuration
|
||||||
|
|
||||||
|
### Out of disk space
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clean Docker
|
||||||
|
docker system prune -a
|
||||||
|
|
||||||
|
# Clean old images
|
||||||
|
docker image prune -a
|
||||||
|
|
||||||
|
# Clean volumes (careful!)
|
||||||
|
docker volume prune
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Resources
|
||||||
|
|
||||||
|
- [Docker Compose Docs](https://docs.docker.com/compose/)
|
||||||
|
- [Coolify Docs](https://coolify.io/docs)
|
||||||
|
- [Supabase Self-Hosting](https://supabase.com/docs/guides/self-hosting)
|
||||||
|
- [Caddy Docs](https://caddyserver.com/docs/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**All documentation complete!** Ready to start development.
|
||||||
678
docs/DEVELOPMENT.md
Normal file
678
docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
# Pantry - Development Guide
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Last Updated:** 2026-02-08
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** 20+ (or Bun 1.0+)
|
||||||
|
- **Docker** & Docker Compose
|
||||||
|
- **Git**
|
||||||
|
- **Code editor** (VS Code recommended)
|
||||||
|
|
||||||
|
### Clone & Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://gitea.jeanlucmakiola.de/pantry-app/pantry.git
|
||||||
|
cd pantry
|
||||||
|
|
||||||
|
# Install dependencies (using Bun)
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Copy environment template
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Start Supabase (PostgreSQL + Auth + Realtime)
|
||||||
|
docker-compose -f docker/docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
cd supabase
|
||||||
|
supabase db reset # Creates schema + seeds data
|
||||||
|
|
||||||
|
# Start Nuxt dev server
|
||||||
|
cd ../app
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Access app at http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pantry/
|
||||||
|
├── app/ # Nuxt 4 frontend
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── inventory/ # Inventory UI components
|
||||||
|
│ │ ├── scan/ # Barcode scanner components
|
||||||
|
│ │ ├── tags/ # Tag management
|
||||||
|
│ │ └── common/ # Shared UI components
|
||||||
|
│ ├── composables/ # Shared logic (Vue Composition API)
|
||||||
|
│ │ ├── useBarcode.ts
|
||||||
|
│ │ ├── useInventory.ts
|
||||||
|
│ │ └── useSupabase.ts
|
||||||
|
│ ├── pages/ # Route pages
|
||||||
|
│ │ ├── index.vue # Inventory list
|
||||||
|
│ │ ├── scan.vue # Barcode scanner
|
||||||
|
│ │ └── settings.vue # Settings
|
||||||
|
│ ├── utils/ # Pure functions
|
||||||
|
│ │ ├── conversions.ts # Unit conversion math
|
||||||
|
│ │ └── validation.ts
|
||||||
|
│ ├── nuxt.config.ts # Nuxt configuration
|
||||||
|
│ └── package.json
|
||||||
|
├── supabase/ # Database & backend
|
||||||
|
│ ├── migrations/ # SQL migrations
|
||||||
|
│ │ ├── 001_schema.sql
|
||||||
|
│ │ ├── 002_seed_units.sql
|
||||||
|
│ │ └── 003_rls.sql
|
||||||
|
│ ├── functions/ # Edge functions
|
||||||
|
│ │ └── product-lookup/
|
||||||
|
│ ├── seed/ # Seed data (JSON)
|
||||||
|
│ │ ├── units.json
|
||||||
|
│ │ └── tags.json
|
||||||
|
│ └── config.toml # Supabase config
|
||||||
|
├── docker/ # Docker configs
|
||||||
|
│ ├── docker-compose.dev.yml # Development
|
||||||
|
│ └── docker-compose.prod.yml # Production
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── scripts/ # Utility scripts
|
||||||
|
│ ├── seed-db.ts
|
||||||
|
│ └── export-schema.ts
|
||||||
|
├── .env.example
|
||||||
|
├── .gitignore
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Development Workflow
|
||||||
|
|
||||||
|
### 1. Create a Feature Branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/barcode-scanner
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Make Changes
|
||||||
|
|
||||||
|
**Add a new component:**
|
||||||
|
```bash
|
||||||
|
# Create component file
|
||||||
|
touch app/components/scan/BarcodeScanner.vue
|
||||||
|
|
||||||
|
# Use in a page
|
||||||
|
# app/pages/scan.vue
|
||||||
|
<template>
|
||||||
|
<BarcodeScanner @scan="handleScan" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add a database migration:**
|
||||||
|
```bash
|
||||||
|
cd supabase
|
||||||
|
supabase migration new add_location_field
|
||||||
|
|
||||||
|
# Edit supabase/migrations/XXX_add_location_field.sql
|
||||||
|
ALTER TABLE inventory_items ADD COLUMN location TEXT;
|
||||||
|
|
||||||
|
# Apply locally
|
||||||
|
supabase db reset
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run type check
|
||||||
|
bun run typecheck
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
bun run lint
|
||||||
|
|
||||||
|
# Run tests (when implemented)
|
||||||
|
bun run test
|
||||||
|
|
||||||
|
# E2E tests
|
||||||
|
bun run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Commit & Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: Add barcode scanner component"
|
||||||
|
git push origin feature/barcode-scanner
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create Pull Request
|
||||||
|
|
||||||
|
- Go to Gitea: https://gitea.jeanlucmakiola.de/pantry-app/pantry
|
||||||
|
- Create PR from your branch to `main`
|
||||||
|
- Request review
|
||||||
|
- Merge when approved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Code Conventions
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Components: `PascalCase.vue` (e.g., `BarcodeScanner.vue`)
|
||||||
|
- Composables: `camelCase.ts` (e.g., `useBarcode.ts`)
|
||||||
|
- Utils: `camelCase.ts` (e.g., `conversions.ts`)
|
||||||
|
- Pages: `kebab-case.vue` (e.g., `item-detail.vue`)
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
- `camelCase` for variables, functions
|
||||||
|
- `PascalCase` for types, interfaces
|
||||||
|
- `SCREAMING_SNAKE_CASE` for constants
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
const itemCount = 5
|
||||||
|
const fetchProducts = () => {}
|
||||||
|
interface Product { ... }
|
||||||
|
const MAX_ITEMS = 100
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
const ItemCount = 5
|
||||||
|
const fetch_products = () => {}
|
||||||
|
interface product { ... }
|
||||||
|
const maxItems = 100 // for constants
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vue Component Structure
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 1. Imports
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { InventoryItem } from '~/types'
|
||||||
|
|
||||||
|
// 2. Props & Emits
|
||||||
|
interface Props {
|
||||||
|
item: InventoryItem
|
||||||
|
editable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
editable: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [item: InventoryItem]
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 3. Composables
|
||||||
|
const { convert } = useUnits()
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
|
||||||
|
// 4. State
|
||||||
|
const quantity = ref(props.item.quantity)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
|
||||||
|
// 5. Computed
|
||||||
|
const displayQuantity = computed(() =>
|
||||||
|
`${quantity.value} ${props.item.unit.abbreviation}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 6. Methods
|
||||||
|
const handleSave = async () => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('inventory_items')
|
||||||
|
.update({ quantity: quantity.value })
|
||||||
|
.eq('id', props.item.id)
|
||||||
|
|
||||||
|
if (!error) emit('save', { ...props.item, quantity: quantity.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Lifecycle (if needed)
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('Component mounted')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="item-card">
|
||||||
|
<h3>{{ item.name }}</h3>
|
||||||
|
<p>{{ displayQuantity }}</p>
|
||||||
|
|
||||||
|
<button v-if="editable" @click="handleSave">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.item-card {
|
||||||
|
/* Prefer Tailwind classes in template */
|
||||||
|
/* Use scoped styles only for complex/unique styles */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
**Prefer interfaces over types:**
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
interface InventoryItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only use type for unions, intersections
|
||||||
|
type Status = 'active' | 'expired'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use strict typing:**
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
const fetchItem = async (id: string): Promise<InventoryItem | null> => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('inventory_items')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad (implicit any)
|
||||||
|
const fetchItem = async (id) => {
|
||||||
|
const { data } = await supabase...
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composables
|
||||||
|
|
||||||
|
**Naming:** Always start with `use`
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```typescript
|
||||||
|
// composables/useInventory.ts
|
||||||
|
export function useInventory() {
|
||||||
|
const supabase = useSupabaseClient<Database>()
|
||||||
|
const items = ref<InventoryItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
loading.value = true
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('inventory_items')
|
||||||
|
.select('*')
|
||||||
|
|
||||||
|
if (data) items.value = data
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const addItem = async (item: NewInventoryItem) => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('inventory_items')
|
||||||
|
.insert(item)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (data) items.value.push(data)
|
||||||
|
return { data, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return reactive state + methods
|
||||||
|
return {
|
||||||
|
items: readonly(items),
|
||||||
|
loading: readonly(loading),
|
||||||
|
fetchItems,
|
||||||
|
addItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
**Naming:**
|
||||||
|
```
|
||||||
|
001_initial_schema.sql
|
||||||
|
002_seed_defaults.sql
|
||||||
|
003_add_location_field.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```sql
|
||||||
|
-- Migration: Add location field to inventory items
|
||||||
|
-- Created: 2026-02-08
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE inventory_items
|
||||||
|
ADD COLUMN location TEXT;
|
||||||
|
|
||||||
|
-- Update existing items (optional)
|
||||||
|
UPDATE inventory_items
|
||||||
|
SET location = 'Pantry'
|
||||||
|
WHERE location IS NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rollback (optional):**
|
||||||
|
```sql
|
||||||
|
-- To rollback:
|
||||||
|
-- ALTER TABLE inventory_items DROP COLUMN location;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Unit Tests (Vitest)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/utils/conversions.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { convertUnit } from './conversions'
|
||||||
|
|
||||||
|
describe('convertUnit', () => {
|
||||||
|
it('converts grams to kilograms', () => {
|
||||||
|
const result = convertUnit(500, {
|
||||||
|
conversion_factor: 0.001,
|
||||||
|
base_unit_id: 'kg'
|
||||||
|
}, {
|
||||||
|
conversion_factor: 1,
|
||||||
|
base_unit_id: null
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe(0.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws error for incompatible units', () => {
|
||||||
|
expect(() => {
|
||||||
|
convertUnit(1,
|
||||||
|
{ unit_type: 'weight', conversion_factor: 1 },
|
||||||
|
{ unit_type: 'volume', conversion_factor: 1 }
|
||||||
|
)
|
||||||
|
}).toThrow('Cannot convert')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run tests:**
|
||||||
|
```bash
|
||||||
|
bun test
|
||||||
|
bun test --watch # Watch mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests (Playwright)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// tests/e2e/inventory.spec.ts
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('can add item to inventory', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Login
|
||||||
|
await page.fill('[name=email]', 'test@example.com')
|
||||||
|
await page.fill('[name=password]', 'password')
|
||||||
|
await page.click('button[type=submit]')
|
||||||
|
|
||||||
|
// Add item
|
||||||
|
await page.click('[data-testid=add-item]')
|
||||||
|
await page.fill('[name=name]', 'Test Item')
|
||||||
|
await page.fill('[name=quantity]', '2')
|
||||||
|
await page.click('button:has-text("Add")')
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
await expect(page.locator('text=Test Item')).toBeVisible()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run E2E:**
|
||||||
|
```bash
|
||||||
|
bun run test:e2e
|
||||||
|
bun run test:e2e --ui # Interactive mode
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Supabase Local Development
|
||||||
|
|
||||||
|
### Start Supabase
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd supabase
|
||||||
|
supabase start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Outputs:**
|
||||||
|
```
|
||||||
|
API URL: http://localhost:54321
|
||||||
|
Studio URL: http://localhost:54323
|
||||||
|
Anon key: eyJhb...
|
||||||
|
Service key: eyJhb...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access Supabase Studio:**
|
||||||
|
- Open http://localhost:54323
|
||||||
|
- View tables, run queries, manage auth
|
||||||
|
|
||||||
|
### Apply Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reset DB (drops all data, reapplies migrations)
|
||||||
|
supabase db reset
|
||||||
|
|
||||||
|
# Create new migration
|
||||||
|
supabase migration new add_feature
|
||||||
|
|
||||||
|
# Apply migrations (non-destructive)
|
||||||
|
supabase migration up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Types
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate TypeScript types from database schema
|
||||||
|
supabase gen types typescript --local > app/types/supabase.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```typescript
|
||||||
|
import type { Database } from '~/types/supabase'
|
||||||
|
|
||||||
|
const supabase = useSupabaseClient<Database>()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Environment Variables
|
||||||
|
|
||||||
|
### `.env` (Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Supabase (from `supabase start`)
|
||||||
|
SUPABASE_URL=http://localhost:54321
|
||||||
|
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
|
||||||
|
# App
|
||||||
|
PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Open Food Facts (optional, no key needed)
|
||||||
|
OPENFOODFACTS_API_URL=https://world.openfoodfacts.org
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.env.production`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Supabase (production)
|
||||||
|
SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
|
||||||
|
# App
|
||||||
|
PUBLIC_APP_URL=https://pantry.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Debugging
|
||||||
|
|
||||||
|
### Nuxt DevTools
|
||||||
|
|
||||||
|
**Enable:**
|
||||||
|
```typescript
|
||||||
|
// nuxt.config.ts
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
devtools: { enabled: true }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access:** Press `Shift + Alt + D` or visit http://localhost:3000/__devtools__
|
||||||
|
|
||||||
|
### Vue DevTools
|
||||||
|
|
||||||
|
Install browser extension:
|
||||||
|
- Chrome: [Vue DevTools](https://chrome.google.com/webstore/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- Firefox: [Vue DevTools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
|
||||||
|
### Supabase Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View realtime logs
|
||||||
|
supabase logs --tail
|
||||||
|
|
||||||
|
# Filter by service
|
||||||
|
supabase logs --service postgres
|
||||||
|
supabase logs --service auth
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Queries
|
||||||
|
|
||||||
|
**Supabase Studio:**
|
||||||
|
- Open http://localhost:54323
|
||||||
|
- Go to "SQL Editor"
|
||||||
|
- Run queries directly
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```bash
|
||||||
|
# psql into local database
|
||||||
|
supabase db shell
|
||||||
|
|
||||||
|
# Run query
|
||||||
|
SELECT * FROM inventory_items LIMIT 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Build & Preview
|
||||||
|
|
||||||
|
### Build for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:** `.output/` directory (ready for deployment)
|
||||||
|
|
||||||
|
### Preview Production Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access:** http://localhost:3000
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Git Workflow
|
||||||
|
|
||||||
|
### Branch Naming
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/barcode-scanner
|
||||||
|
fix/tag-duplication-bug
|
||||||
|
chore/update-dependencies
|
||||||
|
docs/api-reference
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit Messages (Conventional Commits)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format: <type>(<scope>): <subject>
|
||||||
|
|
||||||
|
feat(scan): add barcode detection
|
||||||
|
fix(inventory): prevent duplicate items
|
||||||
|
chore(deps): update Nuxt to 4.1
|
||||||
|
docs(api): add product lookup endpoint
|
||||||
|
refactor(tags): simplify tag picker logic
|
||||||
|
test(units): add conversion edge cases
|
||||||
|
style(ui): apply Tailwind spacing
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types:**
|
||||||
|
- `feat`: New feature
|
||||||
|
- `fix`: Bug fix
|
||||||
|
- `chore`: Maintenance (deps, config)
|
||||||
|
- `docs`: Documentation
|
||||||
|
- `refactor`: Code restructuring
|
||||||
|
- `test`: Adding/updating tests
|
||||||
|
- `style`: Code style (formatting, no logic change)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Common Issues
|
||||||
|
|
||||||
|
### Supabase won't start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Docker
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# Restart Supabase
|
||||||
|
supabase stop
|
||||||
|
supabase start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types out of sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Regenerate types after schema change
|
||||||
|
supabase gen types typescript --local > app/types/supabase.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port already in use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nuxt (3000)
|
||||||
|
lsof -ti:3000 | xargs kill
|
||||||
|
|
||||||
|
# Supabase (54321)
|
||||||
|
supabase stop
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Resources
|
||||||
|
|
||||||
|
**Docs:**
|
||||||
|
- [Nuxt 4](https://nuxt.com)
|
||||||
|
- [Supabase](https://supabase.com/docs)
|
||||||
|
- [Vue 3](https://vuejs.org)
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com)
|
||||||
|
|
||||||
|
**Community:**
|
||||||
|
- [Pantry Discussions](https://gitea.jeanlucmakiola.de/pantry-app/pantry/issues)
|
||||||
|
- [Nuxt Discord](https://discord.com/invite/ps2h6QT)
|
||||||
|
- [Supabase Discord](https://discord.supabase.com)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next:** [Deployment Guide](./DEPLOYMENT.md)
|
||||||
Reference in New Issue
Block a user