docs: restructure documentation into organized folders
Some checks failed
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
Some checks failed
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
Organized docs into logical subdirectories:
**New Structure:**
- docs/
- README.md (index with quick links)
- PROJECT_PLAN.md (root level - main roadmap)
- development/
- getting-started.md (5-min quickstart)
- local-setup.md (detailed Docker Compose guide)
- workflow.md (daily development)
- git-workflow.md (branching strategy)
- architecture/
- overview.md (tech stack, design)
- database.md (schema, RLS, migrations)
- api.md (endpoints, functions)
- deployment/
- production.md (Docker, Coolify)
- ci-cd.md (automated pipelines)
**Cleaned Up:**
- Moved DEV_SETUP.md → docs/development/local-setup.md
- Removed outdated SETUP.md (referenced old Coolify setup)
- Replaced with getting-started.md (current Docker Compose flow)
- Updated README.md links to new structure
All paths tested, no broken links.
This commit is contained in:
746
docs/architecture/api.md
Normal file
746
docs/architecture/api.md
Normal file
@@ -0,0 +1,746 @@
|
||||
# Pantry - API Reference
|
||||
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2026-02-08
|
||||
|
||||
---
|
||||
|
||||
## 🌐 API Architecture
|
||||
|
||||
Pantry uses a **hybrid approach:**
|
||||
|
||||
1. **Supabase Client SDK** — Direct database access from frontend (most operations)
|
||||
2. **Edge Functions** — Custom logic for complex operations (product lookup, etc.)
|
||||
|
||||
### Why This Approach?
|
||||
|
||||
- **Simple CRUD:** Supabase Client (auto-generated, realtime, RLS-protected)
|
||||
- **Complex logic:** Edge Functions (Open Food Facts integration, caching, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Authentication
|
||||
|
||||
All API calls require authentication via Supabase Auth.
|
||||
|
||||
### Get Access Token
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: 'user@example.com',
|
||||
password: 'password'
|
||||
})
|
||||
|
||||
const token = data.session?.access_token
|
||||
```
|
||||
|
||||
### Using the Token
|
||||
|
||||
**Supabase Client (automatic):**
|
||||
```typescript
|
||||
// Token is automatically included in requests
|
||||
const { data } = await supabase
|
||||
.from('inventory_items')
|
||||
.select('*')
|
||||
```
|
||||
|
||||
**Edge Functions (manual):**
|
||||
```typescript
|
||||
fetch('https://your-project.supabase.co/functions/v1/product-lookup', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ barcode: '123' })
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Supabase Client API
|
||||
|
||||
### Inventory Items
|
||||
|
||||
#### List Items
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_items')
|
||||
.select(`
|
||||
*,
|
||||
product:products(*),
|
||||
unit:units(*),
|
||||
tags:item_tags(tag:tags(*))
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "abc-123",
|
||||
"name": "Whole Milk",
|
||||
"quantity": 1.5,
|
||||
"expiry_date": "2026-02-15",
|
||||
"unit": {
|
||||
"abbreviation": "L",
|
||||
"name": "liter"
|
||||
},
|
||||
"tags": [
|
||||
{ "tag": { "name": "Fridge", "icon": "🧊" } },
|
||||
{ "tag": { "name": "Dairy", "icon": "🥛" } }
|
||||
],
|
||||
"product": {
|
||||
"barcode": "8000500310427",
|
||||
"brand": "Milka",
|
||||
"image_url": "https://..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Add Item
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_items')
|
||||
.insert({
|
||||
product_id: 'product-uuid', // or null for custom items
|
||||
name: 'Whole Milk',
|
||||
quantity: 1,
|
||||
unit_id: 'unit-uuid',
|
||||
expiry_date: '2026-02-15',
|
||||
added_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
```
|
||||
|
||||
#### Update Item (Consume)
|
||||
|
||||
```typescript
|
||||
// Decrement quantity
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_items')
|
||||
.update({ quantity: currentQuantity - 1 })
|
||||
.eq('id', itemId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
// Or use SQL function for atomic decrement:
|
||||
const { data, error } = await supabase.rpc('consume_item', {
|
||||
item_id: itemId,
|
||||
amount: 1
|
||||
})
|
||||
```
|
||||
|
||||
#### Delete Item
|
||||
|
||||
```typescript
|
||||
const { error } = await supabase
|
||||
.from('inventory_items')
|
||||
.delete()
|
||||
.eq('id', itemId)
|
||||
```
|
||||
|
||||
#### Filter by Tags
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_items')
|
||||
.select(`
|
||||
*,
|
||||
item_tags!inner(tag:tags!inner(*))
|
||||
`)
|
||||
.eq('item_tags.tag.name', 'Fridge')
|
||||
```
|
||||
|
||||
#### Search Items
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_items')
|
||||
.select('*')
|
||||
.or(`name.ilike.%${query}%,location.ilike.%${query}%`)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Tags
|
||||
|
||||
#### List Tags
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('tags')
|
||||
.select('*')
|
||||
.order('category', { ascending: true })
|
||||
.order('name', { ascending: true })
|
||||
```
|
||||
|
||||
#### Create Tag
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('tags')
|
||||
.insert({
|
||||
name: 'Organic',
|
||||
category: 'dietary',
|
||||
icon: '🍃',
|
||||
color: '#84cc16',
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
```
|
||||
|
||||
#### Add Tag to Item
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('item_tags')
|
||||
.insert({
|
||||
item_id: 'item-uuid',
|
||||
tag_id: 'tag-uuid'
|
||||
})
|
||||
```
|
||||
|
||||
#### Remove Tag from Item
|
||||
|
||||
```typescript
|
||||
const { error } = await supabase
|
||||
.from('item_tags')
|
||||
.delete()
|
||||
.eq('item_id', itemId)
|
||||
.eq('tag_id', tagId)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Units
|
||||
|
||||
#### List Units
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('units')
|
||||
.select('*, base_unit:units(name, abbreviation)')
|
||||
.order('unit_type', { ascending: true })
|
||||
.order('name', { ascending: true })
|
||||
```
|
||||
|
||||
#### Convert Units
|
||||
|
||||
```typescript
|
||||
// Using SQL function
|
||||
const { data, error } = await supabase.rpc('convert_unit', {
|
||||
quantity: 500,
|
||||
from_unit_id: 'gram-uuid',
|
||||
to_unit_id: 'kg-uuid'
|
||||
})
|
||||
|
||||
// Returns: 0.5
|
||||
```
|
||||
|
||||
#### Create Custom Unit
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('units')
|
||||
.insert({
|
||||
name: 'tablespoon',
|
||||
abbreviation: 'tbsp',
|
||||
unit_type: 'volume',
|
||||
base_unit_id: 'liter-uuid',
|
||||
conversion_factor: 0.015, // 15 mL
|
||||
is_default: false,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Products
|
||||
|
||||
#### Search Products (Cached)
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase.rpc('search_products', {
|
||||
search_query: 'chocolate'
|
||||
})
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "product-123",
|
||||
"barcode": "8000500310427",
|
||||
"name": "Nutella",
|
||||
"brand": "Ferrero",
|
||||
"rank": 0.95
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Edge Functions
|
||||
|
||||
### Product Lookup
|
||||
|
||||
**Endpoint:** `POST /functions/v1/product-lookup`
|
||||
|
||||
**Purpose:** Fetch product data from Open Food Facts (with caching)
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"barcode": "8000500310427"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Cached):**
|
||||
```json
|
||||
{
|
||||
"product": {
|
||||
"id": "abc-123",
|
||||
"barcode": "8000500310427",
|
||||
"name": "Nutella",
|
||||
"brand": "Ferrero",
|
||||
"image_url": "https://...",
|
||||
"image_small_url": "https://...",
|
||||
"categories": ["spreads", "chocolate"],
|
||||
"default_unit_id": "gram-uuid",
|
||||
"default_quantity": 400
|
||||
},
|
||||
"cached": true,
|
||||
"source": "database"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Not Cached - Fetched from Open Food Facts):**
|
||||
```json
|
||||
{
|
||||
"product": {
|
||||
"id": "new-uuid",
|
||||
"barcode": "8000500310427",
|
||||
"name": "Nutella",
|
||||
"brand": "Ferrero",
|
||||
"image_url": "https://...",
|
||||
...
|
||||
},
|
||||
"cached": false,
|
||||
"source": "openfoodfacts"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
```json
|
||||
// Product not found
|
||||
{
|
||||
"error": "PRODUCT_NOT_FOUND",
|
||||
"message": "Product not found in Open Food Facts",
|
||||
"barcode": "123invalid"
|
||||
}
|
||||
|
||||
// Invalid barcode format
|
||||
{
|
||||
"error": "INVALID_BARCODE",
|
||||
"message": "Barcode must be 8-13 digits",
|
||||
"barcode": "abc"
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// supabase/functions/product-lookup/index.ts
|
||||
import { serve } from 'std/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
serve(async (req) => {
|
||||
const { barcode } = await req.json()
|
||||
|
||||
// Validate barcode
|
||||
if (!/^\d{8,13}$/.test(barcode)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'INVALID_BARCODE' }),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = createClient(...)
|
||||
|
||||
// Check cache
|
||||
const { data: cached } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.eq('barcode', barcode)
|
||||
.single()
|
||||
|
||||
if (cached) {
|
||||
return new Response(
|
||||
JSON.stringify({ product: cached, cached: true }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch from Open Food Facts
|
||||
const res = await fetch(
|
||||
`https://world.openfoodfacts.org/api/v2/product/${barcode}.json`
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'PRODUCT_NOT_FOUND' }),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const { product } = await res.json()
|
||||
|
||||
// Cache product
|
||||
const { data: newProduct } = await supabase
|
||||
.from('products')
|
||||
.insert({
|
||||
barcode,
|
||||
name: product.product_name,
|
||||
brand: product.brands,
|
||||
image_url: product.image_url,
|
||||
// ... more fields
|
||||
cached_at: new Date().toISOString()
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ product: newProduct, cached: false }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Batch Product Lookup (Future)
|
||||
|
||||
**Endpoint:** `POST /functions/v1/products/batch`
|
||||
|
||||
**Purpose:** Lookup multiple products at once (for offline sync)
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"barcodes": ["8000500310427", "5449000000996", "123456789"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"barcode": "8000500310427",
|
||||
"product": { ... },
|
||||
"cached": true
|
||||
},
|
||||
{
|
||||
"barcode": "5449000000996",
|
||||
"product": { ... },
|
||||
"cached": false
|
||||
},
|
||||
{
|
||||
"barcode": "123456789",
|
||||
"error": "PRODUCT_NOT_FOUND"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Realtime Subscriptions
|
||||
|
||||
### Subscribe to Inventory Changes
|
||||
|
||||
```typescript
|
||||
const subscription = supabase
|
||||
.channel('inventory-changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*', // INSERT, UPDATE, DELETE
|
||||
schema: 'public',
|
||||
table: 'inventory_items'
|
||||
},
|
||||
(payload) => {
|
||||
console.log('Change detected:', payload)
|
||||
|
||||
if (payload.eventType === 'INSERT') {
|
||||
// New item added
|
||||
addItemToList(payload.new)
|
||||
} else if (payload.eventType === 'UPDATE') {
|
||||
// Item updated
|
||||
updateItemInList(payload.new)
|
||||
} else if (payload.eventType === 'DELETE') {
|
||||
// Item deleted
|
||||
removeItemFromList(payload.old.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
// Unsubscribe when done
|
||||
subscription.unsubscribe()
|
||||
```
|
||||
|
||||
### Subscribe to Tag Changes
|
||||
|
||||
```typescript
|
||||
supabase
|
||||
.channel('tag-changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'tags' },
|
||||
refreshTags
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Custom SQL Functions (via RPC)
|
||||
|
||||
### Consume Item (Atomic Decrement)
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION consume_item(
|
||||
item_id UUID,
|
||||
amount DECIMAL
|
||||
)
|
||||
RETURNS inventory_items AS $$
|
||||
DECLARE
|
||||
updated_item inventory_items;
|
||||
BEGIN
|
||||
UPDATE inventory_items
|
||||
SET quantity = GREATEST(quantity - amount, 0),
|
||||
updated_at = NOW()
|
||||
WHERE id = item_id
|
||||
RETURNING * INTO updated_item;
|
||||
|
||||
-- Delete if quantity is 0
|
||||
IF updated_item.quantity = 0 THEN
|
||||
DELETE FROM inventory_items WHERE id = item_id;
|
||||
updated_item.id := NULL; -- Signal deletion
|
||||
END IF;
|
||||
|
||||
RETURN updated_item;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
const { data, error } = await supabase.rpc('consume_item', {
|
||||
item_id: 'item-uuid',
|
||||
amount: 1
|
||||
})
|
||||
|
||||
if (data?.id === null) {
|
||||
// Item was deleted (quantity reached 0)
|
||||
}
|
||||
```
|
||||
|
||||
### Restock Item
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION restock_item(
|
||||
item_id UUID,
|
||||
amount DECIMAL
|
||||
)
|
||||
RETURNS inventory_items AS $$
|
||||
UPDATE inventory_items
|
||||
SET quantity = quantity + amount,
|
||||
updated_at = NOW()
|
||||
WHERE id = item_id
|
||||
RETURNING *;
|
||||
$$ LANGUAGE sql;
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
const { data, error } = await supabase.rpc('restock_item', {
|
||||
item_id: 'item-uuid',
|
||||
amount: 2
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Analytics Queries (Future)
|
||||
|
||||
### Items by Tag
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase.rpc('items_by_tag')
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION items_by_tag()
|
||||
RETURNS TABLE (tag_name TEXT, item_count BIGINT) AS $$
|
||||
SELECT
|
||||
t.name,
|
||||
COUNT(it.item_id)
|
||||
FROM tags t
|
||||
LEFT JOIN item_tags it ON t.id = it.tag_id
|
||||
GROUP BY t.name
|
||||
ORDER BY COUNT(it.item_id) DESC;
|
||||
$$ LANGUAGE sql;
|
||||
```
|
||||
|
||||
### Expiring Soon
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase.rpc('expiring_soon', {
|
||||
days: 7
|
||||
})
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION expiring_soon(days INTEGER)
|
||||
RETURNS SETOF inventory_items AS $$
|
||||
SELECT *
|
||||
FROM inventory_items
|
||||
WHERE expiry_date IS NOT NULL
|
||||
AND expiry_date <= CURRENT_DATE + (days || ' days')::INTERVAL
|
||||
ORDER BY expiry_date ASC;
|
||||
$$ LANGUAGE sql;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Error Handling
|
||||
|
||||
### Supabase Client Errors
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_items')
|
||||
.insert({ ... })
|
||||
|
||||
if (error) {
|
||||
// PostgreSQL error
|
||||
console.error(error.message)
|
||||
console.error(error.code) // e.g., "23505" (unique violation)
|
||||
console.error(error.details)
|
||||
}
|
||||
```
|
||||
|
||||
### Edge Function Errors
|
||||
|
||||
```typescript
|
||||
const res = await fetch('...', { ... })
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
console.error(error.error) // Error code
|
||||
console.error(error.message) // Human-readable message
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Type Definitions (TypeScript)
|
||||
|
||||
```typescript
|
||||
// Generated by Supabase CLI: supabase gen types typescript
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
inventory_items: {
|
||||
Row: {
|
||||
id: string
|
||||
product_id: string | null
|
||||
name: string
|
||||
quantity: number
|
||||
unit_id: string
|
||||
expiry_date: string | null
|
||||
location: string | null
|
||||
notes: string | null
|
||||
added_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
product_id?: string | null
|
||||
name: string
|
||||
quantity: number
|
||||
unit_id: string
|
||||
expiry_date?: string | null
|
||||
location?: string | null
|
||||
notes?: string | null
|
||||
added_by: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
product_id?: string | null
|
||||
name?: string
|
||||
quantity?: number
|
||||
unit_id?: string
|
||||
expiry_date?: string | null
|
||||
location?: string | null
|
||||
notes?: string | null
|
||||
added_by?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
// ... other tables
|
||||
}
|
||||
Functions: {
|
||||
convert_unit: {
|
||||
Args: { quantity: number; from_unit_id: string; to_unit_id: string }
|
||||
Returns: number
|
||||
}
|
||||
consume_item: {
|
||||
Args: { item_id: string; amount: number }
|
||||
Returns: Database['public']['Tables']['inventory_items']['Row']
|
||||
}
|
||||
// ... other functions
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Rate Limiting
|
||||
|
||||
**Supabase Rate Limits (Free Tier):**
|
||||
- 500 requests/second per project
|
||||
- 2GB database size
|
||||
- 1GB file storage
|
||||
|
||||
**Edge Functions:**
|
||||
- 500,000 invocations/month (free tier)
|
||||
- 2s timeout
|
||||
|
||||
**Recommendations:**
|
||||
- Cache product lookups aggressively
|
||||
- Debounce search queries (300ms)
|
||||
- Use realtime subscriptions instead of polling
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Development Guide](./DEVELOPMENT.md)
|
||||
651
docs/architecture/database.md
Normal file
651
docs/architecture/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)
|
||||
655
docs/architecture/overview.md
Normal file
655
docs/architecture/overview.md
Normal file
@@ -0,0 +1,655 @@
|
||||
# Pantry - Architecture
|
||||
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2026-02-08
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ System Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ User Devices │
|
||||
│ (Phone, Tablet, Desktop - PWA) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Nuxt 4 Frontend (SSR) │
|
||||
│ - Vue 3 Components │
|
||||
│ - Tailwind CSS + Nuxt UI │
|
||||
│ - PWA (offline-first) │
|
||||
│ - Barcode Scanner │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│ WebSocket + REST
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Supabase Platform │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ PostgreSQL (Database) │ │
|
||||
│ │ - Items, Products, Tags, Units │ │
|
||||
│ │ - Row Level Security (RLS) │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Auth (GoTrue) │ │
|
||||
│ │ - Email/Password │ │
|
||||
│ │ - OIDC (optional) │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Realtime (WebSocket) │ │
|
||||
│ │ - Live updates across devices │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Edge Functions (Deno) │ │
|
||||
│ │ - Product lookup │ │
|
||||
│ │ - Barcode cache │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Open Food Facts API │
|
||||
│ (External - product data) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tech Stack
|
||||
|
||||
### Frontend
|
||||
|
||||
**Nuxt 4** (Vue 3)
|
||||
- **Why:** Meta-framework, SSR, excellent DX
|
||||
- **Alternatives considered:** Next.js (React), SvelteKit
|
||||
- **Decision:** Vue ecosystem maturity + Nuxt UI
|
||||
|
||||
**Tailwind CSS**
|
||||
- **Why:** Utility-first, fast iteration
|
||||
- **Alternatives:** CSS Modules, UnoCSS
|
||||
- **Decision:** Industry standard, Nuxt UI built on it
|
||||
|
||||
**Nuxt UI**
|
||||
- **Why:** Pre-built components, accessible
|
||||
- **Alternatives:** Headless UI, Shadcn
|
||||
- **Decision:** First-party Nuxt support
|
||||
|
||||
**html5-qrcode**
|
||||
- **Why:** PWA camera support, multi-format
|
||||
- **Alternatives:** ZXing, QuaggaJS
|
||||
- **Decision:** Best mobile performance in tests
|
||||
|
||||
### Backend
|
||||
|
||||
**Supabase**
|
||||
- **Why:** Postgres + Auth + Realtime in one
|
||||
- **Alternatives:** Firebase, Appwrite, custom backend
|
||||
- **Decision:** Self-hosted, SQL flexibility, mature
|
||||
|
||||
**PostgreSQL 15+**
|
||||
- **Why:** Robust, supports JSONB, full-text search
|
||||
- **Alternatives:** MySQL, MongoDB
|
||||
- **Decision:** Comes with Supabase, best for relational data
|
||||
|
||||
**Deno Edge Functions**
|
||||
- **Why:** Serverless, TypeScript-native
|
||||
- **Alternatives:** Node.js functions, custom API
|
||||
- **Decision:** Part of Supabase, fast cold starts
|
||||
|
||||
### Deployment
|
||||
|
||||
**Docker Compose**
|
||||
- **Why:** Simple multi-container orchestration
|
||||
- **Alternatives:** Kubernetes, bare metal
|
||||
- **Decision:** Right complexity level for self-hosted
|
||||
|
||||
**Bun** (runtime)
|
||||
- **Why:** Fast, npm-compatible, built-in TypeScript
|
||||
- **Alternatives:** Node.js, pnpm
|
||||
- **Decision:** Modern, faster than Node
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Data Model
|
||||
|
||||
### Core Entities
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
INVENTORY_ITEMS ||--o| PRODUCTS : references
|
||||
INVENTORY_ITEMS }o--o{ TAGS : tagged_with
|
||||
INVENTORY_ITEMS ||--|| UNITS : uses
|
||||
PRODUCTS ||--|| UNITS : default_unit
|
||||
UNITS ||--o| UNITS : converts_to
|
||||
ITEM_TAGS }|--|| INVENTORY_ITEMS : item
|
||||
ITEM_TAGS }|--|| TAGS : tag
|
||||
|
||||
INVENTORY_ITEMS {
|
||||
uuid id PK
|
||||
uuid product_id FK
|
||||
string name
|
||||
decimal quantity
|
||||
uuid unit_id FK
|
||||
date expiry_date
|
||||
uuid added_by FK
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
PRODUCTS {
|
||||
uuid id PK
|
||||
string barcode UK
|
||||
string name
|
||||
string brand
|
||||
string image_url
|
||||
uuid default_unit_id FK
|
||||
timestamp cached_at
|
||||
}
|
||||
|
||||
TAGS {
|
||||
uuid id PK
|
||||
string name
|
||||
enum category
|
||||
string icon
|
||||
string color
|
||||
uuid created_by FK
|
||||
}
|
||||
|
||||
UNITS {
|
||||
uuid id PK
|
||||
string name
|
||||
string abbreviation
|
||||
enum unit_type
|
||||
uuid base_unit_id FK
|
||||
decimal conversion_factor
|
||||
boolean is_default
|
||||
uuid created_by FK
|
||||
}
|
||||
|
||||
ITEM_TAGS {
|
||||
uuid item_id FK
|
||||
uuid tag_id FK
|
||||
}
|
||||
```
|
||||
|
||||
### Key Relationships
|
||||
|
||||
1. **Items ↔ Products:** Many-to-one (multiple items from same product)
|
||||
2. **Items ↔ Tags:** Many-to-many (items can have multiple tags)
|
||||
3. **Units ↔ Units:** Self-referential (g → kg conversion)
|
||||
4. **Items → Users:** Audit trail (who added)
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Model
|
||||
|
||||
### Authentication
|
||||
|
||||
**Supabase Auth (GoTrue)**
|
||||
|
||||
**Email/Password (Default):**
|
||||
```typescript
|
||||
const { user, error } = await supabase.auth.signUp({
|
||||
email: 'user@example.com',
|
||||
password: 'secure-password'
|
||||
})
|
||||
```
|
||||
|
||||
**OIDC (Optional - Admin Config):**
|
||||
```yaml
|
||||
# Admin sets via environment
|
||||
SUPABASE_AUTH_GOOGLE_ENABLED=true
|
||||
SUPABASE_AUTH_GOOGLE_CLIENT_ID=...
|
||||
SUPABASE_AUTH_GOOGLE_SECRET=...
|
||||
```
|
||||
|
||||
### Authorization
|
||||
|
||||
**Row Level Security (RLS)**
|
||||
|
||||
**Inventory Items:**
|
||||
```sql
|
||||
-- Everyone can read (shared inventory)
|
||||
CREATE POLICY "items_select" ON inventory_items
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- Authenticated users can insert
|
||||
CREATE POLICY "items_insert" ON inventory_items
|
||||
FOR INSERT WITH CHECK (
|
||||
auth.uid() IS NOT NULL
|
||||
);
|
||||
|
||||
-- Users can update any item (trust model)
|
||||
CREATE POLICY "items_update" ON inventory_items
|
||||
FOR UPDATE USING (
|
||||
auth.uid() IS NOT NULL
|
||||
);
|
||||
|
||||
-- Users can delete any item
|
||||
CREATE POLICY "items_delete" ON inventory_items
|
||||
FOR DELETE USING (
|
||||
auth.uid() IS NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**Products (read-only for users):**
|
||||
```sql
|
||||
-- Everyone reads
|
||||
CREATE POLICY "products_select" ON products
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- Only service role writes (via Edge Functions)
|
||||
-- (No user-facing policy needed)
|
||||
```
|
||||
|
||||
**Tags (user-managed):**
|
||||
```sql
|
||||
-- Default tags: everyone reads
|
||||
-- Custom tags: creator manages
|
||||
CREATE POLICY "tags_select" ON tags
|
||||
FOR SELECT USING (true);
|
||||
|
||||
CREATE POLICY "tags_insert" ON tags
|
||||
FOR INSERT WITH CHECK (
|
||||
auth.uid() IS NOT NULL
|
||||
);
|
||||
|
||||
CREATE POLICY "tags_update" ON tags
|
||||
FOR UPDATE USING (
|
||||
created_by = auth.uid() OR created_by IS NULL
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Data Flow
|
||||
|
||||
### 1. Barcode Scan Flow
|
||||
|
||||
```
|
||||
User taps "Scan" button
|
||||
↓
|
||||
Camera permission requested (if first time)
|
||||
↓
|
||||
Camera opens (html5-qrcode)
|
||||
↓
|
||||
Barcode detected (e.g., "8000500310427")
|
||||
↓
|
||||
Frontend calls Edge Function
|
||||
POST /functions/v1/product-lookup
|
||||
{ barcode: "8000500310427" }
|
||||
↓
|
||||
Edge Function checks products table
|
||||
├─ Found: return cached product
|
||||
└─ Not found:
|
||||
↓
|
||||
Fetch from Open Food Facts API
|
||||
https://world.openfoodfacts.org/api/v2/product/{barcode}
|
||||
↓
|
||||
Parse response (name, brand, image, etc.)
|
||||
↓
|
||||
Cache in products table
|
||||
↓
|
||||
Return product data
|
||||
↓
|
||||
Frontend displays product card
|
||||
↓
|
||||
User sets quantity, tags, expiry (optional)
|
||||
↓
|
||||
Frontend inserts into inventory_items table
|
||||
↓
|
||||
Realtime updates other devices (WebSocket)
|
||||
↓
|
||||
Done!
|
||||
```
|
||||
|
||||
### 2. Manual Add Flow
|
||||
|
||||
```
|
||||
User taps "Add Manually"
|
||||
↓
|
||||
Form: name, quantity, unit, tags, expiry
|
||||
↓
|
||||
Frontend validates (required: name, quantity)
|
||||
↓
|
||||
Insert into inventory_items
|
||||
↓
|
||||
Realtime broadcast to other devices
|
||||
↓
|
||||
Done!
|
||||
```
|
||||
|
||||
### 3. Unit Conversion Flow
|
||||
|
||||
```
|
||||
User selects unit (e.g., g)
|
||||
↓
|
||||
Item has quantity (e.g., 500g)
|
||||
↓
|
||||
User wants to view in kg
|
||||
↓
|
||||
Frontend calls conversion logic:
|
||||
convert(500, g, kg)
|
||||
↓
|
||||
Find g.base_unit = kg
|
||||
Find g.conversion_factor = 0.001
|
||||
↓
|
||||
result = 500 * 0.001 = 0.5 kg
|
||||
↓
|
||||
Display: "0.5 kg (500 g)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 API Design
|
||||
|
||||
### Supabase Client (Direct Access)
|
||||
|
||||
**Inventory CRUD:**
|
||||
```typescript
|
||||
// List items
|
||||
const { data } = await supabase
|
||||
.from('inventory_items')
|
||||
.select(`
|
||||
*,
|
||||
product:products(*),
|
||||
unit:units(*),
|
||||
tags:item_tags(tag:tags(*))
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
// Add item
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_items')
|
||||
.insert({
|
||||
product_id: '...',
|
||||
quantity: 1,
|
||||
unit_id: '...',
|
||||
added_by: user.id
|
||||
})
|
||||
|
||||
// Update quantity (consume)
|
||||
const { data } = await supabase
|
||||
.from('inventory_items')
|
||||
.update({ quantity: item.quantity - 1 })
|
||||
.eq('id', itemId)
|
||||
|
||||
// Delete item
|
||||
const { error } = await supabase
|
||||
.from('inventory_items')
|
||||
.delete()
|
||||
.eq('id', itemId)
|
||||
```
|
||||
|
||||
### Edge Functions
|
||||
|
||||
**Product Lookup:**
|
||||
```typescript
|
||||
// POST /functions/v1/product-lookup
|
||||
// Body: { barcode: string }
|
||||
|
||||
interface Request {
|
||||
barcode: string
|
||||
}
|
||||
|
||||
interface Response {
|
||||
product: Product | null
|
||||
cached: boolean
|
||||
}
|
||||
|
||||
// Returns cached product or fetches from Open Food Facts
|
||||
```
|
||||
|
||||
**Batch Lookup (future):**
|
||||
```typescript
|
||||
// POST /functions/v1/products/batch
|
||||
// Body: { barcodes: string[] }
|
||||
|
||||
// For offline sync (scan multiple, upload batch)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 PWA Architecture
|
||||
|
||||
### Service Worker Strategy
|
||||
|
||||
**Network-first with cache fallback:**
|
||||
|
||||
```typescript
|
||||
// App shell: cache-first
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.url.includes('/app')) {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(cached => cached || fetch(event.request))
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// API calls: network-first
|
||||
if (event.request.url.includes('/api')) {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.catch(() => caches.match(event.request))
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Offline Support
|
||||
|
||||
**Offline Queue:**
|
||||
```typescript
|
||||
// Queue scans when offline
|
||||
if (!navigator.onLine) {
|
||||
await db.offlineQueue.add({
|
||||
type: 'scan',
|
||||
barcode: '123456',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
// Sync when online
|
||||
window.addEventListener('online', async () => {
|
||||
const queue = await db.offlineQueue.getAll()
|
||||
for (const item of queue) {
|
||||
await processQueueItem(item)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Frontend Architecture
|
||||
|
||||
### Component Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── components/
|
||||
│ ├── inventory/
|
||||
│ │ ├── ItemList.vue # Main inventory view
|
||||
│ │ ├── ItemCard.vue # Single item card
|
||||
│ │ ├── QuickActions.vue # Consume, restock buttons
|
||||
│ │ └── FilterBar.vue # Search & filter
|
||||
│ ├── scan/
|
||||
│ │ ├── BarcodeScanner.vue # Camera + detection
|
||||
│ │ ├── ScanButton.vue # Floating action button
|
||||
│ │ └── ProductCard.vue # After scan confirmation
|
||||
│ ├── tags/
|
||||
│ │ ├── TagPicker.vue # Multi-select tags
|
||||
│ │ ├── TagBadge.vue # Display tag
|
||||
│ │ └── TagManager.vue # Settings: manage tags
|
||||
│ ├── units/
|
||||
│ │ ├── UnitSelector.vue # Dropdown with conversions
|
||||
│ │ └── UnitDisplay.vue # Show quantity + unit
|
||||
│ └── common/
|
||||
│ ├── AppHeader.vue
|
||||
│ ├── AppFooter.vue
|
||||
│ └── LoadingState.vue
|
||||
├── composables/
|
||||
│ ├── useBarcode.ts # Camera, detection logic
|
||||
│ ├── useInventory.ts # CRUD operations
|
||||
│ ├── useUnits.ts # Conversions
|
||||
│ ├── useTags.ts # Tag filtering
|
||||
│ └── useOpenFoodFacts.ts # API wrapper
|
||||
├── pages/
|
||||
│ ├── index.vue # Inventory list
|
||||
│ ├── scan.vue # Full-screen scanner
|
||||
│ ├── item/[id].vue # Item detail/edit
|
||||
│ └── settings.vue # Tags, units, profile
|
||||
└── utils/
|
||||
├── conversions.ts # Unit conversion math
|
||||
├── barcode-formats.ts # Supported formats
|
||||
└── offline-queue.ts # Offline sync
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
**No Vuex/Pinia needed (for MVP):**
|
||||
- Supabase Realtime = reactive state
|
||||
- Composables for shared logic
|
||||
- Props/emits for component state
|
||||
|
||||
**If needed later:**
|
||||
- Pinia for complex UI state (filters, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Development Workflow
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Terminal 1: Supabase
|
||||
docker-compose -f docker/docker-compose.dev.yml up
|
||||
|
||||
# Terminal 2: Nuxt dev server
|
||||
cd app
|
||||
bun run dev
|
||||
|
||||
# Access:
|
||||
# - App: http://localhost:3000
|
||||
# - Supabase Studio: http://localhost:54323
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
```bash
|
||||
# Create migration
|
||||
supabase migration new add_tags_table
|
||||
|
||||
# Apply locally
|
||||
supabase db reset
|
||||
|
||||
# Push to production
|
||||
supabase db push
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
bun test
|
||||
|
||||
# E2E tests (Playwright)
|
||||
bun run test:e2e
|
||||
|
||||
# Type check
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Docker Compose (Production)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
supabase:
|
||||
image: supabase/postgres:15
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
supabase-auth:
|
||||
image: supabase/gotrue
|
||||
environment:
|
||||
SITE_URL: ${SITE_URL}
|
||||
# OIDC config (optional)
|
||||
|
||||
pantry:
|
||||
build: ./app
|
||||
environment:
|
||||
SUPABASE_URL: http://supabase:8000
|
||||
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
```
|
||||
|
||||
### Coolify Deployment
|
||||
|
||||
**Service Config:**
|
||||
- Type: Docker Compose
|
||||
- Repo: pantry-app/pantry
|
||||
- Compose File: `docker/docker-compose.yml`
|
||||
- Domain: `pantry.yourdomain.com`
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance Considerations
|
||||
|
||||
### Image Optimization
|
||||
- Open Food Facts images cached locally
|
||||
- Nuxt Image module for responsive images
|
||||
- WebP format with fallbacks
|
||||
|
||||
### Database Indexing
|
||||
```sql
|
||||
-- Fast barcode lookup
|
||||
CREATE INDEX idx_products_barcode ON products(barcode);
|
||||
|
||||
-- Fast tag filtering
|
||||
CREATE INDEX idx_item_tags_tag ON item_tags(tag_id);
|
||||
|
||||
-- Fast user queries
|
||||
CREATE INDEX idx_items_added_by ON inventory_items(added_by);
|
||||
```
|
||||
|
||||
### Realtime Optimization
|
||||
- Only subscribe to user's inventory (RLS filters)
|
||||
- Debounce updates (don't broadcast every keystroke)
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Future Considerations
|
||||
|
||||
### Scalability
|
||||
- **Database:** Postgres scales to millions of rows
|
||||
- **Storage:** Images stored in Supabase Storage (S3-compatible)
|
||||
- **Caching:** Redis for product lookup cache (if Open Food Facts is slow)
|
||||
|
||||
### Multi-Tenancy (v2.0+)
|
||||
- Add `household_id` to all tables
|
||||
- RLS policies filter by household
|
||||
- Invite system for family members
|
||||
|
||||
### Native Mobile App (v2.0+)
|
||||
- Capacitor wrapper around Nuxt
|
||||
- Native barcode scanner (faster than PWA)
|
||||
- Push notifications (expiry alerts)
|
||||
|
||||
---
|
||||
|
||||
## 📚 References
|
||||
|
||||
- [Supabase Docs](https://supabase.com/docs)
|
||||
- [Nuxt 4 Docs](https://nuxt.com)
|
||||
- [Open Food Facts API](https://wiki.openfoodfacts.org/API)
|
||||
- [html5-qrcode](https://github.com/mebjas/html5-qrcode)
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Database Schema](./DATABASE.md)
|
||||
Reference in New Issue
Block a user