From 812c0ace74f6d5efb108a4485714e4951f21be21 Mon Sep 17 00:00:00 2001 From: Claw Date: Sun, 8 Feb 2026 18:56:46 +0000 Subject: [PATCH] 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. --- docs/API.md | 746 ++++++++++++++++++++++++++++++++++++++++++++ docs/DATABASE.md | 651 ++++++++++++++++++++++++++++++++++++++ docs/DEPLOYMENT.md | 642 ++++++++++++++++++++++++++++++++++++++ docs/DEVELOPMENT.md | 678 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 2717 insertions(+) create mode 100644 docs/API.md create mode 100644 docs/DATABASE.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/DEVELOPMENT.md diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..d36e696 --- /dev/null +++ b/docs/API.md @@ -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) diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..5fab2e0 --- /dev/null +++ b/docs/DATABASE.md @@ -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) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..4987dd6 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -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= + +# JWT (generate: openssl rand -hex 32) +JWT_SECRET= + +# Realtime +REALTIME_SECRET= + +# Supabase Keys (generate via supabase CLI or manually) +SUPABASE_ANON_KEY= +SUPABASE_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. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..be1ec3e --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -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 + +``` + +**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 + + + + + +``` + +### 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 => { + 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() + const items = ref([]) + 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() +``` + +--- + +## ๐ŸŒ 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: (): + +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)