# Pantry - Architecture **Version:** 1.0 **Last Updated:** 2026-02-08 --- ## 🏗️ System Overview ``` ┌─────────────────────────────────────────────┐ │ User Devices │ │ (Phone, Tablet, Desktop - PWA) │ └──────────────────┬──────────────────────────┘ │ HTTPS ▼ ┌─────────────────────────────────────────────┐ │ Nuxt 4 Frontend (SSR) │ │ - Vue 3 Components │ │ - Tailwind CSS + Nuxt UI │ │ - PWA (offline-first) │ │ - Barcode Scanner │ └──────────────────┬──────────────────────────┘ │ WebSocket + REST ▼ ┌─────────────────────────────────────────────┐ │ Supabase Platform │ │ ┌─────────────────────────────────────┐ │ │ │ PostgreSQL (Database) │ │ │ │ - Items, Products, Tags, Units │ │ │ │ - Row Level Security (RLS) │ │ │ └─────────────────────────────────────┘ │ │ ┌─────────────────────────────────────┐ │ │ │ Auth (GoTrue) │ │ │ │ - Email/Password │ │ │ │ - OIDC (optional) │ │ │ └─────────────────────────────────────┘ │ │ ┌─────────────────────────────────────┐ │ │ │ Realtime (WebSocket) │ │ │ │ - Live updates across devices │ │ │ └─────────────────────────────────────┘ │ │ ┌─────────────────────────────────────┐ │ │ │ Edge Functions (Deno) │ │ │ │ - Product lookup │ │ │ │ - Barcode cache │ │ │ └─────────────────────────────────────┘ │ └──────────────────┬──────────────────────────┘ │ HTTP ▼ ┌─────────────────────────────────────────────┐ │ Open Food Facts API │ │ (External - product data) │ └─────────────────────────────────────────────┘ ``` --- ## 📦 Tech Stack ### Frontend **Nuxt 4** (Vue 3) - **Why:** Meta-framework, SSR, excellent DX - **Alternatives considered:** Next.js (React), SvelteKit - **Decision:** Vue ecosystem maturity + Nuxt UI **Tailwind CSS** - **Why:** Utility-first, fast iteration - **Alternatives:** CSS Modules, UnoCSS - **Decision:** Industry standard, Nuxt UI built on it **Nuxt UI** - **Why:** Pre-built components, accessible - **Alternatives:** Headless UI, Shadcn - **Decision:** First-party Nuxt support **html5-qrcode** - **Why:** PWA camera support, multi-format - **Alternatives:** ZXing, QuaggaJS - **Decision:** Best mobile performance in tests ### Backend **Supabase** - **Why:** Postgres + Auth + Realtime in one - **Alternatives:** Firebase, Appwrite, custom backend - **Decision:** Self-hosted, SQL flexibility, mature **PostgreSQL 15+** - **Why:** Robust, supports JSONB, full-text search - **Alternatives:** MySQL, MongoDB - **Decision:** Comes with Supabase, best for relational data **Deno Edge Functions** - **Why:** Serverless, TypeScript-native - **Alternatives:** Node.js functions, custom API - **Decision:** Part of Supabase, fast cold starts ### Deployment **Docker Compose** - **Why:** Simple multi-container orchestration - **Alternatives:** Kubernetes, bare metal - **Decision:** Right complexity level for self-hosted **Bun** (runtime) - **Why:** Fast, npm-compatible, built-in TypeScript - **Alternatives:** Node.js, pnpm - **Decision:** Modern, faster than Node --- ## 🗄️ Data Model ### Core Entities ```mermaid erDiagram INVENTORY_ITEMS ||--o| PRODUCTS : references INVENTORY_ITEMS }o--o{ TAGS : tagged_with INVENTORY_ITEMS ||--|| UNITS : uses PRODUCTS ||--|| UNITS : default_unit UNITS ||--o| UNITS : converts_to ITEM_TAGS }|--|| INVENTORY_ITEMS : item ITEM_TAGS }|--|| TAGS : tag INVENTORY_ITEMS { uuid id PK uuid product_id FK string name decimal quantity uuid unit_id FK date expiry_date uuid added_by FK timestamp created_at timestamp updated_at } PRODUCTS { uuid id PK string barcode UK string name string brand string image_url uuid default_unit_id FK timestamp cached_at } TAGS { uuid id PK string name enum category string icon string color uuid created_by FK } UNITS { uuid id PK string name string abbreviation enum unit_type uuid base_unit_id FK decimal conversion_factor boolean is_default uuid created_by FK } ITEM_TAGS { uuid item_id FK uuid tag_id FK } ``` ### Key Relationships 1. **Items ↔ Products:** Many-to-one (multiple items from same product) 2. **Items ↔ Tags:** Many-to-many (items can have multiple tags) 3. **Units ↔ Units:** Self-referential (g → kg conversion) 4. **Items → Users:** Audit trail (who added) --- ## 🔐 Security Model ### Authentication **Supabase Auth (GoTrue)** **Email/Password (Default):** ```typescript const { user, error } = await supabase.auth.signUp({ email: 'user@example.com', password: 'secure-password' }) ``` **OIDC (Optional - Admin Config):** ```yaml # Admin sets via environment SUPABASE_AUTH_GOOGLE_ENABLED=true SUPABASE_AUTH_GOOGLE_CLIENT_ID=... SUPABASE_AUTH_GOOGLE_SECRET=... ``` ### Authorization **Row Level Security (RLS)** **Inventory Items:** ```sql -- Everyone can read (shared inventory) CREATE POLICY "items_select" ON inventory_items FOR SELECT USING (true); -- Authenticated users can insert CREATE POLICY "items_insert" ON inventory_items FOR INSERT WITH CHECK ( auth.uid() IS NOT NULL ); -- Users can update any item (trust model) CREATE POLICY "items_update" ON inventory_items FOR UPDATE USING ( auth.uid() IS NOT NULL ); -- Users can delete any item CREATE POLICY "items_delete" ON inventory_items FOR DELETE USING ( auth.uid() IS NOT NULL ); ``` **Products (read-only for users):** ```sql -- Everyone reads CREATE POLICY "products_select" ON products FOR SELECT USING (true); -- Only service role writes (via Edge Functions) -- (No user-facing policy needed) ``` **Tags (user-managed):** ```sql -- Default tags: everyone reads -- Custom tags: creator manages CREATE POLICY "tags_select" ON tags FOR SELECT USING (true); CREATE POLICY "tags_insert" ON tags FOR INSERT WITH CHECK ( auth.uid() IS NOT NULL ); CREATE POLICY "tags_update" ON tags FOR UPDATE USING ( created_by = auth.uid() OR created_by IS NULL ); ``` --- ## 🔄 Data Flow ### 1. Barcode Scan Flow ``` User taps "Scan" button ↓ Camera permission requested (if first time) ↓ Camera opens (html5-qrcode) ↓ Barcode detected (e.g., "8000500310427") ↓ Frontend calls Edge Function POST /functions/v1/product-lookup { barcode: "8000500310427" } ↓ Edge Function checks products table ├─ Found: return cached product └─ Not found: ↓ Fetch from Open Food Facts API https://world.openfoodfacts.org/api/v2/product/{barcode} ↓ Parse response (name, brand, image, etc.) ↓ Cache in products table ↓ Return product data ↓ Frontend displays product card ↓ User sets quantity, tags, expiry (optional) ↓ Frontend inserts into inventory_items table ↓ Realtime updates other devices (WebSocket) ↓ Done! ``` ### 2. Manual Add Flow ``` User taps "Add Manually" ↓ Form: name, quantity, unit, tags, expiry ↓ Frontend validates (required: name, quantity) ↓ Insert into inventory_items ↓ Realtime broadcast to other devices ↓ Done! ``` ### 3. Unit Conversion Flow ``` User selects unit (e.g., g) ↓ Item has quantity (e.g., 500g) ↓ User wants to view in kg ↓ Frontend calls conversion logic: convert(500, g, kg) ↓ Find g.base_unit = kg Find g.conversion_factor = 0.001 ↓ result = 500 * 0.001 = 0.5 kg ↓ Display: "0.5 kg (500 g)" ``` --- ## 🌐 API Design ### Supabase Client (Direct Access) **Inventory CRUD:** ```typescript // List items const { data } = await supabase .from('inventory_items') .select(` *, product:products(*), unit:units(*), tags:item_tags(tag:tags(*)) `) .order('created_at', { ascending: false }) // Add item const { data, error } = await supabase .from('inventory_items') .insert({ product_id: '...', quantity: 1, unit_id: '...', added_by: user.id }) // Update quantity (consume) const { data } = await supabase .from('inventory_items') .update({ quantity: item.quantity - 1 }) .eq('id', itemId) // Delete item const { error } = await supabase .from('inventory_items') .delete() .eq('id', itemId) ``` ### Edge Functions **Product Lookup:** ```typescript // POST /functions/v1/product-lookup // Body: { barcode: string } interface Request { barcode: string } interface Response { product: Product | null cached: boolean } // Returns cached product or fetches from Open Food Facts ``` **Batch Lookup (future):** ```typescript // POST /functions/v1/products/batch // Body: { barcodes: string[] } // For offline sync (scan multiple, upload batch) ``` --- ## 📱 PWA Architecture ### Service Worker Strategy **Network-first with cache fallback:** ```typescript // App shell: cache-first self.addEventListener('fetch', (event) => { if (event.request.url.includes('/app')) { event.respondWith( caches.match(event.request) .then(cached => cached || fetch(event.request)) ) } }) // API calls: network-first if (event.request.url.includes('/api')) { event.respondWith( fetch(event.request) .catch(() => caches.match(event.request)) ) } ``` ### Offline Support **Offline Queue:** ```typescript // Queue scans when offline if (!navigator.onLine) { await db.offlineQueue.add({ type: 'scan', barcode: '123456', timestamp: Date.now() }) } // Sync when online window.addEventListener('online', async () => { const queue = await db.offlineQueue.getAll() for (const item of queue) { await processQueueItem(item) } }) ``` --- ## 🎨 Frontend Architecture ### Component Structure ``` app/ ├── components/ │ ├── inventory/ │ │ ├── ItemList.vue # Main inventory view │ │ ├── ItemCard.vue # Single item card │ │ ├── QuickActions.vue # Consume, restock buttons │ │ └── FilterBar.vue # Search & filter │ ├── scan/ │ │ ├── BarcodeScanner.vue # Camera + detection │ │ ├── ScanButton.vue # Floating action button │ │ └── ProductCard.vue # After scan confirmation │ ├── tags/ │ │ ├── TagPicker.vue # Multi-select tags │ │ ├── TagBadge.vue # Display tag │ │ └── TagManager.vue # Settings: manage tags │ ├── units/ │ │ ├── UnitSelector.vue # Dropdown with conversions │ │ └── UnitDisplay.vue # Show quantity + unit │ └── common/ │ ├── AppHeader.vue │ ├── AppFooter.vue │ └── LoadingState.vue ├── composables/ │ ├── useBarcode.ts # Camera, detection logic │ ├── useInventory.ts # CRUD operations │ ├── useUnits.ts # Conversions │ ├── useTags.ts # Tag filtering │ └── useOpenFoodFacts.ts # API wrapper ├── pages/ │ ├── index.vue # Inventory list │ ├── scan.vue # Full-screen scanner │ ├── item/[id].vue # Item detail/edit │ └── settings.vue # Tags, units, profile └── utils/ ├── conversions.ts # Unit conversion math ├── barcode-formats.ts # Supported formats └── offline-queue.ts # Offline sync ``` ### State Management **No Vuex/Pinia needed (for MVP):** - Supabase Realtime = reactive state - Composables for shared logic - Props/emits for component state **If needed later:** - Pinia for complex UI state (filters, etc.) --- ## 🔧 Development Workflow ### Local Development ```bash # Terminal 1: Supabase docker-compose -f docker/docker-compose.dev.yml up # Terminal 2: Nuxt dev server cd app bun run dev # Access: # - App: http://localhost:3000 # - Supabase Studio: http://localhost:54323 ``` ### Database Migrations ```bash # Create migration supabase migration new add_tags_table # Apply locally supabase db reset # Push to production supabase db push ``` ### Testing ```bash # Unit tests bun test # E2E tests (Playwright) bun run test:e2e # Type check bun run typecheck ``` --- ## 🚀 Deployment ### Docker Compose (Production) ```yaml services: supabase: image: supabase/postgres:15 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data supabase-auth: image: supabase/gotrue environment: SITE_URL: ${SITE_URL} # OIDC config (optional) pantry: build: ./app environment: SUPABASE_URL: http://supabase:8000 SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY} ports: - "3000:3000" ``` ### Coolify Deployment **Service Config:** - Type: Docker Compose - Repo: pantry-app/pantry - Compose File: `docker/docker-compose.yml` - Domain: `pantry.yourdomain.com` --- ## 📊 Performance Considerations ### Image Optimization - Open Food Facts images cached locally - Nuxt Image module for responsive images - WebP format with fallbacks ### Database Indexing ```sql -- Fast barcode lookup CREATE INDEX idx_products_barcode ON products(barcode); -- Fast tag filtering CREATE INDEX idx_item_tags_tag ON item_tags(tag_id); -- Fast user queries CREATE INDEX idx_items_added_by ON inventory_items(added_by); ``` ### Realtime Optimization - Only subscribe to user's inventory (RLS filters) - Debounce updates (don't broadcast every keystroke) --- ## 🔮 Future Considerations ### Scalability - **Database:** Postgres scales to millions of rows - **Storage:** Images stored in Supabase Storage (S3-compatible) - **Caching:** Redis for product lookup cache (if Open Food Facts is slow) ### Multi-Tenancy (v2.0+) - Add `household_id` to all tables - RLS policies filter by household - Invite system for family members ### Native Mobile App (v2.0+) - Capacitor wrapper around Nuxt - Native barcode scanner (faster than PWA) - Push notifications (expiry alerts) --- ## 📚 References - [Supabase Docs](https://supabase.com/docs) - [Nuxt 4 Docs](https://nuxt.com) - [Open Food Facts API](https://wiki.openfoodfacts.org/API) - [html5-qrcode](https://github.com/mebjas/html5-qrcode) --- **Next:** [Database Schema](./DATABASE.md)