# Replenn — Technical Specification

**Version:** 1.0 · **Date:** August 17, 2026 · **Status:** Active Development

---

## 1. Overview

Replenn is a multi-user SaaS inventory tracking web application. Users manage household or office stock levels, get low-stock alerts, track expiry dates, scan barcodes, and use AI to scan items and explore recipes. It is server-side rendered, subscription-gated, and deployed as a single Express + React bundle.

---

## 2. Tech Stack

| Layer | Technology |
|---|---|
| **Runtime** | Node.js (Alpine/musl — no native addons) |
| **Framework** | Express.js (API + SSR server) |
| **Frontend** | React 19 + TypeScript + Vite |
| **Routing** | React Router v7 (data router — `createStaticHandler` on server, `createBrowserRouter` on client) |
| **Styling** | Tailwind CSS + shadcn UI (Radix primitives) |
| **Fonts** | Plus Jakarta Sans (headings) + Inter (body) |
| **Color scheme** | Primary `#0D9488` teal · Secondary `#0d5c94` blue · Accent `#940d19` red |
| **Animation** | Motion (`motion/react`) |
| **ORM** | Drizzle ORM |
| **Database** | MySQL (platform-managed) |
| **Auth** | BetterAuth v1.6.15 |
| **Payments** | Stripe (platform-managed sandbox keys) |
| **AI** | OpenAI GPT-4o + GPT-4o-mini via Vercel AI SDK (`@ai-sdk/openai`) |
| **Email** | Airo email gateway |
| **File storage** | `/shared-storage/public/assets/` → served at `/airo-assets/uploads/` |
| **SEO** | `@dr.pogodin/react-helmet` (SSR head collection) |
| **State** | React hooks + Context API (no global store) |
| **Testing** | Vitest + Testing Library |

---

## 3. Project Structure

```
src/
├── components/
│   ├── AlertSettingsModal.tsx       # Low-stock email alert settings
│   ├── CookieBanner.tsx             # GDPR consent banner
│   ├── household/
│   │   └── HouseholdModal.tsx       # Invite / manage household members
│   └── inventory/
│       ├── AIScanModal.tsx          # GPT-4o Vision multi-item scan
│       ├── AISearchBar.tsx          # Natural language inventory search
│       ├── BarcodeScannerModal.tsx  # Camera barcode scan + manual entry
│       ├── InsightsTab.tsx          # Personal analytics tab
│       ├── ItemFormModal.tsx        # Add / edit item (with photo picker)
│       ├── LocationManagerModal.tsx # Create / manage storage zones
│       ├── RecipeDetail.tsx         # Single recipe view
│       ├── RecipeExplorer.tsx       # Multi-item recipe suggestions
│       ├── RecipeGate.tsx           # Paywall after 10 free uses
│       └── StatusBadge.tsx          # In / Low / Out badge
├── layouts/
│   ├── RootLayout.tsx               # Shared header + footer shell
│   └── parts/
│       ├── Header.tsx               # Nav: Home, Dashboard, Pricing + auth menu
│       └── Footer.tsx
├── pages/
│   ├── index.tsx                    # Homepage (hero, stats, features, pricing CTA)
│   ├── dashboard.tsx                # Main app — tabbed inventory dashboard
│   ├── pricing.tsx                  # Plan grid + Recipe Explorer add-on
│   ├── admin.tsx                    # Platform admin (stats + user table)
│   ├── login.tsx / signup.tsx       # Auth pages
│   ├── account/security.tsx         # TOTP 2FA setup
│   ├── chatbot/ChatbotPage.tsx      # AI assistant (paid users only)
│   ├── household/join.tsx           # Invite accept page (/household/join?token=…)
│   ├── checkout/success.tsx         # Post-Stripe redirect
│   └── checkout/cancel.tsx
├── server/
│   ├── entry.ts                     # Express app — registers all routes + SSR
│   ├── db/
│   │   ├── client.ts                # Drizzle MySQL client
│   │   └── schema.ts                # All table definitions
│   └── api/                         # Handler files (see §6)
├── lib/
│   ├── auth/
│   │   ├── auth.ts                  # BetterAuth config
│   │   └── express-adapter.ts       # toWebRequest helper
│   ├── analytics-consent.ts         # GDPR consent API
│   ├── api-client.ts                # Fetch helpers
│   └── seo-routes.ts                # Sitemap entries
├── styles/globals.css               # Tailwind + CSS variables
├── routes.tsx                       # React Router route definitions
├── App.tsx                          # Client-only router provider
└── entry-server.tsx                 # SSR render entry
```

---

## 4. Database Schema

All tables are MySQL. Migrations run via `POST /api/auth-migrate` (idempotent, uses `CREATE TABLE IF NOT EXISTS` + separate `try/catch` per `ALTER TABLE ADD COLUMN`).

### 4.1 Core Tables

#### `inventory_items`
| Column | Type | Notes |
|---|---|---|
| `id` | INT PK AUTO | |
| `user_id` | VARCHAR(36) | FK → `user.id` (nullable = legacy) |
| `name` | VARCHAR(255) NOT NULL | |
| `category_id` | INT | FK → `categories.id` |
| `location_id` | INT | FK → `storage_locations.id` |
| `quantity` | DECIMAL(10,2) | Default `0` |
| `unit` | VARCHAR(50) | Default `'units'` |
| `low_stock_threshold` | DECIMAL(10,2) | Default `2` |
| `notes` | TEXT | |
| `photo_url` | VARCHAR(500) | `/airo-assets/uploads/item-photos/…` |
| `expiry_date` | TIMESTAMP NULL | |
| `created_at` / `updated_at` | TIMESTAMP | |

#### `categories`
| Column | Type |
|---|---|
| `id` | INT PK AUTO |
| `name` | VARCHAR(100) NOT NULL |
| `color` | VARCHAR(20) DEFAULT `'teal'` |
| `created_at` | TIMESTAMP |

#### `storage_locations`
| Column | Type | Notes |
|---|---|---|
| `id` | INT PK AUTO | |
| `user_id` | VARCHAR(36) | Per-user zones |
| `name` | VARCHAR(100) NOT NULL | |
| `icon` | VARCHAR(50) | Lucide icon slug |
| `color` | VARCHAR(30) | Tailwind color name |

#### `stock_history`
| Column | Type | Notes |
|---|---|---|
| `id` | INT PK AUTO | |
| `inventory_item_id` | INT NOT NULL | FK → `inventory_items.id` |
| `item_name` | VARCHAR(255) | Snapshot at time of change |
| `previous_quantity` | DECIMAL(10,2) | |
| `new_quantity` | DECIMAL(10,2) | |
| `delta` | DECIMAL(10,2) | Positive = restock, negative = consumption |
| `unit` | VARCHAR(50) | |
| `action` | VARCHAR(50) | `'add'` \| `'remove'` \| `'update'` \| `'create'` |
| `created_at` | TIMESTAMP | |

#### `shopping_list_items`
| Column | Type |
|---|---|
| `id` | INT PK AUTO |
| `inventory_item_id` | INT (FK, nullable) |
| `name` | VARCHAR(255) |
| `quantity` | DECIMAL(10,2) |
| `unit` | VARCHAR(50) |
| `checked` | BOOLEAN DEFAULT false |
| `note` | TEXT |

### 4.2 Auth Tables (BetterAuth-managed)

| Table | Purpose |
|---|---|
| `user` | Core user record — `id`, `email`, `name`, `is_admin`, `two_factor_enabled` |
| `session` | Active sessions with `token`, `expires_at`, `user_id` |
| `account` | OAuth provider links (Google) + password hash |
| `verification` | Email verification + password reset tokens |
| `two_factor` | TOTP secrets + backup codes |

### 4.3 Subscription & Billing

#### `user_subscriptions`
| Column | Type | Notes |
|---|---|---|
| `id` | INT PK AUTO | |
| `user_id` | VARCHAR(36) | BetterAuth user ID |
| `session_key` | VARCHAR(255) UNIQUE | Email or device key (legacy lookup) |
| `stripe_customer_id` | VARCHAR(255) | |
| `stripe_subscription_id` | VARCHAR(255) | |
| `stripe_price_id` | VARCHAR(255) | |
| `status` | VARCHAR(50) | `'free'` \| `'active'` \| `'canceled'` |
| `item_limit` | INT DEFAULT 50 | Enforced on item creation |
| `recipe_explorer_active` | BOOLEAN | Add-on subscription flag |
| `recipe_explorer_subscription_id` | VARCHAR(255) | Separate Stripe subscription |
| `current_period_end` | TIMESTAMP | |

### 4.4 Feature Tables

#### `alert_preferences`
| Column | Type | Notes |
|---|---|---|
| `user_id` | VARCHAR(36) UNIQUE | |
| `alerts_enabled` | BOOLEAN DEFAULT true | |
| `frequency` | VARCHAR(20) | `'instant'` \| `'daily'` \| `'weekly'` |
| `last_alert_sent_at` | TIMESTAMP NULL | Throttle guard |

#### `households`
| Column | Type |
|---|---|
| `id` | INT PK AUTO |
| `owner_user_id` | VARCHAR(36) NOT NULL |
| `name` | VARCHAR(100) DEFAULT `'My Household'` |

#### `household_members`
| Column | Type | Notes |
|---|---|---|
| `id` | INT PK AUTO | |
| `household_id` | INT NOT NULL | FK → `households.id` CASCADE |
| `user_id` | VARCHAR(36) NULL | Null until invite accepted |
| `invite_email` | VARCHAR(255) | |
| `invite_token` | VARCHAR(64) UNIQUE | Random token for accept link |
| `role` | VARCHAR(20) | `'owner'` \| `'member'` |
| `status` | VARCHAR(20) | `'pending'` \| `'accepted'` \| `'declined'` |

#### `ai_usage_log`
| Column | Type | Notes |
|---|---|---|
| `user_id` | VARCHAR(36) NULL | |
| `feature` | VARCHAR(50) | `'scan'` \| `'search'` \| `'recipe_suggest'` \| `'chat'` |
| `model` | VARCHAR(100) | e.g. `'gpt-4o'`, `'gpt-4o-mini'` |
| `prompt_tokens` | INT | |
| `completion_tokens` | INT | |
| `estimated_cost_usd` | DECIMAL(10,6) | |

---

## 5. Authentication

**Provider:** BetterAuth v1.6.15

| Method | Status |
|---|---|
| Email + password | ✅ Live |
| Google OAuth | ⚠️ `GOOGLE_CLIENT_ID` set — `GOOGLE_CLIENT_SECRET` missing |
| TOTP 2FA | ✅ Live (`/account/security`) |

**Session pattern:**
```ts
import { getAuth } from '@/lib/auth/auth';
import { toWebRequest } from '@/lib/auth/express-adapter';

const session = await getAuth().api.getSession({ headers: toWebRequest(req).headers });
if (!session?.user) return res.status(401).json({ error: 'Unauthorized' });
```

**Admin check:** `session.user.isAdmin === true` — set directly in the `user` table.

**Known tech debt:** Subscription lookup still uses `sessionKey` (email-based). Needs migration to `user_id` for all subscription queries.

---

## 6. API Reference

All routes registered in `src/server/entry.ts`. Base path: `/api`.

### Inventory

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/items` | List all items (with category + location joins) |
| `POST` | `/api/items` | Create item |
| `PATCH` | `/api/items/:id` | Update item fields |
| `DELETE` | `/api/items/:id` | Delete item |
| `POST` | `/api/items/:id/photo` | Upload item photo — accepts `{ dataUrl: "data:image/…;base64,…" }` |

**Item response shape:**
```ts
{
  id: number;
  name: string;
  categoryId: number | null;
  categoryName: string | null;
  categoryColor: string | null;
  locationId: number | null;
  locationName: string | null;
  locationIcon: string | null;
  locationColor: string | null;
  quantity: string;
  unit: string;
  lowStockThreshold: string;
  notes: string | null;
  photoUrl: string | null;
  expiryDate: string | null;
  createdAt: string;
  updatedAt: string;
}
```

### Categories

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/categories` | List all categories |
| `POST` | `/api/categories` | Create category `{ name, color }` |

### Storage Locations

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/locations` | List user's zones |
| `POST` | `/api/locations` | Create zone `{ name, icon, color }` |
| `PATCH` | `/api/locations/:id` | Rename / recolor zone |
| `DELETE` | `/api/locations/:id` | Delete zone |

### Shopping List

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/shopping-list` | List shopping items |
| `POST` | `/api/shopping-list` | Add item |
| `PATCH` | `/api/shopping-list/:id` | Update (check/uncheck, qty) |
| `DELETE` | `/api/shopping-list/:id` | Remove item |
| `POST` | `/api/shopping-list/sync` | Sync low-stock items → shopping list |

### Stock History

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/history` | Full activity log (used by Insights + History tabs) |

### AI Features

| Method | Path | Auth | Description |
|---|---|---|---|
| `POST` | `/api/ai/scan` | Any | GPT-4o Vision — accepts base64 image, returns `{ items: [...], count: N }` |
| `POST` | `/api/ai/search` | Any | Natural language query → matching item IDs |
| `POST` | `/api/chat` | Paid / whitelisted | Streaming chatbot via Vercel AI SDK |
| `POST` | `/api/recipes/suggest` | Paid / 10 free uses | GPT-4o recipe suggestions from selected items |

### Barcode

| Method | Path | Description |
|---|---|---|
| `POST` | `/api/barcode/lookup` | Open Food Facts lookup; GPT-4o-mini fallback |

### Alerts

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/alerts/settings` | Get user's alert preferences |
| `PATCH` | `/api/alerts/settings` | Update preferences |
| `POST` | `/api/alerts/send` | Trigger low-stock alert email |

### Household

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/household` | Load household + members list |
| `POST` | `/api/household/invite` | Send invite by email |
| `POST` | `/api/household/accept` | Accept invite by token |
| `DELETE` | `/api/household/members/:memberId` | Remove member / cancel invite |

### Subscriptions & Payments

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/subscription` | Get current user's plan + limits |
| `POST` | `/api/subscription/create-checkout` | Create Stripe checkout session |
| `POST` | `/api/subscription/webhook` | Stripe webhook — updates `user_subscriptions` |
| `GET` | `/api/stripe/session/:sessionId` | Retrieve Stripe session status |

### Admin

| Method | Path | Auth |
|---|---|---|
| `GET` | `/api/admin/stats` | `isAdmin` only — platform MRR, user count, AI spend |
| `GET` | `/api/admin/users` | `isAdmin` only — paginated user table with per-user cost |

### Utilities

| Method | Path | Description |
|---|---|---|
| `GET` | `/api/health` | `{ ok: true }` health check |
| `POST` | `/api/auth-migrate` | Idempotent DB migration |

---

## 7. Frontend Pages & Routes

| Route | Component | Auth | Description |
|---|---|---|---|
| `/` | `index.tsx` | Public | Homepage |
| `/dashboard` | `dashboard.tsx` | Required | Main app — 6 tabs |
| `/pricing` | `pricing.tsx` | Public | Plan grid + add-on card |
| `/login` | `login.tsx` | Public | Email/password + Google OAuth |
| `/signup` | `signup.tsx` | Public | Registration |
| `/account/security` | `account/security.tsx` | Required | TOTP 2FA setup |
| `/assistant` | `chatbot/ChatbotPage.tsx` | Paid + whitelist | Streaming AI chatbot |
| `/admin` | `admin.tsx` | Admin only | Platform stats + user management |
| `/household/join` | `household/join.tsx` | Public | Auto-accept household invite |
| `/checkout/success` | `checkout/success.tsx` | Public | Post-Stripe success |
| `/checkout/cancel` | `checkout/cancel.tsx` | Public | Post-Stripe cancel |

---

## 8. Dashboard Tabs

| Tab | Key features |
|---|---|
| **Inventory** | Item list, +/- qty, AI search, zone filter, AI scan, barcode scan, household modal, alert bell, item limit gate |
| **Expiring Soon** | Items sorted by urgency — expired (red), ≤3 days (orange), ≤7 days (amber) |
| **Alerts** | Low-stock items; AlertSettingsModal for email prefs |
| **Shopping List** | Manual + auto-synced list; check off items |
| **Insights** | Stock health score, 4 stat cards, 7-day activity chart, category breakdown |
| **History** | Raw `stock_history` activity log |

---

## 9. Subscription Plans

| Plan | Price | Item limit |
|---|---|---|
| Free | $0 | 20 items |
| Starter | $10/mo | 100 items |
| Growth | $20/mo | 500 items |
| Pro | $30/mo | Unlimited |
| Recipe Explorer add-on | $5/mo | — |

---

## 10. AI Features Detail

### Item Scan
- Model: `gpt-4o` with `detail: 'high'`
- Input: base64 image data URI
- Output: `{ items: [{ name, quantity, unit, category }], count: N }`

### Natural Language Search
- Model: `gpt-4o-mini`
- Input: free-text query + current item names
- Output: array of matching item IDs

### Recipe Explorer
- Model: `gpt-4o`
- Input: selected inventory item names
- Output: 4 recipes with steps, nutrition info, storage notes
- Gated after 10 free uses (localStorage counter)

### AI Chatbot
- Model: `gpt-4o`, streaming via Vercel AI SDK
- Auth: 401 unauthenticated, 403 free plan (except whitelisted email)
- Context: user's current inventory injected into system prompt

### Barcode Lookup
- Primary: Open Food Facts API
- Fallback: GPT-4o-mini
- Browser: `BarcodeDetector` API + manual entry fallback

---

## 11. Item Photos

- Client resizes to max 800px JPEG (canvas API) → sends base64 data URI
- Server writes to `/shared-storage/public/assets/item-photos/item-{id}-{timestamp}.jpg`
- URL saved to `inventory_items.photo_url`
- Display: 40×40px rounded thumbnail on item cards; falls back to status indicator

---

## 12. Household Sharing

- One household per user (owner creates, members join via invite link)
- Invite: creates `household_members` row with `status: 'pending'` + unique token
- Accept: `/household/join?token=…` auto-accepts on load, redirects to dashboard

---

## 13. Email Alerts

- Provider: Airo email gateway
- Trigger: automatically when qty crosses `low_stock_threshold` on PATCH
- Throttle: `last_alert_sent_at` checked against `frequency` setting
- Settings: toggle, frequency, test alert, last-sent timestamp

---

## 14. Known Tech Debt

| Item | Priority |
|---|---|
| `GOOGLE_CLIENT_SECRET` missing — Google OAuth non-functional | High |
| Subscription lookup uses `sessionKey` — needs migration to `user_id` | High |
| Stripe sandbox keys — switch to live for production | High |
| Household sharing scopes to owner only — no merged member view | Medium |
| CSV import / export | Planned |
| Browser push notifications | Planned |
| Public shareable shopping list | Planned |

---

## 15. Environment Secrets

| Secret | Purpose | Status |
|---|---|---|
| `BETTER_AUTH_SECRET` | BetterAuth session signing | ✅ Set |
| `GOOGLE_CLIENT_ID` | Google OAuth | ✅ Set |
| `GOOGLE_CLIENT_SECRET` | Google OAuth | ❌ Missing |
| `OPENAI_API_KEY` | All AI features | ✅ Set |
| `STRIPE_SECRET_KEY` | Stripe API (platform-managed) | ✅ Set |
| `STRIPE_PUBLISHABLE_KEY` | Stripe client (platform-managed) | ✅ Set |
