Compare commits

...

36 Commits

Author SHA1 Message Date
Nico
77ae5be445 refactor
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 1m10s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 3s
Build & Deploy Costco Grocery List / deploy (push) Successful in 11s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
2026-02-22 01:27:03 -08:00
Nico
ee94853084 fix(list): restore added-by attribution with display name fallback
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 1m7s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 3s
Build & Deploy Costco Grocery List / deploy (push) Successful in 12s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
2026-02-21 00:07:22 -08:00
Nico
3dd58f51e8 fix(ui): use bounded member dropdown in assign-item modal 2026-02-21 00:07:17 -08:00
Nico
beb9cdcec7 fix(invites): lock invite row without outer join update error 2026-02-21 00:07:11 -08:00
Nico
9fa48e6eb3 feat: support assigning grocery items to other household members 2026-02-20 23:33:22 -08:00
Nico
a1beb486cb changed dev frontend port 2026-02-18 14:53:29 -08:00
Nico
d62564fd0d refactor: streamline navbar and settings tab cues 2026-02-18 14:52:41 -08:00
Nico
c1259f0bf5 fix: recover when sessions table is missing 2026-02-18 14:52:35 -08:00
Nico
c3c0c33339 fix: harden auth inputs, throttling, and debug exposure 2026-02-18 12:24:15 -08:00
Nico
3469284e98 docs: add project state audit and execution plan 2026-02-16 01:49:44 -08:00
Nico
aa9488755f feat: enable cookie auth flow and database url runtime config 2026-02-16 01:49:03 -08:00
Nico
119994b602 feat: add db-backed session cookie auth compatibility 2026-02-16 01:43:27 -08:00
Nico
0f9d349fa5 feat: add db migration for session storage 2026-02-16 01:40:18 -08:00
Nico
9cb0ac19e5 refactor: use safe request-scoped backend error logging 2026-02-16 01:36:39 -08:00
Nico
e2e9ec9eb4 fix: redact invite codes in logs using last4 policy 2026-02-16 01:34:09 -08:00
Nico
05ad576206 refactor: return json from health endpoints for request ids 2026-02-16 01:28:11 -08:00
Nico
16e60dcf63 refactor: align legacy list controller with sendError 2026-02-16 01:27:35 -08:00
Nico
2a9389532f fix: assign default user role on registration 2026-02-16 01:26:52 -08:00
Nico
9a73cea27d refactor: adopt sendError helper across core controllers 2026-02-16 01:26:18 -08:00
Nico
fec9f1ab25 feat: include request id in all json responses 2026-02-16 01:23:42 -08:00
Nico
a5f99ba475 fix: normalize frontend api errors and remove sensitive debug logs 2026-02-16 01:20:45 -08:00
Nico
ac92bed8a1 feat: standardize error envelope and request id propagation 2026-02-16 01:18:51 -08:00
Nico
b3f607d8f8 feat: add request id middleware for api responses 2026-02-16 01:10:26 -08:00
Nico
7fb28e659f chore: establish governance baseline and migration workflow 2026-02-16 01:09:13 -08:00
Nico
dfaab1dfcb add handling of no stores and fix app naming
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 11s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 2s
Build & Deploy Costco Grocery List / deploy (push) Successful in 5s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
2026-01-28 01:06:19 -08:00
Nico
e9b678c7be revert registry
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 11s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 2s
Build & Deploy Costco Grocery List / deploy (push) Successful in 5s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
2026-01-28 00:54:08 -08:00
Nico
872945c747 test new registry
Some checks failed
Build & Deploy Costco Grocery List / build (push) Failing after 8s
Build & Deploy Costco Grocery List / verify-images (push) Has been skipped
Build & Deploy Costco Grocery List / deploy (push) Has been skipped
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
2026-01-28 00:37:52 -08:00
Nico
78bbcde97f re-run
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 11s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 2s
Build & Deploy Costco Grocery List / deploy (push) Successful in 8s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
2026-01-28 00:19:14 -08:00
Nico
67d681114f update gitea workflow to allow for 2 different actions
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 27s
Build & Deploy Costco Grocery List / deploy (push) Successful in 4s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
2026-01-27 23:48:02 -08:00
Nico
11f23eb643 styling fix and readme files reorg 2026-01-27 00:03:58 -08:00
Nico
31eda793ab polished implementation of new artchitecture 2026-01-26 22:52:16 -08:00
Nico
213134c4a5 Included household/stores management features 2026-01-26 00:37:43 -08:00
Nico
9fc25f2274 phase 3 - create minimal hooks to tie the new architecture between backend and frontend 2026-01-25 23:23:00 -08:00
Nico
4d5d2f0f6d phase2 - get backend api modified for new implmentations and create api test 2026-01-25 01:40:18 -08:00
Nico
ccf0c39294 phase1 - implement database foundation 2026-01-25 00:18:04 -08:00
Nico
fc887bdc65 create plan for multi household 2026-01-24 23:59:11 -08:00
186 changed files with 19563 additions and 1018 deletions

View File

@ -0,0 +1,150 @@
name: Build & Deploy Costco Grocery List
on:
push:
branches: [ "main-new" ]
env:
REGISTRY: git.nicosaya.com/nalalangan/costco-grocery-list
# REGISTRY: grocery-app
IMAGE_TAG: main-new
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 20
# -------------------------
# 🔹 BACKEND TESTS
# -------------------------
- name: Install backend dependencies
working-directory: backend
run: npm ci
- name: Run backend tests
working-directory: backend
run: npm test --if-present
# -------------------------
# 🔹 Docker Login
# -------------------------
- name: Docker login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY \
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
# -------------------------
# 🔹 Build Backend Image
# -------------------------
- name: Build Backend Image
run: |
docker build \
-t $REGISTRY/backend:${{ github.sha }} \
-t $REGISTRY/backend:${{ env.IMAGE_TAG }} \
-f backend/Dockerfile backend/
- name: Push Backend Image
run: |
docker push $REGISTRY/backend:${{ github.sha }}
docker push $REGISTRY/backend:${{ env.IMAGE_TAG }}
# -------------------------
# 🔹 Build Frontend Image
# -------------------------
- name: Build Frontend Image
run: |
docker build \
-t $REGISTRY/frontend:${{ github.sha }} \
-t $REGISTRY/frontend:${{ env.IMAGE_TAG }} \
-f frontend/Dockerfile.dev frontend/
- name: Push Frontend Image
run: |
docker push $REGISTRY/frontend:${{ github.sha }}
docker push $REGISTRY/frontend:${{ env.IMAGE_TAG }}
verify-images:
needs: build
runs-on: ubuntu-latest
steps:
- name: Docker login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY \
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Verify backend image tags exist
run: |
docker manifest inspect $REGISTRY/backend:${{ github.sha }} >/dev/null
docker manifest inspect $REGISTRY/backend:${{ env.IMAGE_TAG }} >/dev/null
- name: Verify frontend image tags exist
run: |
docker manifest inspect $REGISTRY/frontend:${{ github.sha }} >/dev/null
docker manifest inspect $REGISTRY/frontend:${{ env.IMAGE_TAG }} >/dev/null
deploy:
needs: verify-images
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Install SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
# ---------------------------------------------------------
# 1. Upload docker-compose.yml to the production directory
# ---------------------------------------------------------
- name: Upload docker-compose.yml
run: |
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "mkdir -p /opt/costco-app-new"
scp docker-compose.new.yml \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/costco-app-new/docker-compose.yml
# ---------------------------------------------------------
# 2. Deploy using the uploaded compose file
# ---------------------------------------------------------
- name: Deploy via SSH
run: |
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
cd /opt/costco-app-new
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
EOF
notify:
needs: deploy
runs-on: ubuntu-latest
if: always()
steps:
- name: Notify ntfy
run: |
STATUS="${{ needs.deploy.result }}"
echo "Deployment job finished with status: $STATUS"
if [ "$STATUS" = "success" ]; then
MSG="🚀 Grocery App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}"
else
MSG="❌ Grocery App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}"
fi
curl -d "$MSG" \
https://ntfy.nicosaya.com/gitea

View File

@ -1,197 +1,21 @@
# Costco Grocery List - AI Agent Instructions # Copilot Compatibility Instructions
## Architecture Overview ## Precedence
- Source of truth: `PROJECT_INSTRUCTIONS.md` (repo root).
- Agent workflow constraints: `AGENTS.md` (repo root).
- Bugfix protocol: `DEBUGGING_INSTRUCTIONS.md` (repo root).
This is a full-stack grocery list management app with **role-based access control (RBAC)**: If any guidance in this file conflicts with the root instruction files, follow the root instruction files.
- **Backend**: Node.js + Express + PostgreSQL (port 5000)
- **Frontend**: React 19 + TypeScript + Vite (port 3000/5173)
- **Deployment**: Docker Compose with separate dev/prod configurations
### Key Design Patterns ## Current stack note
This repository is currently:
- Backend: Express (`backend/`)
- Frontend: React + Vite (`frontend/`)
**Three-tier RBAC system** (`viewer`, `editor`, `admin`): Apply architecture intent from `PROJECT_INSTRUCTIONS.md` using the current stack mapping in:
- `viewer`: Read-only access to grocery lists - `docs/AGENTIC_CONTRACT_MAP.md`
- `editor`: Can add items and mark as bought
- `admin`: Full user management via admin panel
- Roles defined in [backend/models/user.model.js](backend/models/user.model.js) and mirrored in [frontend/src/constants/roles.js](frontend/src/constants/roles.js)
**Middleware chain pattern** for protected routes: ## Safety reminders
```javascript - External DB only (`DATABASE_URL`), no DB container assumptions.
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem); - No cron/worker additions unless explicitly approved.
``` - Never log secrets, receipt bytes, or full invite codes.
- `auth` middleware extracts JWT from `Authorization: Bearer <token>` header
- `requireRole` checks if user's role matches allowed roles
- See [backend/routes/list.routes.js](backend/routes/list.routes.js) for examples
**Frontend route protection**:
- `<PrivateRoute>`: Requires authentication, redirects to `/login` if no token
- `<RoleGuard allowed={[ROLES.ADMIN]}>`: Requires specific role(s), redirects to `/` if unauthorized
- Example in [frontend/src/App.jsx](frontend/src/App.jsx)
## Database Schema
**PostgreSQL server runs externally** - not in Docker Compose. Connection configured in [backend/.env](backend/.env) via standard environment variables.
**Tables** (inferred from models, no formal migrations):
- **users**: `id`, `username`, `password` (bcrypt hashed), `name`, `role`
- **grocery_list**: `id`, `item_name`, `quantity`, `bought`, `added_by`
- **grocery_history**: Junction table tracking which users added which items
**Important patterns**:
- No migration system - schema changes are manual SQL
- Items use case-insensitive matching (`ILIKE`) to prevent duplicates
- JOINs with `ARRAY_AGG` for multi-contributor queries (see [backend/models/list.model.js](backend/models/list.model.js))
## Development Workflow
### Local Development
```bash
# Start all services with hot-reload against LOCAL database
docker-compose -f docker-compose.dev.yml up
# Backend runs nodemon (watches backend/*.js)
# Frontend runs Vite dev server with HMR on port 3000
```
**Key dev setup details**:
- Volume mounts preserve `node_modules` in containers while syncing source code
- Backend uses `Dockerfile` (standard) with `npm run dev` override
- Frontend uses `Dockerfile.dev` with `CHOKIDAR_USEPOLLING=true` for file watching
- Both connect to **external PostgreSQL server** (configured in `backend/.env`)
- No database container in compose - DB is managed separately
### Production Build
```bash
# Local production build (for testing)
docker-compose -f docker-compose.prod.yml up --build
# Actual production uses pre-built images
docker-compose up # Pulls from private registry
```
### CI/CD Pipeline (Gitea Actions)
See [.gitea/workflows/deploy.yml](.gitea/workflows/deploy.yml) for full workflow:
**Build stage** (on push to `main`):
1. Run backend tests (`npm test --if-present`)
2. Build backend image with tags: `:latest` and `:<commit-sha>`
3. Build frontend image with tags: `:latest` and `:<commit-sha>`
4. Push both images to private registry
**Deploy stage**:
1. SSH to production server
2. Upload `docker-compose.yml` to deployment directory
3. Pull latest images and restart containers with `docker compose up -d`
4. Prune old images
**Notify stage**:
- Sends deployment status via webhook
**Required secrets**:
- `REGISTRY_USER`, `REGISTRY_PASS`: Docker registry credentials
- `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_KEY`: SSH deployment credentials
### Backend Scripts
- `npm run dev`: Start with nodemon
- `npm run build`: esbuild compilation + copy public assets to `dist/`
- `npm test`: Run Jest tests (currently no tests exist)
### Frontend Scripts
- `npm run dev`: Vite dev server (port 5173)
- `npm run build`: TypeScript compilation + Vite production build
### Docker Configurations
**docker-compose.yml** (production):
- Pulls pre-built images from private registry
- Backend on port 5000, frontend on port 3000 (nginx serves on port 80)
- Requires `backend.env` and `frontend.env` files
**docker-compose.dev.yml** (local development):
- Builds images locally from Dockerfile/Dockerfile.dev
- Volume mounts for hot-reload: `./backend:/app` and `./frontend:/app`
- Named volumes preserve `node_modules` between rebuilds
- Backend uses `backend/.env` directly
- Frontend uses `Dockerfile.dev` with polling enabled for cross-platform compatibility
**docker-compose.prod.yml** (local production testing):
- Builds images locally using production Dockerfiles
- Backend: Standard Node.js server
- Frontend: Multi-stage build with nginx serving static files
## Configuration & Environment
**Backend** ([backend/.env](backend/.env)):
- Database connection variables (host, user, password, database name)
- `JWT_SECRET`: Token signing key
- `ALLOWED_ORIGINS`: Comma-separated CORS whitelist (supports static origins + `192.168.*.*` IP ranges)
- `PORT`: Server port (default 5000)
**Frontend** (environment variables):
- `VITE_API_URL`: Backend base URL
**Config accessed via**:
- Backend: `process.env.VAR_NAME`
- Frontend: `import.meta.env.VITE_VAR_NAME` (see [frontend/src/config.ts](frontend/src/config.ts))
## Authentication Flow
1. User logs in → backend returns `{token, role, username}` ([backend/controllers/auth.controller.js](backend/controllers/auth.controller.js))
2. Frontend stores in `localStorage` and `AuthContext` ([frontend/src/context/AuthContext.jsx](frontend/src/context/AuthContext.jsx))
3. Axios interceptor auto-attaches `Authorization: Bearer <token>` header ([frontend/src/api/axios.js](frontend/src/api/axios.js))
4. Backend validates JWT on protected routes ([backend/middleware/auth.js](backend/middleware/auth.js))
5. On 401 "Invalid or expired token" response, frontend clears storage and redirects to login
## Critical Conventions
### Security Practices
- **Never expose credentials**: Do not hardcode or document actual values for `JWT_SECRET`, database passwords, API keys, or any sensitive configuration
- **No infrastructure details**: Avoid documenting specific IP addresses, domain names, deployment paths, or server locations in code or documentation
- **Environment variables**: Reference `.env` files conceptually - never include actual contents
- **Secrets in CI/CD**: Document that secrets are required, not their values
- **Code review**: Scan all changes for accidentally committed credentials before pushing
### Backend
- **No SQL injection**: Always use parameterized queries (`$1`, `$2`, etc.) with [backend/db/pool.js](backend/db/pool.js)
- **Password hashing**: Use `bcryptjs` for hashing (see [backend/controllers/auth.controller.js](backend/controllers/auth.controller.js))
- **CORS**: Dynamic origin validation in [backend/app.js](backend/app.js) allows configured origins + local IPs
- **Error responses**: Return JSON with `{message: "..."}` structure
### Frontend
- **Mixed JSX/TSX**: Some components are `.jsx` (JavaScript), others `.tsx` (TypeScript) - maintain existing file extensions
- **API calls**: Use centralized `api` instance from [frontend/src/api/axios.js](frontend/src/api/axios.js), not raw axios
- **Role checks**: Access role from `AuthContext`, compare with constants from [frontend/src/constants/roles.js](frontend/src/constants/roles.js)
- **Navigation**: Use React Router's `<Navigate>` for redirects, not `window.location` (except in interceptor)
## Common Tasks
**Add a new protected route**:
1. Backend: Add route with `auth` + `requireRole(...)` middleware
2. Frontend: Add route in [frontend/src/App.jsx](frontend/src/App.jsx) wrapped in `<PrivateRoute>` and/or `<RoleGuard>`
**Access user info in backend controller**:
```javascript
const { id, role } = req.user; // Set by auth middleware
```
**Query grocery items with contributors**:
Use the JOIN pattern in [backend/models/list.model.js](backend/models/list.model.js) - aggregates user names via `grocery_history` table.
## Testing
**Backend**:
- Jest configured at root level ([package.json](package.json))
- Currently **no test files exist** - testing infrastructure needs development
- CI/CD runs `npm test --if-present` but will pass if no tests found
- Focus area: API endpoint testing (use `supertest` with Express)
**Frontend**:
- ESLint only (see [frontend/eslint.config.js](frontend/eslint.config.js))
- No test runner configured
- Manual testing workflow in use
**To add backend tests**:
1. Create `backend/__tests__/` directory
2. Use Jest + Supertest pattern for API tests
3. Mock database calls or use test database

4
.gitignore vendored
View File

@ -7,6 +7,10 @@ node_modules/
# Build output (if using a bundler or React later) # Build output (if using a bundler or React later)
dist/ dist/
build/ build/
playwright-report/
test-results/
.npm-cache/
.playwright-browsers/
# Logs # Logs
npm-debug.log* npm-debug.log*

View File

@ -0,0 +1 @@
[]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,12 @@
2026-02-19 01:30:31.762 [error] Error: Unable to create or open registry key
at Object.setDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\storage.js:82:25)
at Module.getDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\devdeviceid.js:46:23)
at async U9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11451)
at async F9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11886)
at async e7.f (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:50696)
at async e7.run (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:49285)
at async Module.t7 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:53186)
at async kn (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/cli.js:29:16018)
2026-02-19 01:30:31.767 [info] CLI main {"_":[],"diff":false,"merge":false,"add":false,"remove":false,"goto":false,"new-window":false,"reuse-window":false,"wait":false,"user-data-dir":".vscode-user","help":false,"extensions-dir":".vscode-extensions","list-extensions":true,"show-versions":false,"pre-release":false,"update-extensions":false,"version":false,"verbose":false,"status":false,"prof-startup":false,"no-cached-data":false,"prof-v8-extensions":false,"disable-extensions":false,"disable-lcd-text":false,"disable-gpu":false,"disable-chromium-sandbox":false,"sandbox":false,"telemetry":false,"debugRenderer":false,"enable-smoke-test-driver":false,"logExtensionHostCommunication":false,"skip-release-notes":false,"skip-welcome":false,"disable-telemetry":false,"disable-updates":false,"transient":false,"use-inmemory-secretstorage":false,"disable-workspace-trust":false,"disable-crash-reporter":false,"skip-add-to-recently-opened":false,"open-url":false,"file-write":false,"file-chmod":false,"force":false,"do-not-sync":false,"do-not-include-pack-dependencies":false,"trace":false,"trace-memory-infra":false,"preserve-env":false,"force-user-env":false,"force-disable-user-env":false,"open-devtools":false,"disable-gpu-sandbox":false,"__enable-file-policy":false,"enable-coi":false,"enable-rdp-display-tracking":false,"disable-layout-restore":false,"disable-experiments":false,"no-proxy-server":false,"no-sandbox":false,"nolazy":false,"force-renderer-accessibility":false,"ignore-certificate-errors":false,"allow-insecure-localhost":false,"disable-dev-shm-usage":false,"profile-temp":false,"logsPath":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-user\\logs\\20260219T013031"}
2026-02-19 01:30:31.783 [info] Started initializing default profile extensions in extensions installation folder. file:///c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions
2026-02-19 01:30:31.817 [info] Completed initializing default profile extensions in extensions installation folder. file:///c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions

View File

@ -0,0 +1,14 @@
2026-02-19 01:30:39.254 [error] Error: Unable to create or open registry key
at Object.setDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\storage.js:82:25)
at Module.getDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\devdeviceid.js:46:23)
at async U9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11451)
at async F9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11886)
at async e7.f (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:50696)
at async e7.run (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:49285)
at async Module.t7 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:53186)
at async kn (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/cli.js:29:16018)
2026-02-19 01:30:39.259 [info] CLI main {"_":[],"diff":false,"merge":false,"add":false,"remove":false,"goto":false,"new-window":false,"reuse-window":false,"wait":false,"user-data-dir":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-user","help":false,"extensions-dir":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-extensions","list-extensions":false,"show-versions":false,"install-extension":["ritwickdey.LiveServer"],"pre-release":false,"update-extensions":false,"version":false,"verbose":false,"status":false,"prof-startup":false,"no-cached-data":false,"prof-v8-extensions":false,"disable-extensions":false,"disable-lcd-text":false,"disable-gpu":false,"disable-chromium-sandbox":false,"sandbox":false,"telemetry":false,"debugRenderer":false,"enable-smoke-test-driver":false,"logExtensionHostCommunication":false,"skip-release-notes":false,"skip-welcome":false,"disable-telemetry":false,"disable-updates":false,"transient":false,"use-inmemory-secretstorage":false,"disable-workspace-trust":false,"disable-crash-reporter":false,"skip-add-to-recently-opened":false,"open-url":false,"file-write":false,"file-chmod":false,"force":false,"do-not-sync":false,"do-not-include-pack-dependencies":false,"trace":false,"trace-memory-infra":false,"preserve-env":false,"force-user-env":false,"force-disable-user-env":false,"open-devtools":false,"disable-gpu-sandbox":false,"__enable-file-policy":false,"enable-coi":false,"enable-rdp-display-tracking":false,"disable-layout-restore":false,"disable-experiments":false,"no-proxy-server":false,"no-sandbox":false,"nolazy":false,"force-renderer-accessibility":false,"ignore-certificate-errors":false,"allow-insecure-localhost":false,"disable-dev-shm-usage":false,"profile-temp":false,"logsPath":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-user\\logs\\20260219T013038"}
2026-02-19 01:30:40.046 [info] Getting Manifest... ritwickdey.liveserver
2026-02-19 01:30:40.071 [info] Installing extension: ritwickdey.liveserver {"isMachineScoped":false,"installPreReleaseVersion":false,"donotIncludePackAndDependencies":false,"profileLocation":{"$mid":1,"external":"vscode-userdata:/c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions/extensions.json","path":"/C:/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions/extensions.json","scheme":"vscode-userdata"},"isBuiltin":false,"installGivenVersion":false,"isApplicationScoped":false,"productVersion":{"version":"1.109.2","date":"2026-02-10T20:18:23.520Z"}}
2026-02-19 01:30:40.581 [info] Extension signature verification result for ritwickdey.liveserver: UnknownError. Executed: false. Duration: 5ms.
2026-02-19 01:30:40.599 [error] Error while installing the extension ritwickdey.liveserver Signature verification failed with 'UnknownError' error. vscode-userdata:/c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions/extensions.json

1
.vscode-user/machineid Normal file
View File

@ -0,0 +1 @@
490a70bb-d2b1-490e-9046-37c8a08b0270

55
AGENTS.md Normal file
View File

@ -0,0 +1,55 @@
# AGENTS.md - Fiddy (External DB)
## Authority
- Source of truth: `PROJECT_INSTRUCTIONS.md` (repo root). If conflict, follow it.
- Bugfix protocol: `DEBUGGING_INSTRUCTIONS.md` (repo root).
- Do not implement features unless required to fix the bug.
## Non-negotiables
- External DB: `DATABASE_URL` points to on-prem Postgres (NOT a container).
- Dev/Prod share schema via migrations in `packages/db/migrations`.
- No cron/worker jobs. Fixes must work without background tasks.
- Server-side RBAC only. Client checks are UX only.
## Security / logging (hard rules)
- Never log secrets (passwords/tokens/cookies).
- Never log receipt bytes.
- Never log full invite codes; logs/audit store last4 only.
## Non-regression contracts
- Sessions are DB-backed (`sessions` table) and cookies are HttpOnly.
- Receipt images stored in `receipts` (`bytea`).
- Entries list endpoints must NEVER return receipt bytes.
- API responses must include `request_id`; audit logs must include `request_id`.
- Frontend actions that manipulate database state must show a toast/bubble notification with basic outcome info (action + target + success/failure).
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`).
## Architecture boundaries (follow existing patterns; do not invent)
1) API routes: `app/api/**/route.ts`
- Thin: parse/validate + call service, return JSON.
2) Server services: `lib/server/*`
- Own DB + authz. Must include `import "server-only";`.
3) Client wrappers: `lib/client/*`
- Typed fetch + error normalization; always send credentials.
4) Hooks: `hooks/use-*.ts`
- Primary UI-facing API layer; components avoid raw `fetch()`.
## Next.js dynamic route params (required)
- In `app/api/**/[param]/route.ts`, treat `context.params` as async:
- `const { id } = await context.params;`
## Working style
- Scan repo first; do not guess file names or patterns.
- Make the smallest change that resolves the issue.
- Keep touched files free of TS warnings and lint errors.
- Add/update tests when API behavior changes (include negative cases).
- Keep text encoding clean (no mojibake).
## Response icon legend
Use the same status icons defined in `PROJECT_INSTRUCTIONS.md` section "Agent Response Legend (required)":
- `🔄` in progress
- `✅` completed
- `🧪` verification/test result
- `⚠️` risk/blocker/manual action
- `❌` failure
- `🧭` recommendation/next step

48
DEBUGGING_INSTRUCTIONS.md Normal file
View File

@ -0,0 +1,48 @@
# Debugging Instructions - Fiddy
## Scope and authority
- This file is required for bugfix work.
- `PROJECT_INSTRUCTIONS.md` remains the source of truth for global project rules.
- For debugging tasks, ship the smallest safe fix that resolves the verified issue.
## Required bugfix workflow
1. Reproduce:
- Capture exact route/page, inputs, actor role, and expected vs actual behavior.
- Record a concrete repro sequence before changing code.
2. Localize:
- Identify the failing boundary (route/controller/model/service/client wrapper/hook/ui).
- Confirm whether failure is validation, authorization, data, or rendering.
3. Fix minimally:
- Modify only the layers needed to resolve the bug.
- Do not introduce parallel mechanisms for the same state flow.
4. Verify:
- Re-run repro.
- Run lint/tests for touched areas.
- Confirm no regression against contracts in `PROJECT_INSTRUCTIONS.md`.
## Guardrails while debugging
- External DB only:
- Use `DATABASE_URL`.
- Never add a DB container for a fix.
- No background jobs:
- Do not add cron, workers, or polling daemons.
- Security:
- Never log secrets, receipt bytes, or full invite codes.
- Invite logs/audit may include only last4.
- Authorization:
- Enforce RBAC server-side; client checks are UX only.
## Contract-specific debug checks
- Auth:
- Sessions must remain DB-backed and cookie-based (HttpOnly).
- Receipts:
- List endpoints must never include receipt bytes.
- Byte retrieval must be through dedicated endpoint only.
- Request IDs/audit:
- Ensure `request_id` appears in responses and audit trail for affected paths.
## Evidence to include with every bugfix
- Root cause summary (one short paragraph).
- Changed files list with rationale.
- Verification steps performed and outcome.
- Any residual risk, fallback, or operator action.

202
PROJECT_INSTRUCTIONS.md Normal file
View File

@ -0,0 +1,202 @@
# Project Instructions - Fiddy (External DB)
## 1) Core expectation
This project connects to an **external Postgres instance (on-prem server)**. Dev and Prod must share the **same schema** through **migrations**.
## 2) Authority & doc order
1) **PROJECT_INSTRUCTIONS.md** (this file) is the source of truth.
2) **DEBUGGING_INSTRUCTIONS.md** (repo root) is required for bugfix work.
3) Other instruction files (e.g. `.github/copilot-instructions.md`) must not conflict with this doc.
If anything conflicts, follow **this** doc.
---
## 3) Non-negotiables (hard rules)
### External DB + migrations
- `DATABASE_URL` points to **on-prem Postgres** (**NOT** a container).
- Dev/Prod share schema via migrations in: `packages/db/migrations`.
- Active migration runbook: `docs/DB_MIGRATION_WORKFLOW.md` (active set + status commands).
### No background jobs
- **No cron/worker jobs**. Any fix must work without background tasks.
### Security / logging
- **Never log secrets** (passwords, tokens, session cookies).
- **Never log receipt bytes**.
- **Never log full invite codes** - logs/audit store **last4 only**.
### Server-side authorization only
- **Server-side RBAC only.** Client checks are UX only and must not be trusted.
---
## 4) Non-regression contracts (do not break)
### Auth
- Custom email/password auth.
- Sessions are **DB-backed** and stored in table `sessions`.
- Session cookies are **HttpOnly**.
### Receipts
- Receipt images are stored in Postgres `bytea` table `receipts`.
- **Entries list endpoints must never return receipt image bytes.**
- Receipt bytes are fetched only via a **separate endpoint** when inspecting a single item.
### Request IDs + audit
- API must generate a **`request_id`** and return it in responses.
- Audit logs must include `request_id`.
- Audit logs must never store full invite codes (store **last4 only**).
---
## 5) Architecture contract (Backend <-> Client <-> Hooks <-> UI)
### No-assumptions rule (required)
Before making structural changes, first scan the repo and identify:
- where `app/`, `components/`, `features/`, `hooks/`, `lib/` live
- existing API routes and helpers
- patterns already in use
Do not invent files/endpoints/conventions. If something is missing, add it **minimally** and **consistently**.
### Single mechanism rule (required)
For any cross-component state propagation concern, keep **one** canonical mechanism only:
- Context **OR** custom events **OR** cache invalidation
Do not keep old and new mechanisms in parallel. Remove superseded utilities/imports/files in the same PR.
### Layering (hard boundaries)
For every domain (auth, groups, entries, receipts, etc.) follow this flow:
1) **API Route Handlers** - `app/api/.../route.ts`
- Thin: parse/validate input, call a server service, return JSON.
- No direct DB queries in route files unless there is no existing server service.
2) **Server Services (DB + authorization)** - `lib/server/*`
- Own all DB access and authorization helpers.
- Server-only modules must include: `import "server-only";`
- Prefer small domain modules: `lib/server/auth.ts`, `lib/server/groups.ts`, `lib/server/entries.ts`, `lib/server/receipts.ts`, `lib/server/session.ts`.
3) **Client API Wrappers** - `lib/client/*`
- Typed fetch helpers only (no React state).
- Centralize fetch + error normalization.
- Always send credentials (cookies) and never trust client-side RBAC.
4) **Hooks (UI-facing API layer)** - `hooks/use-*.ts`
- Hooks are the primary interface for components/pages to call APIs.
- Components should not call `fetch()` directly unless there is a strong reason.
### API conventions
- Prefer consistent JSON error shape:
- `{ error: { code: string, message: string }, request_id?: string }`
- Validate inputs at the route boundary (shape/type), authorize in server services.
- Mirror existing REST style used in the project.
### Next.js route params checklist (required)
For `app/api/**/[param]/route.ts`:
- Treat `context.params` as **async** and `await` it before reading properties.
- Example: `const { id } = await context.params;`
### Frontend structure preference
- Prefer domain-first structure: `features/<domain>/...` + `shared/...`.
- Use `components/*` only for compatibility shims during migrations (remove them after imports are migrated).
### Maintainability thresholds (refactor triggers)
- Component files > **400 lines** should be split into container/presentational parts.
- Hook files > **150 lines** should extract helper functions/services.
- Functions with more than **3 nested branches** should be extracted.
---
## 6) Decisions / constraints (Group Settings)
- Add `GROUP_OWNER` role to group roles; migrate existing groups so the first admin becomes owner.
- Join policy default is `NOT_ACCEPTING`. Policies: `NOT_ACCEPTING`, `AUTO_ACCEPT`, `APPROVAL_REQUIRED`.
- Both owner and admins can approve join requests and manage invite links.
- Invite links:
- TTL limited to 1-7 days.
- Settings are immutable after creation (policy, single-use, etc.).
- Single-use does not override approval-required.
- Expired links are retained and can be revived.
- Single-use links are deleted after successful use.
- Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event.
- No cron/worker jobs for now (auto ownership transfer and invite rotation are paused).
- Group role icons must be consistent: owner, admin, member.
---
## 7) Do first (vertical slice)
1) DB migrate command + schema
2) Register/Login/Logout (custom sessions)
3) Protected dashboard page
4) Group create/join + group switcher (approval-based joins + optional join disable)
5) Entries CRUD (no receipt bytes in list)
6) Receipt upload/download endpoints
7) Settings + Reports
---
## 8) Definition of done
- Works via `docker-compose.dev.yml` with external DB
- Migrations applied via `npm run db:migrate`
- Tests + lint pass
- RBAC enforced server-side
- No large files
- No TypeScript warnings or lint errors in touched files
- No new cron/worker dependencies unless explicitly approved
- No orphaned utilities/hooks/contexts after refactors
- No duplicate mechanisms for the same state flow
- Text encoding remains clean in user-facing strings/docs
---
## 9) Desktop + mobile UX checklist (required)
- Touch: long-press affordance for item-level actions when no visible button.
- Mouse: hover affordance on interactive rows/cards.
- Tap targets remain >= 40px on mobile.
- Modal overlays must close on outside click/tap.
- For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure).
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency.
- Add Playwright UI tests for new UI features and critical flows.
---
## 10) Tests (required)
- Add/update tests for API behavior changes (auth, groups, entries, receipts).
- Include negative cases where applicable:
- unauthorized
- not-a-member
- invalid input
---
## 11) Agent Response Legend (required)
Use emoji/icons in agent progress and final responses so status is obvious at a glance.
Legend:
- `🔄` in progress
- `✅` completed
- `🧪` test/lint/verification result
- `📄` documentation update
- `🗄️` database or migration change
- `🚀` deploy/release step
- `⚠️` risk, blocker, or manual operator action needed
- `❌` failed command or unsuccessful attempt
- `` informational context
- `🧭` recommendation or next-step option
Usage rules:
- Include at least one status icon in each substantive agent response.
- Use one icon per bullet/line; avoid icon spam.
- Keep icon meaning consistent with this legend.
---
## 12) Commit Discipline (required)
- Commit in small, logical slices (no broad mixed-purpose commits).
- Each commit must:
- follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
- include only related files for that slice
- exclude secrets, credentials, and generated noise
- Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas).
- Prefer frequent checkpoint commits during agentic work rather than one large end-state commit.
- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code).

11
backend/.env.example Normal file
View File

@ -0,0 +1,11 @@
DATABASE_URL=postgres://username:password@db-host:5432/database_name
DB_USER=
DB_PASS=
DB_HOST=
DB_PORT=5432
DB_NAME=
PORT=5000
JWT_SECRET=change-me
ALLOWED_ORIGINS=http://localhost:3000
SESSION_COOKIE_NAME=sid
SESSION_TTL_DAYS=30

View File

@ -2,6 +2,8 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
RUN apk add --no-cache postgresql-client
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install

View File

@ -1,12 +1,23 @@
const express = require("express"); const express = require("express");
const cors = require("cors"); const cors = require("cors");
const path = require("path");
const User = require("./models/user.model"); const User = require("./models/user.model");
const requestIdMiddleware = require("./middleware/request-id");
const { sendError } = require("./utils/http");
const app = express(); const app = express();
app.use(requestIdMiddleware);
app.use(express.json()); app.use(express.json());
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim()); // Expose manual API test pages in non-production environments only.
console.log("Allowed Origins:", allowedOrigins); if (process.env.NODE_ENV !== "production") {
app.use("/test", express.static(path.join(__dirname, "public")));
}
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
app.use( app.use(
cors({ cors({
origin: function (origin, callback) { origin: function (origin, callback) {
@ -14,17 +25,20 @@ app.use(
if (allowedOrigins.includes(origin)) return callback(null, true); if (allowedOrigins.includes(origin)) return callback(null, true);
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
callback(new Error("Not allowed by CORS")); console.error(`CORS blocked origin: ${origin}`);
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
}, },
methods: ["GET", "POST", "PUT", "DELETE"], methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
credentials: true,
exposedHeaders: ["X-Request-Id"],
}) })
); );
app.get('/', async (req, res) => { app.get('/', async (req, res) => {
resText = `Grocery List API is running.\n` + res.status(200).json({
`Roles available: ${Object.values(User.ROLES).join(', ')}` message: "Grocery List API is running.",
roles: Object.values(User.ROLES),
res.status(200).type("text/plain").send(resText); });
}); });
@ -43,4 +57,25 @@ app.use("/users", usersRoutes);
const configRoutes = require("./routes/config.routes"); const configRoutes = require("./routes/config.routes");
app.use("/config", configRoutes); app.use("/config", configRoutes);
const householdsRoutes = require("./routes/households.routes");
app.use("/households", householdsRoutes);
const storesRoutes = require("./routes/stores.routes");
app.use("/stores", storesRoutes);
const groupInvitesRoutes = require("./routes/group-invites.routes");
app.use("/api", groupInvitesRoutes);
app.use((err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
const statusCode = err.status || err.statusCode || 500;
const message =
statusCode >= 500 ? "Internal server error" : err.message || "Request failed";
return sendError(res, statusCode, message);
});
module.exports = app; module.exports = app;

View File

@ -1,44 +1,101 @@
const bcrypt = require("bcryptjs"); const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const User = require("../models/user.model"); const User = require("../models/user.model");
const { sendError } = require("../utils/http");
const Session = require("../models/session.model");
const { parseCookieHeader } = require("../utils/cookies");
const { setSessionCookie, clearSessionCookie, cookieName } = require("../utils/session-cookie");
const { logError } = require("../utils/logger");
exports.register = async (req, res) => { exports.register = async (req, res) => {
let { username, password, name } = req.body; let { username, password, name } = req.body;
if (
!username ||
!password ||
!name ||
typeof username !== "string" ||
typeof password !== "string" ||
typeof name !== "string"
) {
return sendError(res, 400, "Username, password, and name are required");
}
username = username.toLowerCase(); username = username.toLowerCase();
console.log(`🆕 Registration attempt for ${name} => username:${username}, password:${password}`); if (password.length < 8) {
return sendError(res, 400, "Password must be at least 8 characters");
}
try { try {
const hash = await bcrypt.hash(password, 10); const hash = await bcrypt.hash(password, 10);
const user = await User.createUser(username, hash, name); const user = await User.createUser(username, hash, name);
console.log(`✅ User registered: ${username}`);
res.json({ message: "User registered", user }); res.json({ message: "User registered", user });
} catch (err) { } catch (err) {
res.status(400).json({ message: "Registration failed", error: err }); logError(req, "auth.register", err);
sendError(res, 400, "Registration failed");
} }
}; };
exports.login = async (req, res) => { exports.login = async (req, res) => {
let { username, password } = req.body; let { username, password } = req.body;
if (
!username ||
!password ||
typeof username !== "string" ||
typeof password !== "string"
) {
return sendError(res, 400, "Username and password are required");
}
username = username.toLowerCase(); username = username.toLowerCase();
const user = await User.findByUsername(username); const user = await User.findByUsername(username);
if (!user) { if (!user) {
console.log(`⚠️ Login attempt -> No user found: ${username}`); return sendError(res, 401, "Invalid credentials");
return res.status(401).json({ message: "User not found" });
} }
const valid = await bcrypt.compare(password, user.password); const valid = await bcrypt.compare(password, user.password);
if (!valid) { if (!valid) {
console.log(`⛔ Login attempt for user ${username} with password ${password}`); return sendError(res, 401, "Invalid credentials");
return res.status(401).json({ message: "Invalid credentials" }); }
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
logError(req, "auth.login.jwtSecretMissing", new Error("JWT_SECRET is not configured"));
return sendError(res, 500, "Authentication is unavailable");
} }
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, role: user.role }, { id: user.id, role: user.role },
process.env.JWT_SECRET, jwtSecret,
{ expiresIn: "1 year" } { expiresIn: "1 year" }
); );
res.json({ token, username, role: user.role }); try {
const session = await Session.createSession(user.id, req.headers["user-agent"] || null);
setSessionCookie(res, session.id);
} catch (err) {
logError(req, "auth.login.createSession", err);
return sendError(res, 500, "Failed to create session");
}
res.json({ token, userId: user.id, username, role: user.role });
};
exports.logout = async (req, res) => {
try {
const cookies = parseCookieHeader(req.headers.cookie);
const sid = cookies[cookieName()];
if (sid) {
await Session.deleteSession(sid);
}
clearSessionCookie(res);
res.json({ message: "Logged out" });
} catch (err) {
logError(req, "auth.logout", err);
sendError(res, 500, "Failed to logout");
}
}; };

View File

@ -0,0 +1,216 @@
const invitesService = require("../services/group-invites.service");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
const { inviteCodeLast4 } = require("../utils/redaction");
function getClientIp(req) {
const forwardedFor = req.headers["x-forwarded-for"];
if (typeof forwardedFor === "string" && forwardedFor.trim()) {
return forwardedFor.split(",")[0].trim();
}
return req.ip || req.socket?.remoteAddress || null;
}
function parseRequestedGroupId(req) {
const headerGroupId = req.headers["x-group-id"] || req.headers["x-household-id"];
if (headerGroupId) {
const raw = Array.isArray(headerGroupId) ? headerGroupId[0] : headerGroupId;
return raw;
}
if (req.query?.groupId !== undefined) {
return req.query.groupId;
}
if (req.body?.groupId !== undefined) {
return req.body.groupId;
}
return undefined;
}
function clampTtlDays(value) {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) return 1;
return Math.max(1, Math.min(7, parsed));
}
function mapServiceError(req, res, error, context, extraLog = {}) {
if (error instanceof invitesService.InviteServiceError) {
return sendError(res, error.statusCode, error.message, error.code);
}
logError(req, context, error, extraLog);
return sendError(res, 500, "Failed to process invite request");
}
exports.listInviteLinks = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const links = await invitesService.listInviteLinks(req.user.id, groupId);
res.json({ links });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.listInviteLinks");
}
};
exports.createInviteLink = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const ttlDays = clampTtlDays(req.body?.ttlDays);
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
const link = await invitesService.createInviteLink(
req.user.id,
groupId,
req.body?.policy,
Boolean(req.body?.singleUse),
expiresAt,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.status(201).json({ link });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.createInviteLink");
}
};
exports.revokeInviteLink = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
await invitesService.revokeInviteLink(
req.user.id,
groupId,
req.body?.linkId,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ ok: true });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.revokeInviteLink");
}
};
exports.reviveInviteLink = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const ttlDays = clampTtlDays(req.body?.ttlDays);
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
await invitesService.reviveInviteLink(
req.user.id,
groupId,
req.body?.linkId,
expiresAt,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ ok: true });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.reviveInviteLink");
}
};
exports.deleteInviteLink = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
await invitesService.deleteInviteLink(
req.user.id,
groupId,
req.body?.linkId,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ ok: true });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.deleteInviteLink");
}
};
exports.getJoinPolicy = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const joinPolicy = await invitesService.getGroupJoinPolicy(req.user.id, groupId);
res.json({ joinPolicy });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.getJoinPolicy");
}
};
exports.setJoinPolicy = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
await invitesService.setGroupJoinPolicy(
req.user.id,
groupId,
req.body?.joinPolicy,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ ok: true });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.setJoinPolicy");
}
};
exports.getInviteLinkSummary = async (req, res) => {
const token = req.params.token;
const inviteLast4 = inviteCodeLast4(token);
try {
const link = await invitesService.getInviteLinkSummaryByToken(
token,
req.user?.id || null
);
res.json({ link });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.getInviteLinkSummary", {
invite_last4: inviteLast4,
});
}
};
exports.acceptInviteLink = async (req, res) => {
const token = req.params.token;
const inviteLast4 = inviteCodeLast4(token);
try {
const result = await invitesService.acceptInviteLink(
req.user.id,
token,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ result });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.acceptInviteLink", {
invite_last4: inviteLast4,
});
}
};

View File

@ -0,0 +1,224 @@
const householdModel = require("../models/household.model");
const { sendError } = require("../utils/http");
const { inviteCodeLast4 } = require("../utils/redaction");
const { logError } = require("../utils/logger");
// Get all households user belongs to
exports.getUserHouseholds = async (req, res) => {
try {
const households = await householdModel.getUserHouseholds(req.user.id);
res.json(households);
} catch (error) {
logError(req, "households.getUserHouseholds", error);
sendError(res, 500, "Failed to fetch households");
}
};
// Get household details
exports.getHousehold = async (req, res) => {
try {
const household = await householdModel.getHouseholdById(
req.params.householdId,
req.user.id
);
if (!household) {
return sendError(res, 404, "Household not found");
}
res.json(household);
} catch (error) {
logError(req, "households.getHousehold", error);
sendError(res, 500, "Failed to fetch household");
}
};
// Create new household
exports.createHousehold = async (req, res) => {
try {
const { name } = req.body;
if (!name || name.trim().length === 0) {
return sendError(res, 400, "Household name is required");
}
if (name.length > 100) {
return sendError(res, 400, "Household name must be 100 characters or less");
}
const household = await householdModel.createHousehold(
name.trim(),
req.user.id
);
res.status(201).json({
message: "Household created successfully",
household
});
} catch (error) {
logError(req, "households.createHousehold", error);
sendError(res, 500, "Failed to create household");
}
};
// Update household
exports.updateHousehold = async (req, res) => {
try {
const { name } = req.body;
if (!name || name.trim().length === 0) {
return sendError(res, 400, "Household name is required");
}
if (name.length > 100) {
return sendError(res, 400, "Household name must be 100 characters or less");
}
const household = await householdModel.updateHousehold(
req.params.householdId,
{ name: name.trim() }
);
res.json({
message: "Household updated successfully",
household
});
} catch (error) {
logError(req, "households.updateHousehold", error);
sendError(res, 500, "Failed to update household");
}
};
// Delete household
exports.deleteHousehold = async (req, res) => {
try {
await householdModel.deleteHousehold(req.params.householdId);
res.json({ message: "Household deleted successfully" });
} catch (error) {
logError(req, "households.deleteHousehold", error);
sendError(res, 500, "Failed to delete household");
}
};
// Refresh invite code
exports.refreshInviteCode = async (req, res) => {
try {
const household = await householdModel.refreshInviteCode(req.params.householdId);
res.json({
message: "Invite code refreshed successfully",
household
});
} catch (error) {
logError(req, "households.refreshInviteCode", error, {
invite_last4: inviteCodeLast4(req.body?.inviteCode),
});
sendError(res, 500, "Failed to refresh invite code");
}
};
// Join household via invite code
exports.joinHousehold = async (req, res) => {
const inviteLast4 = inviteCodeLast4(req.params.inviteCode);
try {
const { inviteCode } = req.params;
if (!inviteCode) return sendError(res, 400, "Invite code is required");
const result = await householdModel.joinHousehold(
inviteCode.toUpperCase(),
req.user.id
);
if (!result) return sendError(res, 404, "Invalid or expired invite code");
if (result.alreadyMember) {
return res.status(200).json({
message: "You are already a member of this household",
household: { id: result.id, name: result.name }
});
}
res.status(200).json({
message: `Successfully joined ${result.name}`,
household: { id: result.id, name: result.name }
});
} catch (error) {
logError(req, "households.joinHousehold", error, { invite_last4: inviteLast4 });
sendError(res, 500, "Failed to join household");
}
};
// Get household members
exports.getMembers = async (req, res) => {
try {
const members = await householdModel.getHouseholdMembers(req.params.householdId);
res.json(members);
} catch (error) {
logError(req, "households.getMembers", error);
sendError(res, 500, "Failed to fetch members");
}
};
// Update member role
exports.updateMemberRole = async (req, res) => {
try {
const { userId } = req.params;
const { role } = req.body;
if (!role || !['admin', 'member'].includes(role)) {
return sendError(res, 400, "Invalid role. Must be 'admin' or 'member'");
}
// Can't change own role
if (parseInt(userId) === req.user.id) {
return sendError(res, 400, "Cannot change your own role");
}
const targetRole = await householdModel.getUserRole(req.params.householdId, userId);
if (!targetRole) {
return sendError(res, 404, "Member not found");
}
if (targetRole === "owner") {
return sendError(res, 403, "Owner role cannot be changed");
}
const updated = await householdModel.updateMemberRole(
req.params.householdId,
userId,
role
);
res.json({
message: "Member role updated successfully",
member: updated
});
} catch (error) {
logError(req, "households.updateMemberRole", error);
sendError(res, 500, "Failed to update member role");
}
};
// Remove member
exports.removeMember = async (req, res) => {
try {
const { userId } = req.params;
const targetUserId = parseInt(userId);
// Allow users to remove themselves, or admins to remove others
if (targetUserId !== req.user.id && !["owner", "admin"].includes(req.household.role)) {
return sendError(res, 403, "Only admins or owners can remove other members");
}
const targetRole = await householdModel.getUserRole(req.params.householdId, userId);
if (targetRole === "owner") {
return sendError(res, 403, "Owner cannot be removed");
}
await householdModel.removeMember(req.params.householdId, userId);
res.json({ message: "Member removed successfully" });
} catch (error) {
logError(req, "households.removeMember", error);
sendError(res, 500, "Failed to remove member");
}
};

View File

@ -1,5 +1,7 @@
const List = require("../models/list.model"); const List = require("../models/list.model");
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
exports.getList = async (req, res) => { exports.getList = async (req, res) => {
@ -58,7 +60,7 @@ exports.updateItemImage = async (req, res) => {
const mimeType = req.processedImage?.mimeType || null; const mimeType = req.processedImage?.mimeType || null;
if (!imageBuffer) { if (!imageBuffer) {
return res.status(400).json({ message: "No image provided" }); return sendError(res, 400, "No image provided");
} }
// Update the item with new image // Update the item with new image
@ -90,15 +92,15 @@ exports.updateItemWithClassification = async (req, res) => {
// Validate classification data // Validate classification data
if (item_type && !isValidItemType(item_type)) { if (item_type && !isValidItemType(item_type)) {
return res.status(400).json({ message: "Invalid item_type" }); return sendError(res, 400, "Invalid item_type");
} }
if (item_group && !isValidItemGroup(item_type, item_group)) { if (item_group && !isValidItemGroup(item_type, item_group)) {
return res.status(400).json({ message: "Invalid item_group for selected item_type" }); return sendError(res, 400, "Invalid item_group for selected item_type");
} }
if (zone && !isValidZone(zone)) { if (zone && !isValidZone(zone)) {
return res.status(400).json({ message: "Invalid zone" }); return sendError(res, 400, "Invalid zone");
} }
// Upsert classification with confidence=1.0 and source='user' // Upsert classification with confidence=1.0 and source='user'
@ -113,7 +115,7 @@ exports.updateItemWithClassification = async (req, res) => {
res.json({ message: "Item updated successfully" }); res.json({ message: "Item updated successfully" });
} catch (error) { } catch (error) {
console.error("Error updating item with classification:", error); logError(req, "listsLegacy.updateItemWithClassification", error);
res.status(500).json({ message: "Failed to update item" }); sendError(res, 500, "Failed to update item");
} }
}; };

View File

@ -0,0 +1,347 @@
const List = require("../models/list.model.v2");
const householdModel = require("../models/household.model");
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
/**
* Get list items for household and store
* GET /households/:householdId/stores/:storeId/list
*/
exports.getList = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const items = await List.getHouseholdStoreList(householdId, storeId);
res.json({ items });
} catch (error) {
logError(req, "listsV2.getList", error);
sendError(res, 500, "Failed to get list");
}
};
/**
* Get specific item by name
* GET /households/:householdId/stores/:storeId/list/item
*/
exports.getItemByName = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const { item_name } = req.query;
if (!item_name) {
return sendError(res, 400, "Item name is required");
}
const item = await List.getItemByName(householdId, storeId, item_name);
if (!item) {
return sendError(res, 404, "Item not found");
}
res.json(item);
} catch (error) {
logError(req, "listsV2.getItemByName", error);
sendError(res, 500, "Failed to get item");
}
};
/**
* Add or update item in household store list
* POST /households/:householdId/stores/:storeId/list/add
*/
exports.addItem = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const { item_name, quantity, notes, added_for_user_id } = req.body;
const userId = req.user.id;
let historyUserId = userId;
if (!item_name || item_name.trim() === "") {
return sendError(res, 400, "Item name is required");
}
if (added_for_user_id !== undefined && added_for_user_id !== null && String(added_for_user_id).trim() !== "") {
const rawAddedForUserId = String(added_for_user_id).trim();
if (!/^\d+$/.test(rawAddedForUserId)) {
return sendError(res, 400, "Added-for user ID must be a positive integer");
}
const parsedUserId = Number.parseInt(rawAddedForUserId, 10);
if (!Number.isInteger(parsedUserId) || parsedUserId <= 0) {
return sendError(res, 400, "Added-for user ID must be a positive integer");
}
const isMember = await householdModel.isHouseholdMember(householdId, parsedUserId);
if (!isMember) {
return sendError(res, 400, "Selected user is not a member of this household");
}
historyUserId = parsedUserId;
}
// Get processed image if uploaded
const imageBuffer = req.processedImage?.buffer || null;
const mimeType = req.processedImage?.mimeType || null;
const result = await List.addOrUpdateItem(
householdId,
storeId,
item_name,
quantity || "1",
userId,
imageBuffer,
mimeType,
notes
);
// Add history record
await List.addHistoryRecord(result.listId, quantity || "1", historyUserId);
res.json({
message: result.isNew ? "Item added" : "Item updated",
item: {
id: result.listId,
item_name: result.itemName,
quantity: quantity || "1",
bought: false
}
});
} catch (error) {
logError(req, "listsV2.addItem", error);
sendError(res, 500, "Failed to add item");
}
};
/**
* Mark item as bought or unbought
* PATCH /households/:householdId/stores/:storeId/list/item
*/
exports.markBought = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const { item_name, bought, quantity_bought } = req.body;
if (!item_name) return sendError(res, 400, "Item name is required");
const item = await List.getItemByName(householdId, storeId, item_name);
if (!item) return sendError(res, 404, "Item not found");
// Update bought status (with optional partial purchase)
await List.setBought(item.id, bought, quantity_bought);
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
} catch (error) {
logError(req, "listsV2.markBought", error);
sendError(res, 500, "Failed to update item");
}
};
/**
* Update item details (quantity, notes)
* PUT /households/:householdId/stores/:storeId/list/item
*/
exports.updateItem = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const { item_name, quantity, notes } = req.body;
if (!item_name) {
return sendError(res, 400, "Item name is required");
}
// Get the list item
const item = await List.getItemByName(householdId, storeId, item_name);
if (!item) {
return sendError(res, 404, "Item not found");
}
// Update item
await List.updateItem(item.id, item_name, quantity, notes);
res.json({
message: "Item updated",
item: {
id: item.id,
item_name,
quantity,
notes
}
});
} catch (error) {
logError(req, "listsV2.updateItem", error);
sendError(res, 500, "Failed to update item");
}
};
/**
* Delete item from list
* DELETE /households/:householdId/stores/:storeId/list/item
*/
exports.deleteItem = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const { item_name } = req.body;
if (!item_name) {
return sendError(res, 400, "Item name is required");
}
// Get the list item
const item = await List.getItemByName(householdId, storeId, item_name);
if (!item) {
return sendError(res, 404, "Item not found");
}
await List.deleteItem(item.id);
res.json({ message: "Item deleted" });
} catch (error) {
logError(req, "listsV2.deleteItem", error);
sendError(res, 500, "Failed to delete item");
}
};
/**
* Get item suggestions based on query
* GET /households/:householdId/stores/:storeId/list/suggestions
*/
exports.getSuggestions = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const { query } = req.query;
const suggestions = await List.getSuggestions(query || "", householdId, storeId);
res.json(suggestions);
} catch (error) {
logError(req, "listsV2.getSuggestions", error);
sendError(res, 500, "Failed to get suggestions");
}
};
/**
* Get recently bought items
* GET /households/:householdId/stores/:storeId/list/recent
*/
exports.getRecentlyBought = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const items = await List.getRecentlyBoughtItems(householdId, storeId);
res.json(items);
} catch (error) {
logError(req, "listsV2.getRecentlyBought", error);
sendError(res, 500, "Failed to get recent items");
}
};
/**
* Get item classification
* GET /households/:householdId/stores/:storeId/list/classification
*/
exports.getClassification = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const { item_name } = req.query;
if (!item_name) {
return sendError(res, 400, "Item name is required");
}
// Get item ID from name
const item = await List.getItemByName(householdId, storeId, item_name);
if (!item) {
return res.json({ classification: null });
}
const classification = await List.getClassification(householdId, item.item_id);
res.json({ classification });
} catch (error) {
logError(req, "listsV2.getClassification", error);
sendError(res, 500, "Failed to get classification");
}
};
/**
* Set/update item classification
* POST /households/:householdId/stores/:storeId/list/classification
*/
exports.setClassification = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const { item_name, classification } = req.body;
if (!item_name) {
return sendError(res, 400, "Item name is required");
}
if (!classification) {
return sendError(res, 400, "Classification is required");
}
// Validate classification
const validClassifications = ['produce', 'dairy', 'meat', 'bakery', 'frozen', 'pantry', 'snacks', 'beverages', 'household', 'other'];
if (!validClassifications.includes(classification)) {
return sendError(res, 400, "Invalid classification value");
}
// Get item - add to master items if not exists
const item = await List.getItemByName(householdId, storeId, item_name);
let itemId;
if (!item) {
// Item doesn't exist in list, need to get from items table or create
const itemResult = await List.addOrUpdateItem(
householdId,
storeId,
item_name,
"1",
req.user.id,
null,
null
);
itemId = itemResult.itemId;
} else {
itemId = item.item_id;
}
// Set classification (using item_type field for simplicity)
await List.upsertClassification(householdId, itemId, {
item_type: classification,
item_group: null,
zone: null
});
res.json({ message: "Classification set", classification });
} catch (error) {
logError(req, "listsV2.setClassification", error);
sendError(res, 500, "Failed to set classification");
}
};
/**
* Update item image
* POST /households/:householdId/stores/:storeId/list/update-image
*/
exports.updateItemImage = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const { item_name, quantity } = req.body;
const userId = req.user.id;
// Get processed image
const imageBuffer = req.processedImage?.buffer || null;
const mimeType = req.processedImage?.mimeType || null;
if (!imageBuffer) {
return sendError(res, 400, "No image provided");
}
// Update the item with new image
await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType);
res.json({ message: "Image updated successfully" });
} catch (error) {
logError(req, "listsV2.updateItemImage", error);
sendError(res, 500, "Failed to update image");
}
};

View File

@ -0,0 +1,147 @@
const storeModel = require("../models/store.model");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
// Get all available stores
exports.getAllStores = async (req, res) => {
try {
const stores = await storeModel.getAllStores();
res.json(stores);
} catch (error) {
logError(req, "stores.getAllStores", error);
sendError(res, 500, "Failed to fetch stores");
}
};
// Get stores for household
exports.getHouseholdStores = async (req, res) => {
try {
const stores = await storeModel.getHouseholdStores(req.params.householdId);
res.json(stores);
} catch (error) {
logError(req, "stores.getHouseholdStores", error);
sendError(res, 500, "Failed to fetch household stores");
}
};
// Add store to household
exports.addStoreToHousehold = async (req, res) => {
try {
const { storeId, isDefault } = req.body;
// console.log("Adding store to household:", { householdId: req.params.householdId, storeId, isDefault });
if (!storeId) {
return sendError(res, 400, "Store ID is required");
}
const store = await storeModel.getStoreById(storeId);
if (!store) return sendError(res, 404, "Store not found");
const foundStores = await storeModel.getHouseholdStores(req.params.householdId);
// if (foundStores.length == 0) isDefault = 'true';
await storeModel.addStoreToHousehold(
req.params.householdId,
storeId,
foundStores.length == 0 ? true : isDefault || false
);
res.status(201).json({
message: "Store added to household successfully",
store
});
} catch (error) {
logError(req, "stores.addStoreToHousehold", error);
sendError(res, 500, "Failed to add store to household");
}
};
// Remove store from household
exports.removeStoreFromHousehold = async (req, res) => {
try {
await storeModel.removeStoreFromHousehold(
req.params.householdId,
req.params.storeId
);
res.json({ message: "Store removed from household successfully" });
} catch (error) {
logError(req, "stores.removeStoreFromHousehold", error);
sendError(res, 500, "Failed to remove store from household");
}
};
// Set default store
exports.setDefaultStore = async (req, res) => {
try {
await storeModel.setDefaultStore(
req.params.householdId,
req.params.storeId
);
res.json({ message: "Default store updated successfully" });
} catch (error) {
logError(req, "stores.setDefaultStore", error);
sendError(res, 500, "Failed to set default store");
}
};
// Create store (system admin only)
exports.createStore = async (req, res) => {
try {
const { name, default_zones } = req.body;
if (!name || name.trim().length === 0) {
return sendError(res, 400, "Store name is required");
}
const store = await storeModel.createStore(name.trim(), default_zones || null);
res.status(201).json({
message: "Store created successfully",
store
});
} catch (error) {
logError(req, "stores.createStore", error);
if (error.code === '23505') { // Unique violation
return sendError(res, 400, "Store with this name already exists");
}
sendError(res, 500, "Failed to create store");
}
};
// Update store (system admin only)
exports.updateStore = async (req, res) => {
try {
const { name, default_zones } = req.body;
const store = await storeModel.updateStore(req.params.storeId, {
name: name?.trim(),
default_zones
});
if (!store) {
return sendError(res, 404, "Store not found");
}
res.json({
message: "Store updated successfully",
store
});
} catch (error) {
logError(req, "stores.updateStore", error);
sendError(res, 500, "Failed to update store");
}
};
// Delete store (system admin only)
exports.deleteStore = async (req, res) => {
try {
await storeModel.deleteStore(req.params.storeId);
res.json({ message: "Store deleted successfully" });
} catch (error) {
logError(req, "stores.deleteStore", error);
if (error.message.includes('in use')) {
return sendError(res, 400, error.message);
}
sendError(res, 500, "Failed to delete store");
}
};

View File

@ -1,13 +1,13 @@
const User = require("../models/user.model"); const User = require("../models/user.model");
const bcrypt = require("bcryptjs"); const bcrypt = require("bcryptjs");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
exports.test = async (req, res) => { exports.test = async (req, res) => {
console.log("User route is working");
res.json({ message: "User route is working" }); res.json({ message: "User route is working" });
}; };
exports.getAllUsers = async (req, res) => { exports.getAllUsers = async (req, res) => {
console.log(req);
const users = await User.getAllUsers(); const users = await User.getAllUsers();
res.json(users); res.json(users);
}; };
@ -16,18 +16,17 @@ exports.getAllUsers = async (req, res) => {
exports.updateUserRole = async (req, res) => { exports.updateUserRole = async (req, res) => {
try { try {
const { id, role } = req.body; const { id, role } = req.body;
console.log(`Updating user ${id} to role ${role}`);
if (!Object.values(User.ROLES).includes(role)) if (!Object.values(User.ROLES).includes(role))
return res.status(400).json({ error: "Invalid role" }); return sendError(res, 400, "Invalid role");
const updated = await User.updateUserRole(id, role); const updated = await User.updateUserRole(id, role);
if (!updated) if (!updated)
return res.status(404).json({ error: "User not found" }); return sendError(res, 404, "User not found");
res.json({ message: "Role updated", id, role }); res.json({ message: "Role updated", id, role });
} catch (err) { } catch (err) {
res.status(500).json({ error: "Failed to update role" }); logError(req, "users.updateUserRole", err);
sendError(res, 500, "Failed to update role");
} }
}; };
@ -37,12 +36,13 @@ exports.deleteUser = async (req, res) => {
const deleted = await User.deleteUser(id); const deleted = await User.deleteUser(id);
if (!deleted) if (!deleted)
return res.status(404).json({ error: "User not found" }); return sendError(res, 404, "User not found");
res.json({ message: "User deleted", id }); res.json({ message: "User deleted", id });
} catch (err) { } catch (err) {
res.status(500).json({ error: "Failed to delete user" }); logError(req, "users.deleteUser", err);
sendError(res, 500, "Failed to delete user");
} }
}; };
@ -58,13 +58,13 @@ exports.getCurrentUser = async (req, res) => {
const user = await User.getUserById(userId); const user = await User.getUserById(userId);
if (!user) { if (!user) {
return res.status(404).json({ error: "User not found" }); return sendError(res, 404, "User not found");
} }
res.json(user); res.json(user);
} catch (err) { } catch (err) {
console.error("Error getting current user:", err); logError(req, "users.getCurrentUser", err);
res.status(500).json({ error: "Failed to get user profile" }); sendError(res, 500, "Failed to get user profile");
} }
}; };
@ -74,23 +74,23 @@ exports.updateCurrentUser = async (req, res) => {
const { display_name } = req.body; const { display_name } = req.body;
if (!display_name || display_name.trim().length === 0) { if (!display_name || display_name.trim().length === 0) {
return res.status(400).json({ error: "Display name is required" }); return sendError(res, 400, "Display name is required");
} }
if (display_name.length > 100) { if (display_name.length > 100) {
return res.status(400).json({ error: "Display name must be 100 characters or less" }); return sendError(res, 400, "Display name must be 100 characters or less");
} }
const updated = await User.updateUserProfile(userId, { display_name: display_name.trim() }); const updated = await User.updateUserProfile(userId, { display_name: display_name.trim() });
if (!updated) { if (!updated) {
return res.status(404).json({ error: "User not found" }); return sendError(res, 404, "User not found");
} }
res.json({ message: "Profile updated successfully", user: updated }); res.json({ message: "Profile updated successfully", user: updated });
} catch (err) { } catch (err) {
console.error("Error updating user profile:", err); logError(req, "users.updateCurrentUser", err);
res.status(500).json({ error: "Failed to update profile" }); sendError(res, 500, "Failed to update profile");
} }
}; };
@ -101,25 +101,25 @@ exports.changePassword = async (req, res) => {
// Validation // Validation
if (!current_password || !new_password) { if (!current_password || !new_password) {
return res.status(400).json({ error: "Current password and new password are required" }); return sendError(res, 400, "Current password and new password are required");
} }
if (new_password.length < 6) { if (new_password.length < 6) {
return res.status(400).json({ error: "New password must be at least 6 characters" }); return sendError(res, 400, "New password must be at least 6 characters");
} }
// Get current password hash // Get current password hash
const currentHash = await User.getUserPasswordHash(userId); const currentHash = await User.getUserPasswordHash(userId);
if (!currentHash) { if (!currentHash) {
return res.status(404).json({ error: "User not found" }); return sendError(res, 404, "User not found");
} }
// Verify current password // Verify current password
const isValidPassword = await bcrypt.compare(current_password, currentHash); const isValidPassword = await bcrypt.compare(current_password, currentHash);
if (!isValidPassword) { if (!isValidPassword) {
return res.status(401).json({ error: "Current password is incorrect" }); return sendError(res, 401, "Current password is incorrect");
} }
// Hash new password // Hash new password
@ -131,7 +131,7 @@ exports.changePassword = async (req, res) => {
res.json({ message: "Password changed successfully" }); res.json({ message: "Password changed successfully" });
} catch (err) { } catch (err) {
console.error("Error changing password:", err); logError(req, "users.changePassword", err);
res.status(500).json({ error: "Failed to change password" }); sendError(res, 500, "Failed to change password");
} }
}; };

View File

@ -1,11 +1,21 @@
const { Pool } = require("pg"); const { Pool } = require("pg");
const pool = new Pool({ function buildPoolConfig() {
if (process.env.DATABASE_URL) {
return {
connectionString: process.env.DATABASE_URL,
};
}
return {
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
host: process.env.DB_HOST, host: process.env.DB_HOST,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: 5432, port: Number(process.env.DB_PORT || 5432),
}); };
}
const pool = new Pool(buildPoolConfig());
module.exports = pool; module.exports = pool;

View File

@ -1,18 +1,54 @@
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const { sendError } = require("../utils/http");
const Session = require("../models/session.model");
const { parseCookieHeader } = require("../utils/cookies");
const { cookieName } = require("../utils/session-cookie");
const { logError } = require("../utils/logger");
function auth(req, res, next) { async function auth(req, res, next) {
const header = req.headers.authorization; const header = req.headers.authorization || "";
if (!header) return res.status(401).json({ message: "Missing token" }); const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null;
const token = header.split(" ")[1]; if (token) {
if (!token) return res.status(401).json({ message: "Invalid token format" }); const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured"));
return sendError(res, 500, "Authentication is unavailable");
}
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, jwtSecret);
req.user = decoded; // id + role req.user = decoded; // id + role
next(); return next();
} catch (err) { } catch (err) {
res.status(401).json({ message: "Invalid or expired token" }); return sendError(res, 401, "Invalid or expired token");
}
}
try {
const cookies = parseCookieHeader(req.headers.cookie);
const sid = cookies[cookieName()];
if (!sid) {
return sendError(res, 401, "Missing authentication");
}
const session = await Session.getActiveSessionWithUser(sid);
if (!session) {
return sendError(res, 401, "Invalid or expired session");
}
req.user = {
id: session.user_id,
role: session.role,
username: session.username,
};
req.session_id = session.id;
return next();
} catch (err) {
logError(req, "middleware.auth", err);
return sendError(res, 500, "Authentication check failed");
} }
} }

View File

@ -0,0 +1,104 @@
const householdModel = require("../models/household.model");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
// Middleware to check if user belongs to household
exports.householdAccess = async (req, res, next) => {
try {
const householdId = parseInt(req.params.householdId || req.params.hId);
const userId = req.user.id;
if (!householdId) {
return sendError(res, 400, "Household ID required");
}
// Check if user is member of household
const isMember = await householdModel.isHouseholdMember(householdId, userId);
if (!isMember) {
return sendError(res, 403, "Access denied. You are not a member of this household.");
}
// Get user's role in household
const role = await householdModel.getUserRole(householdId, userId);
// Attach household info to request
req.household = {
id: householdId,
role: role
};
next();
} catch (error) {
logError(req, "middleware.householdAccess", error);
sendError(res, 500, "Server error checking household access");
}
};
// Middleware to require specific household role(s)
exports.requireHouseholdRole = (...allowedRoles) => {
return (req, res, next) => {
if (!req.household) {
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
}
if (!allowedRoles.includes(req.household.role)) {
return sendError(
res,
403,
`Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}`
);
}
next();
};
};
// Middleware to require admin/owner role in household
exports.requireHouseholdAdmin = exports.requireHouseholdRole('owner', 'admin');
// Middleware to check store access (household must have store)
exports.storeAccess = async (req, res, next) => {
try {
const storeId = parseInt(req.params.storeId || req.params.sId);
if (!storeId) {
return sendError(res, 400, "Store ID required");
}
if (!req.household) {
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
}
// Check if household has access to this store
const storeModel = require("../models/store.model");
const hasStore = await storeModel.householdHasStore(req.household.id, storeId);
if (!hasStore) {
return sendError(res, 403, "This household does not have access to this store.");
}
// Attach store info to request
req.store = {
id: storeId
};
next();
} catch (error) {
logError(req, "middleware.storeAccess", error);
sendError(res, 500, "Server error checking store access");
}
};
// Middleware to require system admin role
exports.requireSystemAdmin = (req, res, next) => {
if (!req.user) {
return sendError(res, 401, "Authentication required");
}
if (req.user.role !== 'system_admin') {
return sendError(res, 403, "Access denied. System administrator privileges required.");
}
next();
};

View File

@ -1,6 +1,7 @@
const multer = require("multer"); const multer = require("multer");
const sharp = require("sharp"); const sharp = require("sharp");
const { MAX_FILE_SIZE_BYTES, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants"); const { MAX_FILE_SIZE_BYTES, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants");
const { sendError } = require("../utils/http");
// Configure multer for memory storage (we'll process before saving to DB) // Configure multer for memory storage (we'll process before saving to DB)
const upload = multer({ const upload = multer({
@ -42,7 +43,7 @@ const processImage = async (req, res, next) => {
next(); next();
} catch (error) { } catch (error) {
res.status(400).json({ message: "Error processing image: " + error.message }); sendError(res, 400, `Error processing image: ${error.message}`);
} }
}; };

View File

@ -0,0 +1,47 @@
const jwt = require("jsonwebtoken");
const Session = require("../models/session.model");
const { parseCookieHeader } = require("../utils/cookies");
const { cookieName } = require("../utils/session-cookie");
const { logError } = require("../utils/logger");
async function optionalAuth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null;
if (token) {
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
return next();
}
try {
const decoded = jwt.verify(token, jwtSecret);
req.user = decoded;
return next();
} catch (err) {
return next();
}
}
try {
const cookies = parseCookieHeader(req.headers.cookie);
const sid = cookies[cookieName()];
if (!sid) return next();
const session = await Session.getActiveSessionWithUser(sid);
if (!session) return next();
req.user = {
id: session.user_id,
role: session.role,
username: session.username,
};
req.session_id = session.id;
} catch (err) {
logError(req, "middleware.optionalAuth", err);
}
return next();
}
module.exports = optionalAuth;

View File

@ -0,0 +1,59 @@
const { sendError } = require("../utils/http");
const buckets = new Map();
function pruneExpired(now) {
for (const [key, value] of buckets.entries()) {
if (value.resetAt <= now) {
buckets.delete(key);
}
}
}
function getClientIp(req) {
const forwardedFor = req.headers["x-forwarded-for"];
if (typeof forwardedFor === "string" && forwardedFor.trim()) {
return forwardedFor.split(",")[0].trim();
}
return req.ip || req.socket?.remoteAddress || "unknown";
}
function createRateLimit({ keyPrefix, windowMs, max, message, keyFn }) {
return (req, res, next) => {
const now = Date.now();
if (buckets.size > 5000) {
pruneExpired(now);
}
const suffix = typeof keyFn === "function" ? keyFn(req) : getClientIp(req);
const key = `${keyPrefix}:${suffix || "unknown"}`;
const existing = buckets.get(key);
const bucket =
!existing || existing.resetAt <= now
? { count: 0, resetAt: now + windowMs }
: existing;
bucket.count += 1;
buckets.set(key, bucket);
if (bucket.count > max) {
const retryAfterSeconds = Math.max(
1,
Math.ceil((bucket.resetAt - now) / 1000)
);
res.setHeader("Retry-After", String(retryAfterSeconds));
return sendError(
res,
429,
message || "Too many requests. Please try again later."
);
}
return next();
};
}
module.exports = {
createRateLimit,
};

View File

@ -1,8 +1,10 @@
const { sendError } = require("../utils/http");
function requireRole(...allowedRoles) { function requireRole(...allowedRoles) {
return (req, res, next) => { return (req, res, next) => {
if (!req.user) return res.status(401).json({ message: "Authentication required" }); if (!req.user) return sendError(res, 401, "Authentication required");
if (!allowedRoles.includes(req.user.role)) if (!allowedRoles.includes(req.user.role))
return res.status(403).json({ message: "Forbidden" }); return sendError(res, 403, "Forbidden");
next(); next();
}; };

View File

@ -0,0 +1,47 @@
const crypto = require("crypto");
const { normalizeErrorPayload } = require("../utils/http");
function generateRequestId() {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString("hex");
}
function isPlainObject(value) {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
function requestIdMiddleware(req, res, next) {
const requestId = generateRequestId();
req.request_id = requestId;
res.locals.request_id = requestId;
res.setHeader("X-Request-Id", requestId);
const originalJson = res.json.bind(res);
res.json = (payload) => {
const normalizedPayload = normalizeErrorPayload(payload, res.statusCode);
if (isPlainObject(normalizedPayload)) {
if (normalizedPayload.request_id === undefined) {
return originalJson({ ...normalizedPayload, request_id: requestId });
}
return originalJson(normalizedPayload);
}
return originalJson({
data: normalizedPayload,
request_id: requestId,
});
};
next();
}
module.exports = requestIdMiddleware;

View File

@ -0,0 +1,243 @@
# Multi-Household Architecture Migration Guide
## Pre-Migration Checklist
- [ ] **Backup Database**
```bash
pg_dump -U your_user -d grocery_list > backup_$(date +%Y%m%d_%H%M%S).sql
```
- [ ] **Test on Staging First**
- Copy production database to staging environment
- Run migration on staging
- Verify all data migrated correctly
- Test application functionality
- [ ] **Review Migration Script**
- Read through `multi_household_architecture.sql`
- Understand each step
- Note verification queries
- [ ] **Announce Maintenance Window**
- Notify users of downtime
- Schedule during low-usage period
- Estimate 15-30 minutes for migration
## Running the Migration
### 1. Connect to Database
```bash
psql -U your_user -d grocery_list
```
### 2. Run Migration
```sql
\i backend/migrations/multi_household_architecture.sql
```
The script will:
1. ✅ Create 8 new tables
2. ✅ Create default "Main Household"
3. ✅ Create default "Costco" store
4. ✅ Migrate all users to household members
5. ✅ Extract items to master catalog
6. ✅ Migrate grocery_list → household_lists
7. ✅ Migrate classifications
8. ✅ Migrate history records
9. ✅ Update user system roles
### 3. Verify Migration
Run these queries inside psql:
```sql
-- Check household created
SELECT * FROM households;
-- Check all users migrated
SELECT u.username, u.role as system_role, hm.role as household_role
FROM users u
JOIN household_members hm ON u.id = hm.user_id
ORDER BY u.id;
-- Check item counts match
SELECT
(SELECT COUNT(DISTINCT item_name) FROM grocery_list) as old_unique_items,
(SELECT COUNT(*) FROM items) as new_items;
-- Check list counts
SELECT
(SELECT COUNT(*) FROM grocery_list) as old_lists,
(SELECT COUNT(*) FROM household_lists) as new_lists;
-- Check classification counts
SELECT
(SELECT COUNT(*) FROM item_classification) as old_classifications,
(SELECT COUNT(*) FROM household_item_classifications) as new_classifications;
-- Check history counts
SELECT
(SELECT COUNT(*) FROM grocery_history) as old_history,
(SELECT COUNT(*) FROM household_list_history) as new_history;
-- Verify no data loss - check if all old items have corresponding new records
SELECT gl.item_name
FROM grocery_list gl
LEFT JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
LEFT JOIN household_lists hl ON hl.item_id = i.id
WHERE hl.id IS NULL;
-- Should return 0 rows
-- Check invite code
SELECT name, invite_code FROM households;
```
### 4. Test Application
- [ ] Users can log in
- [ ] Can view "Main Household" list
- [ ] Can add items
- [ ] Can mark items as bought
- [ ] History shows correctly
- [ ] Classifications preserved
- [ ] Images display correctly
## Post-Migration Cleanup
**Only after verifying everything works correctly:**
```sql
-- Drop old tables (CAREFUL - THIS IS IRREVERSIBLE)
DROP TABLE IF EXISTS grocery_history CASCADE;
DROP TABLE IF EXISTS item_classification CASCADE;
DROP TABLE IF EXISTS grocery_list CASCADE;
```
## Rollback Plan
### If Migration Fails
```sql
-- Inside psql during migration
ROLLBACK;
-- Then restore from backup
\q
psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql
```
### If Issues Found After Migration
```bash
# Drop the database and restore
dropdb grocery_list
createdb grocery_list
psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql
```
## Common Issues & Solutions
### Issue: Duplicate items in items table
**Cause**: Case-insensitive matching not working
**Solution**: Check item names for leading/trailing spaces
### Issue: Foreign key constraint errors
**Cause**: User or item references not found
**Solution**: Verify all users and items exist before migrating lists
### Issue: History not showing
**Cause**: household_list_id references incorrect
**Solution**: Check JOIN conditions in history migration
### Issue: Images not displaying
**Cause**: BYTEA encoding issues
**Solution**: Verify image_mime_type correctly migrated
## Migration Timeline
- **T-0**: Begin maintenance window
- **T+2min**: Backup complete
- **T+3min**: Start migration script
- **T+8min**: Migration complete (for ~1000 items)
- **T+10min**: Run verification queries
- **T+15min**: Test application functionality
- **T+20min**: If successful, announce completion
- **T+30min**: End maintenance window
## Data Integrity Checks
```sql
-- Ensure all users belong to at least one household
SELECT u.id, u.username
FROM users u
LEFT JOIN household_members hm ON u.id = hm.user_id
WHERE hm.id IS NULL;
-- Should return 0 rows
-- Ensure all household lists have valid items
SELECT hl.id
FROM household_lists hl
LEFT JOIN items i ON hl.item_id = i.id
WHERE i.id IS NULL;
-- Should return 0 rows
-- Ensure all history has valid list references
SELECT hlh.id
FROM household_list_history hlh
LEFT JOIN household_lists hl ON hlh.household_list_id = hl.id
WHERE hl.id IS NULL;
-- Should return 0 rows
-- Check for orphaned classifications
SELECT hic.id
FROM household_item_classifications hic
LEFT JOIN household_lists hl ON hic.item_id = hl.item_id
AND hic.household_id = hl.household_id
AND hic.store_id = hl.store_id
WHERE hl.id IS NULL;
-- Should return 0 rows (or classifications for removed items, which is ok)
```
## Success Criteria
✅ All tables created successfully
✅ All users migrated to "Main Household"
✅ Item count matches (unique items from old → new)
✅ List count matches (all grocery_list items → household_lists)
✅ Classification count matches
✅ History count matches
✅ No NULL foreign keys
✅ Application loads without errors
✅ Users can perform all CRUD operations
✅ Images display correctly
✅ Bought items still marked as bought
✅ Recently bought still shows correctly
## Next Steps After Migration
1. ✅ Update backend models (Sprint 2)
2. ✅ Update API routes
3. ✅ Update controllers
4. ✅ Test all endpoints
5. ✅ Update frontend contexts
6. ✅ Update UI components
7. ✅ Enable multi-household features
## Support & Troubleshooting
If issues arise:
1. Check PostgreSQL logs: `/var/log/postgresql/`
2. Check application logs
3. Restore from backup if needed
4. Review migration script for errors
## Monitoring Post-Migration
For the first 24 hours after migration:
- Monitor error logs
- Watch for performance issues
- Verify user activity normal
- Check for any data inconsistencies
- Be ready to rollback if critical issues found

View File

@ -0,0 +1,7 @@
-- Add notes column to household_lists table
-- This allows users to add custom notes/descriptions to list items
ALTER TABLE household_lists
ADD COLUMN IF NOT EXISTS notes TEXT;
COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item';

View File

@ -0,0 +1,397 @@
-- ============================================================================
-- Multi-Household & Multi-Store Architecture Migration
-- ============================================================================
-- This migration transforms the single-list app into a multi-tenant system
-- supporting multiple households, each with multiple stores.
--
-- IMPORTANT: Backup your database before running this migration!
-- pg_dump grocery_list > backup_$(date +%Y%m%d).sql
--
-- Migration Strategy:
-- 1. Create new tables
-- 2. Create "Main Household" for existing users
-- 3. Migrate existing data to new structure
-- 4. Update roles (keep users.role for system admin)
-- 5. Verify data integrity
-- 6. (Manual step) Drop old tables after verification
-- ============================================================================
BEGIN;
-- ============================================================================
-- STEP 1: CREATE NEW TABLES
-- ============================================================================
-- Households table
CREATE TABLE IF NOT EXISTS households (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
invite_code VARCHAR(20) UNIQUE NOT NULL,
code_expires_at TIMESTAMP
);
CREATE INDEX idx_households_invite_code ON households(invite_code);
COMMENT ON TABLE households IS 'Household groups (families, roommates, etc.)';
COMMENT ON COLUMN households.invite_code IS 'Unique code for inviting users to join household';
-- Store types table
CREATE TABLE IF NOT EXISTS stores (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
default_zones JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE stores IS 'Store types/chains (Costco, Target, Walmart, etc.)';
COMMENT ON COLUMN stores.default_zones IS 'JSON array of default zone names for this store type';
-- User-Household membership with per-household roles
CREATE TABLE IF NOT EXISTS household_members (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
joined_at TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, user_id)
);
CREATE INDEX idx_household_members_user ON household_members(user_id);
CREATE INDEX idx_household_members_household ON household_members(household_id);
COMMENT ON TABLE household_members IS 'User membership in households with per-household roles';
COMMENT ON COLUMN household_members.role IS 'admin: full control, user: standard member';
-- Household-Store relationship
CREATE TABLE IF NOT EXISTS household_stores (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
is_default BOOLEAN DEFAULT FALSE,
added_at TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, store_id)
);
CREATE INDEX idx_household_stores_household ON household_stores(household_id);
COMMENT ON TABLE household_stores IS 'Which stores each household shops at';
-- Master item catalog (shared across all households)
CREATE TABLE IF NOT EXISTS items (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
default_image BYTEA,
default_image_mime_type VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
usage_count INTEGER DEFAULT 0
);
CREATE INDEX idx_items_name ON items(name);
CREATE INDEX idx_items_usage_count ON items(usage_count DESC);
COMMENT ON TABLE items IS 'Master item catalog shared across all households';
COMMENT ON COLUMN items.usage_count IS 'Popularity metric for suggestions';
-- Household-specific grocery lists (per store)
CREATE TABLE IF NOT EXISTS household_lists (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
quantity INTEGER NOT NULL DEFAULT 1,
bought BOOLEAN DEFAULT FALSE,
custom_image BYTEA,
custom_image_mime_type VARCHAR(50),
added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
modified_on TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, store_id, item_id)
);
CREATE INDEX idx_household_lists_household_store ON household_lists(household_id, store_id);
CREATE INDEX idx_household_lists_bought ON household_lists(household_id, store_id, bought);
CREATE INDEX idx_household_lists_modified ON household_lists(modified_on DESC);
COMMENT ON TABLE household_lists IS 'Grocery lists scoped to household + store combination';
-- Household-specific item classifications (per store)
CREATE TABLE IF NOT EXISTS household_item_classifications (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
item_type VARCHAR(50),
item_group VARCHAR(100),
zone VARCHAR(100),
confidence DECIMAL(3,2) DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1),
source VARCHAR(20) DEFAULT 'user' CHECK (source IN ('user', 'ml', 'default')),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, store_id, item_id)
);
CREATE INDEX idx_household_classifications ON household_item_classifications(household_id, store_id);
CREATE INDEX idx_household_classifications_type ON household_item_classifications(item_type);
CREATE INDEX idx_household_classifications_zone ON household_item_classifications(zone);
COMMENT ON TABLE household_item_classifications IS 'Item classifications scoped to household + store';
-- History tracking
CREATE TABLE IF NOT EXISTS household_list_history (
id SERIAL PRIMARY KEY,
household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE,
quantity INTEGER NOT NULL,
added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
added_on TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_household_history_list ON household_list_history(household_list_id);
CREATE INDEX idx_household_history_user ON household_list_history(added_by);
CREATE INDEX idx_household_history_date ON household_list_history(added_on DESC);
COMMENT ON TABLE household_list_history IS 'Tracks who added items and when';
-- ============================================================================
-- STEP 2: CREATE DEFAULT HOUSEHOLD AND STORE
-- ============================================================================
-- Create default household for existing users
INSERT INTO households (name, created_by, invite_code)
SELECT
'Main Household',
(SELECT id FROM users WHERE role = 'admin' LIMIT 1), -- First admin as creator
'MAIN' || LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0') -- Random 6-digit code
WHERE NOT EXISTS (SELECT 1 FROM households WHERE name = 'Main Household');
-- Create default Costco store
INSERT INTO stores (name, default_zones)
VALUES (
'Costco',
'{
"zones": [
"Entrance & Seasonal",
"Fresh Produce",
"Meat & Seafood",
"Dairy & Refrigerated",
"Deli & Prepared Foods",
"Bakery & Bread",
"Frozen Foods",
"Beverages",
"Snacks & Candy",
"Pantry & Dry Goods",
"Health & Beauty",
"Household & Cleaning",
"Other"
]
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
-- Link default household to default store
INSERT INTO household_stores (household_id, store_id, is_default)
SELECT
(SELECT id FROM households WHERE name = 'Main Household'),
(SELECT id FROM stores WHERE name = 'Costco'),
TRUE
WHERE NOT EXISTS (
SELECT 1 FROM household_stores
WHERE household_id = (SELECT id FROM households WHERE name = 'Main Household')
);
-- ============================================================================
-- STEP 3: MIGRATE USERS TO HOUSEHOLD MEMBERS
-- ============================================================================
-- Add all existing users to Main Household
-- Old admins become household admins, others become standard users
INSERT INTO household_members (household_id, user_id, role)
SELECT
(SELECT id FROM households WHERE name = 'Main Household'),
id,
CASE
WHEN role = 'admin' THEN 'admin'
ELSE 'user'
END
FROM users
WHERE NOT EXISTS (
SELECT 1 FROM household_members hm
WHERE hm.user_id = users.id
AND hm.household_id = (SELECT id FROM households WHERE name = 'Main Household')
);
-- ============================================================================
-- STEP 4: MIGRATE ITEMS TO MASTER CATALOG
-- ============================================================================
-- Extract unique items from grocery_list into master items table
INSERT INTO items (name, default_image, default_image_mime_type, created_at, usage_count)
SELECT
LOWER(TRIM(item_name)) as name,
item_image,
image_mime_type,
MIN(modified_on) as created_at,
COUNT(*) as usage_count
FROM grocery_list
WHERE NOT EXISTS (
SELECT 1 FROM items WHERE LOWER(items.name) = LOWER(TRIM(grocery_list.item_name))
)
GROUP BY LOWER(TRIM(item_name)), item_image, image_mime_type
ON CONFLICT (name) DO NOTHING;
-- ============================================================================
-- STEP 5: MIGRATE GROCERY_LIST TO HOUSEHOLD_LISTS
-- ============================================================================
-- Migrate current list to household_lists
INSERT INTO household_lists (
household_id,
store_id,
item_id,
quantity,
bought,
custom_image,
custom_image_mime_type,
added_by,
modified_on
)
SELECT
(SELECT id FROM households WHERE name = 'Main Household'),
(SELECT id FROM stores WHERE name = 'Costco'),
i.id,
gl.quantity,
gl.bought,
CASE WHEN gl.item_image != i.default_image THEN gl.item_image ELSE NULL END, -- Only store if different
CASE WHEN gl.item_image != i.default_image THEN gl.image_mime_type ELSE NULL END,
gl.added_by,
gl.modified_on
FROM grocery_list gl
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
WHERE NOT EXISTS (
SELECT 1 FROM household_lists hl
WHERE hl.household_id = (SELECT id FROM households WHERE name = 'Main Household')
AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco')
AND hl.item_id = i.id
)
ON CONFLICT (household_id, store_id, item_id) DO NOTHING;
-- ============================================================================
-- STEP 6: MIGRATE ITEM_CLASSIFICATION TO HOUSEHOLD_ITEM_CLASSIFICATIONS
-- ============================================================================
-- Migrate classifications
INSERT INTO household_item_classifications (
household_id,
store_id,
item_id,
item_type,
item_group,
zone,
confidence,
source,
created_at,
updated_at
)
SELECT
(SELECT id FROM households WHERE name = 'Main Household'),
(SELECT id FROM stores WHERE name = 'Costco'),
i.id,
ic.item_type,
ic.item_group,
ic.zone,
ic.confidence,
ic.source,
ic.created_at,
ic.updated_at
FROM item_classification ic
JOIN grocery_list gl ON ic.id = gl.id
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
WHERE NOT EXISTS (
SELECT 1 FROM household_item_classifications hic
WHERE hic.household_id = (SELECT id FROM households WHERE name = 'Main Household')
AND hic.store_id = (SELECT id FROM stores WHERE name = 'Costco')
AND hic.item_id = i.id
)
ON CONFLICT (household_id, store_id, item_id) DO NOTHING;
-- ============================================================================
-- STEP 7: MIGRATE GROCERY_HISTORY TO HOUSEHOLD_LIST_HISTORY
-- ============================================================================
-- Migrate history records
INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on)
SELECT
hl.id,
gh.quantity,
gh.added_by,
gh.added_on
FROM grocery_history gh
JOIN grocery_list gl ON gh.list_item_id = gl.id
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
JOIN household_lists hl ON hl.item_id = i.id
AND hl.household_id = (SELECT id FROM households WHERE name = 'Main Household')
AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco')
WHERE NOT EXISTS (
SELECT 1 FROM household_list_history hlh
WHERE hlh.household_list_id = hl.id
AND hlh.added_by = gh.added_by
AND hlh.added_on = gh.added_on
);
-- ============================================================================
-- STEP 8: UPDATE USER ROLES (SYSTEM-WIDE)
-- ============================================================================
-- Update system roles: admin → system_admin, others → user
UPDATE users
SET role = 'system_admin'
WHERE role = 'admin';
UPDATE users
SET role = 'user'
WHERE role IN ('editor', 'viewer');
-- ============================================================================
-- VERIFICATION QUERIES
-- ============================================================================
-- Run these to verify migration success:
-- Check household created
-- SELECT * FROM households;
-- Check all users added to household
-- SELECT u.username, u.role as system_role, hm.role as household_role
-- FROM users u
-- JOIN household_members hm ON u.id = hm.user_id
-- ORDER BY u.id;
-- Check items migrated
-- SELECT COUNT(*) as total_items FROM items;
-- SELECT COUNT(*) as original_items FROM (SELECT DISTINCT item_name FROM grocery_list) sub;
-- Check lists migrated
-- SELECT COUNT(*) as new_lists FROM household_lists;
-- SELECT COUNT(*) as old_lists FROM grocery_list;
-- Check classifications migrated
-- SELECT COUNT(*) as new_classifications FROM household_item_classifications;
-- SELECT COUNT(*) as old_classifications FROM item_classification;
-- Check history migrated
-- SELECT COUNT(*) as new_history FROM household_list_history;
-- SELECT COUNT(*) as old_history FROM grocery_history;
-- ============================================================================
-- MANUAL STEPS AFTER VERIFICATION
-- ============================================================================
-- After verifying data integrity, uncomment and run these to clean up:
-- DROP TABLE IF EXISTS grocery_history CASCADE;
-- DROP TABLE IF EXISTS item_classification CASCADE;
-- DROP TABLE IF EXISTS grocery_list CASCADE;
COMMIT;
-- ============================================================================
-- ROLLBACK (if something goes wrong)
-- ============================================================================
-- ROLLBACK;
-- Then restore from backup:
-- psql -U your_user -d grocery_list < backup_YYYYMMDD.sql

View File

@ -0,0 +1,65 @@
{
"generated_at": "2026-02-19T07:24:39.402Z",
"canonical_dir": "packages\\db\\migrations",
"legacy_dir": "backend\\migrations",
"stale_sql_files": [
{
"filename": "add_display_name_column.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
"canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f"
},
{
"filename": "add_image_columns.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a",
"canonical_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a"
},
{
"filename": "add_modified_on_column.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
"canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b"
},
{
"filename": "add_notes_column.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
"canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a"
},
{
"filename": "create_item_classification_table.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
"canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a"
},
{
"filename": "multi_household_architecture.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
"canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e"
}
],
"canonical_only_sql_files": [
{
"filename": "create_sessions_table.sql",
"status": "CANONICAL_ONLY",
"canonical_sha256": "d46e5147eb113042e9c2856d17b38715e66a486ee4d7c6450c960145791bc030"
},
{
"filename": "zz_group_invites_and_join_policies.sql",
"status": "CANONICAL_ONLY",
"canonical_sha256": "de955333667326f8eaf224431ecb62a5d0bd354fa0ccce34af6e52374e55d6e3"
}
],
"legacy_non_sql_files": [
"MIGRATION_GUIDE.md"
],
"summary": {
"stale_total": 6,
"stale_only_in_backend_total": 0,
"stale_duplicate_total": 6,
"stale_diverged_total": 0,
"canonical_only_total": 2
}
}

View File

@ -0,0 +1,392 @@
const pool = require("../db/pool");
function getExecutor(client) {
return client || pool;
}
async function withTransaction(handler) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await handler(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
async function getManageableGroupsForUser(userId, client) {
const result = await getExecutor(client).query(
`SELECT household_id AS group_id
FROM household_members
WHERE user_id = $1
AND role IN ('owner', 'admin')`,
[userId]
);
return result.rows;
}
async function getUserGroupRole(groupId, userId, client) {
const result = await getExecutor(client).query(
`SELECT role
FROM household_members
WHERE household_id = $1
AND user_id = $2`,
[groupId, userId]
);
return result.rows[0]?.role || null;
}
async function getGroupById(groupId, client) {
const result = await getExecutor(client).query(
`SELECT id, name
FROM households
WHERE id = $1`,
[groupId]
);
return result.rows[0] || null;
}
async function listInviteLinks(groupId, client) {
const result = await getExecutor(client).query(
`SELECT
id,
group_id,
created_by,
token,
policy,
single_use,
expires_at,
used_at,
revoked_at,
created_at
FROM group_invite_links
WHERE group_id = $1
ORDER BY created_at DESC`,
[groupId]
);
return result.rows;
}
async function createInviteLink(
{ groupId, createdBy, token, policy, singleUse, expiresAt },
client
) {
const result = await getExecutor(client).query(
`INSERT INTO group_invite_links (
group_id,
created_by,
token,
policy,
single_use,
expires_at
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING
id,
group_id,
created_by,
token,
policy,
single_use,
expires_at,
used_at,
revoked_at,
created_at`,
[groupId, createdBy, token, policy, singleUse, expiresAt]
);
return result.rows[0];
}
async function getInviteLinkById(groupId, linkId, client) {
const result = await getExecutor(client).query(
`SELECT
id,
group_id,
created_by,
token,
policy,
single_use,
expires_at,
used_at,
revoked_at,
created_at
FROM group_invite_links
WHERE group_id = $1
AND id = $2`,
[groupId, linkId]
);
return result.rows[0] || null;
}
async function revokeInviteLink(groupId, linkId, client) {
const result = await getExecutor(client).query(
`UPDATE group_invite_links
SET revoked_at = NOW()
WHERE group_id = $1
AND id = $2
RETURNING
id,
group_id,
created_by,
token,
policy,
single_use,
expires_at,
used_at,
revoked_at,
created_at`,
[groupId, linkId]
);
return result.rows[0] || null;
}
async function reviveInviteLink(groupId, linkId, expiresAt, client) {
const result = await getExecutor(client).query(
`UPDATE group_invite_links
SET used_at = NULL,
revoked_at = NULL,
expires_at = $3
WHERE group_id = $1
AND id = $2
RETURNING
id,
group_id,
created_by,
token,
policy,
single_use,
expires_at,
used_at,
revoked_at,
created_at`,
[groupId, linkId, expiresAt]
);
return result.rows[0] || null;
}
async function deleteInviteLink(groupId, linkId, client) {
const result = await getExecutor(client).query(
`DELETE FROM group_invite_links
WHERE group_id = $1
AND id = $2
RETURNING
id,
group_id,
created_by,
token,
policy,
single_use,
expires_at,
used_at,
revoked_at,
created_at`,
[groupId, linkId]
);
return result.rows[0] || null;
}
async function getInviteLinkSummaryByToken(token, client, forUpdate = false) {
const result = await getExecutor(client).query(
`SELECT
gil.id,
gil.group_id,
gil.created_by,
gil.token,
gil.policy,
gil.single_use,
gil.expires_at,
gil.used_at,
gil.revoked_at,
gil.created_at,
h.name AS group_name,
gs.join_policy AS current_join_policy
FROM group_invite_links gil
JOIN households h ON h.id = gil.group_id
LEFT JOIN group_settings gs ON gs.group_id = gil.group_id
WHERE gil.token = $1
${forUpdate ? "FOR UPDATE OF gil" : ""}`,
[token]
);
return result.rows[0] || null;
}
async function isGroupMember(groupId, userId, client) {
const result = await getExecutor(client).query(
`SELECT 1
FROM household_members
WHERE household_id = $1
AND user_id = $2`,
[groupId, userId]
);
return result.rows.length > 0;
}
async function getPendingJoinRequest(groupId, userId, client) {
const result = await getExecutor(client).query(
`SELECT id, group_id, user_id, status, created_at, updated_at
FROM group_join_requests
WHERE group_id = $1
AND user_id = $2
AND status = 'PENDING'`,
[groupId, userId]
);
return result.rows[0] || null;
}
async function createOrTouchPendingJoinRequest(groupId, userId, client) {
const executor = getExecutor(client);
const existing = await executor.query(
`UPDATE group_join_requests
SET updated_at = NOW()
WHERE group_id = $1
AND user_id = $2
AND status = 'PENDING'
RETURNING id, group_id, user_id, status, created_at, updated_at`,
[groupId, userId]
);
if (existing.rows[0]) {
return existing.rows[0];
}
try {
const inserted = await executor.query(
`INSERT INTO group_join_requests (group_id, user_id, status)
VALUES ($1, $2, 'PENDING')
RETURNING id, group_id, user_id, status, created_at, updated_at`,
[groupId, userId]
);
return inserted.rows[0];
} catch (error) {
if (error.code !== "23505") {
throw error;
}
const fallback = await executor.query(
`SELECT id, group_id, user_id, status, created_at, updated_at
FROM group_join_requests
WHERE group_id = $1
AND user_id = $2
AND status = 'PENDING'
LIMIT 1`,
[groupId, userId]
);
return fallback.rows[0] || null;
}
}
async function addGroupMember(groupId, userId, role = "member", client) {
const result = await getExecutor(client).query(
`INSERT INTO household_members (household_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (household_id, user_id) DO NOTHING
RETURNING id`,
[groupId, userId, role]
);
return result.rows.length > 0;
}
async function consumeSingleUseInvite(linkId, client) {
const result = await getExecutor(client).query(
`UPDATE group_invite_links
SET used_at = NOW(),
revoked_at = NOW()
WHERE id = $1
RETURNING id`,
[linkId]
);
return result.rows.length > 0;
}
async function getGroupSettings(groupId, client) {
const result = await getExecutor(client).query(
`SELECT group_id, join_policy
FROM group_settings
WHERE group_id = $1`,
[groupId]
);
return result.rows[0] || null;
}
async function upsertGroupSettings(groupId, joinPolicy, client) {
const result = await getExecutor(client).query(
`INSERT INTO group_settings (group_id, join_policy)
VALUES ($1, $2)
ON CONFLICT (group_id)
DO UPDATE SET
join_policy = EXCLUDED.join_policy,
updated_at = NOW()
RETURNING group_id, join_policy`,
[groupId, joinPolicy]
);
return result.rows[0];
}
async function createGroupAuditLog(
{
groupId,
actorUserId,
actorRole,
eventType,
requestId,
ip,
userAgent,
success = true,
errorCode = null,
metadata = {},
},
client
) {
const result = await getExecutor(client).query(
`INSERT INTO group_audit_log (
group_id,
actor_user_id,
actor_role,
event_type,
request_id,
ip,
user_agent,
success,
error_code,
metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
RETURNING id`,
[
groupId,
actorUserId,
actorRole,
eventType,
requestId,
ip,
userAgent,
success,
errorCode,
JSON.stringify(metadata || {}),
]
);
return result.rows[0];
}
module.exports = {
addGroupMember,
createGroupAuditLog,
createInviteLink,
createOrTouchPendingJoinRequest,
consumeSingleUseInvite,
deleteInviteLink,
getGroupById,
getGroupSettings,
getInviteLinkById,
getInviteLinkSummaryByToken,
getManageableGroupsForUser,
getPendingJoinRequest,
getUserGroupRole,
isGroupMember,
listInviteLinks,
revokeInviteLink,
reviveInviteLink,
upsertGroupSettings,
withTransaction,
};

View File

@ -0,0 +1,192 @@
const pool = require("../db/pool");
// Get all households a user belongs to
exports.getUserHouseholds = async (userId) => {
const result = await pool.query(
`SELECT
h.id,
h.name,
h.invite_code,
h.created_at,
hm.role,
hm.joined_at,
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
FROM households h
JOIN household_members hm ON h.id = hm.household_id
WHERE hm.user_id = $1
ORDER BY hm.joined_at DESC`,
[userId]
);
return result.rows;
};
// Get household by ID (with member check)
exports.getHouseholdById = async (householdId, userId) => {
const result = await pool.query(
`SELECT
h.id,
h.name,
h.invite_code,
h.created_at,
h.created_by,
hm.role as user_role,
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
FROM households h
LEFT JOIN household_members hm ON h.id = hm.household_id AND hm.user_id = $2
WHERE h.id = $1`,
[householdId, userId]
);
return result.rows[0];
};
// Create new household
exports.createHousehold = async (name, createdBy) => {
// Generate random 6-digit invite code
const inviteCode = 'H' + Math.random().toString(36).substring(2, 8).toUpperCase();
const result = await pool.query(
`INSERT INTO households (name, created_by, invite_code)
VALUES ($1, $2, $3)
RETURNING id, name, invite_code, created_at`,
[name, createdBy, inviteCode]
);
// Add creator as admin
await pool.query(
`INSERT INTO household_members (household_id, user_id, role)
VALUES ($1, $2, 'owner')`,
[result.rows[0].id, createdBy]
);
return result.rows[0];
};
// Update household
exports.updateHousehold = async (householdId, updates) => {
const { name } = updates;
const result = await pool.query(
`UPDATE households
SET name = COALESCE($1, name)
WHERE id = $2
RETURNING id, name, invite_code, created_at`,
[name, householdId]
);
return result.rows[0];
};
// Delete household
exports.deleteHousehold = async (householdId) => {
await pool.query('DELETE FROM households WHERE id = $1', [householdId]);
};
// Refresh invite code
exports.refreshInviteCode = async (householdId) => {
const inviteCode = 'H' + Math.random().toString(36).substring(2, 8).toUpperCase();
const result = await pool.query(
`UPDATE households
SET invite_code = $1, code_expires_at = NULL
WHERE id = $2
RETURNING id, name, invite_code`,
[inviteCode, householdId]
);
return result.rows[0];
};
// Join household via invite code
exports.joinHousehold = async (inviteCode, userId) => {
const householdResult = await pool.query(
`SELECT id, name FROM households
WHERE invite_code = $1
AND (code_expires_at IS NULL OR code_expires_at > NOW())`,
[inviteCode]
);
if (householdResult.rows.length === 0) return null;
const household = householdResult.rows[0];
const existingMember = await pool.query(
`SELECT id FROM household_members
WHERE household_id = $1 AND user_id = $2`,
[household.id, userId]
);
if (existingMember.rows.length > 0) return { ...household, alreadyMember: true };
// Add as user role
await pool.query(
`INSERT INTO household_members (household_id, user_id, role)
VALUES ($1, $2, 'member')`,
[household.id, userId]
);
return { ...household, alreadyMember: false };
};
// Get household members
exports.getHouseholdMembers = async (householdId) => {
const result = await pool.query(
`SELECT
u.id,
u.username,
u.name,
u.display_name,
hm.role,
hm.joined_at
FROM household_members hm
JOIN users u ON hm.user_id = u.id
WHERE hm.household_id = $1
ORDER BY
CASE hm.role
WHEN 'owner' THEN 1
WHEN 'admin' THEN 2
WHEN 'member' THEN 3
END,
hm.joined_at ASC`,
[householdId]
);
return result.rows;
};
// Update member role
exports.updateMemberRole = async (householdId, userId, newRole) => {
const result = await pool.query(
`UPDATE household_members
SET role = $1
WHERE household_id = $2 AND user_id = $3
RETURNING user_id, role`,
[newRole, householdId, userId]
);
return result.rows[0];
};
// Remove member from household
exports.removeMember = async (householdId, userId) => {
await pool.query(
`DELETE FROM household_members
WHERE household_id = $1 AND user_id = $2`,
[householdId, userId]
);
};
// Get user's role in household
exports.getUserRole = async (householdId, userId) => {
const result = await pool.query(
`SELECT role FROM household_members
WHERE household_id = $1 AND user_id = $2`,
[householdId, userId]
);
return result.rows[0]?.role || null;
};
// Check if user is household member
exports.isHouseholdMember = async (householdId, userId) => {
const result = await pool.query(
`SELECT 1 FROM household_members
WHERE household_id = $1 AND user_id = $2`,
[householdId, userId]
);
return result.rows.length > 0;
};

View File

@ -0,0 +1,409 @@
const pool = require("../db/pool");
/**
* Get list items for a specific household and store
* @param {number} householdId - Household ID
* @param {number} storeId - Store ID
* @param {boolean} includeHistory - Include purchase history
* @returns {Promise<Array>} List of items
*/
exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => {
const result = await pool.query(
`SELECT
hl.id,
i.name AS item_name,
hl.quantity,
hl.bought,
ENCODE(hl.custom_image, 'base64') as item_image,
hl.custom_image_mime_type as image_mime_type,
${includeHistory ? `
(
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
FROM (
SELECT DISTINCT
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
FROM household_list_history hlh
JOIN users u ON hlh.added_by = u.id
WHERE hlh.household_list_id = hl.id
) added_by_labels
) as added_by_users,
` : 'NULL as added_by_users,'}
hl.modified_on as last_added_on,
hic.item_type,
hic.item_group,
hic.zone
FROM household_lists hl
JOIN items i ON hl.item_id = i.id
LEFT JOIN household_item_classifications hic
ON hl.household_id = hic.household_id
AND hl.item_id = hic.item_id
WHERE hl.household_id = $1
AND hl.store_id = $2
AND hl.bought = FALSE
ORDER BY hl.id ASC`,
[householdId, storeId]
);
return result.rows;
};
/**
* Get a specific item from household list by name
* @param {number} householdId - Household ID
* @param {number} storeId - Store ID
* @param {string} itemName - Item name to search for
* @returns {Promise<Object|null>} Item or null
*/
exports.getItemByName = async (householdId, storeId, itemName) => {
// First check if item exists in master catalog
const itemResult = await pool.query(
"SELECT id FROM items WHERE name ILIKE $1",
[itemName]
);
if (itemResult.rowCount === 0) {
return null;
}
const itemId = itemResult.rows[0].id;
const result = await pool.query(
`SELECT
hl.id,
i.name AS item_name,
hl.quantity,
hl.bought,
ENCODE(hl.custom_image, 'base64') as item_image,
hl.custom_image_mime_type as image_mime_type,
(
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
FROM (
SELECT DISTINCT
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
FROM household_list_history hlh
JOIN users u ON hlh.added_by = u.id
WHERE hlh.household_list_id = hl.id
) added_by_labels
) as added_by_users,
hl.modified_on as last_added_on,
hic.item_type,
hic.item_group,
hic.zone
FROM household_lists hl
JOIN items i ON hl.item_id = i.id
LEFT JOIN household_item_classifications hic
ON hl.household_id = hic.household_id
AND hl.item_id = hic.item_id
WHERE hl.household_id = $1
AND hl.store_id = $2
AND hl.item_id = $3`,
[householdId, storeId, itemId]
);
return result.rows[0] || null;
};
/**
* Add or update an item in household list
* @param {number} householdId - Household ID
* @param {number} storeId - Store ID
* @param {string} itemName - Item name
* @param {number} quantity - Quantity
* @param {number} userId - User adding the item
* @param {Buffer|null} imageBuffer - Image buffer
* @param {string|null} mimeType - MIME type
* @returns {Promise<number>} List item ID
*/
exports.addOrUpdateItem = async (
householdId,
storeId,
itemName,
quantity,
userId,
imageBuffer = null,
mimeType = null
) => {
const lowerItemName = itemName.toLowerCase();
let itemResult = await pool.query(
"SELECT id FROM items WHERE name ILIKE $1",
[lowerItemName]
);
let itemId;
if (itemResult.rowCount === 0) {
const insertItem = await pool.query(
"INSERT INTO items (name) VALUES ($1) RETURNING id",
[lowerItemName]
);
itemId = insertItem.rows[0].id;
} else {
itemId = itemResult.rows[0].id;
}
const listResult = await pool.query(
`SELECT id, bought FROM household_lists
WHERE household_id = $1
AND store_id = $2
AND item_id = $3`,
[householdId, storeId, itemId]
);
if (listResult.rowCount > 0) {
const listId = listResult.rows[0].id;
if (imageBuffer && mimeType) {
await pool.query(
`UPDATE household_lists
SET quantity = $1,
bought = FALSE,
custom_image = $2,
custom_image_mime_type = $3,
modified_on = NOW()
WHERE id = $4`,
[quantity, imageBuffer, mimeType, listId]
);
} else {
await pool.query(
`UPDATE household_lists
SET quantity = $1,
bought = FALSE,
modified_on = NOW()
WHERE id = $2`,
[quantity, listId]
);
}
return listId;
} else {
const insert = await pool.query(
`INSERT INTO household_lists
(household_id, store_id, item_id, quantity, custom_image, custom_image_mime_type)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
);
return insert.rows[0].id;
}
};
/**
* Mark item as bought (full or partial)
* @param {number} listId - List item ID
* @param {boolean} bought - True to mark as bought, false to unmark
* @param {number} quantityBought - Optional quantity bought (for partial purchases)
*/
exports.setBought = async (listId, bought, quantityBought = null) => {
if (bought === false) {
// Unmarking - just set bought to false
await pool.query(
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
[listId]
);
return;
}
// Marking as bought
if (quantityBought && quantityBought > 0) {
// Partial purchase - reduce quantity
const item = await pool.query(
"SELECT quantity FROM household_lists WHERE id = $1",
[listId]
);
if (!item.rows[0]) return;
const currentQuantity = item.rows[0].quantity;
const remainingQuantity = currentQuantity - quantityBought;
if (remainingQuantity <= 0) {
// All bought - mark as bought
await pool.query(
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[listId]
);
} else {
// Partial - reduce quantity
await pool.query(
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
[remainingQuantity, listId]
);
}
} else {
// Full purchase - mark as bought
await pool.query(
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[listId]
);
}
};
/**
* Add history record for item addition
* @param {number} listId - List item ID
* @param {number} quantity - Quantity added
* @param {number} userId - User who added
*/
exports.addHistoryRecord = async (listId, quantity, userId) => {
await pool.query(
`INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on)
VALUES ($1, $2, $3, NOW())`,
[listId, quantity, userId]
);
};
/**
* Get suggestions for autocomplete
* @param {string} query - Search query
* @param {number} householdId - Household ID (for personalized suggestions)
* @param {number} storeId - Store ID
* @returns {Promise<Array>} Suggestions
*/
exports.getSuggestions = async (query, householdId, storeId) => {
// Get items from both master catalog and household history
const result = await pool.query(
`SELECT DISTINCT
i.name as item_name,
CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END as sort_order
FROM items i
LEFT JOIN household_lists hl
ON i.id = hl.item_id
AND hl.household_id = $2
AND hl.store_id = $3
WHERE i.name ILIKE $1
ORDER BY sort_order, i.name
LIMIT 10`,
[`%${query}%`, householdId, storeId]
);
return result.rows;
};
/**
* Get recently bought items for household/store
* @param {number} householdId - Household ID
* @param {number} storeId - Store ID
* @returns {Promise<Array>} Recently bought items
*/
exports.getRecentlyBoughtItems = async (householdId, storeId) => {
const result = await pool.query(
`SELECT
hl.id,
i.name AS item_name,
hl.quantity,
hl.bought,
ENCODE(hl.custom_image, 'base64') as item_image,
hl.custom_image_mime_type as image_mime_type,
(
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
FROM (
SELECT DISTINCT
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
FROM household_list_history hlh
JOIN users u ON hlh.added_by = u.id
WHERE hlh.household_list_id = hl.id
) added_by_labels
) as added_by_users,
hl.modified_on as last_added_on
FROM household_lists hl
JOIN items i ON hl.item_id = i.id
WHERE hl.household_id = $1
AND hl.store_id = $2
AND hl.bought = TRUE
AND hl.modified_on >= NOW() - INTERVAL '24 hours'
ORDER BY hl.modified_on DESC`,
[householdId, storeId]
);
return result.rows;
};
/**
* Get classification for household item
* @param {number} householdId - Household ID
* @param {number} itemId - Item ID
* @returns {Promise<Object|null>} Classification or null
*/
exports.getClassification = async (householdId, itemId) => {
const result = await pool.query(
`SELECT item_type, item_group, zone, confidence, source
FROM household_item_classifications
WHERE household_id = $1 AND item_id = $2`,
[householdId, itemId]
);
return result.rows[0] || null;
};
/**
* Upsert classification for household item
* @param {number} householdId - Household ID
* @param {number} itemId - Item ID
* @param {Object} classification - Classification data
* @returns {Promise<Object>} Updated classification
*/
exports.upsertClassification = async (householdId, itemId, classification) => {
const { item_type, item_group, zone, confidence, source } = classification;
const result = await pool.query(
`INSERT INTO household_item_classifications
(household_id, item_id, item_type, item_group, zone, confidence, source)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (household_id, item_id)
DO UPDATE SET
item_type = EXCLUDED.item_type,
item_group = EXCLUDED.item_group,
zone = EXCLUDED.zone,
confidence = EXCLUDED.confidence,
source = EXCLUDED.source
RETURNING *`,
[householdId, itemId, item_type, item_group, zone, confidence, source]
);
return result.rows[0];
};
/**
* Update list item details
* @param {number} listId - List item ID
* @param {string} itemName - New item name (optional)
* @param {number} quantity - New quantity (optional)
* @param {string} notes - Notes (optional)
* @returns {Promise<Object>} Updated item
*/
exports.updateItem = async (listId, itemName, quantity, notes) => {
// Build dynamic update query
const updates = [];
const values = [listId];
let paramCount = 1;
if (quantity !== undefined) {
paramCount++;
updates.push(`quantity = $${paramCount}`);
values.push(quantity);
}
if (notes !== undefined) {
paramCount++;
updates.push(`notes = $${paramCount}`);
values.push(notes);
}
// Always update modified_on
updates.push(`modified_on = NOW()`);
if (updates.length === 1) {
// Only modified_on update
const result = await pool.query(
`UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *`,
[listId]
);
return result.rows[0];
}
const result = await pool.query(
`UPDATE household_lists SET ${updates.join(', ')} WHERE id = $1 RETURNING *`,
values
);
return result.rows[0];
};
/**
* Delete a list item
* @param {number} listId - List item ID
*/
exports.deleteItem = async (listId) => {
await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]);
};

View File

@ -0,0 +1,123 @@
const crypto = require("crypto");
const pool = require("../db/pool");
const { SESSION_TTL_DAYS } = require("../utils/session-cookie");
const INSERT_SESSION_SQL = `INSERT INTO sessions (id, user_id, expires_at, user_agent)
VALUES ($1, $2, NOW() + ($3 || ' days')::interval, $4)
RETURNING id, user_id, created_at, expires_at`;
const SELECT_ACTIVE_SESSION_SQL = `SELECT
s.id,
s.user_id,
s.expires_at,
u.username,
u.role
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = $1
AND s.expires_at > NOW()`;
let ensureSessionsTablePromise = null;
function generateSessionId() {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID().replace(/-/g, "") + crypto.randomBytes(8).toString("hex");
}
return crypto.randomBytes(32).toString("hex");
}
function isUndefinedTableError(error) {
return error && error.code === "42P01";
}
async function ensureSessionsTable() {
if (!ensureSessionsTablePromise) {
ensureSessionsTablePromise = (async () => {
await pool.query(`CREATE TABLE IF NOT EXISTS sessions (
id VARCHAR(128) PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
user_agent TEXT
);`);
await pool.query(
"CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);"
);
await pool.query(
"CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);"
);
})().catch((error) => {
ensureSessionsTablePromise = null;
throw error;
});
}
await ensureSessionsTablePromise;
}
async function insertSession(id, userId, userAgent) {
const result = await pool.query(INSERT_SESSION_SQL, [
id,
userId,
String(SESSION_TTL_DAYS),
userAgent,
]);
return result.rows[0];
}
exports.createSession = async (userId, userAgent = null) => {
const id = generateSessionId();
try {
return await insertSession(id, userId, userAgent);
} catch (error) {
if (!isUndefinedTableError(error)) {
throw error;
}
await ensureSessionsTable();
return insertSession(id, userId, userAgent);
}
};
exports.getActiveSessionWithUser = async (sessionId) => {
let result;
try {
result = await pool.query(SELECT_ACTIVE_SESSION_SQL, [sessionId]);
} catch (error) {
if (isUndefinedTableError(error)) {
return null;
}
throw error;
}
const session = result.rows[0] || null;
if (!session) return null;
try {
await pool.query(
`UPDATE sessions
SET last_seen_at = NOW()
WHERE id = $1`,
[sessionId]
);
} catch (error) {
if (!isUndefinedTableError(error)) {
throw error;
}
}
return session;
};
exports.deleteSession = async (sessionId) => {
try {
await pool.query(
`DELETE FROM sessions WHERE id = $1`,
[sessionId]
);
} catch (error) {
if (!isUndefinedTableError(error)) {
throw error;
}
}
};

View File

@ -0,0 +1,143 @@
const pool = require("../db/pool");
// Get all available stores
exports.getAllStores = async () => {
const result = await pool.query(
`SELECT id, name, default_zones, created_at
FROM stores
ORDER BY name ASC`
);
return result.rows;
};
// Get store by ID
exports.getStoreById = async (storeId) => {
const result = await pool.query(
`SELECT id, name, default_zones, created_at
FROM stores
WHERE id = $1`,
[storeId]
);
return result.rows[0];
};
// Get stores for a specific household
exports.getHouseholdStores = async (householdId) => {
const result = await pool.query(
`SELECT
s.id,
s.name,
s.default_zones,
hs.is_default,
hs.added_at
FROM stores s
JOIN household_stores hs ON s.id = hs.store_id
WHERE hs.household_id = $1
ORDER BY hs.is_default DESC, s.name ASC`,
[householdId]
);
return result.rows;
};
// Add store to household
exports.addStoreToHousehold = async (householdId, storeId, isDefault = false) => {
// If setting as default, unset other defaults
if (isDefault) {
await pool.query(
`UPDATE household_stores
SET is_default = FALSE
WHERE household_id = $1`,
[householdId]
);
}
const result = await pool.query(
`INSERT INTO household_stores (household_id, store_id, is_default)
VALUES ($1, $2, $3)
ON CONFLICT (household_id, store_id)
DO UPDATE SET is_default = $3
RETURNING household_id, store_id, is_default`,
[householdId, storeId, isDefault]
);
return result.rows[0];
};
// Remove store from household
exports.removeStoreFromHousehold = async (householdId, storeId) => {
await pool.query(
`DELETE FROM household_stores
WHERE household_id = $1 AND store_id = $2`,
[householdId, storeId]
);
};
// Set default store for household
exports.setDefaultStore = async (householdId, storeId) => {
// Unset all defaults
await pool.query(
`UPDATE household_stores
SET is_default = FALSE
WHERE household_id = $1`,
[householdId]
);
// Set new default
await pool.query(
`UPDATE household_stores
SET is_default = TRUE
WHERE household_id = $1 AND store_id = $2`,
[householdId, storeId]
);
};
// Create new store (system admin only)
exports.createStore = async (name, defaultZones) => {
const result = await pool.query(
`INSERT INTO stores (name, default_zones)
VALUES ($1, $2)
RETURNING id, name, default_zones, created_at`,
[name, JSON.stringify(defaultZones)]
);
return result.rows[0];
};
// Update store (system admin only)
exports.updateStore = async (storeId, updates) => {
const { name, default_zones } = updates;
const result = await pool.query(
`UPDATE stores
SET
name = COALESCE($1, name),
default_zones = COALESCE($2, default_zones)
WHERE id = $3
RETURNING id, name, default_zones, created_at`,
[name, default_zones ? JSON.stringify(default_zones) : null, storeId]
);
return result.rows[0];
};
// Delete store (system admin only, only if not in use)
exports.deleteStore = async (storeId) => {
// Check if store is in use
const usage = await pool.query(
`SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`,
[storeId]
);
if (parseInt(usage.rows[0].count) > 0) {
throw new Error('Cannot delete store that is in use by households');
}
await pool.query('DELETE FROM stores WHERE id = $1', [storeId]);
};
// Check if household has store
exports.householdHasStore = async (householdId, storeId) => {
const result = await pool.query(
`SELECT 1 FROM household_stores
WHERE household_id = $1 AND store_id = $2`,
[householdId, storeId]
);
return result.rows.length > 0;
};

View File

@ -1,23 +1,21 @@
const pool = require("../db/pool"); const pool = require("../db/pool");
exports.ROLES = { exports.ROLES = {
VIEWER: "viewer", SYSTEM_ADMIN: "system_admin",
EDITOR: "editor", USER: "user",
ADMIN: "admin",
} }
exports.findByUsername = async (username) => { exports.findByUsername = async (username) => {
query = `SELECT * FROM users WHERE username = ${username}`;
const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]); const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]);
console.log(query);
return result.rows[0]; return result.rows[0];
}; };
exports.createUser = async (username, hashedPassword, name) => { exports.createUser = async (username, hashedPassword, name) => {
const result = await pool.query( const result = await pool.query(
`INSERT INTO users (username, password, name, role) `INSERT INTO users (username, password, name, role)
VALUES ($1, $2, $3, $4)`, VALUES ($1, $2, $3, $4)
[username, hashedPassword, name, this.ROLES.VIEWER] RETURNING id, username, name, role`,
[username, hashedPassword, name, exports.ROLES.USER]
); );
return result.rows[0]; return result.rows[0];
}; };

View File

@ -0,0 +1,43 @@
# API Test Suite
The test suite has been reorganized into separate files for better maintainability:
## New Modular Structure (✅ Complete)
- **api-tests.html** - Main HTML file
- **test-config.js** - Global state management
- **test-definitions.js** - All 62 test cases across 8 categories
- **test-runner.js** - Test execution logic
- **test-ui.js** - UI manipulation functions
- **test-styles.css** - All CSS styles
## How to Use
1. Start the dev server: `docker-compose -f docker-compose.dev.yml up`
2. Navigate to: `http://localhost:5000/test/api-tests.html`
3. Configure credentials (default: admin/admin123)
4. Click "▶ Run All Tests"
## Features
- ✅ 62 comprehensive tests
- ✅ Collapsible test cards (collapsed by default)
- ✅ Expected field validation with visual indicators
- ✅ Color-coded HTTP status badges
- ✅ Auto-expansion on test run
- ✅ Expand/Collapse all buttons
- ✅ Real-time pass/fail/error states
- ✅ Summary dashboard
## File Structure
```
backend/public/
├── api-tests.html # Main entry point (use this)
├── test-config.js # State management (19 lines)
├── test-definitions.js # Test cases (450+ lines)
├── test-runner.js # Test execution (160+ lines)
├── test-ui.js # UI functions (90+ lines)
└── test-styles.css # All styles (310+ lines)
```
## Old File
- **api-test.html** - Original monolithic version (kept for reference)
Total: ~1030 lines split into 6 clean, modular files

1037
backend/public/api-test.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Test Suite - Grocery List</title>
<link rel="stylesheet" href="test-styles.css">
</head>
<body>
<div class="container">
<h1>🧪 API Test Suite</h1>
<p style="color: #666; margin-bottom: 20px;">Multi-Household Grocery List API Testing</p>
<div class="config">
<h3 style="margin-bottom: 15px;">Configuration</h3>
<div class="config-row">
<label>API URL:</label>
<input type="text" id="apiUrl" value="http://localhost:5000" />
</div>
<div class="config-row">
<label>Username:</label>
<input type="text" id="username" value="admin" />
</div>
<div class="config-row">
<label>Password:</label>
<input type="password" id="password" value="admin123" />
</div>
</div>
<div class="actions">
<button onclick="runAllTests(event)">▶ Run All Tests</button>
<button onclick="clearResults()">🗑 Clear Results</button>
<button onclick="expandAllTests()">📂 Expand All</button>
<button onclick="collapseAllTests()">📁 Collapse All</button>
</div>
<div class="summary" id="summary" style="display: none;">
<div class="summary-item total">
<div class="summary-value" id="totalTests">0</div>
<div class="summary-label">Total Tests</div>
</div>
<div class="summary-item pass">
<div class="summary-value" id="passedTests">0</div>
<div class="summary-label">Passed</div>
</div>
<div class="summary-item fail">
<div class="summary-value" id="failedTests">0</div>
<div class="summary-label">Failed</div>
</div>
</div>
<div id="testResults"></div>
</div>
<script src="test-config.js"></script>
<script src="test-definitions.js"></script>
<script src="test-runner.js"></script>
<script src="test-ui.js"></script>
</body>
</html>

View File

@ -0,0 +1,19 @@
// Global state
let authToken = null;
let householdId = null;
let storeId = null;
let testUserId = null;
let createdHouseholdId = null;
let secondHouseholdId = null;
let inviteCode = null;
// Reset state
function resetState() {
authToken = null;
householdId = null;
storeId = null;
testUserId = null;
createdHouseholdId = null;
secondHouseholdId = null;
inviteCode = null;
}

View File

@ -0,0 +1,826 @@
// Test definitions - 108 tests across 14 categories
const tests = [
{
category: "Authentication",
tests: [
{
name: "Login with valid credentials",
method: "POST",
endpoint: "/auth/login",
auth: false,
body: () => ({ username: document.getElementById('username').value, password: document.getElementById('password').value }),
expect: (res) => res.token && res.role,
expectedFields: ['token', 'username', 'role'],
onSuccess: (res) => { authToken = res.token; }
},
{
name: "Login with invalid credentials",
method: "POST",
endpoint: "/auth/login",
auth: false,
body: { username: "wronguser", password: "wrongpass" },
expectFail: true,
expect: (res, status) => status === 401,
expectedFields: ['message']
},
{
name: "Access protected route without token",
method: "GET",
endpoint: "/households",
auth: false,
expectFail: true,
expect: (res, status) => status === 401
}
]
},
{
category: "Households",
tests: [
{
name: "Get user's households",
method: "GET",
endpoint: "/households",
auth: true,
expect: (res) => Array.isArray(res),
onSuccess: (res) => { if (res.length > 0) householdId = res[0].id; }
},
{
name: "Create new household",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: `Test Household ${Date.now()}` },
expect: (res) => res.household && res.household.invite_code,
expectedFields: ['message', 'household', 'household.id', 'household.name', 'household.invite_code']
},
{
name: "Get household details",
method: "GET",
endpoint: () => `/households/${householdId}`,
auth: true,
skip: () => !householdId,
expect: (res) => res.id === householdId,
expectedFields: ['id', 'name', 'invite_code', 'created_at']
},
{
name: "Update household name",
method: "PATCH",
endpoint: () => `/households/${householdId}`,
auth: true,
skip: () => !householdId,
body: { name: `Updated Household ${Date.now()}` },
expect: (res) => res.household,
expectedFields: ['message', 'household', 'household.id', 'household.name']
},
{
name: "Refresh invite code",
method: "POST",
endpoint: () => `/households/${householdId}/invite/refresh`,
auth: true,
skip: () => !householdId,
expect: (res) => res.household && res.household.invite_code,
expectedFields: ['message', 'household', 'household.invite_code']
},
{
name: "Join household with invalid code",
method: "POST",
endpoint: "/households/join/INVALID123",
auth: true,
expectFail: true,
expect: (res, status) => status === 404
},
{
name: "Create household with empty name (validation)",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: "" },
expectFail: true,
expect: (res, status) => status === 400,
expectedFields: ['error']
}
]
},
{
category: "Members",
tests: [
{
name: "Get household members",
method: "GET",
endpoint: () => `/households/${householdId}/members`,
auth: true,
skip: () => !householdId,
expect: (res) => Array.isArray(res) && res.length > 0,
onSuccess: (res) => { testUserId = res[0].user_id; }
},
{
name: "Update member role (non-admin attempting)",
method: "PATCH",
endpoint: () => `/households/${householdId}/members/${testUserId}/role`,
auth: true,
skip: () => !householdId || !testUserId,
body: { role: "user" },
expectFail: true,
expect: (res, status) => status === 400 || status === 403
}
]
},
{
category: "Stores",
tests: [
{
name: "Get all stores catalog",
method: "GET",
endpoint: "/stores",
auth: true,
expect: (res) => Array.isArray(res),
onSuccess: (res) => { if (res.length > 0) storeId = res[0].id; }
},
{
name: "Get household stores",
method: "GET",
endpoint: () => `/stores/household/${householdId}`,
auth: true,
skip: () => !householdId,
expect: (res) => Array.isArray(res)
},
{
name: "Add store to household",
method: "POST",
endpoint: () => `/stores/household/${householdId}`,
auth: true,
skip: () => !householdId || !storeId,
body: () => ({ storeId: storeId, isDefault: true }),
expect: (res) => res.store,
expectedFields: ['message', 'store', 'store.id', 'store.name']
},
{
name: "Set default store",
method: "PATCH",
endpoint: () => `/stores/household/${householdId}/${storeId}/default`,
auth: true,
skip: () => !householdId || !storeId,
expect: (res) => res.message
},
{
name: "Add invalid store to household",
method: "POST",
endpoint: () => `/stores/household/${householdId}`,
auth: true,
skip: () => !householdId,
body: { storeId: 99999 },
expectFail: true,
expect: (res, status) => status === 404
}
]
},
{
category: "Advanced Household Tests",
tests: [
{
name: "Create household for complex workflows",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: `Workflow Test ${Date.now()}` },
expect: (res) => res.household && res.household.id,
onSuccess: (res) => {
createdHouseholdId = res.household.id;
inviteCode = res.household.invite_code;
}
},
{
name: "Verify invite code format (7 chars)",
method: "GET",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => res.invite_code && res.invite_code.length === 7 && res.invite_code.startsWith('H')
},
{
name: "Get household with no stores added yet",
method: "GET",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => Array.isArray(res) && res.length === 0
},
{
name: "Update household with very long name (validation)",
method: "PATCH",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
body: { name: "A".repeat(101) },
expectFail: true,
expect: (res, status) => status === 400
},
{
name: "Refresh invite code changes value",
method: "POST",
endpoint: () => `/households/${createdHouseholdId}/invite/refresh`,
auth: true,
skip: () => !createdHouseholdId || !inviteCode,
expect: (res) => res.household && res.household.invite_code !== inviteCode,
onSuccess: (res) => { inviteCode = res.household.invite_code; }
},
{
name: "Join same household twice (idempotent)",
method: "POST",
endpoint: () => `/households/join/${inviteCode}`,
auth: true,
skip: () => !inviteCode,
expect: (res, status) => status === 200 && res.message.includes("already a member")
},
{
name: "Get non-existent household",
method: "GET",
endpoint: "/households/99999",
auth: true,
expectFail: true,
expect: (res, status) => status === 404
},
{
name: "Update non-existent household",
method: "PATCH",
endpoint: "/households/99999",
auth: true,
body: { name: "Test" },
expectFail: true,
expect: (res, status) => status === 403 || status === 404
}
]
},
{
category: "Member Management Edge Cases",
tests: [
{
name: "Get members for created household",
method: "GET",
endpoint: () => `/households/${createdHouseholdId}/members`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => Array.isArray(res) && res.length >= 1 && res[0].role === 'admin'
},
{
name: "Update own role (should fail)",
method: "PATCH",
endpoint: () => `/households/${createdHouseholdId}/members/${testUserId}/role`,
auth: true,
skip: () => !createdHouseholdId || !testUserId,
body: { role: "user" },
expectFail: true,
expect: (res, status) => status === 400 && res.error && res.error.includes("own role")
},
{
name: "Update role with invalid value",
method: "PATCH",
endpoint: () => `/households/${createdHouseholdId}/members/1/role`,
auth: true,
skip: () => !createdHouseholdId,
body: { role: "superadmin" },
expectFail: true,
expect: (res, status) => status === 400
},
{
name: "Remove non-existent member",
method: "DELETE",
endpoint: () => `/households/${createdHouseholdId}/members/99999`,
auth: true,
skip: () => !createdHouseholdId,
expectFail: true,
expect: (res, status) => status === 404 || status === 500
}
]
},
{
category: "Store Management Advanced",
tests: [
{
name: "Add multiple stores to household",
method: "POST",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
body: () => ({ storeId: storeId, isDefault: false }),
expect: (res) => res.store
},
{
name: "Add same store twice (duplicate check)",
method: "POST",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
body: () => ({ storeId: storeId, isDefault: false }),
expectFail: true,
expect: (res, status) => status === 400 || status === 409 || status === 500
},
{
name: "Set default store for household",
method: "PATCH",
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}/default`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
expect: (res) => res.message
},
{
name: "Verify default store is first in list",
method: "GET",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
expect: (res) => Array.isArray(res) && res.length > 0 && res[0].is_default === true
},
{
name: "Set non-existent store as default",
method: "PATCH",
endpoint: () => `/stores/household/${createdHouseholdId}/99999/default`,
auth: true,
skip: () => !createdHouseholdId,
expectFail: true,
expect: (res, status) => status === 404 || status === 500
},
{
name: "Remove store from household",
method: "DELETE",
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
expect: (res) => res.message
},
{
name: "Verify store removed from household",
method: "GET",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => Array.isArray(res) && res.length === 0
}
]
},
{
category: "Data Integrity & Cleanup",
tests: [
{
name: "Create second household for testing",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: `Second Test ${Date.now()}` },
expect: (res) => res.household && res.household.id,
onSuccess: (res) => { secondHouseholdId = res.household.id; }
},
{
name: "Verify user belongs to multiple households",
method: "GET",
endpoint: "/households",
auth: true,
expect: (res) => Array.isArray(res) && res.length >= 3
},
{
name: "Delete created test household",
method: "DELETE",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => res.message
},
{
name: "Verify deleted household is gone",
method: "GET",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expectFail: true,
expect: (res, status) => status === 404 || status === 403
},
{
name: "Delete second test household",
method: "DELETE",
endpoint: () => `/households/${secondHouseholdId}`,
auth: true,
skip: () => !secondHouseholdId,
expect: (res) => res.message
},
{
name: "Verify households list updated",
method: "GET",
endpoint: "/households",
auth: true,
expect: (res) => Array.isArray(res)
}
]
},
{
category: "List Operations",
tests: [
{
name: "Get grocery list for household+store",
method: "GET",
endpoint: () => `/households/${householdId}/stores/${storeId}/list`,
auth: true,
skip: () => !householdId || !storeId,
expect: (res) => Array.isArray(res),
expectedFields: ['items']
},
{
name: "Add item to list",
method: "POST",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Test API Item",
quantity: "2 units"
},
expect: (res) => res.item,
expectedFields: ['item', 'item.id', 'item.item_name', 'item.quantity']
},
{
name: "Add duplicate item (should update quantity)",
method: "POST",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Test API Item",
quantity: "3 units"
},
expect: (res) => res.item && res.item.quantity === "3 units"
},
{
name: "Mark item as bought",
method: "PATCH",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Test API Item",
bought: true
},
expect: (res) => res.message,
expectedFields: ['message']
},
{
name: "Unmark item (set bought to false)",
method: "PATCH",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Test API Item",
bought: false
},
expect: (res) => res.message
},
{
name: "Update item details",
method: "PUT",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Test API Item",
quantity: "5 units",
notes: "Updated via API test"
},
expect: (res) => res.item,
expectedFields: ['item', 'item.quantity', 'item.notes']
},
{
name: "Get suggestions based on history",
method: "GET",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/suggestions?query=test`,
auth: true,
skip: () => !householdId || !storeId,
expect: (res) => Array.isArray(res)
},
{
name: "Get recently bought items",
method: "GET",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/recent`,
auth: true,
skip: () => !householdId || !storeId,
expect: (res) => Array.isArray(res)
},
{
name: "Delete item from list",
method: "DELETE",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Test API Item"
},
expect: (res) => res.message
},
{
name: "Try to add item with empty name",
method: "POST",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "",
quantity: "1"
},
expectFail: true,
expect: (res, status) => status === 400
}
]
},
{
category: "Item Classifications",
tests: [
{
name: "Get item classification",
method: "GET",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification?item_name=Milk`,
auth: true,
skip: () => !householdId || !storeId,
expect: (res) => res.classification !== undefined,
expectedFields: ['classification']
},
{
name: "Set item classification",
method: "POST",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Test Classified Item",
classification: "dairy"
},
expect: (res) => res.message || res.classification
},
{
name: "Update item classification",
method: "POST",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Test Classified Item",
classification: "produce"
},
expect: (res) => res.message || res.classification
},
{
name: "Verify classification persists",
method: "GET",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification?item_name=Test Classified Item`,
auth: true,
skip: () => !householdId || !storeId,
expect: (res) => res.classification === "produce"
}
]
},
{
category: "Account Management",
tests: [
{
name: "Get current user profile",
method: "GET",
endpoint: "/users/me",
auth: true,
expect: (res) => res.username,
expectedFields: ['id', 'username', 'name', 'display_name', 'role'],
onSuccess: (res) => { testUserId = res.id; }
},
{
name: "Update display name",
method: "PATCH",
endpoint: "/users/me/display-name",
auth: true,
body: {
display_name: "Test Display Name"
},
expect: (res) => res.message,
expectedFields: ['message']
},
{
name: "Verify display name updated",
method: "GET",
endpoint: "/users/me",
auth: true,
expect: (res) => res.display_name === "Test Display Name"
},
{
name: "Clear display name (set to null)",
method: "PATCH",
endpoint: "/users/me/display-name",
auth: true,
body: {
display_name: null
},
expect: (res) => res.message
},
{
name: "Update password",
method: "PATCH",
endpoint: "/users/me/password",
auth: true,
body: () => ({
currentPassword: document.getElementById('password').value,
newPassword: document.getElementById('password').value
}),
expect: (res) => res.message
},
{
name: "Try to update password with wrong current password",
method: "PATCH",
endpoint: "/users/me/password",
auth: true,
body: {
currentPassword: "wrongpassword",
newPassword: "newpass123"
},
expectFail: true,
expect: (res, status) => status === 401
}
]
},
{
category: "Config Endpoints",
tests: [
{
name: "Get classifications list",
method: "GET",
endpoint: "/config/classifications",
auth: false,
expect: (res) => Array.isArray(res),
expectedFields: ['[0].value', '[0].label', '[0].color']
},
{
name: "Get system config",
method: "GET",
endpoint: "/config",
auth: false,
expect: (res) => res.classifications,
expectedFields: ['classifications']
}
]
},
{
category: "Advanced List Scenarios",
tests: [
{
name: "Add multiple items rapidly",
method: "POST",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Rapid Test Item 1",
quantity: "1"
},
expect: (res) => res.item
},
{
name: "Add second rapid item",
method: "POST",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Rapid Test Item 2",
quantity: "1"
},
expect: (res) => res.item
},
{
name: "Verify list contains both items",
method: "GET",
endpoint: () => `/households/${householdId}/stores/${storeId}/list`,
auth: true,
skip: () => !householdId || !storeId,
expect: (res) => res.items && res.items.length >= 2
},
{
name: "Mark both items as bought",
method: "PATCH",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Rapid Test Item 1",
bought: true
},
expect: (res) => res.message
},
{
name: "Mark second item as bought",
method: "PATCH",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Rapid Test Item 2",
bought: true
},
expect: (res) => res.message
},
{
name: "Verify recent items includes bought items",
method: "GET",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/recent`,
auth: true,
skip: () => !householdId || !storeId,
expect: (res) => Array.isArray(res) && res.length > 0
},
{
name: "Delete first rapid test item",
method: "DELETE",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Rapid Test Item 1"
},
expect: (res) => res.message
},
{
name: "Delete second rapid test item",
method: "DELETE",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Rapid Test Item 2"
},
expect: (res) => res.message
}
]
},
{
category: "Edge Cases & Error Handling",
tests: [
{
name: "Access non-existent household",
method: "GET",
endpoint: "/households/99999",
auth: true,
expectFail: true,
expect: (res, status) => status === 403 || status === 404
},
{
name: "Access non-existent store in household",
method: "GET",
endpoint: () => `/households/${householdId}/stores/99999/list`,
auth: true,
skip: () => !householdId,
expectFail: true,
expect: (res, status) => status === 403 || status === 404
},
{
name: "Try to update non-existent item",
method: "PUT",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Non Existent Item 999",
quantity: "1"
},
expectFail: true,
expect: (res, status) => status === 404
},
{
name: "Try to delete non-existent item",
method: "DELETE",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Non Existent Item 999"
},
expectFail: true,
expect: (res, status) => status === 404
},
{
name: "Invalid classification value",
method: "POST",
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
auth: true,
skip: () => !householdId || !storeId,
body: {
item_name: "Test Item",
classification: "invalid_category_xyz"
},
expectFail: true,
expect: (res, status) => status === 400
},
{
name: "Empty household name on creation",
method: "POST",
endpoint: "/households",
auth: true,
body: {
name: ""
},
expectFail: true,
expect: (res, status) => status === 400
}
]
}
];

View File

@ -0,0 +1,147 @@
async function makeRequest(test) {
const apiUrl = document.getElementById('apiUrl').value;
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
const url = `${apiUrl}${endpoint}`;
const options = {
method: test.method,
headers: {
'Content-Type': 'application/json',
}
};
if (test.auth && authToken) {
options.headers['Authorization'] = `Bearer ${authToken}`;
}
if (test.body) {
options.body = JSON.stringify(typeof test.body === 'function' ? test.body() : test.body);
}
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
return { data, status: response.status };
}
async function runTest(categoryIdx, testIdx) {
const test = tests[categoryIdx].tests[testIdx];
const testId = `test-${categoryIdx}-${testIdx}`;
const testEl = document.getElementById(testId);
const contentEl = document.getElementById(`${testId}-content`);
const toggleEl = document.getElementById(`${testId}-toggle`);
const resultEl = testEl.querySelector('.test-result');
if (test.skip && test.skip()) {
testEl.querySelector('.test-status').textContent = 'SKIPPED';
testEl.querySelector('.test-status').className = 'test-status pending';
resultEl.style.display = 'block';
resultEl.className = 'test-result';
resultEl.innerHTML = '⚠️ Prerequisites not met';
return 'skip';
}
testEl.className = 'test-case running';
testEl.querySelector('.test-status').textContent = 'RUNNING';
testEl.querySelector('.test-status').className = 'test-status running';
resultEl.style.display = 'none';
try {
const { data, status } = await makeRequest(test);
const expectFail = test.expectFail || false;
const passed = test.expect(data, status);
const success = expectFail ? !passed || status >= 400 : passed;
testEl.className = success ? 'test-case pass' : 'test-case fail';
testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL';
testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`;
// Determine status code class
let statusClass = 'status-5xx';
if (status >= 200 && status < 300) statusClass = 'status-2xx';
else if (status >= 300 && status < 400) statusClass = 'status-3xx';
else if (status >= 400 && status < 500) statusClass = 'status-4xx';
// Check expected fields if defined
let expectedFieldsHTML = '';
if (test.expectedFields) {
const fieldChecks = test.expectedFields.map(field => {
const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined;
const icon = exists ? '✓' : '✗';
const className = exists ? 'pass' : 'fail';
return `<div class="field-check ${className}">${icon} ${field}</div>`;
}).join('');
expectedFieldsHTML = `
<div class="expected-section">
<div class="expected-label">Expected Fields:</div>
${fieldChecks}
</div>
`;
}
resultEl.style.display = 'block';
resultEl.className = 'test-result';
resultEl.innerHTML = `
<div style="margin-bottom: 8px;">
<span class="response-status ${statusClass}">HTTP ${status}</span>
<span style="color: #666;">${success ? '✓ Test passed' : '✗ Test failed'}</span>
</div>
${expectedFieldsHTML}
<div style="color: #666; font-size: 12px; margin-bottom: 4px;">Response:</div>
<div>${JSON.stringify(data, null, 2)}</div>
`;
if (success && test.onSuccess) {
test.onSuccess(data);
}
return success ? 'pass' : 'fail';
} catch (error) {
testEl.className = 'test-case fail';
testEl.querySelector('.test-status').textContent = 'ERROR';
testEl.querySelector('.test-status').className = 'test-status fail';
resultEl.style.display = 'block';
resultEl.className = 'test-error';
resultEl.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;"> Network/Request Error</div>
<div>${error.message}</div>
${error.stack ? `<div style="margin-top: 8px; font-size: 11px; opacity: 0.7;">${error.stack}</div>` : ''}
`;
return 'fail';
}
}
async function runAllTests(event) {
resetState();
const button = event.target;
button.disabled = true;
button.textContent = '⏳ Running Tests...';
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
for (let i = 0; i < tests.length; i++) {
for (let j = 0; j < tests[i].tests.length; j++) {
const result = await runTest(i, j);
if (result !== 'skip') {
totalTests++;
if (result === 'pass') passedTests++;
if (result === 'fail') failedTests++;
}
}
}
document.getElementById('summary').style.display = 'flex';
document.getElementById('totalTests').textContent = totalTests;
document.getElementById('passedTests').textContent = passedTests;
document.getElementById('failedTests').textContent = failedTests;
button.disabled = false;
button.textContent = '▶ Run All Tests';
}

View File

@ -0,0 +1,666 @@
let authToken = null;
let householdId = null;
let storeId = null;
let testUserId = null;
let createdHouseholdId = null;
let secondHouseholdId = null;
let inviteCode = null;
const tests = [
{
category: "Authentication",
tests: [
{
name: "Login with valid credentials",
method: "POST",
endpoint: "/auth/login",
auth: false,
body: () => ({ username: document.getElementById('username').value, password: document.getElementById('password').value }),
expect: (res) => res.token && res.role,
expectedFields: ['token', 'username', 'role'],
onSuccess: (res) => { authToken = res.token; }
},
{
name: "Login with invalid credentials",
method: "POST",
endpoint: "/auth/login",
auth: false,
body: { username: "wronguser", password: "wrongpass" },
expectFail: true,
expect: (res, status) => status === 401,
expectedFields: ['message']
},
{
name: "Access protected route without token",
method: "GET",
endpoint: "/households",
auth: false,
expectFail: true,
expect: (res, status) => status === 401
}
]
},
{
category: "Households",
tests: [
{
name: "Get user's households",
method: "GET",
endpoint: "/households",
auth: true,
expect: (res) => Array.isArray(res),
onSuccess: (res) => { if (res.length > 0) householdId = res[0].id; }
},
{
name: "Create new household",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: `Test Household ${Date.now()}` },
expect: (res) => res.household && res.household.invite_code,
expectedFields: ['message', 'household', 'household.id', 'household.name', 'household.invite_code']
},
{
name: "Get household details",
method: "GET",
endpoint: () => `/households/${householdId}`,
auth: true,
skip: () => !householdId,
expect: (res) => res.id === householdId,
expectedFields: ['id', 'name', 'invite_code', 'created_at']
},
{
name: "Update household name",
method: "PATCH",
endpoint: () => `/households/${householdId}`,
auth: true,
skip: () => !householdId,
body: { name: `Updated Household ${Date.now()}` },
expect: (res) => res.household,
expectedFields: ['message', 'household', 'household.id', 'household.name']
},
{
name: "Refresh invite code",
method: "POST",
endpoint: () => `/households/${householdId}/invite/refresh`,
auth: true,
skip: () => !householdId,
expect: (res) => res.household && res.household.invite_code,
expectedFields: ['message', 'household', 'household.invite_code']
},
{
name: "Join household with invalid code",
method: "POST",
endpoint: "/households/join/INVALID123",
auth: true,
expectFail: true,
expect: (res, status) => status === 404
},
{
name: "Create household with empty name (validation)",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: "" },
expectFail: true,
expect: (res, status) => status === 400,
expectedFields: ['error']
}
]
},
{
category: "Members",
tests: [
{
name: "Get household members",
method: "GET",
endpoint: () => `/households/${householdId}/members`,
auth: true,
skip: () => !householdId,
expect: (res) => Array.isArray(res) && res.length > 0,
onSuccess: (res) => { testUserId = res[0].user_id; }
},
{
name: "Update member role (non-admin attempting)",
method: "PATCH",
endpoint: () => `/households/${householdId}/members/${testUserId}/role`,
auth: true,
skip: () => !householdId || !testUserId,
body: { role: "user" },
expectFail: true,
expect: (res, status) => status === 400 || status === 403
}
]
},
{
category: "Stores",
tests: [
{
name: "Get all stores catalog",
method: "GET",
endpoint: "/stores",
auth: true,
expect: (res) => Array.isArray(res),
onSuccess: (res) => { if (res.length > 0) storeId = res[0].id; }
},
{
name: "Get household stores",
method: "GET",
endpoint: () => `/stores/household/${householdId}`,
auth: true,
skip: () => !householdId,
expect: (res) => Array.isArray(res)
},
{
name: "Add store to household",
method: "POST",
endpoint: () => `/stores/household/${householdId}`,
auth: true,
skip: () => !householdId || !storeId,
body: () => ({ storeId: storeId, isDefault: true }),
expect: (res) => res.store,
expectedFields: ['message', 'store', 'store.id', 'store.name']
},
{
name: "Set default store",
method: "PATCH",
endpoint: () => `/stores/household/${householdId}/${storeId}/default`,
auth: true,
skip: () => !householdId || !storeId,
expect: (res) => res.message
},
{
name: "Add invalid store to household",
method: "POST",
endpoint: () => `/stores/household/${householdId}`,
auth: true,
skip: () => !householdId,
body: { storeId: 99999 },
expectFail: true,
expect: (res, status) => status === 404
}
]
},
{
category: "Advanced Household Tests",
tests: [
{
name: "Create household for complex workflows",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: `Workflow Test ${Date.now()}` },
expect: (res) => res.household && res.household.id,
onSuccess: (res) => {
createdHouseholdId = res.household.id;
inviteCode = res.household.invite_code;
}
},
{
name: "Verify invite code format (7 chars)",
method: "GET",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => res.invite_code && res.invite_code.length === 7 && res.invite_code.startsWith('H')
},
{
name: "Get household with no stores added yet",
method: "GET",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => Array.isArray(res) && res.length === 0
},
{
name: "Update household with very long name (validation)",
method: "PATCH",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
body: { name: "A".repeat(101) },
expectFail: true,
expect: (res, status) => status === 400
},
{
name: "Refresh invite code changes value",
method: "POST",
endpoint: () => `/households/${createdHouseholdId}/invite/refresh`,
auth: true,
skip: () => !createdHouseholdId || !inviteCode,
expect: (res) => res.household && res.household.invite_code !== inviteCode,
onSuccess: (res) => { inviteCode = res.household.invite_code; }
},
{
name: "Join same household twice (idempotent)",
method: "POST",
endpoint: () => `/households/join/${inviteCode}`,
auth: true,
skip: () => !inviteCode,
expect: (res, status) => status === 200 && res.message.includes("already a member")
},
{
name: "Get non-existent household",
method: "GET",
endpoint: "/households/99999",
auth: true,
expectFail: true,
expect: (res, status) => status === 404
},
{
name: "Update non-existent household",
method: "PATCH",
endpoint: "/households/99999",
auth: true,
body: { name: "Test" },
expectFail: true,
expect: (res, status) => status === 403 || status === 404
}
]
},
{
category: "Member Management Edge Cases",
tests: [
{
name: "Get members for created household",
method: "GET",
endpoint: () => `/households/${createdHouseholdId}/members`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => Array.isArray(res) && res.length >= 1 && res[0].role === 'admin'
},
{
name: "Update own role (should fail)",
method: "PATCH",
endpoint: () => `/households/${createdHouseholdId}/members/${testUserId}/role`,
auth: true,
skip: () => !createdHouseholdId || !testUserId,
body: { role: "user" },
expectFail: true,
expect: (res, status) => status === 400 && res.error && res.error.includes("own role")
},
{
name: "Update role with invalid value",
method: "PATCH",
endpoint: () => `/households/${createdHouseholdId}/members/1/role`,
auth: true,
skip: () => !createdHouseholdId,
body: { role: "superadmin" },
expectFail: true,
expect: (res, status) => status === 400
},
{
name: "Remove non-existent member",
method: "DELETE",
endpoint: () => `/households/${createdHouseholdId}/members/99999`,
auth: true,
skip: () => !createdHouseholdId,
expectFail: true,
expect: (res, status) => status === 404 || status === 500
}
]
},
{
category: "Store Management Advanced",
tests: [
{
name: "Add multiple stores to household",
method: "POST",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
body: () => ({ storeId: storeId, isDefault: false }),
expect: (res) => res.store
},
{
name: "Add same store twice (duplicate check)",
method: "POST",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
body: () => ({ storeId: storeId, isDefault: false }),
expectFail: true,
expect: (res, status) => status === 400 || status === 409 || status === 500
},
{
name: "Set default store for household",
method: "PATCH",
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}/default`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
expect: (res) => res.message
},
{
name: "Verify default store is first in list",
method: "GET",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
expect: (res) => Array.isArray(res) && res.length > 0 && res[0].is_default === true
},
{
name: "Set non-existent store as default",
method: "PATCH",
endpoint: () => `/stores/household/${createdHouseholdId}/99999/default`,
auth: true,
skip: () => !createdHouseholdId,
expectFail: true,
expect: (res, status) => status === 404 || status === 500
},
{
name: "Remove store from household",
method: "DELETE",
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}`,
auth: true,
skip: () => !createdHouseholdId || !storeId,
expect: (res) => res.message
},
{
name: "Verify store removed from household",
method: "GET",
endpoint: () => `/stores/household/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => Array.isArray(res) && res.length === 0
}
]
},
{
category: "Data Integrity & Cleanup",
tests: [
{
name: "Create second household for testing",
method: "POST",
endpoint: "/households",
auth: true,
body: { name: `Second Test ${Date.now()}` },
expect: (res) => res.household && res.household.id,
onSuccess: (res) => { secondHouseholdId = res.household.id; }
},
{
name: "Verify user belongs to multiple households",
method: "GET",
endpoint: "/households",
auth: true,
expect: (res) => Array.isArray(res) && res.length >= 3
},
{
name: "Delete created test household",
method: "DELETE",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expect: (res) => res.message
},
{
name: "Verify deleted household is gone",
method: "GET",
endpoint: () => `/households/${createdHouseholdId}`,
auth: true,
skip: () => !createdHouseholdId,
expectFail: true,
expect: (res, status) => status === 404 || status === 403
},
{
name: "Delete second test household",
method: "DELETE",
endpoint: () => `/households/${secondHouseholdId}`,
auth: true,
skip: () => !secondHouseholdId,
expect: (res) => res.message
},
{
name: "Verify households list updated",
method: "GET",
endpoint: "/households",
auth: true,
expect: (res) => Array.isArray(res)
}
]
}
];
async function makeRequest(test) {
const apiUrl = document.getElementById('apiUrl').value;
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
const url = `${apiUrl}${endpoint}`;
const options = {
method: test.method,
headers: {
'Content-Type': 'application/json',
}
};
if (test.auth && authToken) {
options.headers['Authorization'] = `Bearer ${authToken}`;
}
if (test.body) {
options.body = JSON.stringify(typeof test.body === 'function' ? test.body() : test.body);
}
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
return { data, status: response.status };
}
async function runTest(categoryIdx, testIdx) {
const test = tests[categoryIdx].tests[testIdx];
const testId = `test-${categoryIdx}-${testIdx}`;
const testEl = document.getElementById(testId);
const contentEl = document.getElementById(`${testId}-content`);
const toggleEl = document.getElementById(`${testId}-toggle`);
const resultEl = testEl.querySelector('.test-result');
// Auto-expand when running
contentEl.classList.add('expanded');
toggleEl.classList.add('expanded');
resultEl.style.display = 'block';
resultEl.className = 'test-result';
resultEl.innerHTML = '⚠️ Prerequisites not met';
return 'skip';
}
testEl.className = 'test-case running';
testEl.querySelector('.test-status').textContent = 'RUNNING';
testEl.querySelector('.test-status').className = 'test-status running';
resultEl.style.display = 'none';
try {
const { data, status } = await makeRequest(test);
const expectFail = test.expectFail || false;
const passed = test.expect(data, status);
const success = expectFail ? !passed || status >= 400 : passed;
testEl.className = success ? 'test-case pass' : 'test-case fail';
testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL';
testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`;
// Determine status code class
let statusClass = 'status-5xx';
if (status >= 200 && status < 300) statusClass = 'status-2xx';
else if (status >= 300 && status < 400) statusClass = 'status-3xx';
else if (status >= 400 && status < 500) statusClass = 'status-4xx';
resultEl.style.display = 'block';
resultEl.className = 'test-result';
// Check expected fields if defined
let expectedFieldsHTML = '';
if (test.expectedFields) {
const fieldChecks = test.expectedFields.map(field => {
const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined;
const icon = exists ? '✓' : '✗';
const className = exists ? 'pass' : 'fail';
return `<div class="field-check ${className}">${icon} ${field}</div>`;
}).join('');
expectedFieldsHTML = `
<div class="expected-section">
<div class="expected-label">Expected Fields:</div>
${fieldChecks}
</div>
`;
}
resultEl.innerHTML = `
<div style="margin-bottom: 8px;">
<span class="response-status ${statusClass}">HTTP ${status}</span>
<span style="color: #666;">${success ? '✓ Test passed' : '✗ Test failed'}</span>
</div>
${expectedFieldsHTML}
<div style="color: #666; font-size: 12px; margin-bottom: 4px;">Response:</div>
<div>${JSON.stringify(data, null, 2)}</div>
`;
if (success && test.onSuccess) {
test.onSuccess(data);
}
return success ? 'pass' : 'fail';
} catch (error) {
testEl.className = 'test-case fail';
testEl.querySelector('.test-status').textContent = 'ERROR';
testEl.querySelector('.test-status').className = 'test-status fail';
resultEl.style.display = 'block';
resultEl.className = 'test-error';
resultEl.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;"> Network/Request Error</div>
<div>${error.message}</div>
${error.stack ? `<div style="margin-top: 8px; font-size: 11px; opacity: 0.7;">${error.stack}</div>` : ''}
`;
return 'fail';
}
}
async function runAllTests(event) {
authToken = null;
householdId = null;
storeId = null;
testUserId = null;
createdHouseholdId = null;
secondHouseholdId = null;
inviteCode = null;
const button = event.target;
button.disabled = true;
button.textContent = '⏳ Running Tests...';
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
for (let i = 0; i < tests.length; i++) {
for (let j = 0; j < tests[i].tests.length; j++) {
const result = await runTest(i, j);
if (result !== 'skip') {
totalTests++;
if (result === 'pass') passedTests++;
if (result === 'fail') failedTests++;
}
}
}
document.getElementById('summary').style.display = 'flex';
document.getElementById('totalTests').textContent = totalTests;
document.getElementById('passedTests').textContent = passedTests;
document.getElementById('failedTests').textContent = failedTests;
button.disabled = false;
button.textContent = '▶ Run All Tests';
}
function toggleTest(testId) {
const content = document.getElementById(`${testId}-content`);
const toggle = document.getElementById(`${testId}-toggle`);
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
toggle.classList.remove('expanded');
} else {
content.classList.add('expanded');
toggle.classList.add('expanded');
}
}
function expandAllTests() {
document.querySelectorAll('.test-content').forEach(content => {
content.classList.add('expanded');
});
document.querySelectorAll('.toggle-icon').forEach(icon => {
icon.classList.add('expanded');
});
}
function collapseAllTests() {
document.querySelectorAll('.test-content').forEach(content => {
content.classList.remove('expanded');
});
document.querySelectorAll('.toggle-icon').forEach(icon => {
icon.classList.remove('expanded');
});
}
function clearResults() {
renderTests();
document.getElementById('summary').style.display = 'none';
authToken = null;
householdId = null;
storeId = null;
testUserId = null;
createdHouseholdId = null;
secondHouseholdId = null;
inviteCode = null;
}
function renderTests() {
const container = document.getElementById('testResults');
container.innerHTML = '';
tests.forEach((category, catIdx) => {
const categoryDiv = document.createElement('div');
categoryDiv.className = 'test-category';
const categoryHeader = document.createElement('h2');
categoryHeader.textContent = category.category;
categoryDiv.appendChild(categoryHeader);
category.tests.forEach((test, testIdx) => {
const testDiv = document.createElement('div');
testDiv.className = 'test-case';
testDiv.id = `test-${catIdx}-${testIdx}`;
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
testDiv.innerHTML = `
<div class="test-header" onclick="toggleTest('${testDiv.id}')">
<div class="test-name">
<span class="toggle-icon" id="${testDiv.id}-toggle"></span>
${test.name}
</div>
<div class="test-status pending">PENDING</div>
</div>
<div class="test-content" id="${testDiv.id}-content">
<div class="test-details">
<strong>${test.method}</strong> ${endpoint}
${test.expectFail ? ' <span style="color: #dc3545; font-weight: 600;">(Expected to fail)</span>' : ''}
${test.auth ? ' <span style="color: #0066cc; font-weight: 600;">🔒 Requires Auth</span>' : ''}
</div>
<div class="test-result" style="display: none;"></div>
</div>
`;
categoryDiv.appendChild(testDiv);
});
container.appendChild(categoryDiv);
});
}
// Initialize
renderTests();

View File

@ -0,0 +1,309 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 10px;
}
.config {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.config-row {
display: flex;
gap: 10px;
margin-bottom: 10px;
align-items: center;
}
.config-row label {
min-width: 100px;
font-weight: 500;
}
.config-row input {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
background: #0066cc;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
button:hover {
background: #0052a3;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.test-category {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.test-category h2 {
color: #333;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #eee;
}
.test-case {
padding: 15px;
margin-bottom: 10px;
border-radius: 6px;
border-left: 4px solid #ddd;
background: #f9f9f9;
}
.test-case.running {
border-left-color: #ffa500;
background: #fff8e6;
}
.test-case.pass {
border-left-color: #28a745;
background: #e8f5e9;
}
.test-case.fail {
border-left-color: #dc3545;
background: #ffebee;
}
.test-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
cursor: pointer;
user-select: none;
}
.test-header:hover {
background: rgba(0, 0, 0, 0.02);
margin: -5px;
padding: 5px;
border-radius: 4px;
}
.toggle-icon {
font-size: 12px;
margin-right: 8px;
transition: transform 0.2s;
display: inline-block;
}
.toggle-icon.expanded {
transform: rotate(90deg);
}
.test-content {
display: none;
}
.test-content.expanded {
display: block;
}
.test-name {
font-weight: 600;
font-size: 15px;
display: flex;
align-items: center;
}
.test-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.test-status.pending {
background: #e0e0e0;
color: #666;
}
.test-status.running {
background: #ffa500;
color: white;
}
.test-status.pass {
background: #28a745;
color: white;
}
.test-status.fail {
background: #dc3545;
color: white;
}
.test-details {
font-size: 13px;
color: #666;
margin-bottom: 5px;
}
.test-result {
font-size: 13px;
margin-top: 8px;
padding: 10px;
background: white;
border-radius: 4px;
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
}
.expected-section {
margin-top: 8px;
padding: 8px;
background: #f0f7ff;
border-left: 3px solid #2196f3;
border-radius: 4px;
font-size: 12px;
}
.expected-label {
font-weight: bold;
color: #1976d2;
margin-bottom: 4px;
}
.field-check {
margin: 2px 0;
padding-left: 16px;
}
.field-check.pass {
color: #2e7d32;
}
.field-check.fail {
color: #c62828;
}
.test-error {
font-size: 13px;
margin-top: 8px;
padding: 10px;
background: #fff5f5;
border: 1px solid #ffcdd2;
border-radius: 4px;
color: #c62828;
font-family: monospace;
white-space: pre-wrap;
}
.response-status {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
margin-right: 8px;
}
.status-2xx {
background: #c8e6c9;
color: #2e7d32;
}
.status-3xx {
background: #fff9c4;
color: #f57f17;
}
.status-4xx {
background: #ffccbc;
color: #d84315;
}
.status-5xx {
background: #ffcdd2;
color: #c62828;
}
.summary {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
gap: 20px;
}
.summary-item {
flex: 1;
text-align: center;
padding: 15px;
border-radius: 6px;
}
.summary-item.total {
background: #e3f2fd;
}
.summary-item.pass {
background: #e8f5e9;
}
.summary-item.fail {
background: #ffebee;
}
.summary-value {
font-size: 32px;
font-weight: bold;
margin-bottom: 5px;
}
.summary-label {
font-size: 14px;
color: #666;
text-transform: uppercase;
}
.actions {
display: flex;
gap: 10px;
margin-bottom: 20px;
}

85
backend/public/test-ui.js Normal file
View File

@ -0,0 +1,85 @@
function toggleTest(testId) {
const content = document.getElementById(`${testId}-content`);
const toggle = document.getElementById(`${testId}-toggle`);
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
toggle.classList.remove('expanded');
} else {
content.classList.add('expanded');
toggle.classList.add('expanded');
}
}
function expandAllTests() {
document.querySelectorAll('.test-content').forEach(content => {
content.classList.add('expanded');
});
document.querySelectorAll('.toggle-icon').forEach(icon => {
icon.classList.add('expanded');
});
}
function collapseAllTests() {
document.querySelectorAll('.test-content').forEach(content => {
content.classList.remove('expanded');
});
document.querySelectorAll('.toggle-icon').forEach(icon => {
icon.classList.remove('expanded');
});
}
function clearResults() {
renderTests();
document.getElementById('summary').style.display = 'none';
resetState();
}
function renderTests() {
const container = document.getElementById('testResults');
container.innerHTML = '';
tests.forEach((category, catIdx) => {
const categoryDiv = document.createElement('div');
categoryDiv.className = 'test-category';
const categoryHeader = document.createElement('h2');
categoryHeader.textContent = category.category;
categoryDiv.appendChild(categoryHeader);
category.tests.forEach((test, testIdx) => {
const testDiv = document.createElement('div');
testDiv.className = 'test-case';
testDiv.id = `test-${catIdx}-${testIdx}`;
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
testDiv.innerHTML = `
<div class="test-header" onclick="toggleTest('${testDiv.id}')">
<div class="test-name">
<span class="toggle-icon" id="${testDiv.id}-toggle"></span>
${test.name}
</div>
<div class="test-status pending">PENDING</div>
</div>
<div class="test-content" id="${testDiv.id}-content">
<div class="test-details">
<strong>${test.method}</strong> ${endpoint}
${test.expectFail ? ' <span style="color: #dc3545; font-weight: 600;">(Expected to fail)</span>' : ''}
${test.auth ? ' <span style="color: #0066cc; font-weight: 600;">🔒 Requires Auth</span>' : ''}
</div>
<div class="test-result" style="display: none;"></div>
</div>
`;
categoryDiv.appendChild(testDiv);
});
container.appendChild(categoryDiv);
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function () {
renderTests();
});

View File

@ -4,8 +4,9 @@ const requireRole = require("../middleware/rbac");
const usersController = require("../controllers/users.controller"); const usersController = require("../controllers/users.controller");
const { ROLES } = require("../models/user.model"); const { ROLES } = require("../models/user.model");
router.get("/users", auth, requireRole(ROLES.ADMIN), usersController.getAllUsers); // router.get("/users", auth, (req, res, next) => next(), usersController.getAllUsers);
router.put("/users", auth, requireRole(ROLES.ADMIN), usersController.updateUserRole); router.get("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.getAllUsers);
router.delete("/users", auth, requireRole(ROLES.ADMIN), usersController.deleteUser); router.put("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.updateUserRole);
router.delete("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.deleteUser);
module.exports = router; module.exports = router;

View File

@ -1,13 +1,30 @@
const router = require("express").Router(); const router = require("express").Router();
const controller = require("../controllers/auth.controller"); const controller = require("../controllers/auth.controller");
const User = require("../models/user.model");
const { createRateLimit } = require("../middleware/rate-limit");
router.post("/register", controller.register); const loginRateLimit = createRateLimit({
router.post("/login", controller.login); keyPrefix: "auth:login",
windowMs: 15 * 60 * 1000,
max: 25,
message: "Too many login attempts. Please try again later.",
});
const registerRateLimit = createRateLimit({
keyPrefix: "auth:register",
windowMs: 15 * 60 * 1000,
max: 10,
message: "Too many registration attempts. Please try again later.",
});
router.post("/register", registerRateLimit, controller.register);
router.post("/login", loginRateLimit, controller.login);
router.post("/logout", controller.logout);
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
resText = `Grocery List API is running.\n` + res.status(200).json({
`Roles available: ${Object.values(User.ROLES).join(', ')}` message: "Auth API is running.",
roles: Object.values(User.ROLES),
res.status(200).type("text/plain").send(resText); });
}); });
module.exports = router; module.exports = router;

View File

@ -0,0 +1,72 @@
const router = require("express").Router();
const auth = require("../middleware/auth");
const optionalAuth = require("../middleware/optional-auth");
const { createRateLimit } = require("../middleware/rate-limit");
const controller = require("../controllers/group-invites.controller");
const inviteSummaryIpRateLimit = createRateLimit({
keyPrefix: "invite:summary:ip",
windowMs: 15 * 60 * 1000,
max: 120,
message: "Too many invite link summary requests. Please try again later.",
});
const inviteAcceptIpRateLimit = createRateLimit({
keyPrefix: "invite:accept:ip",
windowMs: 15 * 60 * 1000,
max: 60,
message: "Too many invite acceptance attempts. Please try again later.",
});
const inviteWriteUserRateLimit = createRateLimit({
keyPrefix: "invite:write:user",
windowMs: 15 * 60 * 1000,
max: 60,
message: "Too many write operations. Please try again later.",
keyFn: (req) => (req.user?.id ? `user:${req.user.id}` : "anon"),
});
router.get("/groups/invites", auth, controller.listInviteLinks);
router.post("/groups/invites", auth, inviteWriteUserRateLimit, controller.createInviteLink);
router.post(
"/groups/invites/revoke",
auth,
inviteWriteUserRateLimit,
controller.revokeInviteLink
);
router.post(
"/groups/invites/revive",
auth,
inviteWriteUserRateLimit,
controller.reviveInviteLink
);
router.post(
"/groups/invites/delete",
auth,
inviteWriteUserRateLimit,
controller.deleteInviteLink
);
router.get("/groups/join-policy", auth, controller.getJoinPolicy);
router.post(
"/groups/join-policy",
auth,
inviteWriteUserRateLimit,
controller.setJoinPolicy
);
router.get(
"/invite-links/:token",
inviteSummaryIpRateLimit,
optionalAuth,
controller.getInviteLinkSummary
);
router.post(
"/invite-links/:token",
auth,
inviteAcceptIpRateLimit,
inviteWriteUserRateLimit,
controller.acceptInviteLink
);
module.exports = router;

View File

@ -0,0 +1,169 @@
const express = require("express");
const router = express.Router();
const controller = require("../controllers/households.controller");
const listsController = require("../controllers/lists.controller.v2");
const auth = require("../middleware/auth");
const {
householdAccess,
requireHouseholdAdmin,
storeAccess,
} = require("../middleware/household");
const { upload, processImage } = require("../middleware/image");
// Public routes (authenticated only)
router.get("/", auth, controller.getUserHouseholds);
router.post("/", auth, controller.createHousehold);
router.post("/join/:inviteCode", auth, controller.joinHousehold);
// Household-scoped routes (member access required)
router.get("/:householdId", auth, householdAccess, controller.getHousehold);
router.patch(
"/:householdId",
auth,
householdAccess,
requireHouseholdAdmin,
controller.updateHousehold
);
router.delete(
"/:householdId",
auth,
householdAccess,
requireHouseholdAdmin,
controller.deleteHousehold
);
router.post(
"/:householdId/invite/refresh",
auth,
householdAccess,
requireHouseholdAdmin,
controller.refreshInviteCode
);
// Member management routes
router.get(
"/:householdId/members",
auth,
householdAccess,
controller.getMembers
);
router.patch(
"/:householdId/members/:userId/role",
auth,
householdAccess,
requireHouseholdAdmin,
controller.updateMemberRole
);
router.delete(
"/:householdId/members/:userId",
auth,
householdAccess,
controller.removeMember
);
// ==================== List Operations Routes ====================
// All list routes require household access AND store access
// Get grocery list
router.get(
"/:householdId/stores/:storeId/list",
auth,
householdAccess,
storeAccess,
listsController.getList
);
// Get specific item by name
router.get(
"/:householdId/stores/:storeId/list/item",
auth,
householdAccess,
storeAccess,
listsController.getItemByName
);
// Add item to list
router.post(
"/:householdId/stores/:storeId/list/add",
auth,
householdAccess,
storeAccess,
upload.single("image"),
processImage,
listsController.addItem
);
// Mark item as bought/unbought
router.patch(
"/:householdId/stores/:storeId/list/item",
auth,
householdAccess,
storeAccess,
listsController.markBought
);
// Update item details (quantity, notes)
router.put(
"/:householdId/stores/:storeId/list/item",
auth,
householdAccess,
storeAccess,
listsController.updateItem
);
// Delete item
router.delete(
"/:householdId/stores/:storeId/list/item",
auth,
householdAccess,
storeAccess,
listsController.deleteItem
);
// Get suggestions
router.get(
"/:householdId/stores/:storeId/list/suggestions",
auth,
householdAccess,
storeAccess,
listsController.getSuggestions
);
// Get recently bought items
router.get(
"/:householdId/stores/:storeId/list/recent",
auth,
householdAccess,
storeAccess,
listsController.getRecentlyBought
);
// Get item classification
router.get(
"/:householdId/stores/:storeId/list/classification",
auth,
householdAccess,
storeAccess,
listsController.getClassification
);
// Set item classification
router.post(
"/:householdId/stores/:storeId/list/classification",
auth,
householdAccess,
storeAccess,
listsController.setClassification
);
// Update item image
router.post(
"/:householdId/stores/:storeId/list/update-image",
auth,
householdAccess,
storeAccess,
upload.single("image"),
processImage,
listsController.updateItemImage
);
module.exports = router;

View File

@ -0,0 +1,48 @@
const express = require("express");
const router = express.Router();
const controller = require("../controllers/stores.controller");
const auth = require("../middleware/auth");
const {
householdAccess,
requireHouseholdAdmin,
requireSystemAdmin,
} = require("../middleware/household");
// Public routes
router.get("/", auth, controller.getAllStores);
// Household store management
router.get(
"/household/:householdId",
auth,
householdAccess,
controller.getHouseholdStores
);
router.post(
"/household/:householdId",
auth,
householdAccess,
requireHouseholdAdmin,
controller.addStoreToHousehold
);
router.delete(
"/household/:householdId/:storeId",
auth,
householdAccess,
requireHouseholdAdmin,
controller.removeStoreFromHousehold
);
router.patch(
"/household/:householdId/:storeId/default",
auth,
householdAccess,
requireHouseholdAdmin,
controller.setDefaultStore
);
// System admin routes
router.post("/", auth, requireSystemAdmin, controller.createStore);
router.patch("/:storeId", auth, requireSystemAdmin, controller.updateStore);
router.delete("/:storeId", auth, requireSystemAdmin, controller.deleteStore);
module.exports = router;

View File

@ -3,9 +3,19 @@ const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac"); const requireRole = require("../middleware/rbac");
const usersController = require("../controllers/users.controller"); const usersController = require("../controllers/users.controller");
const { ROLES } = require("../models/user.model"); const { ROLES } = require("../models/user.model");
const { createRateLimit } = require("../middleware/rate-limit");
router.get("/exists", usersController.checkIfUserExists); const userExistsRateLimit = createRateLimit({
router.get("/test", usersController.test); keyPrefix: "users:exists",
windowMs: 15 * 60 * 1000,
max: 60,
message: "Too many availability checks. Please try again later.",
});
router.get("/exists", userExistsRateLimit, usersController.checkIfUserExists);
if (process.env.NODE_ENV !== "production") {
router.get("/test", usersController.test);
}
// Current user profile routes (authenticated) // Current user profile routes (authenticated)
router.get("/me", auth, usersController.getCurrentUser); router.get("/me", auth, usersController.getCurrentUser);

View File

@ -0,0 +1,557 @@
const crypto = require("crypto");
const net = require("net");
const invitesModel = require("../models/group-invites.model");
const { inviteCodeLast4 } = require("../utils/redaction");
const JOIN_POLICIES = Object.freeze({
NOT_ACCEPTING: "NOT_ACCEPTING",
AUTO_ACCEPT: "AUTO_ACCEPT",
APPROVAL_REQUIRED: "APPROVAL_REQUIRED",
});
const JOIN_RESULTS = Object.freeze({
JOINED: "JOINED",
PENDING: "PENDING",
ALREADY_MEMBER: "ALREADY_MEMBER",
});
class InviteServiceError extends Error {
constructor(code, message, statusCode = 400) {
super(message);
this.name = "InviteServiceError";
this.code = code;
this.statusCode = statusCode;
}
}
function normalizeIp(ip) {
if (!ip || typeof ip !== "string") return null;
const trimmed = ip.trim();
if (!trimmed) return null;
return net.isIP(trimmed) ? trimmed : null;
}
function ensureJoinPolicy(policy) {
if (Object.values(JOIN_POLICIES).includes(policy)) {
return policy;
}
throw new InviteServiceError(
"INVALID_JOIN_POLICY",
"Invalid join policy",
400
);
}
function ensurePositiveInteger(value, fieldName) {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new InviteServiceError("INVALID_INPUT", `${fieldName} is required`, 400);
}
return parsed;
}
function ensureDate(value, fieldName) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
throw new InviteServiceError("INVALID_INPUT", `${fieldName} is invalid`, 400);
}
return date;
}
async function ensureGroupAndManagerRole(userId, groupId, client) {
const group = await invitesModel.getGroupById(groupId, client);
if (!group) {
throw new InviteServiceError("GROUP_NOT_FOUND", "Group not found", 404);
}
const actorRole = await invitesModel.getUserGroupRole(groupId, userId, client);
if (!["owner", "admin"].includes(actorRole)) {
throw new InviteServiceError(
"FORBIDDEN",
"Admin or owner role required",
403
);
}
return { actorRole, group };
}
async function resolveManagedGroupId(userId, requestedGroupId) {
if (requestedGroupId !== undefined && requestedGroupId !== null) {
return ensurePositiveInteger(requestedGroupId, "groupId");
}
const manageableGroups = await invitesModel.getManageableGroupsForUser(userId);
if (manageableGroups.length === 0) {
throw new InviteServiceError(
"FORBIDDEN",
"Admin or owner role required",
403
);
}
if (manageableGroups.length > 1) {
throw new InviteServiceError(
"GROUP_ID_REQUIRED",
"Group ID is required when you manage multiple groups",
400
);
}
return manageableGroups[0].group_id;
}
async function createInviteLink(
userId,
groupId,
policy,
singleUse,
expiresAt,
requestId,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedPolicy = ensureJoinPolicy(policy);
const resolvedExpiresAt = ensureDate(expiresAt, "expiresAt");
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
let link = null;
for (let attempt = 0; attempt < 5; attempt += 1) {
const token = crypto.randomBytes(16).toString("hex");
try {
link = await invitesModel.createInviteLink(
{
groupId: resolvedGroupId,
createdBy: userId,
token,
policy: resolvedPolicy,
singleUse: Boolean(singleUse),
expiresAt: resolvedExpiresAt,
},
client
);
break;
} catch (error) {
if (error.code !== "23505") {
throw error;
}
}
}
if (!link) {
throw new InviteServiceError(
"INVITE_CREATE_FAILED",
"Unable to create invite link",
500
);
}
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_CREATED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(link.token),
},
},
client
);
return link;
});
}
async function listInviteLinks(userId, groupId) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
await ensureGroupAndManagerRole(userId, resolvedGroupId);
return invitesModel.listInviteLinks(resolvedGroupId);
}
async function revokeInviteLink(
userId,
groupId,
linkId,
requestId,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
const link = await invitesModel.revokeInviteLink(
resolvedGroupId,
resolvedLinkId,
client
);
if (!link) {
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
}
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_REVOKED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(link.token),
},
},
client
);
});
}
async function reviveInviteLink(
userId,
groupId,
linkId,
expiresAt,
requestId,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
const resolvedExpiresAt = ensureDate(expiresAt, "expiresAt");
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
const link = await invitesModel.reviveInviteLink(
resolvedGroupId,
resolvedLinkId,
resolvedExpiresAt,
client
);
if (!link) {
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
}
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_REVIVED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(link.token),
},
},
client
);
});
}
async function deleteInviteLink(
userId,
groupId,
linkId,
requestId,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
const link = await invitesModel.deleteInviteLink(
resolvedGroupId,
resolvedLinkId,
client
);
if (!link) {
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
}
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_DELETED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(link.token),
},
},
client
);
});
}
function getInviteStatus(link) {
const now = Date.now();
if (link.single_use && link.used_at) return "USED";
if (link.revoked_at) return "REVOKED";
if (new Date(link.expires_at).getTime() <= now) return "EXPIRED";
return "ACTIVE";
}
async function getInviteLinkSummaryByToken(token, userId = null) {
if (!token || typeof token !== "string") {
throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400);
}
const summary = await invitesModel.getInviteLinkSummaryByToken(token.trim());
if (!summary) {
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
}
let viewerStatus = null;
if (userId) {
const isMember = await invitesModel.isGroupMember(summary.group_id, userId);
if (isMember) {
viewerStatus = JOIN_RESULTS.ALREADY_MEMBER;
} else {
const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId);
if (pending) {
viewerStatus = JOIN_RESULTS.PENDING;
}
}
}
const activePolicy = summary.current_join_policy || summary.policy;
return {
id: summary.id,
group_id: summary.group_id,
group_name: summary.group_name,
token: summary.token,
policy: summary.policy,
current_join_policy: summary.current_join_policy || null,
active_policy: activePolicy,
single_use: summary.single_use,
expires_at: summary.expires_at,
used_at: summary.used_at,
revoked_at: summary.revoked_at,
created_at: summary.created_at,
status: getInviteStatus(summary),
...(viewerStatus ? { viewerStatus } : {}),
};
}
async function acceptInviteLink(userId, token, requestId, ip, userAgent) {
if (!userId) {
throw new InviteServiceError("UNAUTHORIZED", "Authentication required", 401);
}
if (!token || typeof token !== "string") {
throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400);
}
return invitesModel.withTransaction(async (client) => {
const summary = await invitesModel.getInviteLinkSummaryByToken(
token.trim(),
client,
true
);
if (!summary) {
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
}
const group = {
id: summary.group_id,
name: summary.group_name,
};
const memberExists = await invitesModel.isGroupMember(summary.group_id, userId, client);
if (memberExists) {
return { status: JOIN_RESULTS.ALREADY_MEMBER, group };
}
const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId, client);
if (pending) {
return { status: JOIN_RESULTS.PENDING, group };
}
const now = Date.now();
if (summary.revoked_at) {
throw new InviteServiceError(
"INVITE_REVOKED",
"This invite link has been revoked",
410
);
}
if (new Date(summary.expires_at).getTime() <= now) {
throw new InviteServiceError(
"INVITE_EXPIRED",
"This invite link has expired",
410
);
}
if (summary.single_use && summary.used_at) {
throw new InviteServiceError(
"INVITE_USED",
"This invite link has already been used",
410
);
}
const activePolicy =
summary.current_join_policy || summary.policy || JOIN_POLICIES.NOT_ACCEPTING;
if (activePolicy === JOIN_POLICIES.NOT_ACCEPTING) {
throw new InviteServiceError(
"JOIN_NOT_ACCEPTING",
"This group is not accepting new members",
403
);
}
const actorRole = (await invitesModel.getUserGroupRole(summary.group_id, userId, client)) || "guest";
if (activePolicy === JOIN_POLICIES.AUTO_ACCEPT) {
const inserted = await invitesModel.addGroupMember(
summary.group_id,
userId,
"member",
client
);
if (!inserted) {
return { status: JOIN_RESULTS.ALREADY_MEMBER, group };
}
if (summary.single_use) {
await invitesModel.consumeSingleUseInvite(summary.id, client);
}
await invitesModel.createGroupAuditLog(
{
groupId: summary.group_id,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_USED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(summary.token),
},
},
client
);
return { status: JOIN_RESULTS.JOINED, group };
}
if (activePolicy === JOIN_POLICIES.APPROVAL_REQUIRED) {
await invitesModel.createOrTouchPendingJoinRequest(summary.group_id, userId, client);
if (summary.single_use) {
await invitesModel.consumeSingleUseInvite(summary.id, client);
}
await invitesModel.createGroupAuditLog(
{
groupId: summary.group_id,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_REQUESTED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(summary.token),
},
},
client
);
return { status: JOIN_RESULTS.PENDING, group };
}
throw new InviteServiceError("INVALID_JOIN_POLICY", "Invalid join policy", 400);
});
}
async function getGroupJoinPolicy(userId, groupId) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
await ensureGroupAndManagerRole(userId, resolvedGroupId);
const settings = await invitesModel.getGroupSettings(resolvedGroupId);
return settings?.join_policy || JOIN_POLICIES.NOT_ACCEPTING;
}
async function setGroupJoinPolicy(
userId,
groupId,
joinPolicy,
requestId,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedJoinPolicy = ensureJoinPolicy(joinPolicy);
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
await invitesModel.upsertGroupSettings(resolvedGroupId, resolvedJoinPolicy, client);
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_JOIN_POLICY_UPDATED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
joinPolicy: resolvedJoinPolicy,
},
},
client
);
});
}
module.exports = {
InviteServiceError,
JOIN_POLICIES,
JOIN_RESULTS,
acceptInviteLink,
createInviteLink,
deleteInviteLink,
getGroupJoinPolicy,
getInviteLinkSummaryByToken,
listInviteLinks,
resolveManagedGroupId,
revokeInviteLink,
reviveInviteLink,
setGroupJoinPolicy,
};

View File

@ -0,0 +1,74 @@
jest.mock("../middleware/auth", () => (req, res, next) => {
req.user = { id: 42, role: "user" };
next();
});
jest.mock("../middleware/optional-auth", () => (req, res, next) => next());
jest.mock("../services/group-invites.service", () => {
const actual = jest.requireActual("../services/group-invites.service");
return {
...actual,
acceptInviteLink: jest.fn(),
createInviteLink: jest.fn(),
deleteInviteLink: jest.fn(),
getGroupJoinPolicy: jest.fn(),
getInviteLinkSummaryByToken: jest.fn(),
listInviteLinks: jest.fn(),
resolveManagedGroupId: jest.fn(),
revokeInviteLink: jest.fn(),
reviveInviteLink: jest.fn(),
setGroupJoinPolicy: jest.fn(),
};
});
const request = require("supertest");
const invitesService = require("../services/group-invites.service");
const app = require("../app");
describe("group invites routes", () => {
beforeEach(() => {
invitesService.resolveManagedGroupId.mockResolvedValue(1);
invitesService.listInviteLinks.mockResolvedValue([]);
invitesService.createInviteLink.mockResolvedValue({
id: 1,
token: "abcd",
status: "ACTIVE",
});
invitesService.getInviteLinkSummaryByToken.mockResolvedValue({
id: 1,
token: "abcd",
group_name: "Test Group",
status: "ACTIVE",
active_policy: "AUTO_ACCEPT",
});
});
test("admin-only checks are enforced on invite management routes", async () => {
invitesService.createInviteLink.mockRejectedValue(
new invitesService.InviteServiceError(
"FORBIDDEN",
"Admin or owner role required",
403
)
);
const response = await request(app).post("/api/groups/invites").send({
policy: "AUTO_ACCEPT",
singleUse: false,
ttlDays: 3,
});
expect(response.status).toBe(403);
expect(response.body.error.code).toBe("FORBIDDEN");
expect(response.body.request_id).toBeTruthy();
});
test("request_id is present in invite responses", async () => {
const response = await request(app).get("/api/invite-links/abcd1234");
expect(response.status).toBe(200);
expect(response.body.request_id).toBeTruthy();
expect(response.body.link).toBeTruthy();
});
});

View File

@ -0,0 +1,189 @@
jest.mock("../models/group-invites.model", () => ({
addGroupMember: jest.fn(),
createGroupAuditLog: jest.fn(),
createInviteLink: jest.fn(),
createOrTouchPendingJoinRequest: jest.fn(),
consumeSingleUseInvite: jest.fn(),
deleteInviteLink: jest.fn(),
getGroupById: jest.fn(),
getGroupSettings: jest.fn(),
getInviteLinkById: jest.fn(),
getInviteLinkSummaryByToken: jest.fn(),
getManageableGroupsForUser: jest.fn(),
getPendingJoinRequest: jest.fn(),
getUserGroupRole: jest.fn(),
isGroupMember: jest.fn(),
listInviteLinks: jest.fn(),
revokeInviteLink: jest.fn(),
reviveInviteLink: jest.fn(),
upsertGroupSettings: jest.fn(),
withTransaction: jest.fn(),
}));
const invitesModel = require("../models/group-invites.model");
const invitesService = require("../services/group-invites.service");
function inviteSummary(overrides = {}) {
return {
id: 30,
group_id: 10,
group_name: "Test Group",
token: "1234567890abcdef1234567890fedcba",
policy: "AUTO_ACCEPT",
current_join_policy: "AUTO_ACCEPT",
single_use: false,
expires_at: "2030-01-01T00:00:00.000Z",
used_at: null,
revoked_at: null,
...overrides,
};
}
describe("group invites service", () => {
beforeEach(() => {
invitesModel.withTransaction.mockImplementation(async (handler) => handler({}));
});
test("create link success writes audit with request_id and token last4 only", async () => {
invitesModel.getGroupById.mockResolvedValue({ id: 1, name: "G1" });
invitesModel.getUserGroupRole.mockResolvedValue("admin");
invitesModel.createInviteLink.mockResolvedValue({
id: 55,
group_id: 1,
token: "1234567890abcdef1234567890fedcba",
policy: "AUTO_ACCEPT",
single_use: true,
expires_at: "2030-01-01T00:00:00.000Z",
created_at: "2026-01-01T00:00:00.000Z",
});
const link = await invitesService.createInviteLink(
7,
1,
"AUTO_ACCEPT",
true,
"2030-01-01T00:00:00.000Z",
"req-123",
"127.0.0.1",
"ua"
);
expect(link.id).toBe(55);
expect(invitesModel.createGroupAuditLog).toHaveBeenCalledTimes(1);
const auditPayload = invitesModel.createGroupAuditLog.mock.calls[0][0];
expect(auditPayload.requestId).toBe("req-123");
expect(auditPayload.metadata).toEqual({ inviteCodeLast4: "dcba" });
});
test("accept auto-accept adds membership", async () => {
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
invitesModel.isGroupMember.mockResolvedValue(false);
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
invitesModel.getUserGroupRole.mockResolvedValue(null);
invitesModel.addGroupMember.mockResolvedValue(true);
const result = await invitesService.acceptInviteLink(
99,
"token-1",
"req-1",
"127.0.0.1",
"ua"
);
expect(result).toEqual({
status: "JOINED",
group: { id: 10, name: "Test Group" },
});
expect(invitesModel.addGroupMember).toHaveBeenCalled();
expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe(
"GROUP_INVITE_USED"
);
});
test("accept manual policy creates pending request", async () => {
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(
inviteSummary({
current_join_policy: "APPROVAL_REQUIRED",
single_use: true,
})
);
invitesModel.isGroupMember.mockResolvedValue(false);
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
invitesModel.getUserGroupRole.mockResolvedValue(null);
invitesModel.createOrTouchPendingJoinRequest.mockResolvedValue({
id: 1,
status: "PENDING",
});
const result = await invitesService.acceptInviteLink(
99,
"token-2",
"req-2",
"127.0.0.1",
"ua"
);
expect(result).toEqual({
status: "PENDING",
group: { id: 10, name: "Test Group" },
});
expect(invitesModel.createOrTouchPendingJoinRequest).toHaveBeenCalled();
expect(invitesModel.consumeSingleUseInvite).toHaveBeenCalledWith(30, {});
expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe(
"GROUP_INVITE_REQUESTED"
);
});
test.each([
["INVITE_EXPIRED", inviteSummary({ expires_at: "2020-01-01T00:00:00.000Z" })],
["INVITE_REVOKED", inviteSummary({ revoked_at: "2026-01-01T00:00:00.000Z" })],
[
"INVITE_USED",
inviteSummary({ single_use: true, used_at: "2026-01-01T00:00:00.000Z" }),
],
])("rejects %s links", async (expectedCode, summary) => {
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(summary);
invitesModel.isGroupMember.mockResolvedValue(false);
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
await expect(
invitesService.acceptInviteLink(99, "token-3", "req-3", "127.0.0.1", "ua")
).rejects.toMatchObject({ code: expectedCode });
});
test("accept returns ALREADY_MEMBER before pending checks", async () => {
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
invitesModel.isGroupMember.mockResolvedValue(true);
const result = await invitesService.acceptInviteLink(
99,
"token-4",
"req-4",
"127.0.0.1",
"ua"
);
expect(result.status).toBe("ALREADY_MEMBER");
expect(invitesModel.getPendingJoinRequest).not.toHaveBeenCalled();
});
test("accept returns PENDING when request already exists", async () => {
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
invitesModel.isGroupMember.mockResolvedValue(false);
invitesModel.getPendingJoinRequest.mockResolvedValue({
id: 5,
status: "PENDING",
});
const result = await invitesService.acceptInviteLink(
99,
"token-5",
"req-5",
"127.0.0.1",
"ua"
);
expect(result.status).toBe("PENDING");
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,158 @@
jest.mock("../models/list.model.v2", () => ({
addHistoryRecord: jest.fn(),
addOrUpdateItem: jest.fn(),
}));
jest.mock("../models/household.model", () => ({
isHouseholdMember: jest.fn(),
}));
jest.mock("../utils/logger", () => ({
logError: jest.fn(),
}));
const List = require("../models/list.model.v2");
const householdModel = require("../models/household.model");
const controller = require("../controllers/lists.controller.v2");
function createResponse() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
describe("lists.controller.v2 addItem", () => {
beforeEach(() => {
List.addOrUpdateItem.mockResolvedValue({
listId: 42,
itemName: "milk",
isNew: true,
});
List.addHistoryRecord.mockResolvedValue(undefined);
householdModel.isHouseholdMember.mockResolvedValue(true);
});
test("records history for selected added_for_user_id when member is valid", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: { item_name: "milk", quantity: "1", added_for_user_id: "9" },
user: { id: 7 },
processedImage: null,
};
const res = createResponse();
await controller.addItem(req, res);
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
expect(List.addOrUpdateItem).toHaveBeenCalled();
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 9);
expect(res.status).not.toHaveBeenCalledWith(400);
});
test("records history using request user when added_for_user_id is not provided", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: { item_name: "milk", quantity: "1" },
user: { id: 7 },
processedImage: null,
};
const res = createResponse();
await controller.addItem(req, res);
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
expect(List.addOrUpdateItem).toHaveBeenCalled();
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
expect(res.status).not.toHaveBeenCalledWith(400);
});
test("records history using request user when added_for_user_id is blank", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: { item_name: "milk", quantity: "1", added_for_user_id: " " },
user: { id: 7 },
processedImage: null,
};
const res = createResponse();
await controller.addItem(req, res);
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
expect(List.addOrUpdateItem).toHaveBeenCalled();
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
expect(res.status).not.toHaveBeenCalledWith(400);
});
test("rejects invalid added_for_user_id", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: { item_name: "milk", quantity: "1", added_for_user_id: "abc" },
user: { id: 7 },
processedImage: null,
};
const res = createResponse();
await controller.addItem(req, res);
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
expect(List.addHistoryRecord).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Added-for user ID must be a positive integer",
}),
})
);
});
test("rejects malformed numeric-looking added_for_user_id", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: { item_name: "milk", quantity: "1", added_for_user_id: "9abc" },
user: { id: 7 },
processedImage: null,
};
const res = createResponse();
await controller.addItem(req, res);
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
expect(List.addHistoryRecord).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Added-for user ID must be a positive integer",
}),
})
);
});
test("rejects added_for_user_id when target user is not household member", async () => {
householdModel.isHouseholdMember.mockResolvedValue(false);
const req = {
params: { householdId: "1", storeId: "2" },
body: { item_name: "milk", quantity: "1", added_for_user_id: "11" },
user: { id: 7 },
processedImage: null,
};
const res = createResponse();
await controller.addItem(req, res);
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 11);
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
expect(List.addHistoryRecord).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Selected user is not a member of this household",
}),
})
);
});
});

25
backend/utils/cookies.js Normal file
View File

@ -0,0 +1,25 @@
function parseCookieHeader(cookieHeader) {
const cookies = {};
if (!cookieHeader || typeof cookieHeader !== "string") return cookies;
const segments = cookieHeader.split(";");
for (const segment of segments) {
const index = segment.indexOf("=");
if (index === -1) continue;
const key = segment.slice(0, index).trim();
const value = segment.slice(index + 1).trim();
if (!key) continue;
try {
cookies[key] = decodeURIComponent(value);
} catch (_) {
// Ignore malformed cookie values instead of throwing.
continue;
}
}
return cookies;
}
module.exports = {
parseCookieHeader,
};

116
backend/utils/http.js Normal file
View File

@ -0,0 +1,116 @@
function isPlainObject(value) {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
function errorCodeFromStatus(statusCode) {
switch (statusCode) {
case 400:
return "bad_request";
case 401:
return "unauthorized";
case 403:
return "forbidden";
case 404:
return "not_found";
case 409:
return "conflict";
case 413:
return "payload_too_large";
case 415:
return "unsupported_media_type";
case 422:
return "unprocessable_entity";
case 429:
return "rate_limited";
case 500:
return "internal_error";
default:
return statusCode >= 500 ? "internal_error" : "request_error";
}
}
function normalizeErrorPayload(payload, statusCode) {
if (statusCode < 400) return payload;
if (typeof payload === "string") {
return {
error: {
code: errorCodeFromStatus(statusCode),
message: payload,
},
};
}
if (!isPlainObject(payload)) {
return {
error: {
code: errorCodeFromStatus(statusCode),
message: "Request failed",
},
};
}
if (isPlainObject(payload.error)) {
const code = payload.error.code || errorCodeFromStatus(statusCode);
const message = payload.error.message || "Request failed";
return {
...payload,
error: {
...payload.error,
code,
message,
},
};
}
if (typeof payload.error === "string") {
const { error, ...rest } = payload;
return {
...rest,
error: {
code: errorCodeFromStatus(statusCode),
message: error,
},
};
}
if (typeof payload.message === "string") {
const { message, ...rest } = payload;
return {
...rest,
error: {
code: errorCodeFromStatus(statusCode),
message,
},
};
}
return {
...payload,
error: {
code: errorCodeFromStatus(statusCode),
message: "Request failed",
},
};
}
function sendError(res, statusCode, message, code, extra = {}) {
return res.status(statusCode).json({
...extra,
error: {
code: code || errorCodeFromStatus(statusCode),
message,
},
});
}
module.exports = {
errorCodeFromStatus,
normalizeErrorPayload,
sendError,
};

20
backend/utils/logger.js Normal file
View File

@ -0,0 +1,20 @@
const { safeErrorMessage } = require("./redaction");
function formatExtra(extra = {}) {
return Object.entries(extra)
.filter(([, value]) => value !== undefined && value !== null && value !== "")
.map(([key, value]) => `${key}=${String(value)}`)
.join(" ");
}
function logError(req, context, error, extra = {}) {
const requestId = req?.request_id || "unknown";
const message = safeErrorMessage(error);
const extraText = formatExtra(extra);
const suffix = extraText ? ` ${extraText}` : "";
console.error(`[${context}] request_id=${requestId} message=${message}${suffix}`);
}
module.exports = {
logError,
};

View File

@ -0,0 +1,20 @@
function inviteCodeLast4(inviteCode) {
if (!inviteCode || typeof inviteCode !== "string") return "none";
const trimmed = inviteCode.trim();
if (!trimmed) return "none";
return trimmed.slice(-4);
}
function safeErrorMessage(error) {
if (!error) return "unknown_error";
if (typeof error === "string") return error;
if (typeof error.message === "string" && error.message.trim()) {
return error.message;
}
return "unknown_error";
}
module.exports = {
inviteCodeLast4,
safeErrorMessage,
};

View File

@ -0,0 +1,36 @@
const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "sid";
const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS || 30);
function sessionMaxAgeMs() {
return SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
}
function cookieName() {
return SESSION_COOKIE_NAME;
}
function setSessionCookie(res, sessionId) {
res.cookie(cookieName(), sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: sessionMaxAgeMs(),
});
}
function clearSessionCookie(res) {
res.clearCookie(cookieName(), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
}
module.exports = {
SESSION_TTL_DAYS,
clearSessionCookie,
cookieName,
setSessionCookie,
};

6
debug.log Normal file
View File

@ -0,0 +1,6 @@
[0219/013019.369:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
[0219/013019.648:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
[0219/013030.696:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
[0219/013038.475:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
[0219/013103.277:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
[0219/014227.547:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)

View File

@ -1,8 +1,5 @@
#!/bin/bash #!/bin/bash
# Quick script to rebuild Docker Compose dev environment set -euo pipefail
echo "Stopping containers and removing volumes..." SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
docker-compose -f docker-compose.dev.yml down -v exec "$SCRIPT_DIR/rebuild-dev.sh" "$@"
echo "Rebuilding and starting containers..."
docker-compose -f docker-compose.dev.yml up --build

View File

@ -9,7 +9,7 @@ services:
- ./frontend:/app - ./frontend:/app
- frontend_node_modules:/app/node_modules - frontend_node_modules:/app/node_modules
ports: ports:
- "3000:5173" - "3010:5173"
depends_on: depends_on:
- backend - backend
restart: always restart: always

20
docker-compose.new.yml Normal file
View File

@ -0,0 +1,20 @@
services:
backend:
image: git.nicosaya.com/nalalangan/costco-grocery-list/backend:main-new
# image: grocery-app/backend:main-new
restart: always
env_file:
- ./backend.env
ports:
- "5001:5000"
frontend:
image: git.nicosaya.com/nalalangan/costco-grocery-list/frontend:main-new
# image: grocery-app/frontend:main-new
restart: always
env_file:
- ./frontend.env
ports:
- "3001:5173"
depends_on:
- backend

View File

@ -0,0 +1,49 @@
# Agentic Contract Map (Current Stack)
This file maps `PROJECT_INSTRUCTIONS.md` architecture intent to the current repository stack.
## Current stack
- Backend: Express (`backend/`)
- Frontend: React + Vite (`frontend/`)
## Contract mapping
### API Route Handlers (`app/api/**/route.ts` intent)
Current equivalent:
- `backend/routes/*.js`
- `backend/controllers/*.js`
Expectation:
- Keep these thin for parsing/validation and response shape.
- Delegate DB and authorization-heavy logic to model/service layers.
### Server Services (`lib/server/*` intent)
Current equivalent:
- `backend/models/*.js`
- `backend/middleware/*.js`
- `backend/db/*`
Expectation:
- Concentrate DB access and authorization logic in these backend layers.
- Avoid raw DB usage directly in route files unless no service/model exists.
### Client Wrappers (`lib/client/*` intent)
Current equivalent:
- `frontend/src/api/*.js`
Expectation:
- Centralize fetch/axios calls and error normalization here.
- Always send credentials/authorization headers as required.
### Hooks (`hooks/use-*.ts` intent)
Current equivalent:
- `frontend/src/context/*`
- `frontend/src/utils/*` for route guards
Expectation:
- Keep components free of direct raw network calls where possible.
- Favor one canonical state propagation mechanism per concern.
## Notes
- This map does not force a framework migration.
- It defines how to apply the contract consistently in the existing codebase.

View File

@ -0,0 +1,67 @@
# DB Migration Workflow (External Postgres)
This project uses an external on-prem Postgres database. Migration files are canonical in:
- `packages/db/migrations`
## Preconditions
- `DATABASE_URL` is set and points to the on-prem Postgres instance.
- `psql` is installed and available in PATH.
- You are in repo root.
## Commands
- Apply pending migrations:
- `npm run db:migrate`
- Show migration status:
- `npm run db:migrate:status`
- Fail if pending migrations exist:
- `npm run db:migrate:verify`
- Create a new migration file:
- `npm run db:migrate:new -- <migration-name>`
- Track stale legacy SQL in `backend/migrations`:
- `npm run db:migrate:stale`
- Fail when stale legacy SQL exists:
- `npm run db:migrate:stale:check`
## Active migration set
Migration files are applied in lexicographic filename order from `packages/db/migrations`.
`backend/migrations` is legacy reference-only and not part of canonical execution.
`packages/db/migrations/stale-files.json` is the source of truth for canonical files that are intentionally stale/ignored.
Current baseline files:
- `add_display_name_column.sql`
- `add_image_columns.sql`
- `add_modified_on_column.sql`
- `add_notes_column.sql`
- `create_item_classification_table.sql`
- `create_sessions_table.sql`
- `multi_household_architecture.sql`
## Tracking table
Applied migrations are recorded in:
- `schema_migrations(filename text unique, applied_at timestamptz)`
## Expected operator flow
1. Check status:
- `npm run db:migrate:status`
2. If a new implementation needs schema changes, create a new file:
- `npm run db:migrate:new -- <migration-name>`
3. Apply pending:
- `npm run db:migrate`
4. Verify clean state:
- `npm run db:migrate:verify`
## Troubleshooting
- `DATABASE_URL is required`:
- Export/set `DATABASE_URL` in your environment.
- `psql executable was not found in PATH`:
- Install PostgreSQL client tools and retry.
- SQL failure:
- Fix migration SQL and rerun; only successful files are recorded in `schema_migrations`.
- Skip known stale SQL files for a specific environment:
- Set `DB_MIGRATE_SKIP_FILES` to a comma-separated filename list.
- Example: `DB_MIGRATE_SKIP_FILES=add_modified_on_column.sql,add_image_columns.sql`
- Temporarily include files listed in `stale-files.json`:
- Set `DB_MIGRATE_INCLUDE_STALE=true` before running migration commands.

View File

@ -0,0 +1,57 @@
# Project State Audit - Fiddy
Snapshot date: 2026-02-16
## 1) Confirmed stack and structure
- Backend: Express API in `backend/` with `routes/`, `controllers/`, `models/`, `middleware/`, `utils/`.
- Frontend: React + Vite in `frontend/` with API wrappers in `frontend/src/api`, auth/state in `frontend/src/context`, pages in `frontend/src/pages`.
- DB migrations: canonical folder is `packages/db/migrations`.
## 2) Governance and agentic setup status
- Present and aligned:
- `PROJECT_INSTRUCTIONS.md`
- `AGENTS.md`
- `DEBUGGING_INSTRUCTIONS.md`
- `docs/DB_MIGRATION_WORKFLOW.md`
- `docs/AGENTIC_CONTRACT_MAP.md`
- Commit discipline added in `PROJECT_INSTRUCTIONS.md` section 12 and being followed with small conventional commits.
## 3) Current implementation status vs vertical-slice goals
1. DB migrate command + schema:
- Implemented: root scripts `db:migrate`, `db:migrate:status`, `db:migrate:verify`.
- Implemented: migration tracking + runbook.
2. Register/Login/Logout (custom sessions):
- Implemented: DB sessions table migration (`create_sessions_table.sql`).
- Implemented: session model, HttpOnly cookie set/clear, `/auth/logout`, auth middleware fallback to DB session cookie.
- Implemented: frontend credentialed API (`withCredentials`), logout route call.
3. Protected dashboard page:
- Partially implemented via existing `PrivateRoute` token gate.
4. Group create/join + switcher:
- Existing household create/join/switch flow exists but does not yet match all group-policy requirements.
5. Entries CRUD:
- Existing list CRUD exists in legacy and multi-household paths.
6. Receipt upload/download endpoints:
- Not implemented as dedicated receipt domain/endpoints.
7. Settings + Reports:
- Settings page exists; reporting is not fully formalized.
## 4) Contract gaps and risks
- `DATABASE_URL` is now supported in runtime pool config, but local operator environment still needs this variable configured.
- No automated test suite currently exercises the new auth/session behavior; API behavior is mostly validated by static/lint checks.
- Group policy requirements (owner role, join policy states, invite lifecycle constraints, revive semantics) are not fully implemented.
- No explicit audit log persistence layer verified for invite events/request IDs.
- Encoding cleanliness needs ongoing watch; historical mojibake appears in some UI text/log strings.
## 5) Recommended next implementation order
1. Finalize auth session contract:
- Add authenticated session introspection endpoint (`/users/me` already exists) to support cookie-only bootstrapping if token absent.
- Update frontend auth bootstrap so protected routes work with DB session cookie as canonical auth.
2. Add explicit API tests (auth + households/list negative cases):
- unauthorized
- not-a-member
- invalid input
3. Implement group-policy requirements incrementally:
- owner role migration + policy enums
- invite policy and immutable settings
- approval-required flow + revive/single-use semantics
4. Add dedicated receipt domain endpoints (metadata list vs byte retrieval split) if the product scope requires the receipt contract verbatim.

77
docs/README.md Normal file
View File

@ -0,0 +1,77 @@
# Documentation Index
This directory contains all project documentation organized by category.
## 📁 Directory Structure
### `/architecture` - System Design & Structure
- **[component-structure.md](architecture/component-structure.md)** - Frontend component organization and patterns
- **[multi-household-architecture-plan.md](architecture/multi-household-architecture-plan.md)** - Multi-household system architecture design
### `/features` - Feature Implementation Details
- **[classification-implementation.md](features/classification-implementation.md)** - Item classification system (zones, types, groups)
- **[image-storage-implementation.md](features/image-storage-implementation.md)** - Image storage and handling (bytea, MIME types)
### `/guides` - How-To & Reference Guides
- **[api-documentation.md](guides/api-documentation.md)** - REST API endpoints and usage
- **[frontend-readme.md](guides/frontend-readme.md)** - Frontend development guide
- **[MOBILE_RESPONSIVE_AUDIT.md](guides/MOBILE_RESPONSIVE_AUDIT.md)** - Mobile-first design guidelines and audit checklist
- **[setup-checklist.md](guides/setup-checklist.md)** - Development environment setup steps
### `/migration` - Database Migrations & Updates
- **[MIGRATION_GUIDE.md](migration/MIGRATION_GUIDE.md)** - Multi-household migration instructions (also in `backend/migrations/`)
- **[POST_MIGRATION_UPDATES.md](migration/POST_MIGRATION_UPDATES.md)** - Required updates after migration
### `/archive` - Completed Implementation Records
Historical documentation of completed features. Useful for reference but not actively maintained.
- **[ACCOUNT_MANAGEMENT_IMPLEMENTATION.md](archive/ACCOUNT_MANAGEMENT_IMPLEMENTATION.md)** - Phase 4: Display name and password change
- **[code-cleanup-guide.md](archive/code-cleanup-guide.md)** - Code cleanup checklist (completed)
- **[HOUSEHOLD_MANAGEMENT_IMPLEMENTATION.md](archive/HOUSEHOLD_MANAGEMENT_IMPLEMENTATION.md)** - Household management UI implementation
- **[IMPLEMENTATION_STATUS.md](archive/IMPLEMENTATION_STATUS.md)** - Multi-household migration sprint status
- **[settings-dark-mode.md](archive/settings-dark-mode.md)** - Dark mode implementation notes
- **[TEST_SUITE_README.md](archive/TEST_SUITE_README.md)** - Testing infrastructure documentation
---
## 📄 Root-Level Documentation
These files remain at the project root for easy access:
- **[../README.md](../README.md)** - Project overview and quick start
- **[../PROJECT_INSTRUCTIONS.md](../PROJECT_INSTRUCTIONS.md)** - Canonical project constraints and delivery contract
- **[../AGENTS.md](../AGENTS.md)** - Agent behavior and guardrails
- **[../DEBUGGING_INSTRUCTIONS.md](../DEBUGGING_INSTRUCTIONS.md)** - Required bugfix workflow
- **[../.github/copilot-instructions.md](../.github/copilot-instructions.md)** - Copilot compatibility shim to root instructions
---
## 🔍 Quick Reference
**Setting up the project?** → Start with [setup-checklist.md](guides/setup-checklist.md)
**Understanding the API?** → See [api-documentation.md](guides/api-documentation.md)
**Working on mobile UI?** → Check [MOBILE_RESPONSIVE_AUDIT.md](guides/MOBILE_RESPONSIVE_AUDIT.md)
**Need architecture context?** → Read [AGENTIC_CONTRACT_MAP.md](AGENTIC_CONTRACT_MAP.md) and [../PROJECT_INSTRUCTIONS.md](../PROJECT_INSTRUCTIONS.md)
**Running migrations?** → Follow [DB_MIGRATION_WORKFLOW.md](DB_MIGRATION_WORKFLOW.md)
---
## 📝 Contributing to Documentation
When adding new documentation:
1. **Guides** (`/guides`) - General how-to, setup, reference
2. **Features** (`/features`) - Specific feature implementation details
3. **Architecture** (`/architecture`) - System design, patterns, structure
4. **Migration** (`/migration`) - Database migrations and upgrade guides
5. **Archive** (`/archive`) - Completed implementation records (for reference only)
Keep documentation:
- ✅ Up-to-date with code changes
- ✅ Concise and scannable
- ✅ Linked to relevant files (use relative paths)
- ✅ Organized by category

View File

@ -0,0 +1,865 @@
# Multi-Household & Multi-Store Architecture Plan
## Executive Summary
This document outlines the architecture and implementation strategy for extending the application to support:
1. **Multiple Households** - Users can belong to multiple households (families, roommates, etc.)
2. **Multiple Stores** - Households can manage lists for different store types (Costco, Target, Walmart, etc.)
## Current Architecture Analysis
### Existing Schema
```sql
users (id, username, password, name, role, display_name)
grocery_list (id, item_name, quantity, bought, item_image, image_mime_type, added_by, modified_on)
grocery_history (id, list_item_id, quantity, added_by, added_on)
item_classification (id, item_type, item_group, zone, confidence, source)
```
### Current Limitations
- **Single global list** - All users share one grocery list
- **No household concept** - Cannot separate different families' items
- **Store-specific zones** - Classification system assumes Costco layout
- **Single-level roles** - User has same role everywhere (cannot be admin in one household, viewer in another)
---
## Design Considerations & Trade-offs
### Key Questions to Resolve
#### 1. Item Management Strategy
**Option A: Shared Item Master (Recommended)**
- ✅ **Pro**: Single source of truth for item definitions (name, default image, common classification)
- ✅ **Pro**: Consistent item naming across households
- ✅ **Pro**: Can build item recommendation system across all households
- ✅ **Pro**: Easier to implement smart features (price tracking, common items)
- ❌ **Con**: Requires careful privacy controls (who can see which items)
- ❌ **Con**: Different households may classify items differently
**Option B: Per-Household Items**
- ✅ **Pro**: Complete household isolation
- ✅ **Pro**: Each household fully controls item definitions
- ✅ **Pro**: No privacy concerns about item names
- ❌ **Con**: Duplicate data across households
- ❌ **Con**: Cannot leverage cross-household intelligence
- ❌ **Con**: More complex to implement suggestions
**Option C: Hybrid Approach (RECOMMENDED)**
- ✅ **Pro**: Best of both worlds
- ✅ **Pro**: Shared item catalog with household-specific classifications
- ✅ **Pro**: Privacy-preserving (only households share item usage, not personal data)
- **How it works**:
- Global `items` table (id, name, default_image, created_at)
- Household-specific `household_list` table references item + household
- Each household can override classifications per store
---
## Proposed Schema Design
### New Tables
```sql
-- Households (e.g., "Smith Family", "Apartment 5B")
CREATE TABLE households (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
created_by INTEGER REFERENCES users(id),
invite_code VARCHAR(20) UNIQUE NOT NULL, -- Random code for inviting users
code_expires_at TIMESTAMP -- Optional expiration
);
-- Store Types (e.g., "Costco", "Target", "Walmart")
CREATE TABLE stores (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
default_zones JSONB, -- Store-specific zone layout
created_at TIMESTAMP DEFAULT NOW()
);
-- User-Household Membership with per-household roles
CREATE TABLE household_members (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
joined_at TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, user_id)
);
-- Household-Store Relationship (which stores does this household shop at?)
CREATE TABLE household_stores (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
is_default BOOLEAN DEFAULT FALSE, -- Default store for this household
UNIQUE(household_id, store_id)
);
-- Master Item Catalog (shared across all households)
CREATE TABLE items (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
default_image BYTEA,
default_image_mime_type VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
usage_count INTEGER DEFAULT 0 -- For popularity tracking
);
-- Household-specific grocery lists (per store)
CREATE TABLE household_lists (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
quantity INTEGER NOT NULL DEFAULT 1,
bought BOOLEAN DEFAULT FALSE,
custom_image BYTEA, -- Household can override item image
custom_image_mime_type VARCHAR(50),
added_by INTEGER REFERENCES users(id),
modified_on TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, store_id, item_id) -- One item per household+store combo
);
-- Household-specific item classifications (per store)
CREATE TABLE household_item_classifications (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
item_type VARCHAR(50),
item_group VARCHAR(100),
zone VARCHAR(100),
confidence DECIMAL(3,2) DEFAULT 1.0,
source VARCHAR(20) DEFAULT 'user',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, store_id, item_id)
);
-- History tracking (who added what, when, to which household+store list)
CREATE TABLE household_list_history (
id SERIAL PRIMARY KEY,
household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE,
quantity INTEGER NOT NULL,
added_by INTEGER REFERENCES users(id),
added_on TIMESTAMP DEFAULT NOW()
);
```
### Indexes for Performance
```sql
-- Household member lookups
CREATE INDEX idx_household_members_user ON household_members(user_id);
CREATE INDEX idx_household_members_household ON household_members(household_id);
-- List queries (most common operations)
CREATE INDEX idx_household_lists_household_store ON household_lists(household_id, store_id);
CREATE INDEX idx_household_lists_bought ON household_lists(household_id, store_id, bought);
-- Item search
CREATE INDEX idx_items_name ON items(name);
CREATE INDEX idx_items_usage_count ON items(usage_count DESC);
-- Classification lookups
CREATE INDEX idx_household_classifications ON household_item_classifications(household_id, store_id);
```
---
## Role System Redesign
### Dual-Role Hierarchy: System-Wide + Household-Scoped
```typescript
// System-wide roles (app administration)
users {
id, username, password, name, display_name,
role: 'system_admin' | 'user' // Kept for app-wide controls
}
// Household-scoped roles (per-household permissions)
household_members {
household_id, user_id,
role: 'admin' | 'user'
}
```
### System-Wide Role Definitions
| Role | Permissions |
|------|-------------|
| **system_admin** | Create/delete stores globally, view all households (moderation), manage global item catalog, access system metrics, promote users to system_admin |
| **user** | Standard user - can create households, join households via invite, manage own profile |
### Household-Scoped Role Definitions
| Role | Permissions |
|------|-------------|
| **admin** | Full household control: delete household, invite/remove members, change member roles, manage stores, add/edit/delete items, mark bought, upload images, update classifications |
| **user** | Standard member: add/edit/delete items, mark bought, upload images, update classifications, view all lists |
### Role Transition Plan
**Migration Strategy:**
1. Create default household "Main Household"
2. Migrate all existing users → household_members (old admins become household admins, others become users)
3. Keep existing `users.role` column, update values:
- `admin``system_admin` (app-wide admin)
- `editor``user` (standard user)
- `viewer``user` (standard user)
4. Migrate grocery_list → household_lists (all to default household + default store)
5. Migrate item_classification → household_item_classifications
---
, systemRole } // System-wide role
req.household = { id, name, role } // Household-scoped role
req.store = { id, name } // Active store context
### Authentication Context
**Before:**
```javascript
req.user = { id, username, role }
```
**After:**
```javascript
req.user = { id, username }
req.household = { id, name, role } // Set by household middleware
req.store = { id, name } // Set by store middleware
```
### Middleware Chain with systemRole)
router.use(auth);
// 2. Household middleware (validates household access, sets req.household with householdRole)
router.use('/households/:householdId', householdAccess);
// 3. Household role middleware (checks household-scoped permissions)
router.post('/add', requireHouseholdRole(['user', 'admin']), controller.addItem);
// 4. Admin-only household operations
router.delete('/:id', requireHouseholdRole(['admin']), controller.deleteHousehold);
// 5. System admin middleware (for app-wide operations)
router.post('/stores', requireSystemRole('system_admin'), controller.createStore
// 3. Role middleware (checks household-specific role)
rouSystem Administration (system_admin only)
GET /api/admin/stores // Manage all stores
POST /api/admin/stores // Create new store type
PATCH /api/admin/stores/:id // Update store
DELETE /api/admin/stores/:id // Delete store (if unused)
GET /api/admin/households // View all households (moderation)
GET /api/admin/items // Manage global item catalog
GET /api/admin/metrics // System-wide analytics
// Household Management (any user can create)
GET /api/households // Get all households user belongs to
POST /api/households // Create new household (any user)
GET /api/households/:id // Get household details
PATCH /api/households/:id // Update household (admin only)
DELETE /api/households/:id // Delete household (admin only)
// Household Members
GET /api/households/:id/members // List members (all roles)
POST /api/households/:id/invite // Generate/refresh invite code (admin only)
POST /api/households/join/:inviteCode // Join household via invite code (joins as 'user')
PATCH /api/households/:id/members/:userId // Update member role (admin only)
DELETE /api/households/:id/members/:userId // Remove member (admin only, or self)
// Store Management
GET /api/stores // Get all available store types
GET /api/households/:id/stores // Get stores for household
POST /api/households/:id/stores // Add store to household (admin only)
DELETE /api/households/:id/stores/:storeId // Remove store from household (admin only)
// Store Management
GET /api/stores // Get all available stores
POST /api/stores // Create custom store (system admin)
GET /api/households/:id/stores // Get stores for household
POST /api/households/:id/stores // Add store to household (admin+)
DELETE /api/households/:id/stores/:storeId // Remove store (admin+)
// List Operations (now scoped to household + store)
GET /api/households/:hId/stores/:sId/list // Get list
POST /api/households/:hId/stores/:sId/list/add // Add item
PATCH /api/households/:hId/stores/:sId/list/:itemId // Update item
DELETE /api/households/:hId/stores/:sId/list/:itemId // Delete item
POST /api/households/:hId/stores/:sId/list/:itemId/buy // Mark bought
// Item Suggestions (across user's households)
GET /api/items/suggestions?q=milk // Search master catalog
// Classifications (per household + store)
GET /api/households/:hId/stores/:sId/classifications/:itemId
POST /api/households/:hId/stores/:sId/classifications/:itemId
```
---
## React Context Refactoring Pattern
### Current Pattern (To Be Replaced)
```jsx
// Bad: Context is exported, consumers use it directly
export const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
return (
<AuthContext.Provider value={{ user, setUser }}>
{children}
</AuthContext.Provider>
);
}
// Consumer must import context and useContext
import { useContext } from 'react';
import { AuthContext } from '../context/AuthContext';
function MyComponent() {
const { user, setUser } = useContext(AuthContext);
// ...
}
```
### New Pattern (Best Practice)
```jsx
// Good: Context is internal, custom hook is exported
const AuthContext = createContext(null); // Not exported!
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const login = (userData, authToken) => {
setUser(userData);
setToken(authToken);
};
const logout = () => {
setUser(null);
setToken(null);
};
return (
<AuthContext.Provider value={{ user, token, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Export custom hook instead
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
// Consumer usage - clean and simple
import { useAuth } from '../context/AuthContext';
function MyComponent() {
const { user, login, logout } = useAuth();
// ...
}
```
### Benefits
1. **Encapsulation** - Context implementation is hidden, only the hook is public API
2. **Type Safety** - Can add TypeScript types to the hook return value
3. **Validation** - Hook can check if used within provider (prevents null errors)
4. **Cleaner Imports** - One import instead of two (`useContext` + `Context`)
5. **Easier Refactoring** - Can change context internals without affecting consumers
6. **Standard Pattern** - Aligns with React best practices and popular libraries
### Implementation Plan
**Existing Contexts to Refactor:**
- `AuthContext``useAuth()`
- `SettingsContext``useSettings()`
- `ConfigContext``useConfig()` (if still used)
**New Contexts to Create:**
- `HouseholdContext``useHousehold()`
- `StoreContext``useStore()`
**Migration Steps:**
1. Keep old context export temporarily
2. Add custom hook export
3. Update all components to use hook
4. Remove old context export
5. Make context `const` internal to file
---
## Frontend Architecture Changes
### Context Structure
```typescript
// AuthContext - User identity
{
user: { id, username, display_name, systemRole },
token: string,
login, logout,
isSystemAdmin: boolean // Computed from systemRole
}
// HouseholdContext - Active household + household role
{
activeHousehold: { id, name, role }, // role is 'admin' or 'user'
households: Household[],
switchHousehold: (id) => void,
createHousehold: (name) => void,
joinHousehold: (code) => void,
isAdmin: boolean // Computed helper: role === 'admin'
}
// StoreContext - Active store
{
activeStore: { id, name },
householdStores: Store[],
allStores: Store[], // Available store types (for adding)
switchStore: (id) => void,
addStore: (storeId) => void // Admin+ onlyme, role },
households: Household[],
switchHousehold: (id) => void,
createHousehold: (name) => void,
joinHousehold: (code) => void
}
// StoreContext - Active store
{
/admin → System admin panel (system_admin only)
/admin/stores → Manage store types
/admin/households → View all households
/admin/items → Global item catalog
activeStore: { id, name },
householdStores: Store[],
switchStore: (id) => void
} (Owner)</option>
<option value={2}>Work Team (Editor)</option>
<option value={3}>Apartment 5B (Viewer)</option>
<option>+ Create New Household</option>
{user.systemRole === 'system_admin' && (
<option>⚙️ System Admin</option>
)}
</HouseholdDropdown>
```
**Store Tabs** (Within Household)
```tsx
<StoreTabs householdId={activeHousehold.id}>
<Tab active>Costco</Tab>
<Tab>Target</Tab>
<Tab>Walmart</Tab>
{(isAdmin || isOwner) && <Tab>+ Add Store</Tab>} → User settings (personal)
```
### UI Components
**Household Switcher** (Navbar)
```tsx
<HouseholdDropdown>
<option value={1}>Smith Family</option>
<option value={2}>Work Team</option>
<option value={3}>Apartment 5B</option>
<option>+ Create New Household</option>
</HouseholdDropdown>
```
**Store Tabs** (Within Household)
```tsx
<StoreTabs householdId={activeHousehold.id}>
<Tab active>Costco</Tab>
<Tab>Target</Tab>
<Tab>Walmart</Tab>
<Tab>+ Add Store</Tab>
</StoreTabs>
```
---
## Migration Strategy
### Phase 1: Database Schema (Breaking Change)
**Step 1: Backup**
```bash
pg_dump grocery_list > backup_$(date +%Y%m%d).sql
```
**Step 2: Run Migrations**
```sql
-- 1. Create new tables
CREATE TABLE households (...);
CREATE TABLE household_members (...);
-- ... (all new tables)
-- 2. Create default household
INSERT INTO households (name, created_by, invite_code)
VALUES ('Main Household', 1, 'DEFAULT123');
-- 3. Migrate users → household_members
INSERT INTO household_members (household_id, user_id, role)
SELECT 1, id,
CASE
WHEN role = 'admin' THEN 'admin' -- Old admins become household admins
ELSE 'user' -- Everyone else becomes standard user
END
FROM users;
-- 4. Create default store
INSERT INTO stores (name, default_zones)
VALUES ('Costco', '{"zones": [...]}');
-- 5. Link household to store
INSERT INTO household_stores (household_id, store_id, is_default)
VALUES (1, 1, TRUE);
-- 6. Migrate items
INSERT INTO items (name, default_image, default_image_mime_type)
SELECT DISTINCT item_name, item_image, image_mime_type
FROM grocery_list;
-- 7. Migrate grocery_list → household_lists
INSERT INTO household_lists (household_id, store_id, item_id, quantity, bought, added_by, modified_on)
SELECT
1, -- default household
1, -- default store
i.id,
gl.quantity,
gl.bought,
gl.added_by,
gl.modified_on
FROM grocery_list gl
JOIN items i ON LOWER(i.name) = LOWER(gl.item_name);
-- 8. Migrate classifications
INSERT INTO household_item_classifications
(household_id, store_id, item_id, item_type, item_group, zone, confidence, source)
SELECT
1, 1, i.id,
ic.item_type, ic.item_group, ic.zone, ic.confidence, ic.source
FROM item_classification ic
JOIN grUpdate system roles (keep role column)
UPDATE users SET role = 'system_admin' WHERE role = 'admin';
UPDATE users SET role = 'user' WHERE role IN ('editor', 'viewer');
-- 11. Drop old tables (after verification!)
-- DROP TABLE grocery_history;
-- DROP TABLE item_classification;
-- DROP TABLE grocery_listousehold_list_id, quantity, added_by, added_on)
SELECT hl.id, gh.quantity, gh.added_by, gh.added_on
FROM grocery_history gh
JOIN grocery_list gl ON gh.list_item_id = gl.id
JOIN items i ON LOWER(i.name) = LOWER(gl.item_name)
JOIN household_lists hl ON hl.item_id = i.id AND hl.household_id = 1 AND hl.store_id = 1;
-- 10. Drop old tables (after verification!)
-- DROP TABLE grocery_history;
-- DROP TABLE item_classification;
-- DROP TABLE grocery_list;
-- ALTER TABLE users DROP COLUMN role;
```
### Phase 2: Backend API (Incremental)
1. ✅ Create new models (households, stores, household_lists)
2. ✅ Create new middleware (householdAccess, storeAccess)
3. ✅ Create new controllers (households, stores)
4. ✅ Add new routes alongside old ones
5. ✅ Update list controllers to be household+store aware
6. ✅ Deprecate old routes (return 410 Gone)
### Phase 3: Frontend UI (Incremental)
1. ✅ **Refactor Context Pattern** (applies to all contexts)
- Move `createContext` inside component files (not exported)
- Export custom hooks instead: `useAuth()`, `useHousehold()`, `useStore()`, `useSettings()`
- Consumers use hooks directly instead of `useContext(ExportedContext)`
2. ✅ Create HouseholdContext with `useHousehold()` hook
3. ✅ Create StoreContext with `useStore()` hook
4. ✅ Refactor existing AuthContext to use custom `useAuth()` hook
5. ✅ Refactor existing SettingsContext to use custom `useSettings()` hook
6. ✅ Add household switcher to navbar
7. ✅ Create household management pages
8. ✅ Add store tabs to list view
9. ✅ Update all API calls to use household + store IDs
7. ✅ Add invite system UI
8. ✅ Update settings page to show household-specific settings
---
## Advanced Features (Future)
### 1. Item Sharing & Privacy
**Levels:**
- **Private**: Only visible to your household
- **Public**: Available in global item catalog
- **Suggested**: Anonymously contribute to shared catalog
### 2. Smart Features
**Cross-Household Intelligence:**
- "10,000 households buy milk at Costco" → suggest classification
- "Items commonly bought together"
- Price tracking across stores
- Store-specific suggestions
**Household Patterns:**
- "You usually buy milk every 5 days"
- "Bananas are typically added by [User]"
- Auto-add recurring items
### 3. Multi-Store Optimization
**Store Comparison:**
- Track which items each household buys at which store
- "This item is 20% cheaper at Target"
- Generate shopping lists across stores
**Route Optimization:**
- Sort list by store zone
- "You can save 15 minutes by shopping in this order"
### 4. Enhanced Collaboration
**Shopping Mode:**
- Real-time collaboration (one person shops, another adds from home)
- Live updates via WebSockets
- "John is currently at Costco (aisle 12)"
**Shopping Lists:**
- Pre-planned lists (weekly meal prep)
- Recurring lists (monthly bulk buy)
- Shared templates between households
---
## Implementation Timeline
### Sprint 1: Foundation (2-3 weeks)
- [ ] Design finalization & review
- [ ] Create migration scripts
- [ ] Implement new database tables
- [ ] Test migration on staging data
- [ ] Create new models (household, store, household_list)
### Sprint 2: Backend API (2-3 weeks)
- [ ] Implement household management endpoints
- [ ] Implement store management endpoints
- [ ] Update list endpoints for household+store scope
- [ ] Create new middleware (householdAccess, storeAccess)
- [ ] Update authentication to remove global role
### Sprint 3: Frontend Core (2-3 weeks)
- [ ] **Refactor Context Pattern** (foundational change):
- [ ] Refactor AuthContext to internal context + `useAuth()` hook
- [ ] Refactor SettingsContext to internal context + `useSettings()` hook
- [ ] Update all components using old context pattern
- [ ] Create HouseholdContext with `useHousehold()` hook
- [ ] Create StoreContext with `useStore()` hook
- [ ] Build household switcher UI
- [ ] Build store tabs UI
- [ ] Update GroceryList page for new API
- [ ] Create household management pages
### Sprint 4: Member Management (1-2 weeks)
- [ ] Implement invite code system
- [ ] Build member management UI
- [ ] Implement role updates
- [ ] Add join household flow
### Sprint 5: Polish & Testing (1-2 weeks)
- [ ] End-to-end testing
- [ ] Performance optimization
- [ ] Mobile responsiveness
- [ ] Documentation updates
- [ ] Migration dry-run on production backup
### Sprint 6: Production Migration (1 week)
- [ ] Announce maintenance window
- [ ] Run migration on production
- [ ] Verify data integrity
- [ ] Deploy new frontend
- [ ] Monitor for issues
**Total: 9-14 weeks**
---
## Risk Assessment & Mitigation
### High Risk Areas
1. **Data Loss During Migration**
- **Mitigation**: Full backup, dry-run on production copy, rollback plan
2. **Breaking Existing Users**
- **Mitigation**: Default household preserves current behavior, phased rollout
3. **Performance Degradation**
- **Mitigation**: Proper indexing, query optimization, caching strategy
4. **Complexity Creep**
- **Mitigation**: MVP first (basic households), iterate based on feedback
### Testing Strategy
1. **Unit Tests**: All new models and controllers
2. **Integration Tests**: API endpoint flows
3. **Migration Tests**: Verify data integrity post-migration
4. **Load Tests**: Multi-household concurrent access
5. **User Acceptance**: Beta test with small group before full rollout
---
## Open Questions & Decisions Needed
### 1. Item Naming Strategy
- **Question**: Should "milk" from Household A and "Milk" from Household B be the same item?
- **Options**:
- Case-insensitive merge (current behavior, recommended)
- Exact match only
- User prompt for merge confirmation
- **Recommendation**: Case-insensitive with optional household override
### 2. Store Management
- **Question**: Should all stores be predefined, or can users create custom stores?
- **Options**:
- Admin-only store creation (controlled list)
- Users can create custom stores (flexible but messy)
- Hybrid: predefined + custom
- **Recommendation**: Start with predefined stores, add custom later
### 3. Historical Data
- **Question**: When a user leaves a household, what happens to their history?
- **Options**:
- Keep history, anonymize user
- Keep history with user name (allows recovery if re-added)
- Delete history
- **Recommendation**: Keep history with actual user name preserved
- **Rationale**: If user is accidentally removed, their contributions remain attributed correctly when re-added
- History queries should JOIN with users table but handle missing users gracefully
- Display format: Show user name if still exists, otherwise show "User [id]" or handle as deleted account
### 4. Invite System
- **Question**: Should invite codes expire?
- **Options**:
- Never expire (simpler)
- 7-day expiration (more secure)
- Configurable per household
- **Recommendation**: Optional expiration, default to never
### 5. Default Household
- **Question**: When user logs in, which household/store do they see?
- **Options**:
- Last used (remember preference)
- Most recently modified list
- User-configured default
- **Recommendation**: Remember last used in localStorage
---
## Summary & Next Steps
### Recommended Approach: **Hybrid Multi-Tenant Architecture**
**Core Principles:**
1. ✅ Shared item catalog with household-specific lists
2. ✅ Per-household roles (not global)
3. ✅ Store-specific classifications
4. ✅ Invite-based household joining
5. ✅ Backward-compatible migration
### Immediate Actions
1. **Review & Approve**: Get stakeholder buy-in on this architecture
2. **Validate Assumptions**: Confirm design decisions (item sharing, store management)
3. **Create Detailed Tickets**: Break down sprints into individual tasks
4. **Set Up Staging**: Create test environment with production data copy
5. **Begin Sprint 1**: Start with database design and migration scripts
### Success Metrics
- ✅ Zero data loss during migration
- ✅ 100% existing users migrated to default household
- ✅ Performance within 20% of current (queries < 200ms)
- ✅ Users can create households and invite others
- ✅ Lists properly isolated between households
- ✅ Mobile UI remains responsive
---
## Appendix A: Example User Flows
### Creating a Household
1. User clicks "Create Household"
2. Enters name "Smith Family"
3. System generates invite code "SMITH2026"
4. User is set as "admin" role (creator is always admin)
5. User can share code with family members
### Joining a Household
1. User receives invite code "SMITH2026"
2. Navigates to /join/SMITH2026
3. Sees "Join Smith Family?"
4. Confirms, added as "user" role by default
5. Admin can promote to "admin" role if needed
### Managing Multiple Households
1. User belongs to "Smith Family" and "Work Team"
2. Navbar shows dropdown: [Smith Family ▼]
3. Clicks dropdown, sees both households
4. Switches to "Work Team"
5. List updates to show Work Team's items
6. Store tabs show Work Team's configured stores
### Adding Item to Store
1. User in "Smith Family" household
2. Sees store tabs: [Costco] [Target]
3. Clicks "Costco" tab
4. Adds "Milk" - goes to Costco list
5. Switches to "Target" tab
6. Adds "Bread" - goes to Target list
7. Milk and Bread are separate list entries (same item, different stores)
---
## Appendix B: Database Size Estimates
**Current Single List:**
- Users: 10
- Items: 200
- History records: 5,000
**After Multi-Household (10 households, 5 stores each):**
- Users: 10
- Households: 10
- Household_members: 30 (avg 3 users per household)
- Stores: 5
- Household_stores: 50
- Items: 500 (some shared, some unique)
- Household_lists: 2,500 (500 items × 5 stores)
- History: 25,000
**Storage Impact:** ~5x increase in list records, but items are deduplicated.
**Query Performance:**
- Without indexes: O(n) → O(10n) = 10x slower
- With indexes: O(log n) → O(log 10n) = minimal impact
**Conclusion:** With proper indexing, performance should remain acceptable even at 100+ households.

View File

@ -0,0 +1,241 @@
# Household & Store Management - Implementation Summary
## Overview
Built comprehensive household and store management UI for the multi-household grocery list application. Users can now fully manage their households, members, and stores through a polished interface.
## Features Implemented
### 1. Manage Page (`/manage`)
**Location**: [frontend/src/pages/Manage.jsx](frontend/src/pages/Manage.jsx)
- Tab-based interface for Household and Store management
- Context-aware - always operates on the active household
- Accessible via "Manage" link in the navbar
### 2. Household Management
**Component**: [frontend/src/components/manage/ManageHousehold.jsx](frontend/src/components/manage/ManageHousehold.jsx)
**Features**:
- **Edit Household Name**: Admin-only, inline editing
- **Invite Code Management**:
- Show/hide invite code with copy-to-clipboard
- Generate new invite code (invalidates old one)
- Admin-only access
- **Member Management**:
- View all household members with roles
- Promote/demote members between admin and member roles
- Remove members from household
- Cannot remove yourself
- Admin-only actions
- **Delete Household**:
- Admin-only
- Double confirmation required
- Permanently deletes all data
**Permissions**:
- Viewers: Can only see household name and members
- Members: Same as viewers
- Admins: Full access to all features
### 3. Store Management
**Component**: [frontend/src/components/manage/ManageStores.jsx](frontend/src/components/manage/ManageStores.jsx)
**Features**:
- **View Household Stores**:
- Grid layout showing all stores
- Shows store name, location, and default status
- **Add Stores**:
- Select from system-wide store catalog
- Admin-only
- Cannot add already-linked stores
- **Remove Stores**:
- Admin-only
- Cannot remove last store (validation)
- **Set Default Store**:
- Admin-only
- Default store loads automatically
**Permissions**:
- Viewers & Members: Read-only view of stores
- Admins: Full CRUD operations
### 4. Create/Join Household Modal
**Component**: [frontend/src/components/manage/CreateJoinHousehold.jsx](frontend/src/components/manage/CreateJoinHousehold.jsx)
**Features**:
- Tabbed interface: "Create New" or "Join Existing"
- **Create Mode**:
- Enter household name
- Auto-generates invite code
- Creates household with user as admin
- **Join Mode**:
- Enter invite code
- Validates code and adds user as member
- Error handling for invalid codes
**Access**:
- Available from household switcher dropdown
- "+ Create or Join Household" button at bottom
- All authenticated users can access
### 5. Updated Household Switcher
**Component**: [frontend/src/components/household/HouseholdSwitcher.jsx](frontend/src/components/household/HouseholdSwitcher.jsx)
**Enhancements**:
- Added divider between household list and actions
- "+ Create or Join Household" button
- Opens CreateJoinHousehold modal
## Styling
### CSS Files Created
1. **[frontend/src/styles/pages/Manage.css](frontend/src/styles/pages/Manage.css)**
- Page layout and tab navigation
- Responsive design
2. **[frontend/src/styles/components/manage/ManageHousehold.css](frontend/src/styles/components/manage/ManageHousehold.css)**
- Section cards with proper spacing
- Member cards with role badges
- Invite code display
- Danger zone styling
- Button styles (primary, secondary, danger)
3. **[frontend/src/styles/components/manage/ManageStores.css](frontend/src/styles/components/manage/ManageStores.css)**
- Grid layout for store cards
- Default badge styling
- Add store panel
- Available stores grid
4. **[frontend/src/styles/components/manage/CreateJoinHousehold.css](frontend/src/styles/components/manage/CreateJoinHousehold.css)**
- Modal overlay and container
- Mode tabs styling
- Form inputs and buttons
- Error message styling
### Theme Updates
**[frontend/src/styles/theme.css](frontend/src/styles/theme.css)**
Added simplified CSS variable aliases:
```css
--primary: var(--color-primary);
--primary-dark: var(--color-primary-dark);
--primary-light: var(--color-primary-light);
--danger: var(--color-danger);
--danger-dark: var(--color-danger-hover);
--text-primary: var(--color-text-primary);
--text-secondary: var(--color-text-secondary);
--background: var(--color-bg-body);
--border: var(--color-border-light);
--card-hover: var(--color-bg-hover);
```
## Backend Endpoints Used
All endpoints already existed - no backend changes required!
### Household Endpoints
- `GET /households` - Get user's households
- `POST /households` - Create household
- `PATCH /households/:id` - Update household name
- `DELETE /households/:id` - Delete household
- `POST /households/:id/invite/refresh` - Refresh invite code
- `POST /households/join/:inviteCode` - Join via invite code
- `GET /households/:id/members` - Get members
- `PATCH /households/:id/members/:userId/role` - Update member role
- `DELETE /households/:id/members/:userId` - Remove member
### Store Endpoints
- `GET /stores` - Get all stores
- `GET /stores/household/:householdId` - Get household stores
- `POST /stores/household/:householdId` - Add store to household
- `DELETE /stores/household/:householdId/:storeId` - Remove store
- `PATCH /stores/household/:householdId/:storeId/default` - Set default
## User Flow
### Managing Household
1. Click "Manage" in navbar
2. View household overview (name, members, invite code)
3. As admin:
- Edit household name
- Generate new invite codes
- Promote/demote members
- Remove members
- Delete household (danger zone)
### Managing Stores
1. Click "Manage" in navbar
2. Click "Stores" tab
3. View all linked stores with default badge
4. As admin:
- Click "+ Add Store" to see available stores
- Click "Add" on any unlinked store
- Click "Set as Default" on non-default stores
- Click "Remove" to unlink store (except last one)
### Creating/Joining Household
1. Click household name in navbar
2. Click "+ Create or Join Household" at bottom of dropdown
3. Select "Create New" or "Join Existing" tab
4. Fill form and submit
5. New household appears in list and becomes active
## Responsive Design
All components are fully responsive:
- **Desktop**: Grid layouts, side-by-side buttons
- **Tablet**: Adjusted spacing, smaller grids
- **Mobile**:
- Single column layouts
- Full-width buttons
- Stacked form elements
- Optimized spacing
## Permissions Summary
| Feature | Viewer | Member | Admin |
|---------|--------|--------|-------|
| View household info | ✅ | ✅ | ✅ |
| Edit household name | ❌ | ❌ | ✅ |
| View invite code | ❌ | ❌ | ✅ |
| Refresh invite code | ❌ | ❌ | ✅ |
| View members | ✅ | ✅ | ✅ |
| Change member roles | ❌ | ❌ | ✅ |
| Remove members | ❌ | ❌ | ✅ |
| Delete household | ❌ | ❌ | ✅ |
| View stores | ✅ | ✅ | ✅ |
| Add stores | ❌ | ❌ | ✅ |
| Remove stores | ❌ | ❌ | ✅ |
| Set default store | ❌ | ❌ | ✅ |
| Create household | ✅ | ✅ | ✅ |
| Join household | ✅ | ✅ | ✅ |
## Next Steps
Consider adding:
1. **Household Settings**: Description, profile image, preferences
2. **Member Invitations**: Direct user search instead of just invite codes
3. **Store Details**: View item counts, last activity per store
4. **Audit Log**: Track household/store changes
5. **Notifications**: Member added/removed, role changes
6. **Bulk Operations**: Remove multiple members at once
7. **Store Categories**: Group stores by region/type
8. **Export Data**: Download household grocery history
## Testing Checklist
- [ ] Create new household and verify admin role
- [ ] Generate and copy invite code
- [ ] Join household using invite code
- [ ] Edit household name as admin
- [ ] Promote member to admin
- [ ] Demote admin to member
- [ ] Remove member from household
- [ ] Add store to household
- [ ] Set default store
- [ ] Remove store (verify last store protection)
- [ ] Try admin actions as non-admin (should be hidden/disabled)
- [ ] Delete household and verify redirect
- [ ] Test responsive layouts on mobile/tablet/desktop
- [ ] Verify all error messages display properly
- [ ] Test with multiple households

View File

@ -0,0 +1,203 @@
# Multi-Household Implementation - Quick Reference
## Implementation Status
### ✅ Sprint 1: Database Foundation (COMPLETE)
- [x] Created migration script: `multi_household_architecture.sql`
- [x] Created migration guide: `MIGRATION_GUIDE.md`
- [x] Created migration runner scripts: `run-migration.sh` / `run-migration.bat`
- [x] **Tested migration on 'grocery' database (copy of Costco)**
- [x] Migration successful - all data migrated correctly
- [x] Verification passed - 0 data integrity issues
**Migration Results:**
- ✅ 1 Household created: "Main Household" (invite code: MAIN755114)
- ✅ 7 Users migrated (2 system_admins, 5 standard users)
- ✅ 122 Items extracted to master catalog
- ✅ 122 Household lists created
- ✅ 27 Classifications migrated
- ✅ 273 History records preserved
- ✅ All users assigned to household (admin/user roles)
- ✅ 0 orphaned records or data loss
**Database:** `grocery` (using Costco as template for safety)
### ⏳ Sprint 2: Backend API (NEXT - READY TO START)
- [ ] Create household.model.js
- [ ] Create store.model.js
- [ ] Update list.model.js for household+store scope
- [ ] Create householdAccess middleware
- [ ] Create storeAccess middleware
- [ ] Create households.controller.js
- [ ] Create stores.controller.js
- [ ] Update lists.controller.js
- [ ] Update users.controller.js
- [ ] Create/update routes for new structure
### ⏳ Sprint 3: Frontend Core (PENDING)
- [ ] Refactor contexts
- [ ] Create household UI
- [ ] Create store UI
## New Database Schema
### Core Tables
1. **households** - Household entities with invite codes
2. **stores** - Store types (Costco, Target, etc.)
3. **household_members** - User membership with per-household roles
4. **household_stores** - Which stores each household uses
5. **items** - Master item catalog (shared)
6. **household_lists** - Lists scoped to household + store
7. **household_item_classifications** - Classifications per household + store
8. **household_list_history** - History tracking
### Key Relationships
- User → household_members → Household (many-to-many)
- Household → household_stores → Store (many-to-many)
- Household + Store → household_lists → Item (unique per combo)
- household_lists → household_list_history (one-to-many)
## Role System
### System-Wide (users.role)
- **system_admin**: App infrastructure control
- **user**: Standard user
### Household-Scoped (household_members.role)
- **admin**: Full household control
- **user**: Standard member
## Migration Steps
1. **Backup**: `pg_dump grocery_list > backup.sql`
2. **Run**: `psql -d grocery_list -f backend/migrations/multi_household_architecture.sql`
3. **Verify**: Check counts, run integrity queries
4. **Test**: Ensure app functionality
5. **Cleanup**: Drop old tables after verification
## API Changes (Planned)
### Old Format
```
GET /api/list
POST /api/list/add
```
### New Format
```
GET /api/households/:hId/stores/:sId/list
POST /api/households/:hId/stores/:sId/list/add
```
## Frontend Changes (Planned)
### New Contexts
```jsx
const { user, isSystemAdmin } = useAuth();
const { activeHousehold, isAdmin } = useHousehold();
const { activeStore, householdStores } = useStore();
```
### New Routes
```
/households - List households
/households/:id/stores/:sId - Grocery list
/households/:id/members - Manage members
/join/:inviteCode - Join household
```
## Development Workflow
### Phase 1: Database (Current)
1. Review migration script
2. Test on local dev database
3. Run verification queries
4. Document any issues
### Phase 2: Backend API (Next)
1. Create household.model.js
2. Create store.model.js
3. Update list.model.js for household scope
4. Create middleware for household access
5. Update routes
### Phase 3: Frontend
1. Refactor AuthContext → useAuth()
2. Create HouseholdContext → useHousehold()
3. Create StoreContext → useStore()
4. Build household switcher
5. Build store tabs
## Testing Checklist
### Database Migration
- [ ] All tables created
- [ ] All indexes created
- [ ] Users migrated to household
- [ ] Items deduplicated correctly
- [ ] Lists migrated with correct references
- [ ] Classifications preserved
- [ ] History preserved
- [ ] No NULL foreign keys
### Backend API
- [ ] Household CRUD works
- [ ] Member management works
- [ ] Invite codes work
- [ ] Store management works
- [ ] List operations scoped correctly
- [ ] Permissions enforced
- [ ] History tracked correctly
### Frontend UI
- [ ] Login/logout works
- [ ] Household switcher works
- [ ] Store tabs work
- [ ] Can create household
- [ ] Can join household
- [ ] Can add items
- [ ] Can mark bought
- [ ] Roles respected in UI
## Rollback Strategy
If migration fails:
```sql
ROLLBACK;
```
If issues found after:
```bash
psql -d grocery_list < backup.sql
```
## Support Resources
- **Migration Script**: `backend/migrations/multi_household_architecture.sql`
- **Guide**: `backend/migrations/MIGRATION_GUIDE.md`
- **Architecture**: `docs/multi-household-architecture-plan.md`
- **Status**: This file
## Key Decisions
1. ✅ Keep users.role for system admin
2. ✅ Simplify household roles to admin/user
3. ✅ Preserve user names in history (no anonymization)
4. ✅ Shared item catalog with household-specific lists
5. ✅ Context pattern refactoring (internal context + custom hooks)
## Timeline
- **Week 1-2**: Database migration + testing
- **Week 3-4**: Backend API implementation
- **Week 5-6**: Frontend core implementation
- **Week 7**: Member management
- **Week 8-9**: Testing & polish
- **Week 10**: Production migration
## Contact
For questions or issues during implementation, refer to:
- Architecture plan for design decisions
- Migration guide for database steps
- This file for quick status updates

View File

@ -0,0 +1,43 @@
# API Test Suite
The test suite has been reorganized into separate files for better maintainability:
## New Modular Structure (✅ Complete)
- **api-tests.html** - Main HTML file
- **test-config.js** - Global state management
- **test-definitions.js** - All 62 test cases across 8 categories
- **test-runner.js** - Test execution logic
- **test-ui.js** - UI manipulation functions
- **test-styles.css** - All CSS styles
## How to Use
1. Start the dev server: `docker-compose -f docker-compose.dev.yml up`
2. Navigate to: `http://localhost:5000/test/api-tests.html`
3. Configure credentials (default: admin/admin123)
4. Click "▶ Run All Tests"
## Features
- ✅ 62 comprehensive tests
- ✅ Collapsible test cards (collapsed by default)
- ✅ Expected field validation with visual indicators
- ✅ Color-coded HTTP status badges
- ✅ Auto-expansion on test run
- ✅ Expand/Collapse all buttons
- ✅ Real-time pass/fail/error states
- ✅ Summary dashboard
## File Structure
```
backend/public/
├── api-tests.html # Main entry point (use this)
├── test-config.js # State management (19 lines)
├── test-definitions.js # Test cases (450+ lines)
├── test-runner.js # Test execution (160+ lines)
├── test-ui.js # UI functions (90+ lines)
└── test-styles.css # All styles (310+ lines)
```
## Old File
- **api-test.html** - Original monolithic version (kept for reference)
Total: ~1030 lines split into 6 clean, modular files

View File

@ -0,0 +1,283 @@
# Mobile Responsive Design Audit & Recommendations
## ✅ Already Mobile-Friendly
### Components
1. **Navbar** - Just updated with hamburger menu, dropdowns, sticky positioning
2. **AdminPanel** - Has responsive breakpoints (768px, 480px)
3. **Manage page** - Has responsive breakpoints (768px, 480px)
4. **ManageHousehold** - Has 768px breakpoint
5. **Settings** - Has 768px breakpoint
6. **StoreManagement** - Has 768px breakpoint
7. **GroceryList** - Has 480px breakpoint
## ✅ Recently Completed (2026-01-26)
### **All Modals** - Mobile optimization COMPLETE ✓
**Files updated with responsive styles:**
- ✅ `frontend/src/styles/AddImageModal.css` - Added 768px & 480px breakpoints
- ✅ `frontend/src/styles/ImageUploadModal.css` - Added 768px & 480px breakpoints
- ✅ `frontend/src/styles/ItemClassificationModal.css` - Added 768px & 480px breakpoints
- ✅ `frontend/src/styles/SimilarItemModal.css` - Added 768px & 480px breakpoints
- ✅ `frontend/src/styles/components/EditItemModal.css` - Added 768px & 480px breakpoints
- ✅ `frontend/src/styles/components/ConfirmAddExistingModal.css` - Added 768px & 480px breakpoints
- ✅ `frontend/src/styles/ImageModal.css` - Enhanced with 480px breakpoint
- ✅ `frontend/src/styles/components/AddItemWithDetailsModal.css` - Enhanced with 768px breakpoint
- ✅ `frontend/src/styles/ConfirmBuyModal.css` - Already excellent (480px & 360px breakpoints)
**Mobile improvements implemented:**
- Modal width: 95% at 768px, 100% at 480px
- All buttons: Full-width stacking on mobile with 44px minimum height
- Input fields: 16px font-size to prevent iOS zoom
- Image previews: Responsive sizing (180-200px on mobile)
- Touch targets: 44x44px minimum for all interactive elements
- Overflow: Auto scrolling for tall modals (max-height: 90vh)
- Spacing: Reduced padding on small screens
## ⚠️ Needs Improvement
### High Priority
#### 1. **HouseholdSwitcher** - Dropdown might overflow on mobile
**File:** `frontend/src/styles/components/HouseholdSwitcher.css`
**Current:** No mobile breakpoints
**Needs:**
```css
@media (max-width: 480px) {
.household-switcher-dropdown {
max-width: 90vw;
right: auto;
left: 50%;
transform: translateX(-50%);
}
}
```
#### 2. **StoreTabs** - Horizontal scrolling tabs on mobile
**File:** `frontend/src/styles/components/StoreTabs.css`
**Needs:**
```css
@media (max-width: 768px) {
.store-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.store-tab {
min-width: 100px;
font-size: 0.9rem;
padding: 0.6rem 1rem;
}
}
```
#### 3. **Login/Register Pages** - Need better mobile padding
**Files:**
- `frontend/src/styles/pages/Login.css`
- `frontend/src/styles/pages/Register.css`
**Needs:**
```css
@media (max-width: 480px) {
.card {
padding: 1.5rem 1rem;
margin: 0.5rem;
}
.form-input {
font-size: 16px; /* Prevents iOS zoom on focus */
}
}
```
### Medium Priority
#### 4. **GroceryList Item Cards** - Could be more touch-friendly
**File:** `frontend/src/styles/pages/GroceryList.css`
**Current:** Has 480px breakpoint
**Enhancement needed:**
- Increase touch target sizes for mobile
- Better spacing between items on small screens
- Optimize image display on mobile
#### 5. **AddItemForm** - Input width and spacing
**File:** `frontend/src/styles/components/AddItemForm.css`
**Has 480px breakpoint** but verify:
- Input font-size is 16px+ (prevents iOS zoom)
- Buttons are full-width on mobile
- Adequate spacing between form elements
#### 6. **CreateJoinHousehold Modal**
**File:** `frontend/src/styles/components/manage/CreateJoinHousehold.css`
**Has 600px breakpoint** - Review for:
- Full-screen on very small devices
- Button sizing and spacing
- Tab navigation usability
### Low Priority
#### 7. **SuggestionList** - Touch interactions
**File:** `frontend/src/styles/components/SuggestionList.css`
**Needs:** Mobile-specific styles for:
- Larger tap targets
- Better scrolling behavior
- Touch feedback
#### 8. **ClassificationSection** - Zone selection on mobile
**File:** `frontend/src/styles/components/ClassificationSection.css`
**Needs:**
- Ensure zone buttons are touch-friendly
- Stack vertically if needed on small screens
#### 9. **ImageUploadSection**
**File:** `frontend/src/styles/components/ImageUploadSection.css`
**Needs:**
- Camera access optimization for mobile
- Preview image sizing
- Upload button sizing
## 🎯 General Recommendations
### 1. **Global Styles**
Update `frontend/src/index.css`:
```css
/* Prevent zoom on input focus (iOS) */
input, select, textarea {
font-size: 16px;
}
/* Better touch scrolling */
* {
-webkit-overflow-scrolling: touch;
}
/* Ensure body doesn't overflow horizontally */
body {
overflow-x: hidden;
}
```
### 2. **Container Max-Widths**
Standardize across the app:
- Small components: `max-width: 600px`
- Medium pages: `max-width: 800px`
- Wide layouts: `max-width: 1200px`
- Always pair with `margin: 0 auto` and `padding: 1rem`
### 3. **Button Sizing**
Mobile-friendly buttons:
```css
.btn-primary, .btn-secondary {
min-height: 44px; /* Apple's recommended minimum */
padding: 0.75rem 1.5rem;
}
@media (max-width: 768px) {
.btn-primary, .btn-secondary {
width: 100%;
margin-bottom: 0.5rem;
}
}
```
### 4. **Form Layouts**
Stack form fields on mobile:
```css
.form-row {
display: flex;
gap: 1rem;
}
@media (max-width: 768px) {
.form-row {
flex-direction: column;
}
}
```
### 5. **Image Handling**
Responsive images:
```css
img {
max-width: 100%;
height: auto;
}
```
### 6. **Typography**
Adjust for mobile readability:
```css
@media (max-width: 768px) {
h1 { font-size: 1.75rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
body { font-size: 16px; } /* Prevents iOS zoom */
}
```
## 📱 Testing Checklist
Test on these viewports:
- [ ] 320px (iPhone SE)
- [ ] 375px (iPhone 12/13 Pro)
- [ ] 390px (iPhone 14 Pro)
- [ ] 414px (iPhone Pro Max)
- [ ] 768px (iPad Portrait)
- [ ] 1024px (iPad Landscape)
- [ ] 1280px+ (Desktop)
Test these interactions:
- [ ] Navigation menu (hamburger)
- [ ] Dropdowns (household, user menu)
- [ ] All modals
- [ ] Form inputs (no zoom on focus)
- [ ] Touch gestures (swipe, long-press)
- [ ] Scrolling (no horizontal overflow)
- [ ] Image upload/viewing
- [ ] Tab navigation
## 🔄 Future Considerations
1. **Progressive Web App (PWA)**
- Add manifest.json
- Service worker for offline support
- Install prompt
2. **Touch Gestures**
- Swipe to delete items
- Pull to refresh lists
- Long-press for context menu
3. **Keyboard Handling**
- iOS keyboard overlap handling
- Android keyboard behavior
- Input focus management
4. **Performance**
- Lazy load images
- Virtual scrolling for long lists
- Code splitting by route
## 📝 How to Maintain Mobile-First Design
I've updated `.github/copilot-instructions.md` with mobile-first design principles. This will be included in all future conversations automatically.
**To ensure I remember in new conversations:**
1. ✅ Mobile-first guidelines are now in copilot-instructions.md (automatically loaded)
2. Start conversations with: "Remember to keep mobile/desktop responsiveness in mind"
3. Review this audit document before making UI changes
4. Run mobile testing after any CSS/layout changes
**Quick reminder phrases:**
- "Make this mobile-friendly"
- "Add responsive breakpoints"
- "Test on mobile viewports"
- "Ensure touch-friendly targets"

View File

@ -0,0 +1,243 @@
# Multi-Household Architecture Migration Guide
## Pre-Migration Checklist
- [ ] **Backup Database**
```bash
pg_dump -U your_user -d grocery_list > backup_$(date +%Y%m%d_%H%M%S).sql
```
- [ ] **Test on Staging First**
- Copy production database to staging environment
- Run migration on staging
- Verify all data migrated correctly
- Test application functionality
- [ ] **Review Migration Script**
- Read through `multi_household_architecture.sql`
- Understand each step
- Note verification queries
- [ ] **Announce Maintenance Window**
- Notify users of downtime
- Schedule during low-usage period
- Estimate 15-30 minutes for migration
## Running the Migration
### 1. Connect to Database
```bash
psql -U your_user -d grocery_list
```
### 2. Run Migration
```sql
\i backend/migrations/multi_household_architecture.sql
```
The script will:
1. ✅ Create 8 new tables
2. ✅ Create default "Main Household"
3. ✅ Create default "Costco" store
4. ✅ Migrate all users to household members
5. ✅ Extract items to master catalog
6. ✅ Migrate grocery_list → household_lists
7. ✅ Migrate classifications
8. ✅ Migrate history records
9. ✅ Update user system roles
### 3. Verify Migration
Run these queries inside psql:
```sql
-- Check household created
SELECT * FROM households;
-- Check all users migrated
SELECT u.username, u.role as system_role, hm.role as household_role
FROM users u
JOIN household_members hm ON u.id = hm.user_id
ORDER BY u.id;
-- Check item counts match
SELECT
(SELECT COUNT(DISTINCT item_name) FROM grocery_list) as old_unique_items,
(SELECT COUNT(*) FROM items) as new_items;
-- Check list counts
SELECT
(SELECT COUNT(*) FROM grocery_list) as old_lists,
(SELECT COUNT(*) FROM household_lists) as new_lists;
-- Check classification counts
SELECT
(SELECT COUNT(*) FROM item_classification) as old_classifications,
(SELECT COUNT(*) FROM household_item_classifications) as new_classifications;
-- Check history counts
SELECT
(SELECT COUNT(*) FROM grocery_history) as old_history,
(SELECT COUNT(*) FROM household_list_history) as new_history;
-- Verify no data loss - check if all old items have corresponding new records
SELECT gl.item_name
FROM grocery_list gl
LEFT JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
LEFT JOIN household_lists hl ON hl.item_id = i.id
WHERE hl.id IS NULL;
-- Should return 0 rows
-- Check invite code
SELECT name, invite_code FROM households;
```
### 4. Test Application
- [ ] Users can log in
- [ ] Can view "Main Household" list
- [ ] Can add items
- [ ] Can mark items as bought
- [ ] History shows correctly
- [ ] Classifications preserved
- [ ] Images display correctly
## Post-Migration Cleanup
**Only after verifying everything works correctly:**
```sql
-- Drop old tables (CAREFUL - THIS IS IRREVERSIBLE)
DROP TABLE IF EXISTS grocery_history CASCADE;
DROP TABLE IF EXISTS item_classification CASCADE;
DROP TABLE IF EXISTS grocery_list CASCADE;
```
## Rollback Plan
### If Migration Fails
```sql
-- Inside psql during migration
ROLLBACK;
-- Then restore from backup
\q
psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql
```
### If Issues Found After Migration
```bash
# Drop the database and restore
dropdb grocery_list
createdb grocery_list
psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql
```
## Common Issues & Solutions
### Issue: Duplicate items in items table
**Cause**: Case-insensitive matching not working
**Solution**: Check item names for leading/trailing spaces
### Issue: Foreign key constraint errors
**Cause**: User or item references not found
**Solution**: Verify all users and items exist before migrating lists
### Issue: History not showing
**Cause**: household_list_id references incorrect
**Solution**: Check JOIN conditions in history migration
### Issue: Images not displaying
**Cause**: BYTEA encoding issues
**Solution**: Verify image_mime_type correctly migrated
## Migration Timeline
- **T-0**: Begin maintenance window
- **T+2min**: Backup complete
- **T+3min**: Start migration script
- **T+8min**: Migration complete (for ~1000 items)
- **T+10min**: Run verification queries
- **T+15min**: Test application functionality
- **T+20min**: If successful, announce completion
- **T+30min**: End maintenance window
## Data Integrity Checks
```sql
-- Ensure all users belong to at least one household
SELECT u.id, u.username
FROM users u
LEFT JOIN household_members hm ON u.id = hm.user_id
WHERE hm.id IS NULL;
-- Should return 0 rows
-- Ensure all household lists have valid items
SELECT hl.id
FROM household_lists hl
LEFT JOIN items i ON hl.item_id = i.id
WHERE i.id IS NULL;
-- Should return 0 rows
-- Ensure all history has valid list references
SELECT hlh.id
FROM household_list_history hlh
LEFT JOIN household_lists hl ON hlh.household_list_id = hl.id
WHERE hl.id IS NULL;
-- Should return 0 rows
-- Check for orphaned classifications
SELECT hic.id
FROM household_item_classifications hic
LEFT JOIN household_lists hl ON hic.item_id = hl.item_id
AND hic.household_id = hl.household_id
AND hic.store_id = hl.store_id
WHERE hl.id IS NULL;
-- Should return 0 rows (or classifications for removed items, which is ok)
```
## Success Criteria
✅ All tables created successfully
✅ All users migrated to "Main Household"
✅ Item count matches (unique items from old → new)
✅ List count matches (all grocery_list items → household_lists)
✅ Classification count matches
✅ History count matches
✅ No NULL foreign keys
✅ Application loads without errors
✅ Users can perform all CRUD operations
✅ Images display correctly
✅ Bought items still marked as bought
✅ Recently bought still shows correctly
## Next Steps After Migration
1. ✅ Update backend models (Sprint 2)
2. ✅ Update API routes
3. ✅ Update controllers
4. ✅ Test all endpoints
5. ✅ Update frontend contexts
6. ✅ Update UI components
7. ✅ Enable multi-household features
## Support & Troubleshooting
If issues arise:
1. Check PostgreSQL logs: `/var/log/postgresql/`
2. Check application logs
3. Restore from backup if needed
4. Review migration script for errors
## Monitoring Post-Migration
For the first 24 hours after migration:
- Monitor error logs
- Watch for performance issues
- Verify user activity normal
- Check for any data inconsistencies
- Be ready to rollback if critical issues found

View File

@ -0,0 +1,83 @@
# Post-Migration Updates Required
This document outlines the remaining updates needed after migrating to the multi-household architecture.
## ✅ Completed Fixes
1. **Column name corrections** in `list.model.v2.js`:
- Fixed `item_image``custom_image`
- Fixed `image_mime_type``custom_image_mime_type`
- Fixed `hlh.list_id``hlh.household_list_id`
2. **SQL query fixes**:
- Fixed ORDER BY with DISTINCT in `getSuggestions`
- Fixed `setBought` to use boolean instead of quantity logic
3. **Created migration**: `add_notes_column.sql` for missing notes column
## 🔧 Required Database Migration
**Run this SQL on your PostgreSQL database:**
```sql
-- From backend/migrations/add_notes_column.sql
ALTER TABLE household_lists
ADD COLUMN IF NOT EXISTS notes TEXT;
COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item';
```
## 🧹 Optional Cleanup (Not Critical)
### Legacy Files Still Present
These files reference the old `grocery_list` table but are not actively used by the frontend:
- `backend/models/list.model.js` - Old model
- `backend/controllers/lists.controller.js` - Old controller
- `backend/routes/list.routes.js` - Old routes (still mounted at `/list`)
**Recommendation**: Can be safely removed once you confirm the new architecture is working, or kept as fallback.
### Route Cleanup in app.js
The old `/list` route is still mounted in `backend/app.js`:
```javascript
const listRoutes = require("./routes/list.routes");
app.use("/list", listRoutes); // ← Not used by frontend anymore
```
**Recommendation**: Comment out or remove once migration is confirmed successful.
## ✅ No Frontend Changes Needed
The frontend is already correctly calling the new household-scoped endpoints:
- All calls use `/households/:householdId/stores/:storeId/list/*` pattern
- No references to old `/list/*` endpoints
## 🎯 Next Steps
1. **Run the notes column migration** (required for notes feature to work)
2. **Test the application** thoroughly:
- Add items with images
- Mark items as bought/unbought
- Update item quantities and notes
- Test suggestions/autocomplete
- Test recently bought items
3. **Remove legacy files** (optional, once confirmed working)
## 📝 Architecture Notes
**Current Structure:**
- All list operations are scoped to `household_id + store_id`
- History tracking uses `household_list_history` table
- Image storage uses `custom_image` and `custom_image_mime_type` columns
- Classifications use `household_item_classifications` table (per household+store)
**Middleware Chain:**
```javascript
auth → householdAccess → storeAccess → controller
```
This ensures users can only access data for households they belong to and stores linked to those households.

View File

@ -1,12 +1,15 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Costco Grocery List</title> <title>Grocery App</title>
</head> </head>
<body>
<body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@ -15,6 +15,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@playwright/test": "^1.52.0",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
@ -981,6 +982,21 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.47", "version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
@ -2915,6 +2931,50 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View File

@ -7,7 +7,10 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.2", "axios": "^1.13.2",
@ -16,6 +19,7 @@
"react-router-dom": "^7.9.6" "react-router-dom": "^7.9.6"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.52.0",
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",

View File

@ -0,0 +1,29 @@
import { defineConfig } from "@playwright/test";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3010";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chrome",
use: { browserName: "chromium", channel: "chrome" },
},
],
webServer: {
command: "npm run dev -- --host localhost --port 3010",
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});

View File

@ -1,32 +1,41 @@
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ROLES } from "./constants/roles"; import { ROLES } from "./constants/roles";
import { AuthProvider } from "./context/AuthContext.jsx"; import { AuthProvider } from "./context/AuthContext.jsx";
import { ActionToastProvider } from "./context/ActionToastContext.jsx";
import { ConfigProvider } from "./context/ConfigContext.jsx"; import { ConfigProvider } from "./context/ConfigContext.jsx";
import { HouseholdProvider } from "./context/HouseholdContext.jsx";
import { UploadQueueProvider } from "./context/UploadQueueContext.jsx";
import { SettingsProvider } from "./context/SettingsContext.jsx"; import { SettingsProvider } from "./context/SettingsContext.jsx";
import { StoreProvider } from "./context/StoreContext.jsx";
import AdminPanel from "./pages/AdminPanel.jsx"; import AdminPanel from "./pages/AdminPanel.jsx";
import GroceryList from "./pages/GroceryList.jsx"; import GroceryList from "./pages/GroceryList.jsx";
import Login from "./pages/Login.jsx"; import Login from "./pages/Login.jsx";
import Manage from "./pages/Manage.jsx";
import Register from "./pages/Register.jsx"; import Register from "./pages/Register.jsx";
import Settings from "./pages/Settings.jsx"; import Settings from "./pages/Settings.jsx";
import InviteLink from "./pages/InviteLink.jsx";
import AppLayout from "./components/layout/AppLayout.jsx"; import AppLayout from "./components/layout/AppLayout.jsx";
import UploadToaster from "./components/common/UploadToaster.jsx";
import PrivateRoute from "./utils/PrivateRoute.jsx"; import PrivateRoute from "./utils/PrivateRoute.jsx";
import RoleGuard from "./utils/RoleGuard.jsx"; import RoleGuard from "./utils/RoleGuard.jsx";
function App() { function App() {
return ( return (
<ConfigProvider> <ConfigProvider>
<AuthProvider> <AuthProvider>
<HouseholdProvider>
<StoreProvider>
<UploadQueueProvider>
<ActionToastProvider>
<SettingsProvider> <SettingsProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
{/* Public route */} {/* Public route */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/invite/:token" element={<InviteLink />} />
{/* Private routes with layout */} {/* Private routes with layout */}
<Route <Route
@ -37,21 +46,26 @@ function App() {
} }
> >
<Route path="/" element={<GroceryList />} /> <Route path="/" element={<GroceryList />} />
<Route path="/manage" element={<Manage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route <Route
path="/admin" path="/admin"
element={ element={
<RoleGuard allowed={[ROLES.ADMIN]}> <RoleGuard allowed={[ROLES.SYSTEM_ADMIN]}>
<AdminPanel /> <AdminPanel />
</RoleGuard> </RoleGuard>
} }
/> />
</Route> </Route>
</Routes> </Routes>
<UploadToaster />
</BrowserRouter> </BrowserRouter>
</SettingsProvider> </SettingsProvider>
</ActionToastProvider>
</UploadQueueProvider>
</StoreProvider>
</HouseholdProvider>
</AuthProvider> </AuthProvider>
</ConfigProvider> </ConfigProvider>
); );

View File

@ -9,3 +9,8 @@ export const registerRequest = async (username, password, name) => {
const res = await api.post("/auth/register", { username, password, name }); const res = await api.post("/auth/register", { username, password, name });
return res.data; return res.data;
}; };
export const logoutRequest = async () => {
const res = await api.post("/auth/logout");
return res.data;
};

View File

@ -3,6 +3,7 @@ import { API_BASE_URL } from "../config";
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
withCredentials: true,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@ -17,10 +18,39 @@ api.interceptors.request.use((config => {
})); }));
api.interceptors.response.use( api.interceptors.response.use(
response => response, response => {
const payload = response.data;
if (
payload &&
typeof payload === "object" &&
!Array.isArray(payload) &&
Object.keys(payload).length === 2 &&
Object.prototype.hasOwnProperty.call(payload, "data") &&
Object.prototype.hasOwnProperty.call(payload, "request_id")
) {
response.request_id = payload.request_id;
response.data = payload.data;
}
return response;
},
error => { error => {
if (error.response?.status === 401 && const payload = error.response?.data;
error.response?.data?.message === "Invalid or expired token") { const normalizedMessage = payload?.error?.message || payload?.message;
if (payload?.error?.message && payload.message === undefined) {
payload.message = payload.error.message;
}
if (
error.response?.status === 401 &&
window.location.pathname !== "/login" &&
window.location.pathname !== "/register" &&
[
"Invalid or expired token",
"Invalid or expired session",
"Missing authentication",
].includes(normalizedMessage)
) {
localStorage.removeItem("token"); localStorage.removeItem("token");
window.location.href = "/login"; window.location.href = "/login";
alert("Your session has expired. Please log in again."); alert("Your session has expired. Please log in again.");

View File

@ -0,0 +1,93 @@
import api from "./axios";
/**
* Get all households for the current user
*/
export const getUserHouseholds = () => api.get("/households");
/**
* Get details of a specific household
*/
export const getHousehold = (householdId) => api.get(`/households/${householdId}`);
/**
* Create a new household
*/
export const createHousehold = (name) => api.post("/households", { name });
/**
* Update household name
*/
export const updateHousehold = (householdId, name) =>
api.patch(`/households/${householdId}`, { name });
/**
* Delete a household
*/
export const deleteHousehold = (householdId) =>
api.delete(`/households/${householdId}`);
/**
* Refresh household invite code
*/
export const refreshInviteCode = (householdId) =>
api.post(`/households/${householdId}/invite/refresh`);
/**
* Join a household using invite code
*/
export const joinHousehold = (inviteCode) =>
api.post(`/households/join/${inviteCode}`);
/**
* Get household members
*/
export const getHouseholdMembers = (householdId) =>
api.get(`/households/${householdId}/members`);
/**
* Update member role
*/
export const updateMemberRole = (householdId, userId, role) =>
api.patch(`/households/${householdId}/members/${userId}/role`, { role });
/**
* Remove member from household
*/
export const removeMember = (householdId, userId) =>
api.delete(`/households/${householdId}/members/${userId}`);
function groupHeaders(groupId) {
return {
headers: {
"x-group-id": String(groupId),
},
};
}
export const getGroupInviteLinks = (groupId) =>
api.get("/api/groups/invites", groupHeaders(groupId));
export const createGroupInviteLink = (groupId, payload) =>
api.post("/api/groups/invites", payload, groupHeaders(groupId));
export const revokeGroupInviteLink = (groupId, linkId) =>
api.post("/api/groups/invites/revoke", { linkId }, groupHeaders(groupId));
export const reviveGroupInviteLink = (groupId, linkId, ttlDays) =>
api.post("/api/groups/invites/revive", { linkId, ttlDays }, groupHeaders(groupId));
export const deleteGroupInviteLink = (groupId, linkId) =>
api.post("/api/groups/invites/delete", { linkId }, groupHeaders(groupId));
export const getGroupJoinPolicy = (groupId) =>
api.get("/api/groups/join-policy", groupHeaders(groupId));
export const setGroupJoinPolicy = (groupId, joinPolicy) =>
api.post("/api/groups/join-policy", { joinPolicy }, groupHeaders(groupId));
export const getInviteLinkSummary = (token) =>
api.get(`/api/invite-links/${encodeURIComponent(token)}`);
export const acceptInviteLink = (token) =>
api.post(`/api/invite-links/${encodeURIComponent(token)}`);

View File

@ -1,46 +1,143 @@
import api from "./axios"; import api from "./axios";
export const getList = () => api.get("/list"); /**
export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } }); * Get grocery list for household and store
*/
export const getList = (householdId, storeId) =>
api.get(`/households/${householdId}/stores/${storeId}/list`);
export const addItem = (itemName, quantity, imageFile = null) => { /**
* Get specific item by name
*/
export const getItemByName = (householdId, storeId, itemName) =>
api.get(`/households/${householdId}/stores/${storeId}/list/item`, {
params: { item_name: itemName }
});
/**
* Add item to list
*/
export const addItem = (
householdId,
storeId,
itemName,
quantity,
imageFile = null,
notes = null,
addedForUserId = null
) => {
const formData = new FormData(); const formData = new FormData();
formData.append("itemName", itemName); formData.append("item_name", itemName);
formData.append("quantity", quantity); formData.append("quantity", quantity);
if (notes) {
formData.append("notes", notes);
}
if (addedForUserId != null) {
formData.append("added_for_user_id", addedForUserId);
}
if (imageFile) { if (imageFile) {
formData.append("image", imageFile); formData.append("image", imageFile);
} }
return api.post("/list/add", formData, { return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
}); });
}; };
export const getClassification = (id) => api.get(`/list/item/${id}/classification`);
export const updateItemWithClassification = (id, itemName, quantity, classification) => { /**
return api.put(`/list/item/${id}`, { * Get item classification
itemName, */
quantity, export const getClassification = (householdId, storeId, itemName) =>
api.get(`/households/${householdId}/stores/${storeId}/list/classification`, {
params: { item_name: itemName }
});
/**
* Set item classification
*/
export const setClassification = (householdId, storeId, itemName, classification) =>
api.post(`/households/${householdId}/stores/${storeId}/list/classification`, {
item_name: itemName,
classification classification
}); });
};
export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity });
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
export const getRecentlyBought = () => api.get("/list/recently-bought");
export const updateItemImage = (id, itemName, quantity, imageFile) => { /**
* Update item with classification (legacy method - split into separate calls)
*/
export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => {
// This is now two operations: update item + set classification
return Promise.all([
updateItem(householdId, storeId, itemName, quantity),
classification ? setClassification(householdId, storeId, itemName, classification) : Promise.resolve()
]);
};
/**
* Update item details (quantity, notes)
*/
export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
api.put(`/households/${householdId}/stores/${storeId}/list/item`, {
item_name: itemName,
quantity,
notes
});
/**
* Mark item as bought or unbought
*/
export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) =>
api.patch(`/households/${householdId}/stores/${storeId}/list/item`, {
item_name: itemName,
bought,
quantity_bought: quantityBought
});
/**
* Delete item from list
*/
export const deleteItem = (householdId, storeId, itemName) =>
api.delete(`/households/${householdId}/stores/${storeId}/list/item`, {
data: { item_name: itemName }
});
/**
* Get suggestions based on query
*/
export const getSuggestions = (householdId, storeId, query) =>
api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, {
params: { query }
});
/**
* Get recently bought items
*/
export const getRecentlyBought = (householdId, storeId) =>
api.get(`/households/${householdId}/stores/${storeId}/list/recent`);
/**
* Update item image
*/
export const updateItemImage = (
householdId,
storeId,
itemName,
quantity,
imageFile,
options = {}
) => {
const formData = new FormData(); const formData = new FormData();
formData.append("id", id); formData.append("item_name", itemName);
formData.append("itemName", itemName);
formData.append("quantity", quantity); formData.append("quantity", quantity);
formData.append("image", imageFile); formData.append("image", imageFile);
return api.post("/list/update-image", formData, { return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
onUploadProgress: options.onUploadProgress,
signal: options.signal,
timeout: options.timeoutMs,
}); });
}; };

Some files were not shown because too many files have changed in this diff Show More