Some checks failed
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
Organized docs into logical subdirectories:
**New Structure:**
- docs/
- README.md (index with quick links)
- PROJECT_PLAN.md (root level - main roadmap)
- development/
- getting-started.md (5-min quickstart)
- local-setup.md (detailed Docker Compose guide)
- workflow.md (daily development)
- git-workflow.md (branching strategy)
- architecture/
- overview.md (tech stack, design)
- database.md (schema, RLS, migrations)
- api.md (endpoints, functions)
- deployment/
- production.md (Docker, Coolify)
- ci-cd.md (automated pipelines)
**Cleaned Up:**
- Moved DEV_SETUP.md → docs/development/local-setup.md
- Removed outdated SETUP.md (referenced old Coolify setup)
- Replaced with getting-started.md (current Docker Compose flow)
- Updated README.md links to new structure
All paths tested, no broken links.
16 KiB
16 KiB
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
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
- Items ↔ Products: Many-to-one (multiple items from same product)
- Items ↔ Tags: Many-to-many (items can have multiple tags)
- Units ↔ Units: Self-referential (g → kg conversion)
- Items → Users: Audit trail (who added)
🔐 Security Model
Authentication
Supabase Auth (GoTrue)
Email/Password (Default):
const { user, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'secure-password'
})
OIDC (Optional - Admin Config):
# 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:
-- 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):
-- 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):
-- 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:
// 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:
// 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):
// 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:
// 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:
// 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
# 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
# Create migration
supabase migration new add_tags_table
# Apply locally
supabase db reset
# Push to production
supabase db push
Testing
# Unit tests
bun test
# E2E tests (Playwright)
bun run test:e2e
# Type check
bun run typecheck
🚀 Deployment
Docker Compose (Production)
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
-- 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_idto 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
Next: Database Schema