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:
Claw
2026-02-08 18:56:46 +00:00
parent 41537fa84c
commit 812c0ace74
4 changed files with 2717 additions and 0 deletions

746
docs/API.md Normal file
View File

@@ -0,0 +1,746 @@
# Pantry - API Reference
**Version:** 1.0
**Last Updated:** 2026-02-08
---
## 🌐 API Architecture
Pantry uses a **hybrid approach:**
1. **Supabase Client SDK** — Direct database access from frontend (most operations)
2. **Edge Functions** — Custom logic for complex operations (product lookup, etc.)
### Why This Approach?
- **Simple CRUD:** Supabase Client (auto-generated, realtime, RLS-protected)
- **Complex logic:** Edge Functions (Open Food Facts integration, caching, etc.)
---
## 🔑 Authentication
All API calls require authentication via Supabase Auth.
### Get Access Token
```typescript
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'password'
})
const token = data.session?.access_token
```
### Using the Token
**Supabase Client (automatic):**
```typescript
// Token is automatically included in requests
const { data } = await supabase
.from('inventory_items')
.select('*')
```
**Edge Functions (manual):**
```typescript
fetch('https://your-project.supabase.co/functions/v1/product-lookup', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ barcode: '123' })
})
```
---
## 📦 Supabase Client API
### Inventory Items
#### List Items
```typescript
const { data, error } = await supabase
.from('inventory_items')
.select(`
*,
product:products(*),
unit:units(*),
tags:item_tags(tag:tags(*))
`)
.order('created_at', { ascending: false })
```
**Response:**
```json
{
"data": [
{
"id": "abc-123",
"name": "Whole Milk",
"quantity": 1.5,
"expiry_date": "2026-02-15",
"unit": {
"abbreviation": "L",
"name": "liter"
},
"tags": [
{ "tag": { "name": "Fridge", "icon": "🧊" } },
{ "tag": { "name": "Dairy", "icon": "🥛" } }
],
"product": {
"barcode": "8000500310427",
"brand": "Milka",
"image_url": "https://..."
}
}
]
}
```
#### Add Item
```typescript
const { data, error } = await supabase
.from('inventory_items')
.insert({
product_id: 'product-uuid', // or null for custom items
name: 'Whole Milk',
quantity: 1,
unit_id: 'unit-uuid',
expiry_date: '2026-02-15',
added_by: user.id
})
.select()
.single()
```
#### Update Item (Consume)
```typescript
// Decrement quantity
const { data, error } = await supabase
.from('inventory_items')
.update({ quantity: currentQuantity - 1 })
.eq('id', itemId)
.select()
.single()
// Or use SQL function for atomic decrement:
const { data, error } = await supabase.rpc('consume_item', {
item_id: itemId,
amount: 1
})
```
#### Delete Item
```typescript
const { error } = await supabase
.from('inventory_items')
.delete()
.eq('id', itemId)
```
#### Filter by Tags
```typescript
const { data, error } = await supabase
.from('inventory_items')
.select(`
*,
item_tags!inner(tag:tags!inner(*))
`)
.eq('item_tags.tag.name', 'Fridge')
```
#### Search Items
```typescript
const { data, error } = await supabase
.from('inventory_items')
.select('*')
.or(`name.ilike.%${query}%,location.ilike.%${query}%`)
```
---
### Tags
#### List Tags
```typescript
const { data, error } = await supabase
.from('tags')
.select('*')
.order('category', { ascending: true })
.order('name', { ascending: true })
```
#### Create Tag
```typescript
const { data, error } = await supabase
.from('tags')
.insert({
name: 'Organic',
category: 'dietary',
icon: '🍃',
color: '#84cc16',
created_by: user.id
})
.select()
.single()
```
#### Add Tag to Item
```typescript
const { data, error } = await supabase
.from('item_tags')
.insert({
item_id: 'item-uuid',
tag_id: 'tag-uuid'
})
```
#### Remove Tag from Item
```typescript
const { error } = await supabase
.from('item_tags')
.delete()
.eq('item_id', itemId)
.eq('tag_id', tagId)
```
---
### Units
#### List Units
```typescript
const { data, error } = await supabase
.from('units')
.select('*, base_unit:units(name, abbreviation)')
.order('unit_type', { ascending: true })
.order('name', { ascending: true })
```
#### Convert Units
```typescript
// Using SQL function
const { data, error } = await supabase.rpc('convert_unit', {
quantity: 500,
from_unit_id: 'gram-uuid',
to_unit_id: 'kg-uuid'
})
// Returns: 0.5
```
#### Create Custom Unit
```typescript
const { data, error } = await supabase
.from('units')
.insert({
name: 'tablespoon',
abbreviation: 'tbsp',
unit_type: 'volume',
base_unit_id: 'liter-uuid',
conversion_factor: 0.015, // 15 mL
is_default: false,
created_by: user.id
})
.select()
.single()
```
---
### Products
#### Search Products (Cached)
```typescript
const { data, error } = await supabase.rpc('search_products', {
search_query: 'chocolate'
})
```
**Response:**
```json
{
"data": [
{
"id": "product-123",
"barcode": "8000500310427",
"name": "Nutella",
"brand": "Ferrero",
"rank": 0.95
}
]
}
```
---
## ⚡ Edge Functions
### Product Lookup
**Endpoint:** `POST /functions/v1/product-lookup`
**Purpose:** Fetch product data from Open Food Facts (with caching)
**Request:**
```json
{
"barcode": "8000500310427"
}
```
**Response (Cached):**
```json
{
"product": {
"id": "abc-123",
"barcode": "8000500310427",
"name": "Nutella",
"brand": "Ferrero",
"image_url": "https://...",
"image_small_url": "https://...",
"categories": ["spreads", "chocolate"],
"default_unit_id": "gram-uuid",
"default_quantity": 400
},
"cached": true,
"source": "database"
}
```
**Response (Not Cached - Fetched from Open Food Facts):**
```json
{
"product": {
"id": "new-uuid",
"barcode": "8000500310427",
"name": "Nutella",
"brand": "Ferrero",
"image_url": "https://...",
...
},
"cached": false,
"source": "openfoodfacts"
}
```
**Error Responses:**
```json
// Product not found
{
"error": "PRODUCT_NOT_FOUND",
"message": "Product not found in Open Food Facts",
"barcode": "123invalid"
}
// Invalid barcode format
{
"error": "INVALID_BARCODE",
"message": "Barcode must be 8-13 digits",
"barcode": "abc"
}
```
**Implementation:**
```typescript
// supabase/functions/product-lookup/index.ts
import { serve } from 'std/server'
import { createClient } from '@supabase/supabase-js'
serve(async (req) => {
const { barcode } = await req.json()
// Validate barcode
if (!/^\d{8,13}$/.test(barcode)) {
return new Response(
JSON.stringify({ error: 'INVALID_BARCODE' }),
{ status: 400 }
)
}
const supabase = createClient(...)
// Check cache
const { data: cached } = await supabase
.from('products')
.select('*')
.eq('barcode', barcode)
.single()
if (cached) {
return new Response(
JSON.stringify({ product: cached, cached: true }),
{ headers: { 'Content-Type': 'application/json' } }
)
}
// Fetch from Open Food Facts
const res = await fetch(
`https://world.openfoodfacts.org/api/v2/product/${barcode}.json`
)
if (!res.ok) {
return new Response(
JSON.stringify({ error: 'PRODUCT_NOT_FOUND' }),
{ status: 404 }
)
}
const { product } = await res.json()
// Cache product
const { data: newProduct } = await supabase
.from('products')
.insert({
barcode,
name: product.product_name,
brand: product.brands,
image_url: product.image_url,
// ... more fields
cached_at: new Date().toISOString()
})
.select()
.single()
return new Response(
JSON.stringify({ product: newProduct, cached: false }),
{ headers: { 'Content-Type': 'application/json' } }
)
})
```
---
### Batch Product Lookup (Future)
**Endpoint:** `POST /functions/v1/products/batch`
**Purpose:** Lookup multiple products at once (for offline sync)
**Request:**
```json
{
"barcodes": ["8000500310427", "5449000000996", "123456789"]
}
```
**Response:**
```json
{
"results": [
{
"barcode": "8000500310427",
"product": { ... },
"cached": true
},
{
"barcode": "5449000000996",
"product": { ... },
"cached": false
},
{
"barcode": "123456789",
"error": "PRODUCT_NOT_FOUND"
}
]
}
```
---
## 🔄 Realtime Subscriptions
### Subscribe to Inventory Changes
```typescript
const subscription = supabase
.channel('inventory-changes')
.on(
'postgres_changes',
{
event: '*', // INSERT, UPDATE, DELETE
schema: 'public',
table: 'inventory_items'
},
(payload) => {
console.log('Change detected:', payload)
if (payload.eventType === 'INSERT') {
// New item added
addItemToList(payload.new)
} else if (payload.eventType === 'UPDATE') {
// Item updated
updateItemInList(payload.new)
} else if (payload.eventType === 'DELETE') {
// Item deleted
removeItemFromList(payload.old.id)
}
}
)
.subscribe()
// Unsubscribe when done
subscription.unsubscribe()
```
### Subscribe to Tag Changes
```typescript
supabase
.channel('tag-changes')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'tags' },
refreshTags
)
.subscribe()
```
---
## 🔧 Custom SQL Functions (via RPC)
### Consume Item (Atomic Decrement)
```sql
CREATE OR REPLACE FUNCTION consume_item(
item_id UUID,
amount DECIMAL
)
RETURNS inventory_items AS $$
DECLARE
updated_item inventory_items;
BEGIN
UPDATE inventory_items
SET quantity = GREATEST(quantity - amount, 0),
updated_at = NOW()
WHERE id = item_id
RETURNING * INTO updated_item;
-- Delete if quantity is 0
IF updated_item.quantity = 0 THEN
DELETE FROM inventory_items WHERE id = item_id;
updated_item.id := NULL; -- Signal deletion
END IF;
RETURN updated_item;
END;
$$ LANGUAGE plpgsql;
```
**Usage:**
```typescript
const { data, error } = await supabase.rpc('consume_item', {
item_id: 'item-uuid',
amount: 1
})
if (data?.id === null) {
// Item was deleted (quantity reached 0)
}
```
### Restock Item
```sql
CREATE OR REPLACE FUNCTION restock_item(
item_id UUID,
amount DECIMAL
)
RETURNS inventory_items AS $$
UPDATE inventory_items
SET quantity = quantity + amount,
updated_at = NOW()
WHERE id = item_id
RETURNING *;
$$ LANGUAGE sql;
```
**Usage:**
```typescript
const { data, error } = await supabase.rpc('restock_item', {
item_id: 'item-uuid',
amount: 2
})
```
---
## 📊 Analytics Queries (Future)
### Items by Tag
```typescript
const { data, error } = await supabase.rpc('items_by_tag')
```
```sql
CREATE OR REPLACE FUNCTION items_by_tag()
RETURNS TABLE (tag_name TEXT, item_count BIGINT) AS $$
SELECT
t.name,
COUNT(it.item_id)
FROM tags t
LEFT JOIN item_tags it ON t.id = it.tag_id
GROUP BY t.name
ORDER BY COUNT(it.item_id) DESC;
$$ LANGUAGE sql;
```
### Expiring Soon
```typescript
const { data, error } = await supabase.rpc('expiring_soon', {
days: 7
})
```
```sql
CREATE OR REPLACE FUNCTION expiring_soon(days INTEGER)
RETURNS SETOF inventory_items AS $$
SELECT *
FROM inventory_items
WHERE expiry_date IS NOT NULL
AND expiry_date <= CURRENT_DATE + (days || ' days')::INTERVAL
ORDER BY expiry_date ASC;
$$ LANGUAGE sql;
```
---
## 🚨 Error Handling
### Supabase Client Errors
```typescript
const { data, error } = await supabase
.from('inventory_items')
.insert({ ... })
if (error) {
// PostgreSQL error
console.error(error.message)
console.error(error.code) // e.g., "23505" (unique violation)
console.error(error.details)
}
```
### Edge Function Errors
```typescript
const res = await fetch('...', { ... })
if (!res.ok) {
const error = await res.json()
console.error(error.error) // Error code
console.error(error.message) // Human-readable message
}
```
---
## 📚 Type Definitions (TypeScript)
```typescript
// Generated by Supabase CLI: supabase gen types typescript
export interface Database {
public: {
Tables: {
inventory_items: {
Row: {
id: string
product_id: string | null
name: string
quantity: number
unit_id: string
expiry_date: string | null
location: string | null
notes: string | null
added_by: string
created_at: string
updated_at: string
}
Insert: {
id?: string
product_id?: string | null
name: string
quantity: number
unit_id: string
expiry_date?: string | null
location?: string | null
notes?: string | null
added_by: string
created_at?: string
updated_at?: string
}
Update: {
id?: string
product_id?: string | null
name?: string
quantity?: number
unit_id?: string
expiry_date?: string | null
location?: string | null
notes?: string | null
added_by?: string
created_at?: string
updated_at?: string
}
}
// ... other tables
}
Functions: {
convert_unit: {
Args: { quantity: number; from_unit_id: string; to_unit_id: string }
Returns: number
}
consume_item: {
Args: { item_id: string; amount: number }
Returns: Database['public']['Tables']['inventory_items']['Row']
}
// ... other functions
}
}
}
```
---
## 🔐 Rate Limiting
**Supabase Rate Limits (Free Tier):**
- 500 requests/second per project
- 2GB database size
- 1GB file storage
**Edge Functions:**
- 500,000 invocations/month (free tier)
- 2s timeout
**Recommendations:**
- Cache product lookups aggressively
- Debounce search queries (300ms)
- Use realtime subscriptions instead of polling
---
**Next:** [Development Guide](./DEVELOPMENT.md)

651
docs/DATABASE.md Normal file
View 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
View 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
View 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)