Compare commits

..

No commits in common. "76817fb96953eb62db9f474ae925b6aec2401038" and "1281c91c28d6437ef09062f4a0c62e89ba4252c3" have entirely different histories.

230 changed files with 4036 additions and 26806 deletions

View File

@ -1,40 +0,0 @@
---
name: fiddy-verify
description: Run and report the Fiddy repository verification loop. Use when Codex is finishing changes, checking repo health, validating docs/scripts/config updates, or deciding which lint/typecheck/test/build commands are appropriate for this Express/Vite/npm project.
---
# Fiddy Verification
Use this workflow from the repo root.
## Before Running Commands
- Read `AGENTS.md`, `PROJECT_INSTRUCTIONS.md`, and any doc related to the touched area.
- Check `git status --short --branch` so user work is not mistaken for Codex changes.
- Do not print real `.env` values. Inspect keys only if environment context is needed.
- Do not run DB migrations unless the user explicitly asked for migration execution.
## Choose Checks
- Docs-only changes: validate affected Markdown links/content where practical, then run JSON/script sanity checks if package files changed.
- Root or frontend script changes: run `npm run lint`, `npm run typecheck`, and the relevant build command.
- Backend behavior changes: run `npm test`; add focused Jest/Supertest coverage for changed API behavior.
- Frontend behavior changes: run `npm run lint`, `npm run typecheck`, and focused Playwright tests when a browser flow changed.
- Dependency or lockfile changes: run `npm run audit` after install/update commands.
- Migration changes: run `npm run db:migrate:stale:check` and `npm run db:migrate:verify` only against the intended external DB environment.
## Default Safe Loop
Run these when dependencies are already installed and the touched files justify them:
```bash
npm run lint
npm run typecheck
npm run audit
npm test
npm run build:backend
npm run build:frontend
```
## Report
- List exact commands run and pass/fail.
- For failures, include the short relevant error and whether it appears caused by the current changes.
- If a check is skipped, state the concrete reason.
- End with unresolved risks and the smallest useful next step.

View File

@ -5,7 +5,7 @@ on:
branches: [ "main" ]
env:
REGISTRY: git.nicosaya.com/nalalangan/grocery-app
REGISTRY: git.nicosaya.com/nalalangan/costco-grocery-list
jobs:
build:
@ -13,34 +13,26 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version: 22.12.0
node-version: 20
# -------------------------
# Verification gate
# 🔹 BACKEND TESTS
# -------------------------
- name: Install dependencies
run: |
npm ci
npm --prefix backend ci
npm --prefix frontend ci
- name: Install backend dependencies
working-directory: backend
run: npm ci
- name: Run reliability verification
run: |
npm run audit
npm run lint
npm run typecheck
npm test
npm run db:migrate:stale:check
npm run build:backend
npm run build:frontend
- name: Run backend tests
working-directory: backend
run: npm test --if-present
# -------------------------
# Docker Login
# 🔹 Docker Login
# -------------------------
- name: Docker login
run: |
@ -48,7 +40,7 @@ jobs:
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
# -------------------------
# Build Backend Image
# 🔹 Build Backend Image
# -------------------------
- name: Build Backend Image
run: |
@ -63,7 +55,7 @@ jobs:
docker push $REGISTRY/backend:latest
# -------------------------
# Build Frontend Image
# 🔹 Build Frontend Image
# -------------------------
- name: Build Frontend Image
run: |
@ -83,7 +75,7 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Install SSH key
run: |
@ -125,11 +117,12 @@ jobs:
echo "Deployment job finished with status: $STATUS"
if [ "$STATUS" = "success" ]; then
MSG="Costco App Deployment succeeded: $REGISTRY:${{ github.sha }}"
MSG="🚀 Costco App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}"
else
MSG="Costco App Deployment FAILED: $REGISTRY:${{ github.sha }}"
MSG="❌ Costco App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}"
fi
curl -d "$MSG" \
https://ntfy.nicosaya.com/gitea

View File

@ -1,157 +0,0 @@
name: Build & Deploy Costco Grocery List
on:
push:
branches: [ "main-new" ]
env:
REGISTRY: git.nicosaya.com/nalalangan/grocery-app
# REGISTRY: grocery-app
IMAGE_TAG: main-new
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22.12.0
# -------------------------
# Verification gate
# -------------------------
- name: Install dependencies
run: |
npm ci
npm --prefix backend ci
npm --prefix frontend ci
- name: Run reliability verification
run: |
npm run audit
npm run lint
npm run typecheck
npm test
npm run db:migrate:stale:check
npm run build:backend
npm run build:frontend
# -------------------------
# 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@v4
- 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: $REGISTRY:${{ github.sha }}"
else
MSG="Grocery App Deployment FAILED: $REGISTRY:${{ github.sha }}"
fi
curl -d "$MSG" \
https://ntfy.nicosaya.com/gitea

View File

@ -1,21 +1,197 @@
# Copilot Compatibility Instructions
# Costco Grocery List - AI Agent Instructions
## Precedence
- Source of truth: `PROJECT_INSTRUCTIONS.md` (repo root).
- Agent workflow constraints: `AGENTS.md` (repo root).
- Bugfix protocol: `DEBUGGING_INSTRUCTIONS.md` (repo root).
## Architecture Overview
If any guidance in this file conflicts with the root instruction files, follow the root instruction files.
This is a full-stack grocery list management app with **role-based access control (RBAC)**:
- **Backend**: Node.js + Express + PostgreSQL (port 5000)
- **Frontend**: React 19 + TypeScript + Vite (port 3000/5173)
- **Deployment**: Docker Compose with separate dev/prod configurations
## Current stack note
This repository is currently:
- Backend: Express (`backend/`)
- Frontend: React + Vite (`frontend/`)
### Key Design Patterns
Apply architecture intent from `PROJECT_INSTRUCTIONS.md` using the current stack mapping in:
- `docs/AGENTIC_CONTRACT_MAP.md`
**Three-tier RBAC system** (`viewer`, `editor`, `admin`):
- `viewer`: Read-only access to grocery lists
- `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)
## Safety reminders
- External DB only (`DATABASE_URL`), no DB container assumptions.
- No cron/worker additions unless explicitly approved.
- Never log secrets, receipt bytes, or full invite codes.
**Middleware chain pattern** for protected routes:
```javascript
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem);
```
- `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

16
.gitignore vendored
View File

@ -4,15 +4,11 @@
# Node dependencies
node_modules/
# Build output (if using a bundler or React later)
dist/
build/
playwright-report/
test-results/
.npm-cache/
.playwright-browsers/
# Logs
npm-debug.log*
# Build output (if using a bundler or React later)
dist/
build/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -1 +0,0 @@
[]

File diff suppressed because one or more lines are too long

View File

@ -1,12 +0,0 @@
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

@ -1,14 +0,0 @@
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

View File

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

111
AGENTS.md
View File

@ -1,111 +0,0 @@
# AGENTS.md - Fiddy
## Authority
- Source of truth: `PROJECT_INSTRUCTIONS.md` in the repo root.
- Bugfix protocol: `DEBUGGING_INSTRUCTIONS.md`.
- Current-stack mapping: `docs/AGENTIC_CONTRACT_MAP.md`.
- If files conflict, follow `PROJECT_INSTRUCTIONS.md`.
## Project Overview
- Full-stack grocery list app with household/group behavior, RBAC, image support, and Postgres persistence.
- Backend: Express 5 CommonJS API in `backend/`.
- Frontend: React 19 + Vite SPA in `frontend/`, with partial TypeScript.
- Database: external on-prem Postgres. Do not add or assume a DB container.
- Canonical migrations live in `packages/db/migrations`.
## Important Directories
- `backend/routes`, `backend/controllers`: route registration and request/response handling.
- `backend/models`, `backend/services`, `backend/middleware`, `backend/db`: DB access, domain logic, auth, RBAC, request IDs, image handling.
- `frontend/src/api`: client API wrappers using the shared Axios instance.
- `frontend/src/context`, `frontend/src/hooks`, `frontend/src/components`, `frontend/src/pages`: UI state and screens.
- `frontend/tests`: Playwright e2e tests.
- `backend/tests`: Jest/Supertest backend tests.
- `scripts`: DB migration helpers.
- `docs`: practical maps, runbooks, architecture notes, and archived implementation history.
## Setup
- Install root tools: `npm ci`
- Install backend tools: `npm --prefix backend ci`
- Install frontend tools: `npm --prefix frontend ci`
- Use Node.js 20.19+ or 22.12+ for frontend/Vite commands.
- Configure backend env from `backend/.env.example`; never commit real `.env` values.
- For migration scripts, set `DATABASE_URL` in the shell before running root DB commands.
## Run Commands
- Dev with Docker: `docker compose -f docker-compose.dev.yml up`
- Backend only: `npm run dev:backend`
- Frontend only: `npm run dev:frontend`
- Backend default port: `5000`
- Frontend Docker-mapped port: `3010`; Vite direct default is `5173` unless overridden.
## Verification Commands
- Backend unit/API tests: `npm test`
- Frontend lint: `npm run lint`
- Frontend typecheck: `npm run typecheck`
- Vulnerability audit: `npm run audit`
- Backend build: `npm run build:backend`
- Frontend build: `npm run build:frontend`
- Full build: `npm run build`
- E2E tests: `npm run test:e2e`
- Migration status: `npm run db:migrate:status`
- Migration verification: `npm run db:migrate:verify`
## Environment Notes
- `backend/.env` is used by the backend and Docker dev service.
- `frontend/.env` may define `VITE_API_URL` and `VITE_ALLOWED_HOSTS`.
- Do not print, log, or commit secrets, tokens, cookies, DB URLs, receipt bytes, or full invite codes.
- Logs/audit entries for invite codes may include last4 only.
## Coding Conventions
- Preserve the current stack; do not migrate to Next.js or another framework.
- Keep Express routes/controllers thin; put DB-heavy work in models/services and authz in backend layers.
- Keep frontend network calls in `frontend/src/api` wrappers and UI state in context/hooks.
- Always send credentials through the shared Axios client when touching authenticated frontend API calls.
- Enforce RBAC server-side; client guards are UX only.
- Frontend DB-mutating actions must show toast/bubble outcome notifications.
- Progress notifications must reuse `UploadQueueContext` and `UploadToaster`.
- Keep encoding clean; do not introduce mojibake.
## Dependency Rules
- npm is the package manager; do not introduce pnpm, yarn, bun, or another build tool.
- Do not add production dependencies unless the task clearly requires it and the repo has no practical existing option.
- Do not add cron, workers, polling daemons, or background job frameworks.
## Testing Expectations
- Add or update tests when API behavior changes.
- Include negative cases for authz, membership, and invalid input where applicable.
- For UI behavior changes, prefer focused Playwright tests.
- Run the narrowest relevant checks first, then broader checks when risk warrants it.
## Done Means
- Behavior is preserved unless the task explicitly requires a change.
- Relevant tests/lint/typecheck/build commands were run or the reason they could not run is documented.
- Touched files have no known TS/lint errors.
- Migrations are in `packages/db/migrations` when schema changes are required.
- Documentation is updated for changed commands, contracts, or workflows.
## Codex Must Not
- Do not read or expose real `.env` values; inspect variable names only when needed.
- Do not touch production data or run DB migrations without explicit operator intent.
- Do not log secrets, receipt bytes, or full invite codes.
- Do not delete user work or generated artifacts unless explicitly asked.
- Do not make broad refactors, file moves, or framework changes for cleanup alone.
## Deeper Docs
- `docs/PROJECT_MAP.md`: quick repo orientation.
- `docs/DEVELOPMENT.md`: setup, run, verify, and troubleshooting.
- `docs/DB_MIGRATION_WORKFLOW.md`: migration runbook.
- `docs/PLANS.md`: template for multi-step work.
- `.agents/skills/fiddy-verify/SKILL.md`: repo-specific verification workflow for Codex.
## Response Icon Legend
- `🔄` in progress
- `✅` completed
- `🧪` verification/test 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

View File

@ -1,48 +0,0 @@
# 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.

View File

@ -1,205 +0,0 @@
# 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).
- Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception.
- 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)
- Treat committing as a first-class part of the workflow: create frequent, verified checkpoint commits for completed work instead of accumulating large uncommitted changes.
- 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.
- Before switching tasks or stopping after a completed change, check git status and either commit the finished slice or clearly document why it remains uncommitted.
- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code).

View File

@ -2,8 +2,6 @@
A full-stack web application for managing grocery shopping lists with role-based access control, image support, and intelligent item classification.
> Current maintainer notes: `PROJECT_INSTRUCTIONS.md` is the source of truth for project constraints. For current setup, run, and verification commands, start with `docs/DEVELOPMENT.md` and `docs/PROJECT_MAP.md`; some older sections below are historical and should be checked against current code before changing behavior.
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Node](https://img.shields.io/badge/node-20.x-green.svg)
![React](https://img.shields.io/badge/react-19.2.0-blue.svg)
@ -326,8 +324,8 @@ router.post("/add",
1. **Clone the repository**
```bash
git clone https://git.nicosaya.com/nalalangan/grocery-app.git
cd grocery-app
git clone https://github.com/your-org/costco-grocery-list.git
cd costco-grocery-list
```
2. **Configure environment variables**
@ -423,7 +421,7 @@ Authorization: Bearer <your_jwt_token>
## 📁 Project Structure
```
grocery-app/
costco-grocery-list/
├── .gitea/
│ └── workflows/
│ └── deploy.yml # CI/CD pipeline configuration
@ -749,7 +747,7 @@ This project is licensed under the MIT License - see the LICENSE file for detail
## 👤 Author
**Nico Saya**
- Repository: [git.nicosaya.com/nalalangan/grocery-app](https://git.nicosaya.com/nalalangan/grocery-app)
- Repository: [git.nicosaya.com/nalalangan/costco-grocery-list](https://git.nicosaya.com/nalalangan/costco-grocery-list)
---

View File

@ -1,11 +0,0 @@
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

@ -1,14 +1,12 @@
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache postgresql-client
COPY package*.json ./
RUN npm install
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5000
CMD ["npm", "run", "dev"]
CMD ["npm", "run", "dev"]

View File

@ -1,81 +1,46 @@
const express = require("express");
const cors = require("cors");
const path = require("path");
const User = require("./models/user.model");
const requestIdMiddleware = require("./middleware/request-id");
const { sendError } = require("./utils/http");
const app = express();
app.use(requestIdMiddleware);
app.use(express.json());
// Expose manual API test pages in non-production environments only.
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(
cors({
origin: function (origin, callback) {
if (!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 (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
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", "PATCH"],
credentials: true,
exposedHeaders: ["X-Request-Id"],
})
);
app.get('/', async (req, res) => {
res.status(200).json({
message: "Grocery List API is running.",
roles: Object.values(User.ROLES),
});
});
const authRoutes = require("./routes/auth.routes");
app.use("/auth", authRoutes);
const listRoutes = require("./routes/list.routes");
app.use("/list", listRoutes);
const adminRoutes = require("./routes/admin.routes");
app.use("/admin", adminRoutes);
const usersRoutes = require("./routes/users.routes");
app.use("/users", usersRoutes);
const configRoutes = require("./routes/config.routes");
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;
const express = require("express");
const cors = require("cors");
const User = require("./models/user.model");
const app = express();
app.use(express.json());
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim());
console.log("Allowed Origins:", allowedOrigins);
app.use(
cors({
origin: function (origin, callback) {
if (!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 (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
callback(new Error("Not allowed by CORS"));
},
methods: ["GET", "POST", "PUT", "DELETE"],
})
);
app.get('/', async (req, res) => {
resText = `Grocery List API is running.\n` +
`Roles available: ${Object.values(User.ROLES).join(', ')}`
res.status(200).type("text/plain").send(resText);
});
const authRoutes = require("./routes/auth.routes");
app.use("/auth", authRoutes);
const listRoutes = require("./routes/list.routes");
app.use("/list", listRoutes);
const adminRoutes = require("./routes/admin.routes");
app.use("/admin", adminRoutes);
const usersRoutes = require("./routes/users.routes");
app.use("/users", usersRoutes);
const configRoutes = require("./routes/config.routes");
app.use("/config", configRoutes);
module.exports = app;

View File

@ -1,62 +0,0 @@
"use strict";
const fs = require("fs");
const path = require("path");
const rootDir = __dirname;
const distDir = path.join(rootDir, "dist");
const directoriesToCopy = [
"config",
"constants",
"controllers",
"db",
"middleware",
"models",
"routes",
"services",
"utils",
"public",
];
const filesToCopy = ["app.js", "server.js", "package.json", "package-lock.json"];
function copyFile(sourcePath, targetPath) {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.copyFileSync(sourcePath, targetPath);
}
function copyDirectory(sourceDir, targetDir) {
if (!fs.existsSync(sourceDir)) {
return;
}
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
copyDirectory(sourcePath, targetPath);
continue;
}
if (entry.isFile()) {
copyFile(sourcePath, targetPath);
}
}
}
fs.mkdirSync(distDir, { recursive: true });
for (const directory of directoriesToCopy) {
copyDirectory(path.join(rootDir, directory), path.join(distDir, directory));
}
for (const file of filesToCopy) {
const sourcePath = path.join(rootDir, file);
if (fs.existsSync(sourcePath)) {
copyFile(sourcePath, path.join(distDir, file));
}
}
console.log(`Backend build copied runtime files to ${path.relative(rootDir, distDir)}`);

View File

@ -1,101 +1,44 @@
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
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) => {
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();
if (password.length < 8) {
return sendError(res, 400, "Password must be at least 8 characters");
}
try {
const hash = await bcrypt.hash(password, 10);
const user = await User.createUser(username, hash, name);
res.json({ message: "User registered", user });
} catch (err) {
logError(req, "auth.register", err);
sendError(res, 400, "Registration failed");
}
};
exports.login = async (req, res) => {
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();
const user = await User.findByUsername(username);
if (!user) {
return sendError(res, 401, "Invalid credentials");
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return sendError(res, 401, "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(
{ id: user.id, role: user.role },
jwtSecret,
{ expiresIn: "1 year" }
);
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");
}
};
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const User = require("../models/user.model");
exports.register = async (req, res) => {
let { username, password, name } = req.body;
username = username.toLowerCase();
console.log(`🆕 Registration attempt for ${name} => username:${username}, password:${password}`);
try {
const hash = await bcrypt.hash(password, 10);
const user = await User.createUser(username, hash, name);
console.log(`✅ User registered: ${username}`);
res.json({ message: "User registered", user });
} catch (err) {
res.status(400).json({ message: "Registration failed", error: err });
}
};
exports.login = async (req, res) => {
let { username, password } = req.body;
username = username.toLowerCase();
const user = await User.findByUsername(username);
if (!user) {
console.log(`⚠️ Login attempt -> No user found: ${username}`);
return res.status(401).json({ message: "User not found" });
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
console.log(`⛔ Login attempt for user ${username} with password ${password}`);
return res.status(401).json({ message: "Invalid credentials" });
}
const token = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "1 year" }
);
res.json({ token, username, role: user.role });
};

View File

@ -1,319 +0,0 @@
const AvailableItems = require("../models/available-item.model");
const List = require("../models/list.model.v2");
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
const LEGACY_ITEM_TYPE_MAP = {
beverages: "beverage",
snacks: "snack",
};
function parseBoolean(value) {
return value === true || value === "true" || value === "1";
}
function isCatalogTableMissing(error) {
return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || "");
}
function parseClassificationInput(value) {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed === "null") {
return null;
}
if (trimmed.startsWith("{")) {
try {
return JSON.parse(trimmed);
} catch (error) {
return Symbol.for("invalid-classification-json");
}
}
return trimmed;
}
return value;
}
function normalizeClassificationPayload(classification) {
if (typeof classification === "string") {
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
return {
item_type: normalizedItemType,
item_group: null,
zone: null,
};
}
if (!classification || typeof classification !== "object" || Array.isArray(classification)) {
return null;
}
const item_type =
typeof classification.item_type === "string" && classification.item_type.trim() !== ""
? classification.item_type.trim()
: null;
const item_group =
typeof classification.item_group === "string" && classification.item_group.trim() !== ""
? classification.item_group.trim()
: null;
const zone =
typeof classification.zone === "string" && classification.zone.trim() !== ""
? classification.zone.trim()
: null;
if (!item_type && !item_group && !zone) {
return null;
}
return { item_type, item_group, zone };
}
function validateClassification(res, classification) {
if (!classification) {
return false;
}
const { item_type, item_group, zone } = classification;
if (item_type && !isValidItemType(item_type)) {
sendError(res, 400, "Invalid item_type");
return true;
}
if (item_group && !item_type) {
sendError(res, 400, "Item type is required when item group is provided");
return true;
}
if (item_group && !isValidItemGroup(item_type, item_group)) {
sendError(res, 400, "Invalid item_group for selected item_type");
return true;
}
if (zone && !isValidZone(zone)) {
sendError(res, 400, "Invalid zone");
return true;
}
return false;
}
function parseItemId(value) {
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
exports.getAvailableItems = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || "");
res.json({ items, catalog_ready: true });
} catch (error) {
if (isCatalogTableMissing(error)) {
return res.json({
items: [],
catalog_ready: false,
message: "Store item management is unavailable until the latest database migration is applied.",
});
}
logError(req, "availableItems.getAvailableItems", error);
sendError(res, 500, "Failed to load available items");
}
};
exports.createAvailableItem = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const { item_name } = req.body;
if (!item_name || item_name.trim() === "") {
return sendError(res, 400, "Item name is required");
}
const parsedClassification = parseClassificationInput(req.body.classification);
if (parsedClassification === Symbol.for("invalid-classification-json")) {
return sendError(res, 400, "Classification payload must be valid JSON");
}
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
if (validateClassification(res, normalizedClassification)) {
return;
}
const imageBuffer = req.processedImage?.buffer || null;
const mimeType = req.processedImage?.mimeType || null;
const item = await AvailableItems.createAvailableItem(
householdId,
storeId,
item_name,
imageBuffer,
mimeType
);
if (normalizedClassification) {
await List.upsertClassification(householdId, storeId, item.item_id, {
...normalizedClassification,
confidence: 1.0,
source: "user",
});
}
const refreshedItem = await AvailableItems.getAvailableItemById(householdId, storeId, item.item_id);
res.status(201).json({
message: "Available item added",
item: refreshedItem,
});
} catch (error) {
if (isCatalogTableMissing(error)) {
return sendError(
res,
503,
"Store item management is unavailable until the latest database migration is applied"
);
}
logError(req, "availableItems.createAvailableItem", error);
if (error.code === "23505") {
return sendError(res, 400, "Available item already exists for this store");
}
sendError(res, 500, "Failed to add available item");
}
};
exports.updateAvailableItem = async (req, res) => {
try {
const { householdId, storeId, itemId: rawItemId } = req.params;
const itemId = parseItemId(rawItemId);
if (!itemId) {
return sendError(res, 400, "Item ID must be a positive integer");
}
const hasClassificationField = Object.prototype.hasOwnProperty.call(req.body, "classification");
const parsedClassification = parseClassificationInput(req.body.classification);
if (parsedClassification === Symbol.for("invalid-classification-json")) {
return sendError(res, 400, "Classification payload must be valid JSON");
}
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
if (normalizedClassification && validateClassification(res, normalizedClassification)) {
return;
}
const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeId, itemId, {
itemName: req.body.item_name,
imageBuffer: req.processedImage?.buffer || null,
mimeType: req.processedImage?.mimeType || null,
removeImage: parseBoolean(req.body.remove_image),
});
if (!updatedItem) {
return sendError(res, 404, "Available item not found");
}
if (hasClassificationField) {
if (normalizedClassification) {
await List.upsertClassification(householdId, storeId, updatedItem.item_id, {
...normalizedClassification,
confidence: 1.0,
source: "user",
});
} else {
await List.deleteClassification(householdId, storeId, updatedItem.item_id);
}
}
const refreshedItem = await AvailableItems.getAvailableItemById(
householdId,
storeId,
updatedItem.item_id
);
res.json({
message: "Available item updated",
item: refreshedItem,
});
} catch (error) {
if (isCatalogTableMissing(error)) {
return sendError(
res,
503,
"Store item management is unavailable until the latest database migration is applied"
);
}
logError(req, "availableItems.updateAvailableItem", error);
if (error.code === "23505") {
return sendError(res, 400, "Available item already exists for this store");
}
sendError(res, 500, "Failed to update available item");
}
};
exports.deleteAvailableItem = async (req, res) => {
try {
const { householdId, storeId, itemId: rawItemId } = req.params;
const itemId = parseItemId(rawItemId);
if (!itemId) {
return sendError(res, 400, "Item ID must be a positive integer");
}
const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId);
if (!deleted) {
return sendError(res, 404, "Store item not found");
}
res.json({ message: "Store item deleted" });
} catch (error) {
if (isCatalogTableMissing(error)) {
return sendError(
res,
503,
"Store item management is unavailable until the latest database migration is applied"
);
}
logError(req, "availableItems.deleteAvailableItem", error);
sendError(res, 500, "Failed to delete store item");
}
};
exports.importCurrentItems = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeId);
res.json({
message: importedCount > 0 ? "Imported current list items" : "No current list items to import",
imported_count: importedCount,
});
} catch (error) {
if (isCatalogTableMissing(error)) {
return sendError(
res,
503,
"Store item management is unavailable until the latest database migration is applied"
);
}
logError(req, "availableItems.importCurrentItems", error);
sendError(res, 500, "Failed to import current list items");
}
};

View File

@ -1,252 +0,0 @@
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.listPendingJoinRequests = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const requests = await invitesService.listPendingJoinRequests(req.user.id, groupId);
res.json({ requests });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.listPendingJoinRequests");
}
};
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.decideJoinRequest = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const decision = await invitesService.decideJoinRequest(
req.user.id,
groupId,
req.body?.requestId,
req.body?.decision,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ request: decision });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.decideJoinRequest");
}
};
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

@ -1,239 +0,0 @@
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 || !["owner", "admin", "member"].includes(role)) {
return sendError(res, 400, "Invalid role. Must be 'owner', '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");
}
let updated;
if (role === "owner") {
if (req.household.role !== "owner") {
return sendError(res, 403, "Only the household owner can transfer ownership");
}
updated = await householdModel.transferOwnership(
req.params.householdId,
req.user.id,
parseInt(userId, 10)
);
} else {
updated = await householdModel.updateMemberRole(
req.params.householdId,
userId,
role
);
}
res.json({
message: role === "owner"
? "Household ownership transferred successfully"
: "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,7 +1,5 @@
const List = require("../models/list.model");
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
const List = require("../models/list.model");
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
exports.getList = async (req, res) => {
@ -59,9 +57,9 @@ exports.updateItemImage = async (req, res) => {
const imageBuffer = req.processedImage?.buffer || null;
const mimeType = req.processedImage?.mimeType || null;
if (!imageBuffer) {
return sendError(res, 400, "No image provided");
}
if (!imageBuffer) {
return res.status(400).json({ message: "No image provided" });
}
// Update the item with new image
await List.addOrUpdateItem(itemName, quantity, userId, imageBuffer, mimeType);
@ -91,17 +89,17 @@ exports.updateItemWithClassification = async (req, res) => {
const { item_type, item_group, zone } = classification;
// Validate classification data
if (item_type && !isValidItemType(item_type)) {
return sendError(res, 400, "Invalid item_type");
}
if (item_group && !isValidItemGroup(item_type, item_group)) {
return sendError(res, 400, "Invalid item_group for selected item_type");
}
if (zone && !isValidZone(zone)) {
return sendError(res, 400, "Invalid zone");
}
if (item_type && !isValidItemType(item_type)) {
return res.status(400).json({ message: "Invalid item_type" });
}
if (item_group && !isValidItemGroup(item_type, item_group)) {
return res.status(400).json({ message: "Invalid item_group for selected item_type" });
}
if (zone && !isValidZone(zone)) {
return res.status(400).json({ message: "Invalid zone" });
}
// Upsert classification with confidence=1.0 and source='user'
await List.upsertClassification(id, {
@ -114,8 +112,8 @@ exports.updateItemWithClassification = async (req, res) => {
}
res.json({ message: "Item updated successfully" });
} catch (error) {
logError(req, "listsLegacy.updateItemWithClassification", error);
sendError(res, 500, "Failed to update item");
}
};
} catch (error) {
console.error("Error updating item with classification:", error);
res.status(500).json({ message: "Failed to update item" });
}
};

View File

@ -1,396 +0,0 @@
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");
const LEGACY_ITEM_TYPE_MAP = {
beverages: "beverage",
snacks: "snack",
};
function normalizeClassificationPayload(classification) {
if (typeof classification === "string") {
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
return {
item_type: normalizedItemType,
item_group: null,
zone: null,
};
}
if (!classification || typeof classification !== "object" || Array.isArray(classification)) {
return null;
}
const item_type =
typeof classification.item_type === "string" && classification.item_type.trim() !== ""
? classification.item_type.trim()
: null;
const item_group =
typeof classification.item_group === "string" && classification.item_group.trim() !== ""
? classification.item_group.trim()
: null;
const zone =
typeof classification.zone === "string" && classification.zone.trim() !== ""
? classification.zone.trim()
: null;
if (!item_type && !item_group && !zone) {
return null;
}
return { item_type, item_group, zone };
}
/**
* 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, result.householdStoreItemId, 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, storeId, 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");
}
const normalizedClassification = normalizeClassificationPayload(classification);
if (!normalizedClassification) {
return sendError(res, 400, "Classification is required");
}
const { item_type, item_group, zone } = normalizedClassification;
if (item_type && !isValidItemType(item_type)) {
return sendError(res, 400, "Invalid item_type");
}
if (item_group && !item_type) {
return sendError(res, 400, "Item type is required when item group is provided");
}
if (item_group && !isValidItemGroup(item_type, item_group)) {
return sendError(res, 400, "Invalid item_group for selected item_type");
}
if (zone && !isValidZone(zone)) {
return sendError(res, 400, "Invalid zone");
}
// 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.ensureHouseholdStoreItem(
householdId,
storeId,
item_name
);
itemId = itemResult.id;
} else {
itemId = item.item_id;
}
await List.upsertClassification(householdId, storeId, itemId, {
item_type,
item_group,
zone,
confidence: 1.0,
source: "user",
});
res.json({ message: "Classification set", classification: normalizedClassification });
} 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

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

View File

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

View File

@ -1,54 +1,18 @@
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");
async function auth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null;
function auth(req, res, next) {
const header = req.headers.authorization;
if (!header) return res.status(401).json({ message: "Missing token" });
if (token) {
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 {
const decoded = jwt.verify(token, jwtSecret);
req.user = decoded; // id + role
return next();
} catch (err) {
return sendError(res, 401, "Invalid or expired token");
}
}
const token = header.split(" ")[1];
if (!token) return res.status(401).json({ message: "Invalid token format" });
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();
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // id + role
next();
} catch (err) {
logError(req, "middleware.auth", err);
return sendError(res, 500, "Authentication check failed");
res.status(401).json({ message: "Invalid or expired token" });
}
}

View File

@ -1,104 +0,0 @@
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,7 +1,6 @@
const multer = require("multer");
const sharp = require("sharp");
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)
const upload = multer({
@ -43,7 +42,7 @@ const processImage = async (req, res, next) => {
next();
} catch (error) {
sendError(res, 400, `Error processing image: ${error.message}`);
res.status(400).json({ message: "Error processing image: " + error.message });
}
};

View File

@ -1,47 +0,0 @@
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

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

View File

@ -1,47 +0,0 @@
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

@ -1,243 +0,0 @@
# 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

@ -1,8 +1,20 @@
-- Add image columns to grocery_list table
ALTER TABLE grocery_list
ADD COLUMN IF NOT EXISTS item_image BYTEA,
ADD COLUMN IF NOT EXISTS image_mime_type VARCHAR(50);
# Database Migration: Add Image Support
-- Index to speed up queries that filter by rows with images.
CREATE INDEX IF NOT EXISTS idx_grocery_list_has_image
ON grocery_list ((item_image IS NOT NULL));
Run these SQL commands on your PostgreSQL database:
```sql
-- Add image columns to grocery_list table
ALTER TABLE grocery_list
ADD COLUMN item_image BYTEA,
ADD COLUMN image_mime_type VARCHAR(50);
-- Optional: Add index for faster queries when filtering by items with images
CREATE INDEX idx_grocery_list_has_image ON grocery_list ((item_image IS NOT NULL));
```
## To Verify:
```sql
\d grocery_list
```
You should see the new columns `item_image` and `image_mime_type`.

View File

@ -1,7 +0,0 @@
-- 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

@ -1,397 +0,0 @@
-- ============================================================================
-- 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

@ -1,99 +0,0 @@
{
"generated_at": "2026-05-25T23:06:21.741Z",
"canonical_dir": "packages\\db\\migrations",
"legacy_dir": "backend\\migrations",
"stale_sql_files": [
{
"filename": "add_display_name_column.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"requires_action": false,
"backend_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
"canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
"normalized_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f"
},
{
"filename": "add_image_columns.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"requires_action": false,
"backend_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded",
"canonical_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded",
"normalized_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded"
},
{
"filename": "add_modified_on_column.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"requires_action": false,
"backend_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
"canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
"normalized_sha256": "cf4f5dcd2e470954499fc5a191428401bda033d2d32f4851b5674530e56e9b08"
},
{
"filename": "add_notes_column.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"requires_action": false,
"backend_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
"canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
"normalized_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a"
},
{
"filename": "create_item_classification_table.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"requires_action": false,
"backend_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
"canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
"normalized_sha256": "473e804290863e92ae4d732d4a241be96e827c3194139e32172f6012caf60c50"
},
{
"filename": "multi_household_architecture.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"requires_action": false,
"backend_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
"canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
"normalized_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e"
}
],
"canonical_only_sql_files": [
{
"filename": "20260328_010000_add_household_store_available_items.sql",
"status": "CANONICAL_ONLY",
"requires_action": false,
"canonical_sha256": "58eaf6b526e0317edd45083ba64432fb973ab4a489c0bfd320c422ee501a6206"
},
{
"filename": "20260329_010000_add_household_store_items.sql",
"status": "CANONICAL_ONLY",
"requires_action": false,
"canonical_sha256": "4421515183150c388b19dde66e682807269fbc31414cc1ccfc095abab3788188"
},
{
"filename": "20260329_020000_fix_household_item_classification_upsert.sql",
"status": "CANONICAL_ONLY",
"requires_action": false,
"canonical_sha256": "8c86cde57bf98b0c9bf5340d685150e89a2fdb873d1bda83893506b2b2478e62"
},
{
"filename": "create_sessions_table.sql",
"status": "CANONICAL_ONLY",
"requires_action": false,
"canonical_sha256": "d46e5147eb113042e9c2856d17b38715e66a486ee4d7c6450c960145791bc030"
},
{
"filename": "zz_group_invites_and_join_policies.sql",
"status": "CANONICAL_ONLY",
"requires_action": false,
"canonical_sha256": "47e31807356c6682a926aa0d9fd9c46b9edf0b8a586d6c39a36c931e5de5ca5b"
}
],
"legacy_non_sql_files": [
"MIGRATION_GUIDE.md",
"stale-sql-report.json"
],
"summary": {
"stale_total": 6,
"stale_only_in_backend_total": 0,
"stale_duplicate_total": 6,
"stale_diverged_total": 0,
"action_required_total": 0,
"canonical_only_total": 5
}
}

View File

@ -1,273 +0,0 @@
const pool = require("../db/pool");
function normalizeItemName(itemName) {
return String(itemName || "").trim().toLowerCase();
}
async function getHouseholdStoreItemRecord(householdId, storeId, itemId) {
const result = await pool.query(
`WITH latest_list_items AS (
SELECT DISTINCT ON (hl.household_store_item_id)
hl.household_store_item_id,
hl.custom_image,
hl.custom_image_mime_type,
hl.modified_on,
hl.id
FROM household_lists hl
WHERE hl.household_id = $1
AND hl.store_id = $2
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
)
SELECT
hsi.id AS item_id,
hsi.name AS item_name,
ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image,
COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
hic.item_type,
hic.item_group,
hic.zone
FROM household_store_items hsi
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hsi.household_id
AND hic.store_id = hsi.store_id
AND hic.household_store_item_id = hsi.id
WHERE hsi.household_id = $1
AND hsi.store_id = $2
AND hsi.id = $3`,
[householdId, storeId, itemId]
);
return result.rows[0] || null;
}
async function findOrCreateHouseholdStoreItem(householdId, storeId, itemName) {
const normalizedName = normalizeItemName(itemName);
const existing = await pool.query(
`SELECT id, name
FROM household_store_items
WHERE household_id = $1
AND store_id = $2
AND normalized_name = $3`,
[householdId, storeId, normalizedName]
);
if (existing.rowCount > 0) {
return {
itemId: existing.rows[0].id,
itemName: existing.rows[0].name,
};
}
const created = await pool.query(
`INSERT INTO household_store_items
(household_id, store_id, name, normalized_name, updated_at)
VALUES ($1, $2, $3, $4, NOW())
RETURNING id, name`,
[householdId, storeId, normalizedName, normalizedName]
);
return {
itemId: created.rows[0].id,
itemName: created.rows[0].name,
};
}
exports.listAvailableItems = async (householdId, storeId, query = "") => {
const trimmedQuery = String(query || "").trim();
const values = [householdId, storeId];
let filterClause = "";
if (trimmedQuery) {
values.push(`%${trimmedQuery}%`);
filterClause = "AND hsi.name ILIKE $3";
}
const result = await pool.query(
`WITH latest_list_items AS (
SELECT DISTINCT ON (hl.household_store_item_id)
hl.household_store_item_id,
hl.custom_image,
hl.custom_image_mime_type,
hl.modified_on,
hl.id
FROM household_lists hl
WHERE hl.household_id = $1
AND hl.store_id = $2
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
)
SELECT
hsi.id AS item_id,
hsi.name AS item_name,
ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image,
COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
hic.item_type,
hic.item_group,
hic.zone,
(
hsi.custom_image IS NOT NULL
OR hic.household_store_item_id IS NOT NULL
) AS has_managed_settings
FROM household_store_items hsi
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hsi.household_id
AND hic.store_id = hsi.store_id
AND hic.household_store_item_id = hsi.id
WHERE hsi.household_id = $1
AND hsi.store_id = $2
${filterClause}
ORDER BY hsi.name ASC
LIMIT 100`,
values
);
return result.rows;
};
exports.getAvailableItemById = async (householdId, storeId, itemId) =>
getHouseholdStoreItemRecord(householdId, storeId, itemId);
exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => {
const normalizedName = normalizeItemName(itemName);
const result = await pool.query(
`SELECT
id AS item_id,
name AS item_name,
custom_image,
custom_image_mime_type
FROM household_store_items
WHERE household_id = $1
AND store_id = $2
AND normalized_name = $3`,
[householdId, storeId, normalizedName]
);
return result.rows[0] || null;
};
exports.createAvailableItem = async (
householdId,
storeId,
itemName,
imageBuffer = null,
mimeType = null
) => {
const { itemId } = await findOrCreateHouseholdStoreItem(householdId, storeId, itemName);
if (imageBuffer && mimeType) {
await pool.query(
`UPDATE household_store_items
SET custom_image = $1,
custom_image_mime_type = $2,
updated_at = NOW()
WHERE id = $3
AND household_id = $4
AND store_id = $5`,
[imageBuffer, mimeType, itemId, householdId, storeId]
);
}
return getHouseholdStoreItemRecord(householdId, storeId, itemId);
};
exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => {
const {
itemName,
imageBuffer,
mimeType,
removeImage = false,
} = updates;
const assignments = ["updated_at = NOW()"];
const values = [householdId, storeId, itemId];
let parameterIndex = values.length;
if (itemName !== undefined && String(itemName).trim() !== "") {
const normalizedName = normalizeItemName(itemName);
parameterIndex += 1;
assignments.push(`name = $${parameterIndex}`);
values.push(normalizedName);
parameterIndex += 1;
assignments.push(`normalized_name = $${parameterIndex}`);
values.push(normalizedName);
}
if (removeImage) {
assignments.push("custom_image = NULL", "custom_image_mime_type = NULL");
} else if (imageBuffer && mimeType) {
parameterIndex += 1;
assignments.push(`custom_image = $${parameterIndex}`);
values.push(imageBuffer);
parameterIndex += 1;
assignments.push(`custom_image_mime_type = $${parameterIndex}`);
values.push(mimeType);
}
const result = await pool.query(
`UPDATE household_store_items
SET ${assignments.join(", ")}
WHERE household_id = $1
AND store_id = $2
AND id = $3
RETURNING id`,
values
);
if (result.rowCount === 0) {
return null;
}
return getHouseholdStoreItemRecord(householdId, storeId, result.rows[0].id);
};
exports.deleteAvailableItem = async (householdId, storeId, itemId) => {
const result = await pool.query(
`DELETE FROM household_store_items
WHERE household_id = $1
AND store_id = $2
AND id = $3`,
[householdId, storeId, itemId]
);
return result.rowCount > 0;
};
exports.importCurrentListItems = async (householdId, storeId) => {
const result = await pool.query(
`INSERT INTO household_store_items
(household_id, store_id, name, normalized_name, custom_image, custom_image_mime_type, updated_at)
SELECT DISTINCT ON (hl.household_store_item_id)
hl.household_id,
hl.store_id,
hsi.name,
hsi.normalized_name,
hsi.custom_image,
hsi.custom_image_mime_type,
NOW()
FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
WHERE hl.household_id = $1
AND hl.store_id = $2
ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING
RETURNING id`,
[householdId, storeId]
);
return result.rowCount;
};
exports.hasAvailableItems = async (householdId, storeId) => {
const result = await pool.query(
`SELECT 1
FROM household_store_items
WHERE household_id = $1
AND store_id = $2
LIMIT 1`,
[householdId, storeId]
);
return result.rowCount > 0;
};

View File

@ -1,458 +0,0 @@
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 listPendingJoinRequests(groupId, client) {
const result = await getExecutor(client).query(
`SELECT
gjr.id,
gjr.group_id,
gjr.user_id,
gjr.status,
gjr.created_at,
gjr.updated_at,
u.username,
u.name,
u.display_name
FROM group_join_requests gjr
JOIN users u ON u.id = gjr.user_id
WHERE gjr.group_id = $1
AND gjr.status = 'PENDING'
ORDER BY gjr.created_at ASC`,
[groupId]
);
return result.rows;
}
async function getPendingJoinRequestById(groupId, requestId, client, forUpdate = false) {
const result = await getExecutor(client).query(
`SELECT
gjr.id,
gjr.group_id,
gjr.user_id,
gjr.status,
gjr.decided_by,
gjr.decided_at,
gjr.created_at,
gjr.updated_at,
u.username,
u.name,
u.display_name
FROM group_join_requests gjr
JOIN users u ON u.id = gjr.user_id
WHERE gjr.group_id = $1
AND gjr.id = $2
AND gjr.status = 'PENDING'
${forUpdate ? "FOR UPDATE OF gjr" : ""}`,
[groupId, requestId]
);
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 updateJoinRequestDecision(groupId, requestId, status, decidedBy, client) {
const result = await getExecutor(client).query(
`UPDATE group_join_requests
SET status = $3,
decided_by = $4,
decided_at = NOW(),
updated_at = NOW()
WHERE group_id = $1
AND id = $2
AND status = 'PENDING'
RETURNING id, group_id, user_id, status, decided_by, decided_at, created_at, updated_at`,
[groupId, requestId, status, decidedBy]
);
return result.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,
getPendingJoinRequestById,
getPendingJoinRequest,
getUserGroupRole,
isGroupMember,
listPendingJoinRequests,
listInviteLinks,
revokeInviteLink,
reviveInviteLink,
updateJoinRequestDecision,
upsertGroupSettings,
withTransaction,
};

View File

@ -1,233 +0,0 @@
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];
};
// Transfer household ownership from one member to another atomically.
exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUserId) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const promoteResult = await client.query(
`UPDATE household_members
SET role = 'owner'
WHERE household_id = $1 AND user_id = $2
RETURNING user_id, role`,
[householdId, nextOwnerUserId]
);
if (promoteResult.rows.length === 0) {
throw new Error("TARGET_MEMBER_NOT_FOUND");
}
const demoteResult = await client.query(
`UPDATE household_members
SET role = 'admin'
WHERE household_id = $1 AND user_id = $2
RETURNING user_id, role`,
[householdId, currentOwnerUserId]
);
if (demoteResult.rows.length === 0) {
throw new Error("CURRENT_OWNER_NOT_FOUND");
}
await client.query("COMMIT");
return promoteResult.rows[0];
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
// 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

@ -1,387 +0,0 @@
const pool = require("../db/pool");
function normalizeItemName(itemName) {
return String(itemName || "").trim().toLowerCase();
}
async function getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName) {
const result = await pool.query(
`SELECT id, name, normalized_name, custom_image, custom_image_mime_type
FROM household_store_items
WHERE household_id = $1
AND store_id = $2
AND normalized_name = $3`,
[householdId, storeId, normalizedName]
);
return result.rows[0] || null;
}
exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => {
const normalizedName = normalizeItemName(itemName);
let item = await getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName);
if (item) {
return item;
}
const result = await pool.query(
`INSERT INTO household_store_items
(household_id, store_id, name, normalized_name, updated_at)
VALUES ($1, $2, $3, $4, NOW())
RETURNING id, name, normalized_name, custom_image, custom_image_mime_type`,
[householdId, storeId, normalizedName, normalizedName]
);
return result.rows[0];
};
/**
* 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,
hl.household_store_item_id AS item_id,
hl.household_store_item_id,
hsi.name AS item_name,
hl.quantity,
hl.bought,
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
COALESCE(hl.custom_image_mime_type, hsi.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 household_store_items hsi ON hsi.id = hl.household_store_item_id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hl.household_id
AND hic.store_id = hl.store_id
AND hic.household_store_item_id = hl.household_store_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) => {
const normalizedName = normalizeItemName(itemName);
const result = await pool.query(
`SELECT
hl.id,
hl.household_store_item_id AS item_id,
hl.household_store_item_id,
hsi.name AS item_name,
hl.quantity,
hl.bought,
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
COALESCE(hl.custom_image_mime_type, hsi.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 household_store_items hsi ON hsi.id = hl.household_store_item_id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hl.household_id
AND hic.store_id = hl.store_id
AND hic.household_store_item_id = hl.household_store_item_id
WHERE hl.household_id = $1
AND hl.store_id = $2
AND hsi.normalized_name = $3`,
[householdId, storeId, normalizedName]
);
return result.rows[0] || null;
};
/**
* Add or update an item in household list
* @returns {Promise<{listId:number,itemId:number,householdStoreItemId:number,itemName:string,isNew:boolean}>}
*/
exports.addOrUpdateItem = async (
householdId,
storeId,
itemName,
quantity,
userId,
imageBuffer = null,
mimeType = null
) => {
const householdStoreItem = await exports.ensureHouseholdStoreItem(householdId, storeId, itemName);
const listResult = await pool.query(
`SELECT id, bought
FROM household_lists
WHERE household_id = $1
AND store_id = $2
AND household_store_item_id = $3`,
[householdId, storeId, householdStoreItem.id]
);
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,
itemId: householdStoreItem.id,
householdStoreItemId: householdStoreItem.id,
itemName: householdStoreItem.name,
isNew: false,
};
}
const insert = await pool.query(
`INSERT INTO household_lists
(household_id, store_id, household_store_item_id, quantity, custom_image, custom_image_mime_type, added_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[householdId, storeId, householdStoreItem.id, quantity, imageBuffer, mimeType, userId]
);
return {
listId: insert.rows[0].id,
itemId: householdStoreItem.id,
householdStoreItemId: householdStoreItem.id,
itemName: householdStoreItem.name,
isNew: true,
};
};
exports.setBought = async (listId, bought, quantityBought = null) => {
if (bought === false) {
await pool.query(
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
[listId]
);
return;
}
if (quantityBought && quantityBought > 0) {
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) {
await pool.query(
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[listId]
);
} else {
await pool.query(
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
[remainingQuantity, listId]
);
}
} else {
await pool.query(
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[listId]
);
}
};
exports.addHistoryRecord = async (listId, householdStoreItemId, quantity, userId) => {
await pool.query(
`INSERT INTO household_list_history (household_list_id, household_store_item_id, quantity, added_by, added_on)
VALUES ($1, $2, $3, $4, NOW())`,
[listId, householdStoreItemId, quantity, userId]
);
};
exports.getSuggestions = async (query, householdId, storeId) => {
const result = await pool.query(
`SELECT DISTINCT
hsi.name AS item_name,
CASE WHEN hl.id IS NOT NULL AND hl.bought = FALSE THEN 0 ELSE 1 END AS sort_order
FROM household_store_items hsi
LEFT JOIN household_lists hl
ON hl.household_store_item_id = hsi.id
AND hl.household_id = $2
AND hl.store_id = $3
WHERE hsi.household_id = $2
AND hsi.store_id = $3
AND hsi.name ILIKE $1
ORDER BY sort_order, hsi.name
LIMIT 10`,
[`%${query}%`, householdId, storeId]
);
return result.rows;
};
exports.getRecentlyBoughtItems = async (householdId, storeId) => {
const result = await pool.query(
`SELECT
hl.id,
hl.household_store_item_id AS item_id,
hl.household_store_item_id,
hsi.name AS item_name,
hl.quantity,
hl.bought,
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
COALESCE(hl.custom_image_mime_type, hsi.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 household_store_items hsi ON hsi.id = hl.household_store_item_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;
};
exports.getClassification = async (householdId, storeId, itemId) => {
const result = await pool.query(
`SELECT item_type, item_group, zone, confidence, source
FROM household_item_classifications
WHERE household_id = $1 AND store_id = $2 AND household_store_item_id = $3`,
[householdId, storeId, itemId]
);
return result.rows[0] || null;
};
exports.upsertClassification = async (householdId, storeId, itemId, classification) => {
const { item_type, item_group, zone, confidence, source } = classification;
const result = await pool.query(
`INSERT INTO household_item_classifications
(household_id, store_id, household_store_item_id, item_type, item_group, zone, confidence, source)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (household_id, store_id, household_store_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, storeId, itemId, item_type, item_group, zone, confidence, source]
);
return result.rows[0];
};
exports.deleteClassification = async (householdId, storeId, itemId) => {
const result = await pool.query(
`DELETE FROM household_item_classifications
WHERE household_id = $1
AND store_id = $2
AND household_store_item_id = $3`,
[householdId, storeId, itemId]
);
return result.rowCount > 0;
};
exports.updateItem = async (listId, itemName, quantity, notes) => {
const updates = [];
const values = [listId];
let paramCount = 1;
if (quantity !== undefined) {
paramCount += 1;
updates.push(`quantity = $${paramCount}`);
values.push(quantity);
}
if (notes !== undefined) {
paramCount += 1;
updates.push(`notes = $${paramCount}`);
values.push(notes);
}
updates.push("modified_on = NOW()");
if (updates.length === 1) {
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];
};
exports.deleteItem = async (listId) => {
await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]);
};

View File

@ -1,123 +0,0 @@
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

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

2208
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,12 +10,13 @@
"sharp": "^0.34.5"
},
"devDependencies": {
"cpx": "^1.5.0",
"esbuild": "^0.25.5",
"nodemon": "^3.1.11",
"rimraf": "^6.0.1"
},
"scripts": {
"build": "rimraf dist && node build.js",
"build": "rimraf dist && node build.js && cpx \"public/**/*\" dist/public",
"dev": "nodemon server.js"
}
}

View File

@ -1,43 +0,0 @@
# 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

File diff suppressed because it is too large Load Diff

View File

@ -1,63 +0,0 @@
<!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

@ -1,19 +0,0 @@
// 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

@ -1,826 +0,0 @@
// 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

@ -1,147 +0,0 @@
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

@ -1,666 +0,0 @@
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

@ -1,309 +0,0 @@
* {
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;
}

View File

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

View File

@ -1,30 +1,13 @@
const router = require("express").Router();
const controller = require("../controllers/auth.controller");
const User = require("../models/user.model");
const { createRateLimit } = require("../middleware/rate-limit");
const loginRateLimit = createRateLimit({
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) => {
res.status(200).json({
message: "Auth API is running.",
roles: Object.values(User.ROLES),
});
});
const router = require("express").Router();
const controller = require("../controllers/auth.controller");
router.post("/register", controller.register);
router.post("/login", controller.login);
router.post("/", async (req, res) => {
resText = `Grocery List API is running.\n` +
`Roles available: ${Object.values(User.ROLES).join(', ')}`
res.status(200).type("text/plain").send(resText);
});
module.exports = router;

View File

@ -1,79 +0,0 @@
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.get("/groups/join-requests", auth, controller.listPendingJoinRequests);
router.post(
"/groups/join-requests/decision",
auth,
inviteWriteUserRateLimit,
controller.decideJoinRequest
);
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

@ -1,214 +0,0 @@
const express = require("express");
const router = express.Router();
const controller = require("../controllers/households.controller");
const listsController = require("../controllers/lists.controller.v2");
const availableItemsController = require("../controllers/available-items.controller");
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
);
router.get(
"/:householdId/stores/:storeId/available-items",
auth,
householdAccess,
storeAccess,
availableItemsController.getAvailableItems
);
router.post(
"/:householdId/stores/:storeId/available-items",
auth,
householdAccess,
storeAccess,
requireHouseholdAdmin,
upload.single("image"),
processImage,
availableItemsController.createAvailableItem
);
router.patch(
"/:householdId/stores/:storeId/available-items/:itemId",
auth,
householdAccess,
storeAccess,
requireHouseholdAdmin,
upload.single("image"),
processImage,
availableItemsController.updateAvailableItem
);
router.delete(
"/:householdId/stores/:storeId/available-items/:itemId",
auth,
householdAccess,
storeAccess,
requireHouseholdAdmin,
availableItemsController.deleteAvailableItem
);
router.post(
"/:householdId/stores/:storeId/available-items/import-current",
auth,
householdAccess,
storeAccess,
requireHouseholdAdmin,
availableItemsController.importCurrentItems
);
// 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

@ -1,48 +0,0 @@
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

@ -1,21 +1,11 @@
const router = require("express").Router();
const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac");
const usersController = require("../controllers/users.controller");
const { ROLES } = require("../models/user.model");
const { createRateLimit } = require("../middleware/rate-limit");
const userExistsRateLimit = createRateLimit({
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);
}
const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac");
const usersController = require("../controllers/users.controller");
const { ROLES } = require("../models/user.model");
router.get("/exists", usersController.checkIfUserExists);
router.get("/test", usersController.test);
// Current user profile routes (authenticated)
router.get("/me", auth, usersController.getCurrentUser);

View File

@ -1,675 +0,0 @@
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 listPendingJoinRequests(userId, groupId) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
await ensureGroupAndManagerRole(userId, resolvedGroupId);
return invitesModel.listPendingJoinRequests(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
);
});
}
async function decideJoinRequest(
userId,
groupId,
requestId,
decision,
requestIdForAudit,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedRequestId = ensurePositiveInteger(requestId, "requestId");
const normalizedDecision = typeof decision === "string" ? decision.trim().toUpperCase() : "";
if (!["APPROVE", "DENY"].includes(normalizedDecision)) {
throw new InviteServiceError("INVALID_INPUT", "Decision is required", 400);
}
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
const pendingRequest = await invitesModel.getPendingJoinRequestById(
resolvedGroupId,
resolvedRequestId,
client,
true
);
if (!pendingRequest) {
throw new InviteServiceError(
"JOIN_REQUEST_NOT_FOUND",
"Pending join request not found",
404
);
}
if (normalizedDecision === "APPROVE") {
const isExistingMember = await invitesModel.isGroupMember(
resolvedGroupId,
pendingRequest.user_id,
client
);
if (!isExistingMember) {
await invitesModel.addGroupMember(
resolvedGroupId,
pendingRequest.user_id,
"member",
client
);
}
const approvedRequest = await invitesModel.updateJoinRequestDecision(
resolvedGroupId,
resolvedRequestId,
"APPROVED",
userId,
client
);
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_JOIN_REQUEST_APPROVED",
requestId: requestIdForAudit,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
joinRequestId: approvedRequest.id,
targetUserId: approvedRequest.user_id,
},
},
client
);
return approvedRequest;
}
const deniedRequest = await invitesModel.updateJoinRequestDecision(
resolvedGroupId,
resolvedRequestId,
"DENIED",
userId,
client
);
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_JOIN_REQUEST_DENIED",
requestId: requestIdForAudit,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
joinRequestId: deniedRequest.id,
targetUserId: deniedRequest.user_id,
},
},
client
);
return deniedRequest;
});
}
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,
decideJoinRequest,
deleteInviteLink,
getGroupJoinPolicy,
getInviteLinkSummaryByToken,
listPendingJoinRequests,
listInviteLinks,
resolveManagedGroupId,
revokeInviteLink,
reviveInviteLink,
setGroupJoinPolicy,
};

View File

@ -1,96 +0,0 @@
jest.mock("../db/pool", () => ({
query: jest.fn(),
}));
const pool = require("../db/pool");
const AvailableItems = require("../models/available-item.model");
describe("available-item.model", () => {
beforeEach(() => {
pool.query.mockReset();
});
test("lists household store items", async () => {
pool.query.mockResolvedValueOnce({
rowCount: 1,
rows: [
{
item_id: 55,
item_name: "milk",
item_image: null,
image_mime_type: null,
item_type: null,
item_group: null,
zone: null,
has_managed_settings: false,
},
],
});
const result = await AvailableItems.listAvailableItems(1, 2);
expect(result).toEqual([
expect.objectContaining({
item_id: 55,
item_name: "milk",
}),
]);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("FROM household_store_items hsi"),
[1, 2]
);
});
test("creates a household store item when needed", async () => {
pool.query
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 77, name: "granola" }] })
.mockResolvedValueOnce({
rowCount: 1,
rows: [{ item_id: 77, item_name: "granola" }],
});
const result = await AvailableItems.createAvailableItem(1, 2, "Granola");
expect(result).toEqual(expect.objectContaining({ item_id: 77, item_name: "granola" }));
expect(pool.query).toHaveBeenNthCalledWith(
2,
expect.stringContaining("INSERT INTO household_store_items"),
[1, 2, "granola", "granola"]
);
});
test("updates household store item images and returns refreshed data", async () => {
const imageBuffer = Buffer.from("abc");
pool.query
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] })
.mockResolvedValueOnce({
rowCount: 1,
rows: [{ item_id: 55, item_name: "milk", item_image: "YWJj", image_mime_type: "image/jpeg" }],
});
const result = await AvailableItems.updateAvailableItem(1, 2, 55, {
imageBuffer,
mimeType: "image/jpeg",
});
expect(result).toEqual(expect.objectContaining({ item_id: 55, image_mime_type: "image/jpeg" }));
expect(pool.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining("UPDATE household_store_items"),
[1, 2, 55, imageBuffer, "image/jpeg"]
);
});
test("deletes the household store item", async () => {
pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [] });
const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55);
expect(deleted).toBe(true);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("DELETE FROM household_store_items"),
[1, 2, 55]
);
});
});

View File

@ -1,199 +0,0 @@
jest.mock("../models/available-item.model", () => ({
createAvailableItem: jest.fn(),
deleteAvailableItem: jest.fn(),
getAvailableItemById: jest.fn(),
importCurrentListItems: jest.fn(),
listAvailableItems: jest.fn(),
updateAvailableItem: jest.fn(),
}));
jest.mock("../models/list.model.v2", () => ({
deleteClassification: jest.fn(),
upsertClassification: jest.fn(),
}));
jest.mock("../utils/logger", () => ({
logError: jest.fn(),
}));
const AvailableItems = require("../models/available-item.model");
const List = require("../models/list.model.v2");
const controller = require("../controllers/available-items.controller");
function createResponse() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
describe("available-items.controller", () => {
beforeEach(() => {
jest.clearAllMocks();
AvailableItems.createAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
AvailableItems.getAvailableItemById.mockResolvedValue({
item_id: 99,
item_name: "milk",
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
});
AvailableItems.updateAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
AvailableItems.deleteAvailableItem.mockResolvedValue(true);
AvailableItems.importCurrentListItems.mockResolvedValue(2);
AvailableItems.listAvailableItems.mockResolvedValue([]);
List.upsertClassification.mockResolvedValue(undefined);
List.deleteClassification.mockResolvedValue(false);
});
test("creates an available item and persists classification metadata", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: JSON.stringify({
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
}),
},
processedImage: null,
};
const res = createResponse();
await controller.createAvailableItem(req, res);
expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null);
expect(List.upsertClassification).toHaveBeenCalledWith(
"1",
"2",
99,
expect.objectContaining({
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
})
);
expect(res.status).toHaveBeenCalledWith(201);
});
test("rejects invalid item_group values", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: JSON.stringify({
item_type: "dairy",
item_group: "Bread",
}),
},
};
const res = createResponse();
await controller.createAvailableItem(req, res);
expect(AvailableItems.createAvailableItem).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Invalid item_group for selected item_type",
}),
})
);
});
test("clears classification on update when classification is explicitly empty", async () => {
const req = {
params: { householdId: "1", storeId: "2", itemId: "99" },
body: {
classification: "null",
},
processedImage: null,
};
const res = createResponse();
await controller.updateAvailableItem(req, res);
expect(List.deleteClassification).toHaveBeenCalledWith("1", "2", 99);
expect(res.status).not.toHaveBeenCalledWith(400);
});
test("imports current list items and reports the import count", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
};
const res = createResponse();
await controller.importCurrentItems(req, res);
expect(AvailableItems.importCurrentListItems).toHaveBeenCalledWith("1", "2");
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
imported_count: 2,
})
);
});
test("deletes a store item", async () => {
const req = {
params: { householdId: "1", storeId: "2", itemId: "99" },
};
const res = createResponse();
await controller.deleteAvailableItem(req, res);
expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99);
expect(List.deleteClassification).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" });
});
test("returns an empty catalog payload when the available items table is missing", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
query: {},
};
const res = createResponse();
AvailableItems.listAvailableItems.mockRejectedValueOnce({
code: "42P01",
message: 'relation "household_store_items" does not exist',
});
await controller.getAvailableItems(req, res);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
items: [],
catalog_ready: false,
})
);
});
test("returns a setup error when creating while the available items table is missing", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
},
processedImage: null,
};
const res = createResponse();
AvailableItems.createAvailableItem.mockRejectedValueOnce({
code: "42P01",
message: 'relation "household_store_items" does not exist',
});
await controller.createAvailableItem(req, res);
expect(res.status).toHaveBeenCalledWith(503);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: expect.stringContaining("latest database migration"),
}),
})
);
});
});

View File

@ -1,109 +0,0 @@
jest.mock("../middleware/auth", () => (req, res, next) => {
req.user = { id: 42, role: "user" };
next();
});
jest.mock("../middleware/household", () => ({
householdAccess: (req, res, next) => {
req.household = {
id: Number.parseInt(req.params.householdId, 10),
role: req.headers["x-household-role"] || "user",
};
next();
},
requireHouseholdAdmin: (req, res, next) => {
if (["owner", "admin"].includes(req.household?.role)) {
return next();
}
return res.status(403).json({
error: { code: "FORBIDDEN", message: "Admin role required" },
request_id: req.request_id,
});
},
storeAccess: (req, res, next) => next(),
}));
jest.mock("../middleware/image", () => ({
upload: {
single: () => (req, res, next) => next(),
},
processImage: (req, res, next) => next(),
}));
jest.mock("../controllers/households.controller", () => ({
createHousehold: jest.fn(),
deleteHousehold: jest.fn(),
getHousehold: jest.fn(),
getMembers: jest.fn(),
getUserHouseholds: jest.fn(),
joinHousehold: jest.fn(),
refreshInviteCode: jest.fn(),
removeMember: jest.fn(),
updateHousehold: jest.fn(),
updateMemberRole: jest.fn(),
}));
jest.mock("../controllers/lists.controller.v2", () => ({
addItem: jest.fn(),
deleteItem: jest.fn(),
getClassification: jest.fn(),
getItemByName: jest.fn(),
getList: jest.fn(),
getRecentlyBought: jest.fn(),
getSuggestions: jest.fn(),
markBought: jest.fn(),
setClassification: jest.fn(),
updateItem: jest.fn(),
updateItemImage: jest.fn(),
}));
jest.mock("../controllers/available-items.controller", () => ({
createAvailableItem: jest.fn((req, res) => res.status(201).json({ message: "created" })),
deleteAvailableItem: jest.fn((req, res) => res.json({ message: "deleted" })),
getAvailableItems: jest.fn((req, res) => res.json({ items: [] })),
importCurrentItems: jest.fn((req, res) => res.json({ imported_count: 1 })),
updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })),
}));
const express = require("express");
const request = require("supertest");
const router = require("../routes/households.routes");
const availableItemsController = require("../controllers/available-items.controller");
describe("available-items routes", () => {
let app;
beforeEach(() => {
app = express();
app.use(express.json());
app.use("/households", router);
jest.clearAllMocks();
});
test("members can read available items", async () => {
const response = await request(app).get("/households/1/stores/2/available-items");
expect(response.status).toBe(200);
expect(availableItemsController.getAvailableItems).toHaveBeenCalled();
});
test("members cannot mutate available items", async () => {
const response = await request(app)
.post("/households/1/stores/2/available-items")
.set("x-household-role", "user")
.send({ item_name: "milk" });
expect(response.status).toBe(403);
expect(availableItemsController.createAvailableItem).not.toHaveBeenCalled();
});
test("admins can create available items", async () => {
const response = await request(app)
.post("/households/1/stores/2/available-items")
.set("x-household-role", "admin")
.send({ item_name: "milk" });
expect(response.status).toBe(201);
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
});
});

View File

@ -1,110 +0,0 @@
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(),
decideJoinRequest: jest.fn(),
getGroupJoinPolicy: jest.fn(),
getInviteLinkSummaryByToken: jest.fn(),
listPendingJoinRequests: 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(() => {
jest.clearAllMocks();
invitesService.resolveManagedGroupId.mockResolvedValue(1);
invitesService.listInviteLinks.mockResolvedValue([]);
invitesService.listPendingJoinRequests.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();
});
test("pending join requests can be listed with request_id", async () => {
invitesService.listPendingJoinRequests.mockResolvedValue([
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
]);
const response = await request(app).get("/api/groups/join-requests");
expect(response.status).toBe(200);
expect(response.body.request_id).toBeTruthy();
expect(response.body.requests).toEqual([
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
]);
});
test("decision route maps service validation errors", async () => {
invitesService.decideJoinRequest.mockRejectedValue(
new invitesService.InviteServiceError(
"JOIN_REQUEST_NOT_FOUND",
"Pending join request not found",
404
)
);
const response = await request(app)
.post("/api/groups/join-requests/decision")
.send({ requestId: 99, decision: "APPROVE" });
expect(response.status).toBe(404);
expect(response.body.request_id).toBeTruthy();
expect(response.body.error.code).toBe("JOIN_REQUEST_NOT_FOUND");
});
});

View File

@ -1,309 +0,0 @@
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(),
getPendingJoinRequestById: jest.fn(),
getPendingJoinRequest: jest.fn(),
getUserGroupRole: jest.fn(),
isGroupMember: jest.fn(),
listPendingJoinRequests: jest.fn(),
listInviteLinks: jest.fn(),
revokeInviteLink: jest.fn(),
reviveInviteLink: jest.fn(),
updateJoinRequestDecision: 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(() => {
jest.clearAllMocks();
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();
});
test("listPendingJoinRequests requires manager role and returns pending requests", async () => {
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
invitesModel.getUserGroupRole.mockResolvedValue("owner");
invitesModel.listPendingJoinRequests.mockResolvedValue([
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
]);
const result = await invitesService.listPendingJoinRequests(99, 10);
expect(invitesModel.listPendingJoinRequests).toHaveBeenCalledWith(10);
expect(result).toEqual([
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
]);
});
test("approve join request adds membership, updates request, and audits decision", async () => {
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
invitesModel.getUserGroupRole.mockResolvedValue("admin");
invitesModel.getPendingJoinRequestById.mockResolvedValue({
id: 77,
group_id: 10,
user_id: 55,
username: "pending-user",
status: "PENDING",
});
invitesModel.isGroupMember.mockResolvedValue(false);
invitesModel.addGroupMember.mockResolvedValue(true);
invitesModel.updateJoinRequestDecision.mockResolvedValue({
id: 77,
group_id: 10,
user_id: 55,
status: "APPROVED",
decided_by: 99,
});
const result = await invitesService.decideJoinRequest(
99,
10,
77,
"APPROVE",
"req-approve",
"127.0.0.1",
"ua"
);
expect(invitesModel.getPendingJoinRequestById).toHaveBeenCalledWith(
10,
77,
{},
true
);
expect(invitesModel.addGroupMember).toHaveBeenCalledWith(10, 55, "member", {});
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
10,
77,
"APPROVED",
99,
{}
);
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
eventType: "GROUP_JOIN_REQUEST_APPROVED",
requestId: "req-approve",
metadata: {
joinRequestId: 77,
targetUserId: 55,
},
});
expect(result.status).toBe("APPROVED");
});
test("deny join request updates request and audits decision", async () => {
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
invitesModel.getUserGroupRole.mockResolvedValue("owner");
invitesModel.getPendingJoinRequestById.mockResolvedValue({
id: 78,
group_id: 10,
user_id: 56,
status: "PENDING",
});
invitesModel.updateJoinRequestDecision.mockResolvedValue({
id: 78,
group_id: 10,
user_id: 56,
status: "DENIED",
decided_by: 99,
});
const result = await invitesService.decideJoinRequest(
99,
10,
78,
"DENY",
"req-deny",
"127.0.0.1",
"ua"
);
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
10,
78,
"DENIED",
99,
{}
);
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
eventType: "GROUP_JOIN_REQUEST_DENIED",
requestId: "req-deny",
metadata: {
joinRequestId: 78,
targetUserId: 56,
},
});
expect(result.status).toBe("DENIED");
});
});

View File

@ -1,88 +0,0 @@
jest.mock("../models/household.model", () => ({
getUserRole: jest.fn(),
transferOwnership: jest.fn(),
updateMemberRole: jest.fn(),
}));
jest.mock("../utils/logger", () => ({
logError: jest.fn(),
}));
const householdModel = require("../models/household.model");
const controller = require("../controllers/households.controller");
function createResponse() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
describe("households.controller updateMemberRole", () => {
beforeEach(() => {
jest.clearAllMocks();
householdModel.getUserRole.mockResolvedValue("member");
householdModel.transferOwnership.mockResolvedValue({ user_id: 7, role: "owner" });
householdModel.updateMemberRole.mockResolvedValue({ user_id: 7, role: "admin" });
});
test("owner can transfer household ownership", async () => {
const req = {
params: { householdId: "3", userId: "7" },
body: { role: "owner" },
user: { id: 1 },
household: { id: 3, role: "owner" },
};
const res = createResponse();
await controller.updateMemberRole(req, res);
expect(householdModel.transferOwnership).toHaveBeenCalledWith("3", 1, 7);
expect(householdModel.updateMemberRole).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({
message: "Household ownership transferred successfully",
member: { user_id: 7, role: "owner" },
});
});
test("admin cannot transfer household ownership", async () => {
const req = {
params: { householdId: "3", userId: "7" },
body: { role: "owner" },
user: { id: 1 },
household: { id: 3, role: "admin" },
};
const res = createResponse();
await controller.updateMemberRole(req, res);
expect(householdModel.transferOwnership).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Only the household owner can transfer ownership",
}),
})
);
});
test("owner can still update a member to admin without transfer flow", async () => {
const req = {
params: { householdId: "3", userId: "7" },
body: { role: "admin" },
user: { id: 1 },
household: { id: 3, role: "owner" },
};
const res = createResponse();
await controller.updateMemberRole(req, res);
expect(householdModel.updateMemberRole).toHaveBeenCalledWith("3", "7", "admin");
expect(householdModel.transferOwnership).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({
message: "Member role updated successfully",
member: { user_id: 7, role: "admin" },
});
});
});

View File

@ -1,136 +0,0 @@
jest.mock("../db/pool", () => ({
query: jest.fn(),
}));
const pool = require("../db/pool");
const List = require("../models/list.model.v2");
describe("list.model.v2 addOrUpdateItem", () => {
beforeEach(() => {
pool.query.mockReset();
});
test("returns household store item metadata when creating a new list item", async () => {
pool.query
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88 }] });
const result = await List.addOrUpdateItem(1, 2, "Milk", 3, 7);
expect(result).toEqual({
listId: 88,
itemId: 55,
householdStoreItemId: 55,
itemName: "milk",
isNew: true,
});
expect(pool.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining("FROM household_store_items"),
[1, 2, "milk"]
);
expect(pool.query).toHaveBeenNthCalledWith(
2,
expect.stringContaining("INSERT INTO household_store_items"),
[1, 2, "milk", "milk"]
);
});
test("returns household store item metadata when updating an existing list item", async () => {
pool.query
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
expect(result).toEqual({
listId: 88,
itemId: 55,
householdStoreItemId: 55,
itemName: "milk",
isNew: false,
});
expect(pool.query).toHaveBeenNthCalledWith(
3,
expect.stringContaining("UPDATE household_lists"),
[4, 88]
);
});
});
describe("list.model.v2 classification helpers", () => {
beforeEach(() => {
pool.query.mockReset();
});
test("gets classification using household, store, and household-store item ids", async () => {
pool.query.mockResolvedValueOnce({
rowCount: 1,
rows: [
{
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
confidence: 1,
source: "user",
},
],
});
const result = await List.getClassification(1, 2, 55);
expect(result).toEqual({
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
confidence: 1,
source: "user",
});
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("household_store_item_id = $3"),
[1, 2, 55]
);
});
test("upserts classification using household-store item conflict target", async () => {
pool.query.mockResolvedValueOnce({
rowCount: 1,
rows: [
{
household_id: 1,
store_id: 2,
household_store_item_id: 55,
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
confidence: 1,
source: "user",
},
],
});
const result = await List.upsertClassification(1, 2, 55, {
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
confidence: 1,
source: "user",
});
expect(result).toEqual(
expect.objectContaining({
household_id: 1,
store_id: 2,
household_store_item_id: 55,
item_type: "dairy",
})
);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("ON CONFLICT (household_id, store_id, household_store_item_id)"),
[1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"]
);
});
});

View File

@ -1,379 +0,0 @@
jest.mock("../models/list.model.v2", () => ({
addHistoryRecord: jest.fn(),
addOrUpdateItem: jest.fn(),
ensureHouseholdStoreItem: jest.fn(),
getItemByName: jest.fn(),
upsertClassification: 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(() => {
jest.clearAllMocks();
List.addOrUpdateItem.mockResolvedValue({
listId: 42,
itemId: 99,
householdStoreItemId: 99,
itemName: "milk",
isNew: true,
});
List.addHistoryRecord.mockResolvedValue(undefined);
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
List.upsertClassification.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, 99, "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, 99, "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, 99, "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",
}),
})
);
});
});
describe("lists.controller.v2 setClassification", () => {
beforeEach(() => {
jest.clearAllMocks();
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
List.upsertClassification.mockResolvedValue(undefined);
List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" });
});
test("accepts object classification with type, group, and zone", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: {
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
},
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).toHaveBeenCalledWith(
"1",
"2",
99,
expect.objectContaining({
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
confidence: 1.0,
source: "user",
})
);
expect(res.status).not.toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: "Classification set",
classification: {
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
},
})
);
});
test("accepts zone-only classification updates", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: {
zone: "Checkout Area",
},
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).toHaveBeenCalledWith(
"1",
"2",
99,
expect.objectContaining({
item_type: null,
item_group: null,
zone: "Checkout Area",
})
);
expect(res.status).not.toHaveBeenCalledWith(400);
});
test("rejects invalid item_type", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: {
item_type: "invalid-type",
},
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Invalid item_type",
}),
})
);
});
test("rejects invalid item_group for selected item_type", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: {
item_type: "dairy",
item_group: "Bread",
},
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Invalid item_group for selected item_type",
}),
})
);
});
test("rejects invalid zone", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: {
zone: "Space Aisle",
},
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Invalid zone",
}),
})
);
});
test("accepts legacy string classification values", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: "beverages",
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).toHaveBeenCalledWith(
"1",
"2",
99,
expect.objectContaining({
item_type: "beverage",
item_group: null,
zone: null,
})
);
expect(res.status).not.toHaveBeenCalledWith(400);
});
test("creates a household store item when classification target is not yet on the list", async () => {
List.getItemByName.mockResolvedValueOnce(null);
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "granola",
classification: {
zone: "Snacks & Candy",
},
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.ensureHouseholdStoreItem).toHaveBeenCalledWith("1", "2", "granola");
expect(List.upsertClassification).toHaveBeenCalledWith(
"1",
"2",
99,
expect.objectContaining({
zone: "Snacks & Candy",
})
);
expect(res.status).not.toHaveBeenCalledWith(400);
});
});

View File

@ -1,25 +0,0 @@
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,
};

View File

@ -1,116 +0,0 @@
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,
};

View File

@ -1,20 +0,0 @@
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

@ -1,20 +0,0 @@
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

@ -1,36 +0,0 @@
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,
};

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
services:
backend:
image: git.nicosaya.com/nalalangan/grocery-app/backend:latest
image: git.nicosaya.com/nalalangan/costco-grocery-list/backend:latest
restart: always
env_file:
- ./backend.env
@ -8,7 +8,7 @@ services:
- "5000:5000"
frontend:
image: git.nicosaya.com/nalalangan/grocery-app/frontend:latest
image: git.nicosaya.com/nalalangan/costco-grocery-list/frontend:latest
restart: always
env_file:
- ./frontend.env

View File

@ -1,49 +0,0 @@
# 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

@ -1,70 +0,0 @@
# 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 legacy SQL needs operator attention:
- `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.
Duplicate reference copies are reported by the stale tracker, but the check fails only
when a legacy SQL file exists only in `backend/migrations` or diverges from its
canonical file.
`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

@ -1,99 +0,0 @@
# Development
Use this as the practical setup and verification guide. `PROJECT_INSTRUCTIONS.md` remains the source of truth for constraints.
## Prerequisites
- Node.js 20.19+ or 22.12+ for the current Vite toolchain.
- npm.
- PostgreSQL client tools if running migration scripts (`psql` must be on `PATH`).
- Access to the external Postgres database through `DATABASE_URL` or backend DB variables.
- Docker is optional for local app containers.
## Install
Run installs separately because this repo does not define npm workspaces.
```bash
npm ci
npm --prefix backend ci
npm --prefix frontend ci
```
## Environment
- Copy `backend/.env.example` to `backend/.env` for backend runtime configuration.
- Copy `frontend/.env.example` to `frontend/.env` if the frontend needs non-default API or host settings.
- Do not commit real `.env` files.
- Root DB migration scripts read `DATABASE_URL` from the shell environment; they do not load `backend/.env` automatically.
Important variables:
| Variable | Used by | Notes |
| --- | --- | --- |
| `DATABASE_URL` | backend, root migration scripts | Preferred external Postgres connection string. |
| `DB_USER`, `DB_PASS`, `DB_HOST`, `DB_PORT`, `DB_NAME` | backend fallback | Used only when `DATABASE_URL` is absent. |
| `JWT_SECRET` | backend auth | Required for token/session-compatible auth paths. |
| `ALLOWED_ORIGINS` | backend CORS | Comma-separated allowed frontend origins. |
| `SESSION_COOKIE_NAME`, `SESSION_TTL_DAYS` | backend cookies | Optional; defaults are defined in code. |
| `VITE_API_URL` | frontend | Defaults to `http://localhost:5000`. |
| `VITE_ALLOWED_HOSTS` | Vite dev server | Comma-separated host allowlist. |
| `PLAYWRIGHT_BASE_URL` | Playwright | Defaults to `http://localhost:3010`; the e2e runner starts Vite on this URL. |
## Run Locally
Docker dev stack:
```bash
docker compose -f docker-compose.dev.yml up
```
Separate terminals:
```bash
npm run dev:backend
npm run dev:frontend
```
Default local endpoints:
- Backend: `http://localhost:5000`
- Frontend through Docker compose port mapping: `http://localhost:3010`
- Frontend direct Vite default: `http://localhost:5173`
## Verification Commands
Root entry points:
```bash
npm test
npm run lint
npm run typecheck
npm run audit
npm run build:backend
npm run build:frontend
npm run build
npm run test:e2e
```
`npm run test:e2e` uses `frontend/scripts/run-playwright.mjs` to start Vite, run
Playwright, and shut Vite down cleanly. Pass Playwright flags after `--`, for
example `npm run test:e2e -- --reporter=list --workers=1`.
Migration checks:
```bash
npm run db:migrate:status
npm run db:migrate:verify
npm run db:migrate:stale:check
```
Do not run `npm run db:migrate` against a shared or production database unless that is the explicit operator task.
## Common Troubleshooting
- `DATABASE_URL is required`: export/set `DATABASE_URL` in the shell before root migration commands.
- `psql executable was not found in PATH`: install PostgreSQL client tools.
- Frontend cannot reach backend: confirm `VITE_API_URL`, backend port `5000`, and backend `ALLOWED_ORIGINS`.
- Playwright starts the frontend but API calls fail: start the backend separately or use the Docker dev stack.
- CORS blocked origin: add the exact frontend origin to `ALLOWED_ORIGINS`.
## Before Finishing Work
- Re-read `AGENTS.md` and any relevant deeper doc.
- Run the smallest useful checks first.
- For API behavior changes, add/update Jest tests with negative cases.
- For user-facing UI behavior changes, add/update focused Playwright coverage.
- Summarize any command that failed, whether it appears pre-existing, and the unresolved risk.

View File

@ -1,36 +0,0 @@
# Planning Template
Use this template for multi-step features, refactors, risky bugfixes, or DB work. Keep plans short and update them as evidence changes.
## Goal
- What user-visible or operator-visible outcome should be true?
## Context
- Relevant files, routes, data tables, docs, tests, and prior decisions.
- Current behavior and desired behavior.
## Constraints
- External DB only; migrations go in `packages/db/migrations`.
- No cron, workers, or background jobs.
- RBAC must be enforced server-side.
- No secrets, receipt bytes, or full invite codes in logs.
- Preserve current behavior outside the target area.
## Milestones
1. Recon and evidence.
2. Minimal design.
3. Implementation slice.
4. Tests and verification.
5. Documentation update.
## Validation
- Commands to run.
- Manual checks needed.
- Negative cases to cover.
## Rollback
- Files or migrations that would need reverting.
- Data or operator action needed, if any.
## Open Questions
- Only questions that materially affect architecture, runtime behavior, public APIs, data storage, security, deployment, package manager, or dependency footprint.

View File

@ -1,61 +0,0 @@
# Project Map
This is the fast orientation map for Fiddy.
## Stack
- Backend: Express 5 on Node 20, CommonJS modules, PostgreSQL via `pg`.
- Frontend: React 19 + Vite, mostly JSX with partial TypeScript.
- Package manager: npm.
- Database: external on-prem Postgres. Migrations are canonical in `packages/db/migrations`.
## Root
- `PROJECT_INSTRUCTIONS.md`: source-of-truth constraints and delivery contract.
- `AGENTS.md`: concise Codex/human working guide.
- `DEBUGGING_INSTRUCTIONS.md`: required bugfix workflow.
- `package.json`: root DB, test, lint, typecheck, build, and e2e command entry points.
- `docker-compose.dev.yml`: local app containers with backend env loaded from `backend/.env`.
- `.gitea/workflows`: deploy workflows for `main` and `main-new`.
- `.github/copilot-instructions.md`: compatibility shim that points back to root instructions.
## Backend
- `backend/server.js`: starts the Express app.
- `backend/app.js`: middleware, CORS, route mounting, and error handling.
- `backend/build.js`: copies runtime backend files into `backend/dist` for the existing backend build script.
- `backend/routes`: Express routers.
- `backend/controllers`: request handlers.
- `backend/models`: database query modules.
- `backend/services`: domain service logic where present.
- `backend/middleware`: auth, optional auth, RBAC, request IDs, rate limiting, and image upload processing.
- `backend/utils`: logging, HTTP response helpers, cookies, redaction.
- `backend/tests`: Jest/Supertest tests run from the root Jest config.
- `backend/migrations`: legacy/reference SQL only; do not add new canonical migrations here.
## Frontend
- `frontend/src/main.tsx`: browser entry point.
- `frontend/src/App.jsx`: top-level routing and providers.
- `frontend/src/config.ts`: API base URL.
- `frontend/src/api`: API wrappers and shared Axios client.
- `frontend/src/context`: app state providers.
- `frontend/src/hooks`: reusable UI-facing hooks.
- `frontend/src/pages`: route-level pages.
- `frontend/src/components`: shared and domain UI components.
- `frontend/src/styles`: global, page, component, and theme CSS.
- `frontend/tests`: Playwright e2e tests.
## Database and Scripts
- `packages/db/migrations`: canonical SQL migration set.
- `packages/db/migrations/stale-files.json`: known skipped/stale migration filenames.
- `scripts/db-migrate*.js`: migration apply/status/verify/new/stale helpers.
- `docs/DB_MIGRATION_WORKFLOW.md`: operator runbook for DB changes.
## Documentation
- `docs/DEVELOPMENT.md`: setup, run, verification, and troubleshooting.
- `docs/AGENTIC_CONTRACT_MAP.md`: maps Next.js-oriented architecture language to the current Express/Vite stack.
- `docs/PLANS.md`: lightweight template for multi-step work.
- `docs/architecture`, `docs/features`, `docs/guides`, `docs/migration`: deeper reference docs.
- `docs/archive`: historical implementation notes; useful context, not necessarily current.
## Known Maintainability Hotspots
- `frontend/src/pages/GroceryList.jsx` is large and should be split only during focused UI work.
- `frontend/src/components/manage/ManageHousehold.jsx`, `backend/services/group-invites.service.js`, and `backend/models/group-invites.model.js` are also large enough to review carefully before editing.
- Some older README/guides may describe pre-session or pre-household behavior; verify against current code and root instructions before relying on them.

View File

@ -1,57 +0,0 @@
# 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.

View File

@ -1,41 +0,0 @@
# Documentation Index
This directory contains practical project documentation. Root-level rules still take precedence:
1. `../PROJECT_INSTRUCTIONS.md`
2. `../DEBUGGING_INSTRUCTIONS.md` for bugfix work
3. `../AGENTS.md` for Codex and human workflow shortcuts
## Start Here
- `PROJECT_MAP.md`: quick repo structure and ownership map.
- `DEVELOPMENT.md`: install, run, test, lint, build, and troubleshooting.
- `DB_MIGRATION_WORKFLOW.md`: external Postgres migration runbook.
- `AGENTIC_CONTRACT_MAP.md`: maps source-of-truth architecture language to the current Express/Vite stack.
- `PLANS.md`: lightweight template for multi-step work.
## Architecture
- `architecture/component-structure.md`: frontend component organization and patterns.
- `architecture/multi-household-architecture-plan.md`: multi-household system planning notes.
## Features
- `features/classification-implementation.md`: item classification notes.
- `features/image-storage-implementation.md`: image storage and handling notes.
## Guides
- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs.
- `guides/frontend-readme.md`: frontend development notes.
- `guides/MOBILE_RESPONSIVE_AUDIT.md`: mobile design and audit checklist.
- `guides/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands.
## Migration
- `migration/MIGRATION_GUIDE.md`: historical multi-household migration instructions.
- `migration/POST_MIGRATION_UPDATES.md`: historical post-migration notes.
## Archive
Files in `archive/` are historical implementation records. They are useful for context, but they are not guaranteed to describe current behavior.
## Documentation Rules
- Keep docs concise and linked to real files or commands.
- Prefer updating `PROJECT_MAP.md` and `DEVELOPMENT.md` before adding new top-level docs.
- Move completed implementation narratives to `archive/` when they are no longer active runbooks.
- Keep text encoding clean.

View File

@ -1,865 +0,0 @@
# 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

@ -1,241 +0,0 @@
# 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

@ -1,203 +0,0 @@
# 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

@ -1,43 +0,0 @@
# 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

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