Compare commits
No commits in common. "main-new" and "main" have entirely different histories.
@ -1,150 +0,0 @@
|
||||
name: Build & Deploy Costco Grocery List
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main-new" ]
|
||||
|
||||
env:
|
||||
REGISTRY: git.nicosaya.com/nalalangan/costco-grocery-list
|
||||
# REGISTRY: grocery-app
|
||||
IMAGE_TAG: main-new
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
# -------------------------
|
||||
# 🔹 BACKEND TESTS
|
||||
# -------------------------
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: backend
|
||||
run: npm test --if-present
|
||||
|
||||
# -------------------------
|
||||
# 🔹 Docker Login
|
||||
# -------------------------
|
||||
- name: Docker login
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY \
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
# -------------------------
|
||||
# 🔹 Build Backend Image
|
||||
# -------------------------
|
||||
- name: Build Backend Image
|
||||
run: |
|
||||
docker build \
|
||||
-t $REGISTRY/backend:${{ github.sha }} \
|
||||
-t $REGISTRY/backend:${{ env.IMAGE_TAG }} \
|
||||
-f backend/Dockerfile backend/
|
||||
|
||||
- name: Push Backend Image
|
||||
run: |
|
||||
docker push $REGISTRY/backend:${{ github.sha }}
|
||||
docker push $REGISTRY/backend:${{ env.IMAGE_TAG }}
|
||||
|
||||
# -------------------------
|
||||
# 🔹 Build Frontend Image
|
||||
# -------------------------
|
||||
- name: Build Frontend Image
|
||||
run: |
|
||||
docker build \
|
||||
-t $REGISTRY/frontend:${{ github.sha }} \
|
||||
-t $REGISTRY/frontend:${{ env.IMAGE_TAG }} \
|
||||
-f frontend/Dockerfile.dev frontend/
|
||||
|
||||
- name: Push Frontend Image
|
||||
run: |
|
||||
docker push $REGISTRY/frontend:${{ github.sha }}
|
||||
docker push $REGISTRY/frontend:${{ env.IMAGE_TAG }}
|
||||
|
||||
verify-images:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Docker login
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY \
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Verify backend image tags exist
|
||||
run: |
|
||||
docker manifest inspect $REGISTRY/backend:${{ github.sha }} >/dev/null
|
||||
docker manifest inspect $REGISTRY/backend:${{ env.IMAGE_TAG }} >/dev/null
|
||||
|
||||
- name: Verify frontend image tags exist
|
||||
run: |
|
||||
docker manifest inspect $REGISTRY/frontend:${{ github.sha }} >/dev/null
|
||||
docker manifest inspect $REGISTRY/frontend:${{ env.IMAGE_TAG }} >/dev/null
|
||||
|
||||
deploy:
|
||||
needs: verify-images
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 1. Upload docker-compose.yml to the production directory
|
||||
# ---------------------------------------------------------
|
||||
- name: Upload docker-compose.yml
|
||||
run: |
|
||||
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "mkdir -p /opt/costco-app-new"
|
||||
scp docker-compose.new.yml \
|
||||
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/costco-app-new/docker-compose.yml
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 2. Deploy using the uploaded compose file
|
||||
# ---------------------------------------------------------
|
||||
- name: Deploy via SSH
|
||||
run: |
|
||||
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
||||
cd /opt/costco-app-new
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
docker image prune -f
|
||||
EOF
|
||||
|
||||
notify:
|
||||
needs: deploy
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Notify ntfy
|
||||
run: |
|
||||
STATUS="${{ needs.deploy.result }}"
|
||||
echo "Deployment job finished with status: $STATUS"
|
||||
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
MSG="🚀 Grocery App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}"
|
||||
else
|
||||
MSG="❌ Grocery App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}"
|
||||
fi
|
||||
|
||||
curl -d "$MSG" \
|
||||
https://ntfy.nicosaya.com/gitea
|
||||
|
||||
|
||||
208
.github/copilot-instructions.md
vendored
208
.github/copilot-instructions.md
vendored
@ -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
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -7,10 +7,6 @@ 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*
|
||||
|
||||
@ -1 +0,0 @@
|
||||
[]
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -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
|
||||
@ -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
|
||||
@ -1 +0,0 @@
|
||||
490a70bb-d2b1-490e-9046-37c8a08b0270
|
||||
55
AGENTS.md
55
AGENTS.md
@ -1,55 +0,0 @@
|
||||
# AGENTS.md - Fiddy (External DB)
|
||||
|
||||
## Authority
|
||||
- Source of truth: `PROJECT_INSTRUCTIONS.md` (repo root). If conflict, follow it.
|
||||
- Bugfix protocol: `DEBUGGING_INSTRUCTIONS.md` (repo root).
|
||||
- Do not implement features unless required to fix the bug.
|
||||
|
||||
## Non-negotiables
|
||||
- External DB: `DATABASE_URL` points to on-prem Postgres (NOT a container).
|
||||
- Dev/Prod share schema via migrations in `packages/db/migrations`.
|
||||
- No cron/worker jobs. Fixes must work without background tasks.
|
||||
- Server-side RBAC only. Client checks are UX only.
|
||||
|
||||
## Security / logging (hard rules)
|
||||
- Never log secrets (passwords/tokens/cookies).
|
||||
- Never log receipt bytes.
|
||||
- Never log full invite codes; logs/audit store last4 only.
|
||||
|
||||
## Non-regression contracts
|
||||
- Sessions are DB-backed (`sessions` table) and cookies are HttpOnly.
|
||||
- Receipt images stored in `receipts` (`bytea`).
|
||||
- Entries list endpoints must NEVER return receipt bytes.
|
||||
- API responses must include `request_id`; audit logs must include `request_id`.
|
||||
- Frontend actions that manipulate database state must show a toast/bubble notification with basic outcome info (action + target + success/failure).
|
||||
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`).
|
||||
|
||||
## Architecture boundaries (follow existing patterns; do not invent)
|
||||
1) API routes: `app/api/**/route.ts`
|
||||
- Thin: parse/validate + call service, return JSON.
|
||||
2) Server services: `lib/server/*`
|
||||
- Own DB + authz. Must include `import "server-only";`.
|
||||
3) Client wrappers: `lib/client/*`
|
||||
- Typed fetch + error normalization; always send credentials.
|
||||
4) Hooks: `hooks/use-*.ts`
|
||||
- Primary UI-facing API layer; components avoid raw `fetch()`.
|
||||
|
||||
## Next.js dynamic route params (required)
|
||||
- In `app/api/**/[param]/route.ts`, treat `context.params` as async:
|
||||
- `const { id } = await context.params;`
|
||||
|
||||
## Working style
|
||||
- Scan repo first; do not guess file names or patterns.
|
||||
- Make the smallest change that resolves the issue.
|
||||
- Keep touched files free of TS warnings and lint errors.
|
||||
- Add/update tests when API behavior changes (include negative cases).
|
||||
- Keep text encoding clean (no mojibake).
|
||||
|
||||
## Response icon legend
|
||||
Use the same status icons defined in `PROJECT_INSTRUCTIONS.md` section "Agent Response Legend (required)":
|
||||
- `🔄` in progress
|
||||
- `✅` completed
|
||||
- `🧪` verification/test result
|
||||
- `⚠️` risk/blocker/manual action
|
||||
- `❌` failure
|
||||
- `🧭` recommendation/next step
|
||||
@ -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.
|
||||
@ -1,202 +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).
|
||||
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency.
|
||||
- Add Playwright UI tests for new UI features and critical flows.
|
||||
|
||||
---
|
||||
|
||||
## 10) Tests (required)
|
||||
- Add/update tests for API behavior changes (auth, groups, entries, receipts).
|
||||
- Include negative cases where applicable:
|
||||
- unauthorized
|
||||
- not-a-member
|
||||
- invalid input
|
||||
|
||||
---
|
||||
|
||||
## 11) Agent Response Legend (required)
|
||||
Use emoji/icons in agent progress and final responses so status is obvious at a glance.
|
||||
|
||||
Legend:
|
||||
- `🔄` in progress
|
||||
- `✅` completed
|
||||
- `🧪` test/lint/verification result
|
||||
- `📄` documentation update
|
||||
- `🗄️` database or migration change
|
||||
- `🚀` deploy/release step
|
||||
- `⚠️` risk, blocker, or manual operator action needed
|
||||
- `❌` failed command or unsuccessful attempt
|
||||
- `ℹ️` informational context
|
||||
- `🧭` recommendation or next-step option
|
||||
|
||||
Usage rules:
|
||||
- Include at least one status icon in each substantive agent response.
|
||||
- Use one icon per bullet/line; avoid icon spam.
|
||||
- Keep icon meaning consistent with this legend.
|
||||
|
||||
---
|
||||
|
||||
## 12) Commit Discipline (required)
|
||||
- Commit in small, logical slices (no broad mixed-purpose commits).
|
||||
- Each commit must:
|
||||
- follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
|
||||
- include only related files for that slice
|
||||
- exclude secrets, credentials, and generated noise
|
||||
- Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas).
|
||||
- Prefer frequent checkpoint commits during agentic work rather than one large end-state commit.
|
||||
- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code).
|
||||
@ -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
|
||||
@ -2,8 +2,6 @@ FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache postgresql-client
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
|
||||
@ -1,23 +1,12 @@
|
||||
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);
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim());
|
||||
console.log("Allowed Origins:", allowedOrigins);
|
||||
app.use(
|
||||
cors({
|
||||
origin: function (origin, callback) {
|
||||
@ -25,20 +14,17 @@ app.use(
|
||||
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.`));
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
credentials: true,
|
||||
exposedHeaders: ["X-Request-Id"],
|
||||
methods: ["GET", "POST", "PUT", "DELETE"],
|
||||
})
|
||||
);
|
||||
|
||||
app.get('/', async (req, res) => {
|
||||
res.status(200).json({
|
||||
message: "Grocery List API is running.",
|
||||
roles: Object.values(User.ROLES),
|
||||
});
|
||||
resText = `Grocery List API is running.\n` +
|
||||
`Roles available: ${Object.values(User.ROLES).join(', ')}`
|
||||
|
||||
res.status(200).type("text/plain").send(resText);
|
||||
});
|
||||
|
||||
|
||||
@ -57,25 +43,4 @@ 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;
|
||||
@ -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");
|
||||
}
|
||||
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) {
|
||||
logError(req, "auth.register", err);
|
||||
sendError(res, 400, "Registration failed");
|
||||
res.status(400).json({ message: "Registration failed", error: err });
|
||||
}
|
||||
};
|
||||
|
||||
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");
|
||||
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) {
|
||||
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");
|
||||
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 },
|
||||
jwtSecret,
|
||||
process.env.JWT_SECRET,
|
||||
{ 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");
|
||||
}
|
||||
res.json({ token, username, role: user.role });
|
||||
};
|
||||
|
||||
@ -1,216 +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.revokeInviteLink = async (req, res) => {
|
||||
try {
|
||||
const requestedGroupId = parseRequestedGroupId(req);
|
||||
const groupId = await invitesService.resolveManagedGroupId(
|
||||
req.user.id,
|
||||
requestedGroupId
|
||||
);
|
||||
await invitesService.revokeInviteLink(
|
||||
req.user.id,
|
||||
groupId,
|
||||
req.body?.linkId,
|
||||
req.request_id,
|
||||
getClientIp(req),
|
||||
req.headers["user-agent"] || null
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
return mapServiceError(req, res, error, "groupInvites.revokeInviteLink");
|
||||
}
|
||||
};
|
||||
|
||||
exports.reviveInviteLink = async (req, res) => {
|
||||
try {
|
||||
const requestedGroupId = parseRequestedGroupId(req);
|
||||
const groupId = await invitesService.resolveManagedGroupId(
|
||||
req.user.id,
|
||||
requestedGroupId
|
||||
);
|
||||
const ttlDays = clampTtlDays(req.body?.ttlDays);
|
||||
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||
await invitesService.reviveInviteLink(
|
||||
req.user.id,
|
||||
groupId,
|
||||
req.body?.linkId,
|
||||
expiresAt,
|
||||
req.request_id,
|
||||
getClientIp(req),
|
||||
req.headers["user-agent"] || null
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
return mapServiceError(req, res, error, "groupInvites.reviveInviteLink");
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteInviteLink = async (req, res) => {
|
||||
try {
|
||||
const requestedGroupId = parseRequestedGroupId(req);
|
||||
const groupId = await invitesService.resolveManagedGroupId(
|
||||
req.user.id,
|
||||
requestedGroupId
|
||||
);
|
||||
await invitesService.deleteInviteLink(
|
||||
req.user.id,
|
||||
groupId,
|
||||
req.body?.linkId,
|
||||
req.request_id,
|
||||
getClientIp(req),
|
||||
req.headers["user-agent"] || null
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
return mapServiceError(req, res, error, "groupInvites.deleteInviteLink");
|
||||
}
|
||||
};
|
||||
|
||||
exports.getJoinPolicy = async (req, res) => {
|
||||
try {
|
||||
const requestedGroupId = parseRequestedGroupId(req);
|
||||
const groupId = await invitesService.resolveManagedGroupId(
|
||||
req.user.id,
|
||||
requestedGroupId
|
||||
);
|
||||
const joinPolicy = await invitesService.getGroupJoinPolicy(req.user.id, groupId);
|
||||
res.json({ joinPolicy });
|
||||
} catch (error) {
|
||||
return mapServiceError(req, res, error, "groupInvites.getJoinPolicy");
|
||||
}
|
||||
};
|
||||
|
||||
exports.setJoinPolicy = async (req, res) => {
|
||||
try {
|
||||
const requestedGroupId = parseRequestedGroupId(req);
|
||||
const groupId = await invitesService.resolveManagedGroupId(
|
||||
req.user.id,
|
||||
requestedGroupId
|
||||
);
|
||||
await invitesService.setGroupJoinPolicy(
|
||||
req.user.id,
|
||||
groupId,
|
||||
req.body?.joinPolicy,
|
||||
req.request_id,
|
||||
getClientIp(req),
|
||||
req.headers["user-agent"] || null
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
return mapServiceError(req, res, error, "groupInvites.setJoinPolicy");
|
||||
}
|
||||
};
|
||||
|
||||
exports.getInviteLinkSummary = async (req, res) => {
|
||||
const token = req.params.token;
|
||||
const inviteLast4 = inviteCodeLast4(token);
|
||||
try {
|
||||
const link = await invitesService.getInviteLinkSummaryByToken(
|
||||
token,
|
||||
req.user?.id || null
|
||||
);
|
||||
res.json({ link });
|
||||
} catch (error) {
|
||||
return mapServiceError(req, res, error, "groupInvites.getInviteLinkSummary", {
|
||||
invite_last4: inviteLast4,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.acceptInviteLink = async (req, res) => {
|
||||
const token = req.params.token;
|
||||
const inviteLast4 = inviteCodeLast4(token);
|
||||
try {
|
||||
const result = await invitesService.acceptInviteLink(
|
||||
req.user.id,
|
||||
token,
|
||||
req.request_id,
|
||||
getClientIp(req),
|
||||
req.headers["user-agent"] || null
|
||||
);
|
||||
res.json({ result });
|
||||
} catch (error) {
|
||||
return mapServiceError(req, res, error, "groupInvites.acceptInviteLink", {
|
||||
invite_last4: inviteLast4,
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,224 +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 || !['admin', 'member'].includes(role)) {
|
||||
return sendError(res, 400, "Invalid role. Must be 'admin' or 'member'");
|
||||
}
|
||||
|
||||
// Can't change own role
|
||||
if (parseInt(userId) === req.user.id) {
|
||||
return sendError(res, 400, "Cannot change your own role");
|
||||
}
|
||||
|
||||
const targetRole = await householdModel.getUserRole(req.params.householdId, userId);
|
||||
if (!targetRole) {
|
||||
return sendError(res, 404, "Member not found");
|
||||
}
|
||||
if (targetRole === "owner") {
|
||||
return sendError(res, 403, "Owner role cannot be changed");
|
||||
}
|
||||
|
||||
const updated = await householdModel.updateMemberRole(
|
||||
req.params.householdId,
|
||||
userId,
|
||||
role
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Member role updated successfully",
|
||||
member: updated
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "households.updateMemberRole", error);
|
||||
sendError(res, 500, "Failed to update member role");
|
||||
}
|
||||
};
|
||||
|
||||
// Remove member
|
||||
exports.removeMember = async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const targetUserId = parseInt(userId);
|
||||
|
||||
// Allow users to remove themselves, or admins to remove others
|
||||
if (targetUserId !== req.user.id && !["owner", "admin"].includes(req.household.role)) {
|
||||
return sendError(res, 403, "Only admins or owners can remove other members");
|
||||
}
|
||||
|
||||
const targetRole = await householdModel.getUserRole(req.params.householdId, userId);
|
||||
if (targetRole === "owner") {
|
||||
return sendError(res, 403, "Owner cannot be removed");
|
||||
}
|
||||
|
||||
await householdModel.removeMember(req.params.householdId, userId);
|
||||
|
||||
res.json({ message: "Member removed successfully" });
|
||||
} catch (error) {
|
||||
logError(req, "households.removeMember", error);
|
||||
sendError(res, 500, "Failed to remove member");
|
||||
}
|
||||
};
|
||||
@ -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");
|
||||
|
||||
|
||||
exports.getList = async (req, res) => {
|
||||
@ -60,7 +58,7 @@ exports.updateItemImage = async (req, res) => {
|
||||
const mimeType = req.processedImage?.mimeType || null;
|
||||
|
||||
if (!imageBuffer) {
|
||||
return sendError(res, 400, "No image provided");
|
||||
return res.status(400).json({ message: "No image provided" });
|
||||
}
|
||||
|
||||
// Update the item with new image
|
||||
@ -92,15 +90,15 @@ exports.updateItemWithClassification = async (req, res) => {
|
||||
|
||||
// Validate classification data
|
||||
if (item_type && !isValidItemType(item_type)) {
|
||||
return sendError(res, 400, "Invalid item_type");
|
||||
return res.status(400).json({ message: "Invalid item_type" });
|
||||
}
|
||||
|
||||
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
||||
return sendError(res, 400, "Invalid item_group for selected item_type");
|
||||
return res.status(400).json({ message: "Invalid item_group for selected item_type" });
|
||||
}
|
||||
|
||||
if (zone && !isValidZone(zone)) {
|
||||
return sendError(res, 400, "Invalid zone");
|
||||
return res.status(400).json({ message: "Invalid zone" });
|
||||
}
|
||||
|
||||
// Upsert classification with confidence=1.0 and source='user'
|
||||
@ -115,7 +113,7 @@ 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");
|
||||
console.error("Error updating item with classification:", error);
|
||||
res.status(500).json({ message: "Failed to update item" });
|
||||
}
|
||||
};
|
||||
@ -1,347 +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");
|
||||
|
||||
/**
|
||||
* Get list items for household and store
|
||||
* GET /households/:householdId/stores/:storeId/list
|
||||
*/
|
||||
exports.getList = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const items = await List.getHouseholdStoreList(householdId, storeId);
|
||||
res.json({ items });
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.getList", error);
|
||||
sendError(res, 500, "Failed to get list");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get specific item by name
|
||||
* GET /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.getItemByName = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name } = req.query;
|
||||
|
||||
if (!item_name) {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
if (!item) {
|
||||
return sendError(res, 404, "Item not found");
|
||||
}
|
||||
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.getItemByName", error);
|
||||
sendError(res, 500, "Failed to get item");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add or update item in household store list
|
||||
* POST /households/:householdId/stores/:storeId/list/add
|
||||
*/
|
||||
exports.addItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name, quantity, notes, added_for_user_id } = req.body;
|
||||
const userId = req.user.id;
|
||||
let historyUserId = userId;
|
||||
|
||||
if (!item_name || item_name.trim() === "") {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
if (added_for_user_id !== undefined && added_for_user_id !== null && String(added_for_user_id).trim() !== "") {
|
||||
const rawAddedForUserId = String(added_for_user_id).trim();
|
||||
if (!/^\d+$/.test(rawAddedForUserId)) {
|
||||
return sendError(res, 400, "Added-for user ID must be a positive integer");
|
||||
}
|
||||
|
||||
const parsedUserId = Number.parseInt(rawAddedForUserId, 10);
|
||||
|
||||
if (!Number.isInteger(parsedUserId) || parsedUserId <= 0) {
|
||||
return sendError(res, 400, "Added-for user ID must be a positive integer");
|
||||
}
|
||||
|
||||
const isMember = await householdModel.isHouseholdMember(householdId, parsedUserId);
|
||||
if (!isMember) {
|
||||
return sendError(res, 400, "Selected user is not a member of this household");
|
||||
}
|
||||
|
||||
historyUserId = parsedUserId;
|
||||
}
|
||||
|
||||
// Get processed image if uploaded
|
||||
const imageBuffer = req.processedImage?.buffer || null;
|
||||
const mimeType = req.processedImage?.mimeType || null;
|
||||
|
||||
const result = await List.addOrUpdateItem(
|
||||
householdId,
|
||||
storeId,
|
||||
item_name,
|
||||
quantity || "1",
|
||||
userId,
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
notes
|
||||
);
|
||||
|
||||
// Add history record
|
||||
await List.addHistoryRecord(result.listId, quantity || "1", historyUserId);
|
||||
|
||||
res.json({
|
||||
message: result.isNew ? "Item added" : "Item updated",
|
||||
item: {
|
||||
id: result.listId,
|
||||
item_name: result.itemName,
|
||||
quantity: quantity || "1",
|
||||
bought: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.addItem", error);
|
||||
sendError(res, 500, "Failed to add item");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark item as bought or unbought
|
||||
* PATCH /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.markBought = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name, bought, quantity_bought } = req.body;
|
||||
|
||||
if (!item_name) return sendError(res, 400, "Item name is required");
|
||||
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
if (!item) return sendError(res, 404, "Item not found");
|
||||
|
||||
|
||||
// Update bought status (with optional partial purchase)
|
||||
await List.setBought(item.id, bought, quantity_bought);
|
||||
|
||||
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.markBought", error);
|
||||
sendError(res, 500, "Failed to update item");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update item details (quantity, notes)
|
||||
* PUT /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.updateItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name, quantity, notes } = req.body;
|
||||
|
||||
if (!item_name) {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
// Get the list item
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
if (!item) {
|
||||
return sendError(res, 404, "Item not found");
|
||||
}
|
||||
|
||||
// Update item
|
||||
await List.updateItem(item.id, item_name, quantity, notes);
|
||||
|
||||
res.json({
|
||||
message: "Item updated",
|
||||
item: {
|
||||
id: item.id,
|
||||
item_name,
|
||||
quantity,
|
||||
notes
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.updateItem", error);
|
||||
sendError(res, 500, "Failed to update item");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete item from list
|
||||
* DELETE /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.deleteItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name } = req.body;
|
||||
|
||||
if (!item_name) {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
// Get the list item
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
if (!item) {
|
||||
return sendError(res, 404, "Item not found");
|
||||
}
|
||||
|
||||
await List.deleteItem(item.id);
|
||||
|
||||
res.json({ message: "Item deleted" });
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.deleteItem", error);
|
||||
sendError(res, 500, "Failed to delete item");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get item suggestions based on query
|
||||
* GET /households/:householdId/stores/:storeId/list/suggestions
|
||||
*/
|
||||
exports.getSuggestions = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { query } = req.query;
|
||||
|
||||
const suggestions = await List.getSuggestions(query || "", householdId, storeId);
|
||||
res.json(suggestions);
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.getSuggestions", error);
|
||||
sendError(res, 500, "Failed to get suggestions");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get recently bought items
|
||||
* GET /households/:householdId/stores/:storeId/list/recent
|
||||
*/
|
||||
exports.getRecentlyBought = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const items = await List.getRecentlyBoughtItems(householdId, storeId);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.getRecentlyBought", error);
|
||||
sendError(res, 500, "Failed to get recent items");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get item classification
|
||||
* GET /households/:householdId/stores/:storeId/list/classification
|
||||
*/
|
||||
exports.getClassification = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name } = req.query;
|
||||
|
||||
if (!item_name) {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
// Get item ID from name
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
if (!item) {
|
||||
return res.json({ classification: null });
|
||||
}
|
||||
|
||||
const classification = await List.getClassification(householdId, item.item_id);
|
||||
res.json({ classification });
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.getClassification", error);
|
||||
sendError(res, 500, "Failed to get classification");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set/update item classification
|
||||
* POST /households/:householdId/stores/:storeId/list/classification
|
||||
*/
|
||||
exports.setClassification = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name, classification } = req.body;
|
||||
|
||||
if (!item_name) {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
if (!classification) {
|
||||
return sendError(res, 400, "Classification is required");
|
||||
}
|
||||
|
||||
// Validate classification
|
||||
const validClassifications = ['produce', 'dairy', 'meat', 'bakery', 'frozen', 'pantry', 'snacks', 'beverages', 'household', 'other'];
|
||||
if (!validClassifications.includes(classification)) {
|
||||
return sendError(res, 400, "Invalid classification value");
|
||||
}
|
||||
|
||||
// Get item - add to master items if not exists
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
let itemId;
|
||||
|
||||
if (!item) {
|
||||
// Item doesn't exist in list, need to get from items table or create
|
||||
const itemResult = await List.addOrUpdateItem(
|
||||
householdId,
|
||||
storeId,
|
||||
item_name,
|
||||
"1",
|
||||
req.user.id,
|
||||
null,
|
||||
null
|
||||
);
|
||||
itemId = itemResult.itemId;
|
||||
} else {
|
||||
itemId = item.item_id;
|
||||
}
|
||||
|
||||
// Set classification (using item_type field for simplicity)
|
||||
await List.upsertClassification(householdId, itemId, {
|
||||
item_type: classification,
|
||||
item_group: null,
|
||||
zone: null
|
||||
});
|
||||
|
||||
res.json({ message: "Classification set", classification });
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.setClassification", error);
|
||||
sendError(res, 500, "Failed to set classification");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update item image
|
||||
* POST /households/:householdId/stores/:storeId/list/update-image
|
||||
*/
|
||||
exports.updateItemImage = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name, quantity } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Get processed image
|
||||
const imageBuffer = req.processedImage?.buffer || null;
|
||||
const mimeType = req.processedImage?.mimeType || null;
|
||||
|
||||
if (!imageBuffer) {
|
||||
return sendError(res, 400, "No image provided");
|
||||
}
|
||||
|
||||
// Update the item with new image
|
||||
await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType);
|
||||
|
||||
res.json({ message: "Image updated successfully" });
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.updateItemImage", error);
|
||||
sendError(res, 500, "Failed to update image");
|
||||
}
|
||||
};
|
||||
@ -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");
|
||||
}
|
||||
};
|
||||
@ -1,13 +1,13 @@
|
||||
const User = require("../models/user.model");
|
||||
const bcrypt = require("bcryptjs");
|
||||
const { sendError } = require("../utils/http");
|
||||
const { logError } = require("../utils/logger");
|
||||
|
||||
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);
|
||||
};
|
||||
@ -16,17 +16,18 @@ exports.getAllUsers = async (req, res) => {
|
||||
exports.updateUserRole = async (req, res) => {
|
||||
try {
|
||||
const { id, role } = req.body;
|
||||
|
||||
console.log(`Updating user ${id} to role ${role}`);
|
||||
if (!Object.values(User.ROLES).includes(role))
|
||||
return sendError(res, 400, "Invalid role");
|
||||
return res.status(400).json({ error: "Invalid role" });
|
||||
|
||||
const updated = await User.updateUserRole(id, role);
|
||||
if (!updated)
|
||||
return sendError(res, 404, "User not found");
|
||||
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");
|
||||
res.status(500).json({ error: "Failed to update role" });
|
||||
}
|
||||
};
|
||||
|
||||
@ -36,13 +37,12 @@ exports.deleteUser = async (req, res) => {
|
||||
|
||||
const deleted = await User.deleteUser(id);
|
||||
if (!deleted)
|
||||
return sendError(res, 404, "User not found");
|
||||
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");
|
||||
res.status(500).json({ error: "Failed to delete user" });
|
||||
}
|
||||
};
|
||||
|
||||
@ -58,13 +58,13 @@ exports.getCurrentUser = async (req, res) => {
|
||||
const user = await User.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return sendError(res, 404, "User not found");
|
||||
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");
|
||||
console.error("Error getting current user:", err);
|
||||
res.status(500).json({ error: "Failed to get user profile" });
|
||||
}
|
||||
};
|
||||
|
||||
@ -74,23 +74,23 @@ exports.updateCurrentUser = async (req, res) => {
|
||||
const { display_name } = req.body;
|
||||
|
||||
if (!display_name || display_name.trim().length === 0) {
|
||||
return sendError(res, 400, "Display name is required");
|
||||
return res.status(400).json({ error: "Display name is required" });
|
||||
}
|
||||
|
||||
if (display_name.length > 100) {
|
||||
return sendError(res, 400, "Display name must be 100 characters or less");
|
||||
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");
|
||||
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");
|
||||
console.error("Error updating user profile:", err);
|
||||
res.status(500).json({ error: "Failed to update profile" });
|
||||
}
|
||||
};
|
||||
|
||||
@ -101,25 +101,25 @@ exports.changePassword = async (req, res) => {
|
||||
|
||||
// Validation
|
||||
if (!current_password || !new_password) {
|
||||
return sendError(res, 400, "Current password and new password are required");
|
||||
return res.status(400).json({ error: "Current password and new password are required" });
|
||||
}
|
||||
|
||||
if (new_password.length < 6) {
|
||||
return sendError(res, 400, "New password must be at least 6 characters");
|
||||
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");
|
||||
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");
|
||||
return res.status(401).json({ error: "Current password is incorrect" });
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
@ -131,7 +131,7 @@ exports.changePassword = async (req, res) => {
|
||||
|
||||
res.json({ message: "Password changed successfully" });
|
||||
} catch (err) {
|
||||
logError(req, "users.changePassword", err);
|
||||
sendError(res, 500, "Failed to change password");
|
||||
console.error("Error changing password:", err);
|
||||
res.status(500).json({ error: "Failed to change password" });
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,21 +1,11 @@
|
||||
const { Pool } = require("pg");
|
||||
|
||||
function buildPoolConfig() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
return {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
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: Number(process.env.DB_PORT || 5432),
|
||||
};
|
||||
}
|
||||
|
||||
const pool = new Pool(buildPoolConfig());
|
||||
port: 5432,
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
|
||||
@ -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");
|
||||
}
|
||||
const token = header.split(" ")[1];
|
||||
if (!token) return res.status(401).json({ message: "Invalid token format" });
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, jwtSecret);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = decoded; // id + role
|
||||
return next();
|
||||
next();
|
||||
} catch (err) {
|
||||
return sendError(res, 401, "Invalid or expired token");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const cookies = parseCookieHeader(req.headers.cookie);
|
||||
const sid = cookies[cookieName()];
|
||||
|
||||
if (!sid) {
|
||||
return sendError(res, 401, "Missing authentication");
|
||||
}
|
||||
|
||||
const session = await Session.getActiveSessionWithUser(sid);
|
||||
if (!session) {
|
||||
return sendError(res, 401, "Invalid or expired session");
|
||||
}
|
||||
|
||||
req.user = {
|
||||
id: session.user_id,
|
||||
role: session.role,
|
||||
username: session.username,
|
||||
};
|
||||
req.session_id = session.id;
|
||||
|
||||
return next();
|
||||
} catch (err) {
|
||||
logError(req, "middleware.auth", err);
|
||||
return sendError(res, 500, "Authentication check failed");
|
||||
res.status(401).json({ message: "Invalid or expired token" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
};
|
||||
@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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,
|
||||
};
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
@ -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';
|
||||
@ -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
|
||||
@ -1,65 +0,0 @@
|
||||
{
|
||||
"generated_at": "2026-02-19T07:24:39.402Z",
|
||||
"canonical_dir": "packages\\db\\migrations",
|
||||
"legacy_dir": "backend\\migrations",
|
||||
"stale_sql_files": [
|
||||
{
|
||||
"filename": "add_display_name_column.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"backend_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
|
||||
"canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f"
|
||||
},
|
||||
{
|
||||
"filename": "add_image_columns.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"backend_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a",
|
||||
"canonical_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a"
|
||||
},
|
||||
{
|
||||
"filename": "add_modified_on_column.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"backend_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
|
||||
"canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b"
|
||||
},
|
||||
{
|
||||
"filename": "add_notes_column.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"backend_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
|
||||
"canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a"
|
||||
},
|
||||
{
|
||||
"filename": "create_item_classification_table.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"backend_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
|
||||
"canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a"
|
||||
},
|
||||
{
|
||||
"filename": "multi_household_architecture.sql",
|
||||
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||
"backend_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
|
||||
"canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e"
|
||||
}
|
||||
],
|
||||
"canonical_only_sql_files": [
|
||||
{
|
||||
"filename": "create_sessions_table.sql",
|
||||
"status": "CANONICAL_ONLY",
|
||||
"canonical_sha256": "d46e5147eb113042e9c2856d17b38715e66a486ee4d7c6450c960145791bc030"
|
||||
},
|
||||
{
|
||||
"filename": "zz_group_invites_and_join_policies.sql",
|
||||
"status": "CANONICAL_ONLY",
|
||||
"canonical_sha256": "de955333667326f8eaf224431ecb62a5d0bd354fa0ccce34af6e52374e55d6e3"
|
||||
}
|
||||
],
|
||||
"legacy_non_sql_files": [
|
||||
"MIGRATION_GUIDE.md"
|
||||
],
|
||||
"summary": {
|
||||
"stale_total": 6,
|
||||
"stale_only_in_backend_total": 0,
|
||||
"stale_duplicate_total": 6,
|
||||
"stale_diverged_total": 0,
|
||||
"canonical_only_total": 2
|
||||
}
|
||||
}
|
||||
@ -1,392 +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 createOrTouchPendingJoinRequest(groupId, userId, client) {
|
||||
const executor = getExecutor(client);
|
||||
const existing = await executor.query(
|
||||
`UPDATE group_join_requests
|
||||
SET updated_at = NOW()
|
||||
WHERE group_id = $1
|
||||
AND user_id = $2
|
||||
AND status = 'PENDING'
|
||||
RETURNING id, group_id, user_id, status, created_at, updated_at`,
|
||||
[groupId, userId]
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
return existing.rows[0];
|
||||
}
|
||||
|
||||
try {
|
||||
const inserted = await executor.query(
|
||||
`INSERT INTO group_join_requests (group_id, user_id, status)
|
||||
VALUES ($1, $2, 'PENDING')
|
||||
RETURNING id, group_id, user_id, status, created_at, updated_at`,
|
||||
[groupId, userId]
|
||||
);
|
||||
return inserted.rows[0];
|
||||
} catch (error) {
|
||||
if (error.code !== "23505") {
|
||||
throw error;
|
||||
}
|
||||
const fallback = await executor.query(
|
||||
`SELECT id, group_id, user_id, status, created_at, updated_at
|
||||
FROM group_join_requests
|
||||
WHERE group_id = $1
|
||||
AND user_id = $2
|
||||
AND status = 'PENDING'
|
||||
LIMIT 1`,
|
||||
[groupId, userId]
|
||||
);
|
||||
return fallback.rows[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
async function addGroupMember(groupId, userId, role = "member", client) {
|
||||
const result = await getExecutor(client).query(
|
||||
`INSERT INTO household_members (household_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (household_id, user_id) DO NOTHING
|
||||
RETURNING id`,
|
||||
[groupId, userId, role]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
async function consumeSingleUseInvite(linkId, client) {
|
||||
const result = await getExecutor(client).query(
|
||||
`UPDATE group_invite_links
|
||||
SET used_at = NOW(),
|
||||
revoked_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id`,
|
||||
[linkId]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
async function getGroupSettings(groupId, client) {
|
||||
const result = await getExecutor(client).query(
|
||||
`SELECT group_id, join_policy
|
||||
FROM group_settings
|
||||
WHERE group_id = $1`,
|
||||
[groupId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function upsertGroupSettings(groupId, joinPolicy, client) {
|
||||
const result = await getExecutor(client).query(
|
||||
`INSERT INTO group_settings (group_id, join_policy)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (group_id)
|
||||
DO UPDATE SET
|
||||
join_policy = EXCLUDED.join_policy,
|
||||
updated_at = NOW()
|
||||
RETURNING group_id, join_policy`,
|
||||
[groupId, joinPolicy]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function createGroupAuditLog(
|
||||
{
|
||||
groupId,
|
||||
actorUserId,
|
||||
actorRole,
|
||||
eventType,
|
||||
requestId,
|
||||
ip,
|
||||
userAgent,
|
||||
success = true,
|
||||
errorCode = null,
|
||||
metadata = {},
|
||||
},
|
||||
client
|
||||
) {
|
||||
const result = await getExecutor(client).query(
|
||||
`INSERT INTO group_audit_log (
|
||||
group_id,
|
||||
actor_user_id,
|
||||
actor_role,
|
||||
event_type,
|
||||
request_id,
|
||||
ip,
|
||||
user_agent,
|
||||
success,
|
||||
error_code,
|
||||
metadata
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
|
||||
RETURNING id`,
|
||||
[
|
||||
groupId,
|
||||
actorUserId,
|
||||
actorRole,
|
||||
eventType,
|
||||
requestId,
|
||||
ip,
|
||||
userAgent,
|
||||
success,
|
||||
errorCode,
|
||||
JSON.stringify(metadata || {}),
|
||||
]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addGroupMember,
|
||||
createGroupAuditLog,
|
||||
createInviteLink,
|
||||
createOrTouchPendingJoinRequest,
|
||||
consumeSingleUseInvite,
|
||||
deleteInviteLink,
|
||||
getGroupById,
|
||||
getGroupSettings,
|
||||
getInviteLinkById,
|
||||
getInviteLinkSummaryByToken,
|
||||
getManageableGroupsForUser,
|
||||
getPendingJoinRequest,
|
||||
getUserGroupRole,
|
||||
isGroupMember,
|
||||
listInviteLinks,
|
||||
revokeInviteLink,
|
||||
reviveInviteLink,
|
||||
upsertGroupSettings,
|
||||
withTransaction,
|
||||
};
|
||||
@ -1,192 +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];
|
||||
};
|
||||
|
||||
// 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;
|
||||
};
|
||||
@ -1,409 +0,0 @@
|
||||
const pool = require("../db/pool");
|
||||
|
||||
/**
|
||||
* Get list items for a specific household and store
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @param {boolean} includeHistory - Include purchase history
|
||||
* @returns {Promise<Array>} List of items
|
||||
*/
|
||||
exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
i.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||
hl.custom_image_mime_type as image_mime_type,
|
||||
${includeHistory ? `
|
||||
(
|
||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
|
||||
FROM household_list_history hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
WHERE hlh.household_list_id = hl.id
|
||||
) added_by_labels
|
||||
) as added_by_users,
|
||||
` : 'NULL as added_by_users,'}
|
||||
hl.modified_on as last_added_on,
|
||||
hic.item_type,
|
||||
hic.item_group,
|
||||
hic.zone
|
||||
FROM household_lists hl
|
||||
JOIN items i ON hl.item_id = i.id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hl.household_id = hic.household_id
|
||||
AND hl.item_id = hic.item_id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
AND hl.bought = FALSE
|
||||
ORDER BY hl.id ASC`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific item from household list by name
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @param {string} itemName - Item name to search for
|
||||
* @returns {Promise<Object|null>} Item or null
|
||||
*/
|
||||
exports.getItemByName = async (householdId, storeId, itemName) => {
|
||||
// First check if item exists in master catalog
|
||||
const itemResult = await pool.query(
|
||||
"SELECT id FROM items WHERE name ILIKE $1",
|
||||
[itemName]
|
||||
);
|
||||
|
||||
if (itemResult.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemId = itemResult.rows[0].id;
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
i.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||
hl.custom_image_mime_type as image_mime_type,
|
||||
(
|
||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
|
||||
FROM household_list_history hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
WHERE hlh.household_list_id = hl.id
|
||||
) added_by_labels
|
||||
) as added_by_users,
|
||||
hl.modified_on as last_added_on,
|
||||
hic.item_type,
|
||||
hic.item_group,
|
||||
hic.zone
|
||||
FROM household_lists hl
|
||||
JOIN items i ON hl.item_id = i.id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hl.household_id = hic.household_id
|
||||
AND hl.item_id = hic.item_id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
AND hl.item_id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add or update an item in household list
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @param {string} itemName - Item name
|
||||
* @param {number} quantity - Quantity
|
||||
* @param {number} userId - User adding the item
|
||||
* @param {Buffer|null} imageBuffer - Image buffer
|
||||
* @param {string|null} mimeType - MIME type
|
||||
* @returns {Promise<number>} List item ID
|
||||
*/
|
||||
exports.addOrUpdateItem = async (
|
||||
householdId,
|
||||
storeId,
|
||||
itemName,
|
||||
quantity,
|
||||
userId,
|
||||
imageBuffer = null,
|
||||
mimeType = null
|
||||
) => {
|
||||
const lowerItemName = itemName.toLowerCase();
|
||||
|
||||
let itemResult = await pool.query(
|
||||
"SELECT id FROM items WHERE name ILIKE $1",
|
||||
[lowerItemName]
|
||||
);
|
||||
|
||||
let itemId;
|
||||
if (itemResult.rowCount === 0) {
|
||||
const insertItem = await pool.query(
|
||||
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
||||
[lowerItemName]
|
||||
);
|
||||
itemId = insertItem.rows[0].id;
|
||||
} else {
|
||||
itemId = itemResult.rows[0].id;
|
||||
}
|
||||
|
||||
const listResult = await pool.query(
|
||||
`SELECT id, bought FROM household_lists
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND item_id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
);
|
||||
|
||||
if (listResult.rowCount > 0) {
|
||||
const listId = listResult.rows[0].id;
|
||||
if (imageBuffer && mimeType) {
|
||||
await pool.query(
|
||||
`UPDATE household_lists
|
||||
SET quantity = $1,
|
||||
bought = FALSE,
|
||||
custom_image = $2,
|
||||
custom_image_mime_type = $3,
|
||||
modified_on = NOW()
|
||||
WHERE id = $4`,
|
||||
[quantity, imageBuffer, mimeType, listId]
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`UPDATE household_lists
|
||||
SET quantity = $1,
|
||||
bought = FALSE,
|
||||
modified_on = NOW()
|
||||
WHERE id = $2`,
|
||||
[quantity, listId]
|
||||
);
|
||||
}
|
||||
return listId;
|
||||
} else {
|
||||
const insert = await pool.query(
|
||||
`INSERT INTO household_lists
|
||||
(household_id, store_id, item_id, quantity, custom_image, custom_image_mime_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
|
||||
);
|
||||
return insert.rows[0].id;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark item as bought (full or partial)
|
||||
* @param {number} listId - List item ID
|
||||
* @param {boolean} bought - True to mark as bought, false to unmark
|
||||
* @param {number} quantityBought - Optional quantity bought (for partial purchases)
|
||||
*/
|
||||
exports.setBought = async (listId, bought, quantityBought = null) => {
|
||||
if (bought === false) {
|
||||
// Unmarking - just set bought to false
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Marking as bought
|
||||
if (quantityBought && quantityBought > 0) {
|
||||
// Partial purchase - reduce quantity
|
||||
const item = await pool.query(
|
||||
"SELECT quantity FROM household_lists WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
|
||||
if (!item.rows[0]) return;
|
||||
|
||||
const currentQuantity = item.rows[0].quantity;
|
||||
const remainingQuantity = currentQuantity - quantityBought;
|
||||
|
||||
if (remainingQuantity <= 0) {
|
||||
// All bought - mark as bought
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
} else {
|
||||
// Partial - reduce quantity
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
|
||||
[remainingQuantity, listId]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Full purchase - mark as bought
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add history record for item addition
|
||||
* @param {number} listId - List item ID
|
||||
* @param {number} quantity - Quantity added
|
||||
* @param {number} userId - User who added
|
||||
*/
|
||||
exports.addHistoryRecord = async (listId, quantity, userId) => {
|
||||
await pool.query(
|
||||
`INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on)
|
||||
VALUES ($1, $2, $3, NOW())`,
|
||||
[listId, quantity, userId]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get suggestions for autocomplete
|
||||
* @param {string} query - Search query
|
||||
* @param {number} householdId - Household ID (for personalized suggestions)
|
||||
* @param {number} storeId - Store ID
|
||||
* @returns {Promise<Array>} Suggestions
|
||||
*/
|
||||
exports.getSuggestions = async (query, householdId, storeId) => {
|
||||
// Get items from both master catalog and household history
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT
|
||||
i.name as item_name,
|
||||
CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END as sort_order
|
||||
FROM items i
|
||||
LEFT JOIN household_lists hl
|
||||
ON i.id = hl.item_id
|
||||
AND hl.household_id = $2
|
||||
AND hl.store_id = $3
|
||||
WHERE i.name ILIKE $1
|
||||
ORDER BY sort_order, i.name
|
||||
LIMIT 10`,
|
||||
[`%${query}%`, householdId, storeId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get recently bought items for household/store
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @returns {Promise<Array>} Recently bought items
|
||||
*/
|
||||
exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
i.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||
hl.custom_image_mime_type as image_mime_type,
|
||||
(
|
||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
|
||||
FROM household_list_history hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
WHERE hlh.household_list_id = hl.id
|
||||
) added_by_labels
|
||||
) as added_by_users,
|
||||
hl.modified_on as last_added_on
|
||||
FROM household_lists hl
|
||||
JOIN items i ON hl.item_id = i.id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
AND hl.bought = TRUE
|
||||
AND hl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY hl.modified_on DESC`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get classification for household item
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} itemId - Item ID
|
||||
* @returns {Promise<Object|null>} Classification or null
|
||||
*/
|
||||
exports.getClassification = async (householdId, itemId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT item_type, item_group, zone, confidence, source
|
||||
FROM household_item_classifications
|
||||
WHERE household_id = $1 AND item_id = $2`,
|
||||
[householdId, itemId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upsert classification for household item
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} itemId - Item ID
|
||||
* @param {Object} classification - Classification data
|
||||
* @returns {Promise<Object>} Updated classification
|
||||
*/
|
||||
exports.upsertClassification = async (householdId, itemId, classification) => {
|
||||
const { item_type, item_group, zone, confidence, source } = classification;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO household_item_classifications
|
||||
(household_id, item_id, item_type, item_group, zone, confidence, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (household_id, item_id)
|
||||
DO UPDATE SET
|
||||
item_type = EXCLUDED.item_type,
|
||||
item_group = EXCLUDED.item_group,
|
||||
zone = EXCLUDED.zone,
|
||||
confidence = EXCLUDED.confidence,
|
||||
source = EXCLUDED.source
|
||||
RETURNING *`,
|
||||
[householdId, itemId, item_type, item_group, zone, confidence, source]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Update list item details
|
||||
* @param {number} listId - List item ID
|
||||
* @param {string} itemName - New item name (optional)
|
||||
* @param {number} quantity - New quantity (optional)
|
||||
* @param {string} notes - Notes (optional)
|
||||
* @returns {Promise<Object>} Updated item
|
||||
*/
|
||||
exports.updateItem = async (listId, itemName, quantity, notes) => {
|
||||
// Build dynamic update query
|
||||
const updates = [];
|
||||
const values = [listId];
|
||||
let paramCount = 1;
|
||||
|
||||
if (quantity !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`quantity = $${paramCount}`);
|
||||
values.push(quantity);
|
||||
}
|
||||
|
||||
if (notes !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`notes = $${paramCount}`);
|
||||
values.push(notes);
|
||||
}
|
||||
|
||||
// Always update modified_on
|
||||
updates.push(`modified_on = NOW()`);
|
||||
|
||||
if (updates.length === 1) {
|
||||
// Only modified_on update
|
||||
const result = await pool.query(
|
||||
`UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *`,
|
||||
[listId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE household_lists SET ${updates.join(', ')} WHERE id = $1 RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a list item
|
||||
* @param {number} listId - List item ID
|
||||
*/
|
||||
exports.deleteItem = async (listId) => {
|
||||
await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -1,21 +1,23 @@
|
||||
const pool = require("../db/pool");
|
||||
|
||||
exports.ROLES = {
|
||||
SYSTEM_ADMIN: "system_admin",
|
||||
USER: "user",
|
||||
VIEWER: "viewer",
|
||||
EDITOR: "editor",
|
||||
ADMIN: "admin",
|
||||
}
|
||||
|
||||
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]
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[username, hashedPassword, name, this.ROLES.VIEWER]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
@ -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
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@ -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';
|
||||
}
|
||||
@ -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();
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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("/register", controller.register);
|
||||
router.post("/login", controller.login);
|
||||
router.post("/", async (req, res) => {
|
||||
res.status(200).json({
|
||||
message: "Auth API is running.",
|
||||
roles: Object.values(User.ROLES),
|
||||
});
|
||||
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;
|
||||
|
||||
@ -1,72 +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.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;
|
||||
@ -1,169 +0,0 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const controller = require("../controllers/households.controller");
|
||||
const listsController = require("../controllers/lists.controller.v2");
|
||||
const auth = require("../middleware/auth");
|
||||
const {
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
storeAccess,
|
||||
} = require("../middleware/household");
|
||||
const { upload, processImage } = require("../middleware/image");
|
||||
|
||||
// Public routes (authenticated only)
|
||||
router.get("/", auth, controller.getUserHouseholds);
|
||||
router.post("/", auth, controller.createHousehold);
|
||||
router.post("/join/:inviteCode", auth, controller.joinHousehold);
|
||||
|
||||
// Household-scoped routes (member access required)
|
||||
router.get("/:householdId", auth, householdAccess, controller.getHousehold);
|
||||
router.patch(
|
||||
"/:householdId",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
controller.updateHousehold
|
||||
);
|
||||
router.delete(
|
||||
"/:householdId",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
controller.deleteHousehold
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/invite/refresh",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
controller.refreshInviteCode
|
||||
);
|
||||
|
||||
// Member management routes
|
||||
router.get(
|
||||
"/:householdId/members",
|
||||
auth,
|
||||
householdAccess,
|
||||
controller.getMembers
|
||||
);
|
||||
router.patch(
|
||||
"/:householdId/members/:userId/role",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
controller.updateMemberRole
|
||||
);
|
||||
router.delete(
|
||||
"/:householdId/members/:userId",
|
||||
auth,
|
||||
householdAccess,
|
||||
controller.removeMember
|
||||
);
|
||||
|
||||
// ==================== List Operations Routes ====================
|
||||
// All list routes require household access AND store access
|
||||
|
||||
// Get grocery list
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/list",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.getList
|
||||
);
|
||||
|
||||
// Get specific item by name
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.getItemByName
|
||||
);
|
||||
|
||||
// Add item to list
|
||||
router.post(
|
||||
"/:householdId/stores/:storeId/list/add",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
upload.single("image"),
|
||||
processImage,
|
||||
listsController.addItem
|
||||
);
|
||||
|
||||
// Mark item as bought/unbought
|
||||
router.patch(
|
||||
"/:householdId/stores/:storeId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.markBought
|
||||
);
|
||||
|
||||
// Update item details (quantity, notes)
|
||||
router.put(
|
||||
"/:householdId/stores/:storeId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.updateItem
|
||||
);
|
||||
|
||||
// Delete item
|
||||
router.delete(
|
||||
"/:householdId/stores/:storeId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.deleteItem
|
||||
);
|
||||
|
||||
// Get suggestions
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/list/suggestions",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.getSuggestions
|
||||
);
|
||||
|
||||
// Get recently bought items
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/list/recent",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.getRecentlyBought
|
||||
);
|
||||
|
||||
// Get item classification
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/list/classification",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.getClassification
|
||||
);
|
||||
|
||||
// Set item classification
|
||||
router.post(
|
||||
"/:householdId/stores/:storeId/list/classification",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.setClassification
|
||||
);
|
||||
|
||||
// Update item image
|
||||
router.post(
|
||||
"/:householdId/stores/:storeId/list/update-image",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
upload.single("image"),
|
||||
processImage,
|
||||
listsController.updateItemImage
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@ -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;
|
||||
@ -3,19 +3,9 @@ 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("/exists", usersController.checkIfUserExists);
|
||||
router.get("/test", usersController.test);
|
||||
}
|
||||
|
||||
// Current user profile routes (authenticated)
|
||||
router.get("/me", auth, usersController.getCurrentUser);
|
||||
|
||||
@ -1,557 +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 revokeInviteLink(
|
||||
userId,
|
||||
groupId,
|
||||
linkId,
|
||||
requestId,
|
||||
ip,
|
||||
userAgent
|
||||
) {
|
||||
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
|
||||
|
||||
return invitesModel.withTransaction(async (client) => {
|
||||
const { actorRole } = await ensureGroupAndManagerRole(
|
||||
userId,
|
||||
resolvedGroupId,
|
||||
client
|
||||
);
|
||||
const link = await invitesModel.revokeInviteLink(
|
||||
resolvedGroupId,
|
||||
resolvedLinkId,
|
||||
client
|
||||
);
|
||||
if (!link) {
|
||||
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
|
||||
}
|
||||
|
||||
await invitesModel.createGroupAuditLog(
|
||||
{
|
||||
groupId: resolvedGroupId,
|
||||
actorUserId: userId,
|
||||
actorRole,
|
||||
eventType: "GROUP_INVITE_REVOKED",
|
||||
requestId,
|
||||
ip: normalizeIp(ip),
|
||||
userAgent: userAgent || null,
|
||||
metadata: {
|
||||
inviteCodeLast4: inviteCodeLast4(link.token),
|
||||
},
|
||||
},
|
||||
client
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function reviveInviteLink(
|
||||
userId,
|
||||
groupId,
|
||||
linkId,
|
||||
expiresAt,
|
||||
requestId,
|
||||
ip,
|
||||
userAgent
|
||||
) {
|
||||
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
|
||||
const resolvedExpiresAt = ensureDate(expiresAt, "expiresAt");
|
||||
|
||||
return invitesModel.withTransaction(async (client) => {
|
||||
const { actorRole } = await ensureGroupAndManagerRole(
|
||||
userId,
|
||||
resolvedGroupId,
|
||||
client
|
||||
);
|
||||
const link = await invitesModel.reviveInviteLink(
|
||||
resolvedGroupId,
|
||||
resolvedLinkId,
|
||||
resolvedExpiresAt,
|
||||
client
|
||||
);
|
||||
if (!link) {
|
||||
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
|
||||
}
|
||||
|
||||
await invitesModel.createGroupAuditLog(
|
||||
{
|
||||
groupId: resolvedGroupId,
|
||||
actorUserId: userId,
|
||||
actorRole,
|
||||
eventType: "GROUP_INVITE_REVIVED",
|
||||
requestId,
|
||||
ip: normalizeIp(ip),
|
||||
userAgent: userAgent || null,
|
||||
metadata: {
|
||||
inviteCodeLast4: inviteCodeLast4(link.token),
|
||||
},
|
||||
},
|
||||
client
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteInviteLink(
|
||||
userId,
|
||||
groupId,
|
||||
linkId,
|
||||
requestId,
|
||||
ip,
|
||||
userAgent
|
||||
) {
|
||||
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
|
||||
|
||||
return invitesModel.withTransaction(async (client) => {
|
||||
const { actorRole } = await ensureGroupAndManagerRole(
|
||||
userId,
|
||||
resolvedGroupId,
|
||||
client
|
||||
);
|
||||
const link = await invitesModel.deleteInviteLink(
|
||||
resolvedGroupId,
|
||||
resolvedLinkId,
|
||||
client
|
||||
);
|
||||
if (!link) {
|
||||
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
|
||||
}
|
||||
|
||||
await invitesModel.createGroupAuditLog(
|
||||
{
|
||||
groupId: resolvedGroupId,
|
||||
actorUserId: userId,
|
||||
actorRole,
|
||||
eventType: "GROUP_INVITE_DELETED",
|
||||
requestId,
|
||||
ip: normalizeIp(ip),
|
||||
userAgent: userAgent || null,
|
||||
metadata: {
|
||||
inviteCodeLast4: inviteCodeLast4(link.token),
|
||||
},
|
||||
},
|
||||
client
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getInviteStatus(link) {
|
||||
const now = Date.now();
|
||||
if (link.single_use && link.used_at) return "USED";
|
||||
if (link.revoked_at) return "REVOKED";
|
||||
if (new Date(link.expires_at).getTime() <= now) return "EXPIRED";
|
||||
return "ACTIVE";
|
||||
}
|
||||
|
||||
async function getInviteLinkSummaryByToken(token, userId = null) {
|
||||
if (!token || typeof token !== "string") {
|
||||
throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400);
|
||||
}
|
||||
|
||||
const summary = await invitesModel.getInviteLinkSummaryByToken(token.trim());
|
||||
if (!summary) {
|
||||
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
|
||||
}
|
||||
|
||||
let viewerStatus = null;
|
||||
if (userId) {
|
||||
const isMember = await invitesModel.isGroupMember(summary.group_id, userId);
|
||||
if (isMember) {
|
||||
viewerStatus = JOIN_RESULTS.ALREADY_MEMBER;
|
||||
} else {
|
||||
const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId);
|
||||
if (pending) {
|
||||
viewerStatus = JOIN_RESULTS.PENDING;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activePolicy = summary.current_join_policy || summary.policy;
|
||||
return {
|
||||
id: summary.id,
|
||||
group_id: summary.group_id,
|
||||
group_name: summary.group_name,
|
||||
token: summary.token,
|
||||
policy: summary.policy,
|
||||
current_join_policy: summary.current_join_policy || null,
|
||||
active_policy: activePolicy,
|
||||
single_use: summary.single_use,
|
||||
expires_at: summary.expires_at,
|
||||
used_at: summary.used_at,
|
||||
revoked_at: summary.revoked_at,
|
||||
created_at: summary.created_at,
|
||||
status: getInviteStatus(summary),
|
||||
...(viewerStatus ? { viewerStatus } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function acceptInviteLink(userId, token, requestId, ip, userAgent) {
|
||||
if (!userId) {
|
||||
throw new InviteServiceError("UNAUTHORIZED", "Authentication required", 401);
|
||||
}
|
||||
if (!token || typeof token !== "string") {
|
||||
throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400);
|
||||
}
|
||||
|
||||
return invitesModel.withTransaction(async (client) => {
|
||||
const summary = await invitesModel.getInviteLinkSummaryByToken(
|
||||
token.trim(),
|
||||
client,
|
||||
true
|
||||
);
|
||||
if (!summary) {
|
||||
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
|
||||
}
|
||||
|
||||
const group = {
|
||||
id: summary.group_id,
|
||||
name: summary.group_name,
|
||||
};
|
||||
|
||||
const memberExists = await invitesModel.isGroupMember(summary.group_id, userId, client);
|
||||
if (memberExists) {
|
||||
return { status: JOIN_RESULTS.ALREADY_MEMBER, group };
|
||||
}
|
||||
|
||||
const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId, client);
|
||||
if (pending) {
|
||||
return { status: JOIN_RESULTS.PENDING, group };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (summary.revoked_at) {
|
||||
throw new InviteServiceError(
|
||||
"INVITE_REVOKED",
|
||||
"This invite link has been revoked",
|
||||
410
|
||||
);
|
||||
}
|
||||
if (new Date(summary.expires_at).getTime() <= now) {
|
||||
throw new InviteServiceError(
|
||||
"INVITE_EXPIRED",
|
||||
"This invite link has expired",
|
||||
410
|
||||
);
|
||||
}
|
||||
if (summary.single_use && summary.used_at) {
|
||||
throw new InviteServiceError(
|
||||
"INVITE_USED",
|
||||
"This invite link has already been used",
|
||||
410
|
||||
);
|
||||
}
|
||||
|
||||
const activePolicy =
|
||||
summary.current_join_policy || summary.policy || JOIN_POLICIES.NOT_ACCEPTING;
|
||||
if (activePolicy === JOIN_POLICIES.NOT_ACCEPTING) {
|
||||
throw new InviteServiceError(
|
||||
"JOIN_NOT_ACCEPTING",
|
||||
"This group is not accepting new members",
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const actorRole = (await invitesModel.getUserGroupRole(summary.group_id, userId, client)) || "guest";
|
||||
|
||||
if (activePolicy === JOIN_POLICIES.AUTO_ACCEPT) {
|
||||
const inserted = await invitesModel.addGroupMember(
|
||||
summary.group_id,
|
||||
userId,
|
||||
"member",
|
||||
client
|
||||
);
|
||||
if (!inserted) {
|
||||
return { status: JOIN_RESULTS.ALREADY_MEMBER, group };
|
||||
}
|
||||
|
||||
if (summary.single_use) {
|
||||
await invitesModel.consumeSingleUseInvite(summary.id, client);
|
||||
}
|
||||
|
||||
await invitesModel.createGroupAuditLog(
|
||||
{
|
||||
groupId: summary.group_id,
|
||||
actorUserId: userId,
|
||||
actorRole,
|
||||
eventType: "GROUP_INVITE_USED",
|
||||
requestId,
|
||||
ip: normalizeIp(ip),
|
||||
userAgent: userAgent || null,
|
||||
metadata: {
|
||||
inviteCodeLast4: inviteCodeLast4(summary.token),
|
||||
},
|
||||
},
|
||||
client
|
||||
);
|
||||
|
||||
return { status: JOIN_RESULTS.JOINED, group };
|
||||
}
|
||||
|
||||
if (activePolicy === JOIN_POLICIES.APPROVAL_REQUIRED) {
|
||||
await invitesModel.createOrTouchPendingJoinRequest(summary.group_id, userId, client);
|
||||
|
||||
if (summary.single_use) {
|
||||
await invitesModel.consumeSingleUseInvite(summary.id, client);
|
||||
}
|
||||
|
||||
await invitesModel.createGroupAuditLog(
|
||||
{
|
||||
groupId: summary.group_id,
|
||||
actorUserId: userId,
|
||||
actorRole,
|
||||
eventType: "GROUP_INVITE_REQUESTED",
|
||||
requestId,
|
||||
ip: normalizeIp(ip),
|
||||
userAgent: userAgent || null,
|
||||
metadata: {
|
||||
inviteCodeLast4: inviteCodeLast4(summary.token),
|
||||
},
|
||||
},
|
||||
client
|
||||
);
|
||||
|
||||
return { status: JOIN_RESULTS.PENDING, group };
|
||||
}
|
||||
|
||||
throw new InviteServiceError("INVALID_JOIN_POLICY", "Invalid join policy", 400);
|
||||
});
|
||||
}
|
||||
|
||||
async function getGroupJoinPolicy(userId, groupId) {
|
||||
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||
await ensureGroupAndManagerRole(userId, resolvedGroupId);
|
||||
const settings = await invitesModel.getGroupSettings(resolvedGroupId);
|
||||
return settings?.join_policy || JOIN_POLICIES.NOT_ACCEPTING;
|
||||
}
|
||||
|
||||
async function setGroupJoinPolicy(
|
||||
userId,
|
||||
groupId,
|
||||
joinPolicy,
|
||||
requestId,
|
||||
ip,
|
||||
userAgent
|
||||
) {
|
||||
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||
const resolvedJoinPolicy = ensureJoinPolicy(joinPolicy);
|
||||
|
||||
return invitesModel.withTransaction(async (client) => {
|
||||
const { actorRole } = await ensureGroupAndManagerRole(
|
||||
userId,
|
||||
resolvedGroupId,
|
||||
client
|
||||
);
|
||||
await invitesModel.upsertGroupSettings(resolvedGroupId, resolvedJoinPolicy, client);
|
||||
|
||||
await invitesModel.createGroupAuditLog(
|
||||
{
|
||||
groupId: resolvedGroupId,
|
||||
actorUserId: userId,
|
||||
actorRole,
|
||||
eventType: "GROUP_JOIN_POLICY_UPDATED",
|
||||
requestId,
|
||||
ip: normalizeIp(ip),
|
||||
userAgent: userAgent || null,
|
||||
metadata: {
|
||||
joinPolicy: resolvedJoinPolicy,
|
||||
},
|
||||
},
|
||||
client
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InviteServiceError,
|
||||
JOIN_POLICIES,
|
||||
JOIN_RESULTS,
|
||||
acceptInviteLink,
|
||||
createInviteLink,
|
||||
deleteInviteLink,
|
||||
getGroupJoinPolicy,
|
||||
getInviteLinkSummaryByToken,
|
||||
listInviteLinks,
|
||||
resolveManagedGroupId,
|
||||
revokeInviteLink,
|
||||
reviveInviteLink,
|
||||
setGroupJoinPolicy,
|
||||
};
|
||||
@ -1,74 +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(),
|
||||
getGroupJoinPolicy: jest.fn(),
|
||||
getInviteLinkSummaryByToken: jest.fn(),
|
||||
listInviteLinks: jest.fn(),
|
||||
resolveManagedGroupId: jest.fn(),
|
||||
revokeInviteLink: jest.fn(),
|
||||
reviveInviteLink: jest.fn(),
|
||||
setGroupJoinPolicy: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const request = require("supertest");
|
||||
const invitesService = require("../services/group-invites.service");
|
||||
const app = require("../app");
|
||||
|
||||
describe("group invites routes", () => {
|
||||
beforeEach(() => {
|
||||
invitesService.resolveManagedGroupId.mockResolvedValue(1);
|
||||
invitesService.listInviteLinks.mockResolvedValue([]);
|
||||
invitesService.createInviteLink.mockResolvedValue({
|
||||
id: 1,
|
||||
token: "abcd",
|
||||
status: "ACTIVE",
|
||||
});
|
||||
invitesService.getInviteLinkSummaryByToken.mockResolvedValue({
|
||||
id: 1,
|
||||
token: "abcd",
|
||||
group_name: "Test Group",
|
||||
status: "ACTIVE",
|
||||
active_policy: "AUTO_ACCEPT",
|
||||
});
|
||||
});
|
||||
|
||||
test("admin-only checks are enforced on invite management routes", async () => {
|
||||
invitesService.createInviteLink.mockRejectedValue(
|
||||
new invitesService.InviteServiceError(
|
||||
"FORBIDDEN",
|
||||
"Admin or owner role required",
|
||||
403
|
||||
)
|
||||
);
|
||||
|
||||
const response = await request(app).post("/api/groups/invites").send({
|
||||
policy: "AUTO_ACCEPT",
|
||||
singleUse: false,
|
||||
ttlDays: 3,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error.code).toBe("FORBIDDEN");
|
||||
expect(response.body.request_id).toBeTruthy();
|
||||
});
|
||||
|
||||
test("request_id is present in invite responses", async () => {
|
||||
const response = await request(app).get("/api/invite-links/abcd1234");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.request_id).toBeTruthy();
|
||||
expect(response.body.link).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -1,189 +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(),
|
||||
getPendingJoinRequest: jest.fn(),
|
||||
getUserGroupRole: jest.fn(),
|
||||
isGroupMember: jest.fn(),
|
||||
listInviteLinks: jest.fn(),
|
||||
revokeInviteLink: jest.fn(),
|
||||
reviveInviteLink: jest.fn(),
|
||||
upsertGroupSettings: jest.fn(),
|
||||
withTransaction: jest.fn(),
|
||||
}));
|
||||
|
||||
const invitesModel = require("../models/group-invites.model");
|
||||
const invitesService = require("../services/group-invites.service");
|
||||
|
||||
function inviteSummary(overrides = {}) {
|
||||
return {
|
||||
id: 30,
|
||||
group_id: 10,
|
||||
group_name: "Test Group",
|
||||
token: "1234567890abcdef1234567890fedcba",
|
||||
policy: "AUTO_ACCEPT",
|
||||
current_join_policy: "AUTO_ACCEPT",
|
||||
single_use: false,
|
||||
expires_at: "2030-01-01T00:00:00.000Z",
|
||||
used_at: null,
|
||||
revoked_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("group invites service", () => {
|
||||
beforeEach(() => {
|
||||
invitesModel.withTransaction.mockImplementation(async (handler) => handler({}));
|
||||
});
|
||||
|
||||
test("create link success writes audit with request_id and token last4 only", async () => {
|
||||
invitesModel.getGroupById.mockResolvedValue({ id: 1, name: "G1" });
|
||||
invitesModel.getUserGroupRole.mockResolvedValue("admin");
|
||||
invitesModel.createInviteLink.mockResolvedValue({
|
||||
id: 55,
|
||||
group_id: 1,
|
||||
token: "1234567890abcdef1234567890fedcba",
|
||||
policy: "AUTO_ACCEPT",
|
||||
single_use: true,
|
||||
expires_at: "2030-01-01T00:00:00.000Z",
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const link = await invitesService.createInviteLink(
|
||||
7,
|
||||
1,
|
||||
"AUTO_ACCEPT",
|
||||
true,
|
||||
"2030-01-01T00:00:00.000Z",
|
||||
"req-123",
|
||||
"127.0.0.1",
|
||||
"ua"
|
||||
);
|
||||
|
||||
expect(link.id).toBe(55);
|
||||
expect(invitesModel.createGroupAuditLog).toHaveBeenCalledTimes(1);
|
||||
const auditPayload = invitesModel.createGroupAuditLog.mock.calls[0][0];
|
||||
expect(auditPayload.requestId).toBe("req-123");
|
||||
expect(auditPayload.metadata).toEqual({ inviteCodeLast4: "dcba" });
|
||||
});
|
||||
|
||||
test("accept auto-accept adds membership", async () => {
|
||||
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
|
||||
invitesModel.isGroupMember.mockResolvedValue(false);
|
||||
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
|
||||
invitesModel.getUserGroupRole.mockResolvedValue(null);
|
||||
invitesModel.addGroupMember.mockResolvedValue(true);
|
||||
|
||||
const result = await invitesService.acceptInviteLink(
|
||||
99,
|
||||
"token-1",
|
||||
"req-1",
|
||||
"127.0.0.1",
|
||||
"ua"
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "JOINED",
|
||||
group: { id: 10, name: "Test Group" },
|
||||
});
|
||||
expect(invitesModel.addGroupMember).toHaveBeenCalled();
|
||||
expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe(
|
||||
"GROUP_INVITE_USED"
|
||||
);
|
||||
});
|
||||
|
||||
test("accept manual policy creates pending request", async () => {
|
||||
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(
|
||||
inviteSummary({
|
||||
current_join_policy: "APPROVAL_REQUIRED",
|
||||
single_use: true,
|
||||
})
|
||||
);
|
||||
invitesModel.isGroupMember.mockResolvedValue(false);
|
||||
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
|
||||
invitesModel.getUserGroupRole.mockResolvedValue(null);
|
||||
invitesModel.createOrTouchPendingJoinRequest.mockResolvedValue({
|
||||
id: 1,
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
const result = await invitesService.acceptInviteLink(
|
||||
99,
|
||||
"token-2",
|
||||
"req-2",
|
||||
"127.0.0.1",
|
||||
"ua"
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "PENDING",
|
||||
group: { id: 10, name: "Test Group" },
|
||||
});
|
||||
expect(invitesModel.createOrTouchPendingJoinRequest).toHaveBeenCalled();
|
||||
expect(invitesModel.consumeSingleUseInvite).toHaveBeenCalledWith(30, {});
|
||||
expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe(
|
||||
"GROUP_INVITE_REQUESTED"
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["INVITE_EXPIRED", inviteSummary({ expires_at: "2020-01-01T00:00:00.000Z" })],
|
||||
["INVITE_REVOKED", inviteSummary({ revoked_at: "2026-01-01T00:00:00.000Z" })],
|
||||
[
|
||||
"INVITE_USED",
|
||||
inviteSummary({ single_use: true, used_at: "2026-01-01T00:00:00.000Z" }),
|
||||
],
|
||||
])("rejects %s links", async (expectedCode, summary) => {
|
||||
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(summary);
|
||||
invitesModel.isGroupMember.mockResolvedValue(false);
|
||||
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
invitesService.acceptInviteLink(99, "token-3", "req-3", "127.0.0.1", "ua")
|
||||
).rejects.toMatchObject({ code: expectedCode });
|
||||
});
|
||||
|
||||
test("accept returns ALREADY_MEMBER before pending checks", async () => {
|
||||
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
|
||||
invitesModel.isGroupMember.mockResolvedValue(true);
|
||||
|
||||
const result = await invitesService.acceptInviteLink(
|
||||
99,
|
||||
"token-4",
|
||||
"req-4",
|
||||
"127.0.0.1",
|
||||
"ua"
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ALREADY_MEMBER");
|
||||
expect(invitesModel.getPendingJoinRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("accept returns PENDING when request already exists", async () => {
|
||||
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
|
||||
invitesModel.isGroupMember.mockResolvedValue(false);
|
||||
invitesModel.getPendingJoinRequest.mockResolvedValue({
|
||||
id: 5,
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
const result = await invitesService.acceptInviteLink(
|
||||
99,
|
||||
"token-5",
|
||||
"req-5",
|
||||
"127.0.0.1",
|
||||
"ua"
|
||||
);
|
||||
|
||||
expect(result.status).toBe("PENDING");
|
||||
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,158 +0,0 @@
|
||||
jest.mock("../models/list.model.v2", () => ({
|
||||
addHistoryRecord: jest.fn(),
|
||||
addOrUpdateItem: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../models/household.model", () => ({
|
||||
isHouseholdMember: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/logger", () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
const List = require("../models/list.model.v2");
|
||||
const householdModel = require("../models/household.model");
|
||||
const controller = require("../controllers/lists.controller.v2");
|
||||
|
||||
function createResponse() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("lists.controller.v2 addItem", () => {
|
||||
beforeEach(() => {
|
||||
List.addOrUpdateItem.mockResolvedValue({
|
||||
listId: 42,
|
||||
itemName: "milk",
|
||||
isNew: true,
|
||||
});
|
||||
List.addHistoryRecord.mockResolvedValue(undefined);
|
||||
householdModel.isHouseholdMember.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("records history for selected added_for_user_id when member is valid", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: { item_name: "milk", quantity: "1", added_for_user_id: "9" },
|
||||
user: { id: 7 },
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.addItem(req, res);
|
||||
|
||||
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
|
||||
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 9);
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
test("records history using request user when added_for_user_id is not provided", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: { item_name: "milk", quantity: "1" },
|
||||
user: { id: 7 },
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.addItem(req, res);
|
||||
|
||||
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
test("records history using request user when added_for_user_id is blank", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: { item_name: "milk", quantity: "1", added_for_user_id: " " },
|
||||
user: { id: 7 },
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.addItem(req, res);
|
||||
|
||||
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
test("rejects invalid added_for_user_id", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: { item_name: "milk", quantity: "1", added_for_user_id: "abc" },
|
||||
user: { id: 7 },
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.addItem(req, res);
|
||||
|
||||
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
|
||||
expect(List.addHistoryRecord).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Added-for user ID must be a positive integer",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects malformed numeric-looking added_for_user_id", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: { item_name: "milk", quantity: "1", added_for_user_id: "9abc" },
|
||||
user: { id: 7 },
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.addItem(req, res);
|
||||
|
||||
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
|
||||
expect(List.addHistoryRecord).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Added-for user ID must be a positive integer",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects added_for_user_id when target user is not household member", async () => {
|
||||
householdModel.isHouseholdMember.mockResolvedValue(false);
|
||||
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: { item_name: "milk", quantity: "1", added_for_user_id: "11" },
|
||||
user: { id: 7 },
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.addItem(req, res);
|
||||
|
||||
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 11);
|
||||
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
|
||||
expect(List.addHistoryRecord).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Selected user is not a member of this household",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -9,7 +9,7 @@ services:
|
||||
- ./frontend:/app
|
||||
- frontend_node_modules:/app/node_modules
|
||||
ports:
|
||||
- "3010:5173"
|
||||
- "3000:5173"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: always
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
services:
|
||||
backend:
|
||||
image: git.nicosaya.com/nalalangan/costco-grocery-list/backend:main-new
|
||||
# image: grocery-app/backend:main-new
|
||||
restart: always
|
||||
env_file:
|
||||
- ./backend.env
|
||||
ports:
|
||||
- "5001:5000"
|
||||
|
||||
frontend:
|
||||
image: git.nicosaya.com/nalalangan/costco-grocery-list/frontend:main-new
|
||||
# image: grocery-app/frontend:main-new
|
||||
restart: always
|
||||
env_file:
|
||||
- ./frontend.env
|
||||
ports:
|
||||
- "3001:5173"
|
||||
depends_on:
|
||||
- backend
|
||||
@ -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.
|
||||
@ -1,67 +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 stale legacy SQL exists:
|
||||
- `npm run db:migrate:stale:check`
|
||||
|
||||
## Active migration set
|
||||
Migration files are applied in lexicographic filename order from `packages/db/migrations`.
|
||||
|
||||
`backend/migrations` is legacy reference-only and not part of canonical execution.
|
||||
`packages/db/migrations/stale-files.json` is the source of truth for canonical files that are intentionally stale/ignored.
|
||||
|
||||
Current baseline files:
|
||||
- `add_display_name_column.sql`
|
||||
- `add_image_columns.sql`
|
||||
- `add_modified_on_column.sql`
|
||||
- `add_notes_column.sql`
|
||||
- `create_item_classification_table.sql`
|
||||
- `create_sessions_table.sql`
|
||||
- `multi_household_architecture.sql`
|
||||
|
||||
## Tracking table
|
||||
Applied migrations are recorded in:
|
||||
|
||||
- `schema_migrations(filename text unique, applied_at timestamptz)`
|
||||
|
||||
## Expected operator flow
|
||||
1. Check status:
|
||||
- `npm run db:migrate:status`
|
||||
2. If a new implementation needs schema changes, create a new file:
|
||||
- `npm run db:migrate:new -- <migration-name>`
|
||||
3. Apply pending:
|
||||
- `npm run db:migrate`
|
||||
4. Verify clean state:
|
||||
- `npm run db:migrate:verify`
|
||||
|
||||
## Troubleshooting
|
||||
- `DATABASE_URL is required`:
|
||||
- Export/set `DATABASE_URL` in your environment.
|
||||
- `psql executable was not found in PATH`:
|
||||
- Install PostgreSQL client tools and retry.
|
||||
- SQL failure:
|
||||
- Fix migration SQL and rerun; only successful files are recorded in `schema_migrations`.
|
||||
- Skip known stale SQL files for a specific environment:
|
||||
- Set `DB_MIGRATE_SKIP_FILES` to a comma-separated filename list.
|
||||
- Example: `DB_MIGRATE_SKIP_FILES=add_modified_on_column.sql,add_image_columns.sql`
|
||||
- Temporarily include files listed in `stale-files.json`:
|
||||
- Set `DB_MIGRATE_INCLUDE_STALE=true` before running migration commands.
|
||||
@ -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.
|
||||
@ -1,77 +0,0 @@
|
||||
# Documentation Index
|
||||
|
||||
This directory contains all project documentation organized by category.
|
||||
|
||||
## 📁 Directory Structure
|
||||
|
||||
### `/architecture` - System Design & Structure
|
||||
- **[component-structure.md](architecture/component-structure.md)** - Frontend component organization and patterns
|
||||
- **[multi-household-architecture-plan.md](architecture/multi-household-architecture-plan.md)** - Multi-household system architecture design
|
||||
|
||||
### `/features` - Feature Implementation Details
|
||||
- **[classification-implementation.md](features/classification-implementation.md)** - Item classification system (zones, types, groups)
|
||||
- **[image-storage-implementation.md](features/image-storage-implementation.md)** - Image storage and handling (bytea, MIME types)
|
||||
|
||||
### `/guides` - How-To & Reference Guides
|
||||
- **[api-documentation.md](guides/api-documentation.md)** - REST API endpoints and usage
|
||||
- **[frontend-readme.md](guides/frontend-readme.md)** - Frontend development guide
|
||||
- **[MOBILE_RESPONSIVE_AUDIT.md](guides/MOBILE_RESPONSIVE_AUDIT.md)** - Mobile-first design guidelines and audit checklist
|
||||
- **[setup-checklist.md](guides/setup-checklist.md)** - Development environment setup steps
|
||||
|
||||
### `/migration` - Database Migrations & Updates
|
||||
- **[MIGRATION_GUIDE.md](migration/MIGRATION_GUIDE.md)** - Multi-household migration instructions (also in `backend/migrations/`)
|
||||
- **[POST_MIGRATION_UPDATES.md](migration/POST_MIGRATION_UPDATES.md)** - Required updates after migration
|
||||
|
||||
### `/archive` - Completed Implementation Records
|
||||
Historical documentation of completed features. Useful for reference but not actively maintained.
|
||||
|
||||
- **[ACCOUNT_MANAGEMENT_IMPLEMENTATION.md](archive/ACCOUNT_MANAGEMENT_IMPLEMENTATION.md)** - Phase 4: Display name and password change
|
||||
- **[code-cleanup-guide.md](archive/code-cleanup-guide.md)** - Code cleanup checklist (completed)
|
||||
- **[HOUSEHOLD_MANAGEMENT_IMPLEMENTATION.md](archive/HOUSEHOLD_MANAGEMENT_IMPLEMENTATION.md)** - Household management UI implementation
|
||||
- **[IMPLEMENTATION_STATUS.md](archive/IMPLEMENTATION_STATUS.md)** - Multi-household migration sprint status
|
||||
- **[settings-dark-mode.md](archive/settings-dark-mode.md)** - Dark mode implementation notes
|
||||
- **[TEST_SUITE_README.md](archive/TEST_SUITE_README.md)** - Testing infrastructure documentation
|
||||
|
||||
---
|
||||
|
||||
## 📄 Root-Level Documentation
|
||||
|
||||
These files remain at the project root for easy access:
|
||||
|
||||
- **[../README.md](../README.md)** - Project overview and quick start
|
||||
- **[../PROJECT_INSTRUCTIONS.md](../PROJECT_INSTRUCTIONS.md)** - Canonical project constraints and delivery contract
|
||||
- **[../AGENTS.md](../AGENTS.md)** - Agent behavior and guardrails
|
||||
- **[../DEBUGGING_INSTRUCTIONS.md](../DEBUGGING_INSTRUCTIONS.md)** - Required bugfix workflow
|
||||
- **[../.github/copilot-instructions.md](../.github/copilot-instructions.md)** - Copilot compatibility shim to root instructions
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Quick Reference
|
||||
|
||||
**Setting up the project?** → Start with [setup-checklist.md](guides/setup-checklist.md)
|
||||
|
||||
**Understanding the API?** → See [api-documentation.md](guides/api-documentation.md)
|
||||
|
||||
**Working on mobile UI?** → Check [MOBILE_RESPONSIVE_AUDIT.md](guides/MOBILE_RESPONSIVE_AUDIT.md)
|
||||
|
||||
**Need architecture context?** → Read [AGENTIC_CONTRACT_MAP.md](AGENTIC_CONTRACT_MAP.md) and [../PROJECT_INSTRUCTIONS.md](../PROJECT_INSTRUCTIONS.md)
|
||||
|
||||
**Running migrations?** → Follow [DB_MIGRATION_WORKFLOW.md](DB_MIGRATION_WORKFLOW.md)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Contributing to Documentation
|
||||
|
||||
When adding new documentation:
|
||||
|
||||
1. **Guides** (`/guides`) - General how-to, setup, reference
|
||||
2. **Features** (`/features`) - Specific feature implementation details
|
||||
3. **Architecture** (`/architecture`) - System design, patterns, structure
|
||||
4. **Migration** (`/migration`) - Database migrations and upgrade guides
|
||||
5. **Archive** (`/archive`) - Completed implementation records (for reference only)
|
||||
|
||||
Keep documentation:
|
||||
- ✅ Up-to-date with code changes
|
||||
- ✅ Concise and scannable
|
||||
- ✅ Linked to relevant files (use relative paths)
|
||||
- ✅ Organized by category
|
||||
@ -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.
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -1,283 +0,0 @@
|
||||
# Mobile Responsive Design Audit & Recommendations
|
||||
|
||||
## ✅ Already Mobile-Friendly
|
||||
|
||||
### Components
|
||||
1. **Navbar** - Just updated with hamburger menu, dropdowns, sticky positioning
|
||||
2. **AdminPanel** - Has responsive breakpoints (768px, 480px)
|
||||
3. **Manage page** - Has responsive breakpoints (768px, 480px)
|
||||
4. **ManageHousehold** - Has 768px breakpoint
|
||||
5. **Settings** - Has 768px breakpoint
|
||||
6. **StoreManagement** - Has 768px breakpoint
|
||||
7. **GroceryList** - Has 480px breakpoint
|
||||
|
||||
## ✅ Recently Completed (2026-01-26)
|
||||
|
||||
### **All Modals** - Mobile optimization COMPLETE ✓
|
||||
**Files updated with responsive styles:**
|
||||
- ✅ `frontend/src/styles/AddImageModal.css` - Added 768px & 480px breakpoints
|
||||
- ✅ `frontend/src/styles/ImageUploadModal.css` - Added 768px & 480px breakpoints
|
||||
- ✅ `frontend/src/styles/ItemClassificationModal.css` - Added 768px & 480px breakpoints
|
||||
- ✅ `frontend/src/styles/SimilarItemModal.css` - Added 768px & 480px breakpoints
|
||||
- ✅ `frontend/src/styles/components/EditItemModal.css` - Added 768px & 480px breakpoints
|
||||
- ✅ `frontend/src/styles/components/ConfirmAddExistingModal.css` - Added 768px & 480px breakpoints
|
||||
- ✅ `frontend/src/styles/ImageModal.css` - Enhanced with 480px breakpoint
|
||||
- ✅ `frontend/src/styles/components/AddItemWithDetailsModal.css` - Enhanced with 768px breakpoint
|
||||
- ✅ `frontend/src/styles/ConfirmBuyModal.css` - Already excellent (480px & 360px breakpoints)
|
||||
|
||||
**Mobile improvements implemented:**
|
||||
- Modal width: 95% at 768px, 100% at 480px
|
||||
- All buttons: Full-width stacking on mobile with 44px minimum height
|
||||
- Input fields: 16px font-size to prevent iOS zoom
|
||||
- Image previews: Responsive sizing (180-200px on mobile)
|
||||
- Touch targets: 44x44px minimum for all interactive elements
|
||||
- Overflow: Auto scrolling for tall modals (max-height: 90vh)
|
||||
- Spacing: Reduced padding on small screens
|
||||
|
||||
## ⚠️ Needs Improvement
|
||||
|
||||
### High Priority
|
||||
|
||||
#### 1. **HouseholdSwitcher** - Dropdown might overflow on mobile
|
||||
**File:** `frontend/src/styles/components/HouseholdSwitcher.css`
|
||||
|
||||
**Current:** No mobile breakpoints
|
||||
**Needs:**
|
||||
```css
|
||||
@media (max-width: 480px) {
|
||||
.household-switcher-dropdown {
|
||||
max-width: 90vw;
|
||||
right: auto;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. **StoreTabs** - Horizontal scrolling tabs on mobile
|
||||
**File:** `frontend/src/styles/components/StoreTabs.css`
|
||||
|
||||
**Needs:**
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
.store-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.store-tab {
|
||||
min-width: 100px;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. **Login/Register Pages** - Need better mobile padding
|
||||
**Files:**
|
||||
- `frontend/src/styles/pages/Login.css`
|
||||
- `frontend/src/styles/pages/Register.css`
|
||||
|
||||
**Needs:**
|
||||
```css
|
||||
@media (max-width: 480px) {
|
||||
.card {
|
||||
padding: 1.5rem 1rem;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
font-size: 16px; /* Prevents iOS zoom on focus */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Medium Priority
|
||||
|
||||
#### 4. **GroceryList Item Cards** - Could be more touch-friendly
|
||||
**File:** `frontend/src/styles/pages/GroceryList.css`
|
||||
|
||||
**Current:** Has 480px breakpoint
|
||||
**Enhancement needed:**
|
||||
- Increase touch target sizes for mobile
|
||||
- Better spacing between items on small screens
|
||||
- Optimize image display on mobile
|
||||
|
||||
#### 5. **AddItemForm** - Input width and spacing
|
||||
**File:** `frontend/src/styles/components/AddItemForm.css`
|
||||
|
||||
**Has 480px breakpoint** but verify:
|
||||
- Input font-size is 16px+ (prevents iOS zoom)
|
||||
- Buttons are full-width on mobile
|
||||
- Adequate spacing between form elements
|
||||
|
||||
#### 6. **CreateJoinHousehold Modal**
|
||||
**File:** `frontend/src/styles/components/manage/CreateJoinHousehold.css`
|
||||
|
||||
**Has 600px breakpoint** - Review for:
|
||||
- Full-screen on very small devices
|
||||
- Button sizing and spacing
|
||||
- Tab navigation usability
|
||||
|
||||
### Low Priority
|
||||
|
||||
#### 7. **SuggestionList** - Touch interactions
|
||||
**File:** `frontend/src/styles/components/SuggestionList.css`
|
||||
|
||||
**Needs:** Mobile-specific styles for:
|
||||
- Larger tap targets
|
||||
- Better scrolling behavior
|
||||
- Touch feedback
|
||||
|
||||
#### 8. **ClassificationSection** - Zone selection on mobile
|
||||
**File:** `frontend/src/styles/components/ClassificationSection.css`
|
||||
|
||||
**Needs:**
|
||||
- Ensure zone buttons are touch-friendly
|
||||
- Stack vertically if needed on small screens
|
||||
|
||||
#### 9. **ImageUploadSection**
|
||||
**File:** `frontend/src/styles/components/ImageUploadSection.css`
|
||||
|
||||
**Needs:**
|
||||
- Camera access optimization for mobile
|
||||
- Preview image sizing
|
||||
- Upload button sizing
|
||||
|
||||
## 🎯 General Recommendations
|
||||
|
||||
### 1. **Global Styles**
|
||||
Update `frontend/src/index.css`:
|
||||
```css
|
||||
/* Prevent zoom on input focus (iOS) */
|
||||
input, select, textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Better touch scrolling */
|
||||
* {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Ensure body doesn't overflow horizontally */
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **Container Max-Widths**
|
||||
Standardize across the app:
|
||||
- Small components: `max-width: 600px`
|
||||
- Medium pages: `max-width: 800px`
|
||||
- Wide layouts: `max-width: 1200px`
|
||||
- Always pair with `margin: 0 auto` and `padding: 1rem`
|
||||
|
||||
### 3. **Button Sizing**
|
||||
Mobile-friendly buttons:
|
||||
```css
|
||||
.btn-primary, .btn-secondary {
|
||||
min-height: 44px; /* Apple's recommended minimum */
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.btn-primary, .btn-secondary {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **Form Layouts**
|
||||
Stack form fields on mobile:
|
||||
```css
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. **Image Handling**
|
||||
Responsive images:
|
||||
```css
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
```
|
||||
|
||||
### 6. **Typography**
|
||||
Adjust for mobile readability:
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
h1 { font-size: 1.75rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
body { font-size: 16px; } /* Prevents iOS zoom */
|
||||
}
|
||||
```
|
||||
|
||||
## 📱 Testing Checklist
|
||||
|
||||
Test on these viewports:
|
||||
- [ ] 320px (iPhone SE)
|
||||
- [ ] 375px (iPhone 12/13 Pro)
|
||||
- [ ] 390px (iPhone 14 Pro)
|
||||
- [ ] 414px (iPhone Pro Max)
|
||||
- [ ] 768px (iPad Portrait)
|
||||
- [ ] 1024px (iPad Landscape)
|
||||
- [ ] 1280px+ (Desktop)
|
||||
|
||||
Test these interactions:
|
||||
- [ ] Navigation menu (hamburger)
|
||||
- [ ] Dropdowns (household, user menu)
|
||||
- [ ] All modals
|
||||
- [ ] Form inputs (no zoom on focus)
|
||||
- [ ] Touch gestures (swipe, long-press)
|
||||
- [ ] Scrolling (no horizontal overflow)
|
||||
- [ ] Image upload/viewing
|
||||
- [ ] Tab navigation
|
||||
|
||||
## 🔄 Future Considerations
|
||||
|
||||
1. **Progressive Web App (PWA)**
|
||||
- Add manifest.json
|
||||
- Service worker for offline support
|
||||
- Install prompt
|
||||
|
||||
2. **Touch Gestures**
|
||||
- Swipe to delete items
|
||||
- Pull to refresh lists
|
||||
- Long-press for context menu
|
||||
|
||||
3. **Keyboard Handling**
|
||||
- iOS keyboard overlap handling
|
||||
- Android keyboard behavior
|
||||
- Input focus management
|
||||
|
||||
4. **Performance**
|
||||
- Lazy load images
|
||||
- Virtual scrolling for long lists
|
||||
- Code splitting by route
|
||||
|
||||
## 📝 How to Maintain Mobile-First Design
|
||||
|
||||
I've updated `.github/copilot-instructions.md` with mobile-first design principles. This will be included in all future conversations automatically.
|
||||
|
||||
**To ensure I remember in new conversations:**
|
||||
1. ✅ Mobile-first guidelines are now in copilot-instructions.md (automatically loaded)
|
||||
2. Start conversations with: "Remember to keep mobile/desktop responsiveness in mind"
|
||||
3. Review this audit document before making UI changes
|
||||
4. Run mobile testing after any CSS/layout changes
|
||||
|
||||
**Quick reminder phrases:**
|
||||
- "Make this mobile-friendly"
|
||||
- "Add responsive breakpoints"
|
||||
- "Test on mobile viewports"
|
||||
- "Ensure touch-friendly targets"
|
||||
@ -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
|
||||
@ -1,83 +0,0 @@
|
||||
# Post-Migration Updates Required
|
||||
|
||||
This document outlines the remaining updates needed after migrating to the multi-household architecture.
|
||||
|
||||
## ✅ Completed Fixes
|
||||
|
||||
1. **Column name corrections** in `list.model.v2.js`:
|
||||
- Fixed `item_image` → `custom_image`
|
||||
- Fixed `image_mime_type` → `custom_image_mime_type`
|
||||
- Fixed `hlh.list_id` → `hlh.household_list_id`
|
||||
|
||||
2. **SQL query fixes**:
|
||||
- Fixed ORDER BY with DISTINCT in `getSuggestions`
|
||||
- Fixed `setBought` to use boolean instead of quantity logic
|
||||
|
||||
3. **Created migration**: `add_notes_column.sql` for missing notes column
|
||||
|
||||
## 🔧 Required Database Migration
|
||||
|
||||
**Run this SQL on your PostgreSQL database:**
|
||||
|
||||
```sql
|
||||
-- From backend/migrations/add_notes_column.sql
|
||||
ALTER TABLE household_lists
|
||||
ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
|
||||
COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item';
|
||||
```
|
||||
|
||||
## 🧹 Optional Cleanup (Not Critical)
|
||||
|
||||
### Legacy Files Still Present
|
||||
|
||||
These files reference the old `grocery_list` table but are not actively used by the frontend:
|
||||
|
||||
- `backend/models/list.model.js` - Old model
|
||||
- `backend/controllers/lists.controller.js` - Old controller
|
||||
- `backend/routes/list.routes.js` - Old routes (still mounted at `/list`)
|
||||
|
||||
**Recommendation**: Can be safely removed once you confirm the new architecture is working, or kept as fallback.
|
||||
|
||||
### Route Cleanup in app.js
|
||||
|
||||
The old `/list` route is still mounted in `backend/app.js`:
|
||||
|
||||
```javascript
|
||||
const listRoutes = require("./routes/list.routes");
|
||||
app.use("/list", listRoutes); // ← Not used by frontend anymore
|
||||
```
|
||||
|
||||
**Recommendation**: Comment out or remove once migration is confirmed successful.
|
||||
|
||||
## ✅ No Frontend Changes Needed
|
||||
|
||||
The frontend is already correctly calling the new household-scoped endpoints:
|
||||
- All calls use `/households/:householdId/stores/:storeId/list/*` pattern
|
||||
- No references to old `/list/*` endpoints
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Run the notes column migration** (required for notes feature to work)
|
||||
2. **Test the application** thoroughly:
|
||||
- Add items with images
|
||||
- Mark items as bought/unbought
|
||||
- Update item quantities and notes
|
||||
- Test suggestions/autocomplete
|
||||
- Test recently bought items
|
||||
3. **Remove legacy files** (optional, once confirmed working)
|
||||
|
||||
## 📝 Architecture Notes
|
||||
|
||||
**Current Structure:**
|
||||
- All list operations are scoped to `household_id + store_id`
|
||||
- History tracking uses `household_list_history` table
|
||||
- Image storage uses `custom_image` and `custom_image_mime_type` columns
|
||||
- Classifications use `household_item_classifications` table (per household+store)
|
||||
|
||||
**Middleware Chain:**
|
||||
```javascript
|
||||
auth → householdAccess → storeAccess → controller
|
||||
```
|
||||
|
||||
This ensures users can only access data for households they belong to and stores linked to those households.
|
||||
@ -1,15 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Grocery App</title>
|
||||
<title>Costco Grocery List</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
@ -15,7 +15,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
@ -982,21 +981,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.47",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
|
||||
@ -2931,50 +2915,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@ -7,10 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
@ -19,7 +16,6 @@
|
||||
"react-router-dom": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.5",
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3010";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chrome",
|
||||
use: { browserName: "chromium", channel: "chrome" },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run dev -- --host localhost --port 3010",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
@ -1,41 +1,32 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { ROLES } from "./constants/roles";
|
||||
import { AuthProvider } from "./context/AuthContext.jsx";
|
||||
import { ActionToastProvider } from "./context/ActionToastContext.jsx";
|
||||
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
||||
import { HouseholdProvider } from "./context/HouseholdContext.jsx";
|
||||
import { UploadQueueProvider } from "./context/UploadQueueContext.jsx";
|
||||
import { SettingsProvider } from "./context/SettingsContext.jsx";
|
||||
import { StoreProvider } from "./context/StoreContext.jsx";
|
||||
|
||||
import AdminPanel from "./pages/AdminPanel.jsx";
|
||||
import GroceryList from "./pages/GroceryList.jsx";
|
||||
import Login from "./pages/Login.jsx";
|
||||
import Manage from "./pages/Manage.jsx";
|
||||
import Register from "./pages/Register.jsx";
|
||||
import Settings from "./pages/Settings.jsx";
|
||||
import InviteLink from "./pages/InviteLink.jsx";
|
||||
|
||||
import AppLayout from "./components/layout/AppLayout.jsx";
|
||||
import UploadToaster from "./components/common/UploadToaster.jsx";
|
||||
import PrivateRoute from "./utils/PrivateRoute.jsx";
|
||||
|
||||
import RoleGuard from "./utils/RoleGuard.jsx";
|
||||
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<AuthProvider>
|
||||
<HouseholdProvider>
|
||||
<StoreProvider>
|
||||
<UploadQueueProvider>
|
||||
<ActionToastProvider>
|
||||
<SettingsProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
|
||||
{/* Public route */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/invite/:token" element={<InviteLink />} />
|
||||
|
||||
{/* Private routes with layout */}
|
||||
<Route
|
||||
@ -46,26 +37,21 @@ function App() {
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<GroceryList />} />
|
||||
<Route path="/manage" element={<Manage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RoleGuard allowed={[ROLES.SYSTEM_ADMIN]}>
|
||||
<RoleGuard allowed={[ROLES.ADMIN]}>
|
||||
<AdminPanel />
|
||||
</RoleGuard>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
</Routes>
|
||||
<UploadToaster />
|
||||
</BrowserRouter>
|
||||
</SettingsProvider>
|
||||
</ActionToastProvider>
|
||||
</UploadQueueProvider>
|
||||
</StoreProvider>
|
||||
</HouseholdProvider>
|
||||
</AuthProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@ -9,8 +9,3 @@ export const registerRequest = async (username, password, name) => {
|
||||
const res = await api.post("/auth/register", { username, password, name });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const logoutRequest = async () => {
|
||||
const res = await api.post("/auth/logout");
|
||||
return res.data;
|
||||
};
|
||||
|
||||
@ -3,7 +3,6 @@ import { API_BASE_URL } from "../config";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
@ -18,39 +17,10 @@ api.interceptors.request.use((config => {
|
||||
}));
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
const payload = response.data;
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
!Array.isArray(payload) &&
|
||||
Object.keys(payload).length === 2 &&
|
||||
Object.prototype.hasOwnProperty.call(payload, "data") &&
|
||||
Object.prototype.hasOwnProperty.call(payload, "request_id")
|
||||
) {
|
||||
response.request_id = payload.request_id;
|
||||
response.data = payload.data;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
response => response,
|
||||
error => {
|
||||
const payload = error.response?.data;
|
||||
const normalizedMessage = payload?.error?.message || payload?.message;
|
||||
|
||||
if (payload?.error?.message && payload.message === undefined) {
|
||||
payload.message = payload.error.message;
|
||||
}
|
||||
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
window.location.pathname !== "/login" &&
|
||||
window.location.pathname !== "/register" &&
|
||||
[
|
||||
"Invalid or expired token",
|
||||
"Invalid or expired session",
|
||||
"Missing authentication",
|
||||
].includes(normalizedMessage)
|
||||
) {
|
||||
if (error.response?.status === 401 &&
|
||||
error.response?.data?.message === "Invalid or expired token") {
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
alert("Your session has expired. Please log in again.");
|
||||
|
||||
@ -1,93 +0,0 @@
|
||||
import api from "./axios";
|
||||
|
||||
/**
|
||||
* Get all households for the current user
|
||||
*/
|
||||
export const getUserHouseholds = () => api.get("/households");
|
||||
|
||||
/**
|
||||
* Get details of a specific household
|
||||
*/
|
||||
export const getHousehold = (householdId) => api.get(`/households/${householdId}`);
|
||||
|
||||
/**
|
||||
* Create a new household
|
||||
*/
|
||||
export const createHousehold = (name) => api.post("/households", { name });
|
||||
|
||||
/**
|
||||
* Update household name
|
||||
*/
|
||||
export const updateHousehold = (householdId, name) =>
|
||||
api.patch(`/households/${householdId}`, { name });
|
||||
|
||||
/**
|
||||
* Delete a household
|
||||
*/
|
||||
export const deleteHousehold = (householdId) =>
|
||||
api.delete(`/households/${householdId}`);
|
||||
|
||||
/**
|
||||
* Refresh household invite code
|
||||
*/
|
||||
export const refreshInviteCode = (householdId) =>
|
||||
api.post(`/households/${householdId}/invite/refresh`);
|
||||
|
||||
/**
|
||||
* Join a household using invite code
|
||||
*/
|
||||
export const joinHousehold = (inviteCode) =>
|
||||
api.post(`/households/join/${inviteCode}`);
|
||||
|
||||
/**
|
||||
* Get household members
|
||||
*/
|
||||
export const getHouseholdMembers = (householdId) =>
|
||||
api.get(`/households/${householdId}/members`);
|
||||
|
||||
/**
|
||||
* Update member role
|
||||
*/
|
||||
export const updateMemberRole = (householdId, userId, role) =>
|
||||
api.patch(`/households/${householdId}/members/${userId}/role`, { role });
|
||||
|
||||
/**
|
||||
* Remove member from household
|
||||
*/
|
||||
export const removeMember = (householdId, userId) =>
|
||||
api.delete(`/households/${householdId}/members/${userId}`);
|
||||
|
||||
function groupHeaders(groupId) {
|
||||
return {
|
||||
headers: {
|
||||
"x-group-id": String(groupId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const getGroupInviteLinks = (groupId) =>
|
||||
api.get("/api/groups/invites", groupHeaders(groupId));
|
||||
|
||||
export const createGroupInviteLink = (groupId, payload) =>
|
||||
api.post("/api/groups/invites", payload, groupHeaders(groupId));
|
||||
|
||||
export const revokeGroupInviteLink = (groupId, linkId) =>
|
||||
api.post("/api/groups/invites/revoke", { linkId }, groupHeaders(groupId));
|
||||
|
||||
export const reviveGroupInviteLink = (groupId, linkId, ttlDays) =>
|
||||
api.post("/api/groups/invites/revive", { linkId, ttlDays }, groupHeaders(groupId));
|
||||
|
||||
export const deleteGroupInviteLink = (groupId, linkId) =>
|
||||
api.post("/api/groups/invites/delete", { linkId }, groupHeaders(groupId));
|
||||
|
||||
export const getGroupJoinPolicy = (groupId) =>
|
||||
api.get("/api/groups/join-policy", groupHeaders(groupId));
|
||||
|
||||
export const setGroupJoinPolicy = (groupId, joinPolicy) =>
|
||||
api.post("/api/groups/join-policy", { joinPolicy }, groupHeaders(groupId));
|
||||
|
||||
export const getInviteLinkSummary = (token) =>
|
||||
api.get(`/api/invite-links/${encodeURIComponent(token)}`);
|
||||
|
||||
export const acceptInviteLink = (token) =>
|
||||
api.post(`/api/invite-links/${encodeURIComponent(token)}`);
|
||||
@ -1,143 +1,46 @@
|
||||
import api from "./axios";
|
||||
|
||||
/**
|
||||
* Get grocery list for household and store
|
||||
*/
|
||||
export const getList = (householdId, storeId) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list`);
|
||||
export const getList = () => api.get("/list");
|
||||
export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } });
|
||||
|
||||
/**
|
||||
* Get specific item by name
|
||||
*/
|
||||
export const getItemByName = (householdId, storeId, itemName) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/item`, {
|
||||
params: { item_name: itemName }
|
||||
});
|
||||
|
||||
/**
|
||||
* Add item to list
|
||||
*/
|
||||
export const addItem = (
|
||||
householdId,
|
||||
storeId,
|
||||
itemName,
|
||||
quantity,
|
||||
imageFile = null,
|
||||
notes = null,
|
||||
addedForUserId = null
|
||||
) => {
|
||||
export const addItem = (itemName, quantity, imageFile = null) => {
|
||||
const formData = new FormData();
|
||||
formData.append("item_name", itemName);
|
||||
formData.append("itemName", itemName);
|
||||
formData.append("quantity", quantity);
|
||||
if (notes) {
|
||||
formData.append("notes", notes);
|
||||
}
|
||||
if (addedForUserId != null) {
|
||||
formData.append("added_for_user_id", addedForUserId);
|
||||
}
|
||||
|
||||
if (imageFile) {
|
||||
formData.append("image", imageFile);
|
||||
}
|
||||
|
||||
return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, {
|
||||
return api.post("/list/add", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
};
|
||||
export const getClassification = (id) => api.get(`/list/item/${id}/classification`);
|
||||
|
||||
/**
|
||||
* Get item classification
|
||||
*/
|
||||
export const getClassification = (householdId, storeId, itemName) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/classification`, {
|
||||
params: { item_name: itemName }
|
||||
});
|
||||
|
||||
/**
|
||||
* Set item classification
|
||||
*/
|
||||
export const setClassification = (householdId, storeId, itemName, classification) =>
|
||||
api.post(`/households/${householdId}/stores/${storeId}/list/classification`, {
|
||||
item_name: itemName,
|
||||
classification
|
||||
});
|
||||
|
||||
/**
|
||||
* Update item with classification (legacy method - split into separate calls)
|
||||
*/
|
||||
export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => {
|
||||
// This is now two operations: update item + set classification
|
||||
return Promise.all([
|
||||
updateItem(householdId, storeId, itemName, quantity),
|
||||
classification ? setClassification(householdId, storeId, itemName, classification) : Promise.resolve()
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update item details (quantity, notes)
|
||||
*/
|
||||
export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
|
||||
api.put(`/households/${householdId}/stores/${storeId}/list/item`, {
|
||||
item_name: itemName,
|
||||
quantity,
|
||||
notes
|
||||
});
|
||||
|
||||
/**
|
||||
* Mark item as bought or unbought
|
||||
*/
|
||||
export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) =>
|
||||
api.patch(`/households/${householdId}/stores/${storeId}/list/item`, {
|
||||
item_name: itemName,
|
||||
bought,
|
||||
quantity_bought: quantityBought
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete item from list
|
||||
*/
|
||||
export const deleteItem = (householdId, storeId, itemName) =>
|
||||
api.delete(`/households/${householdId}/stores/${storeId}/list/item`, {
|
||||
data: { item_name: itemName }
|
||||
});
|
||||
|
||||
/**
|
||||
* Get suggestions based on query
|
||||
*/
|
||||
export const getSuggestions = (householdId, storeId, query) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, {
|
||||
params: { query }
|
||||
});
|
||||
|
||||
/**
|
||||
* Get recently bought items
|
||||
*/
|
||||
export const getRecentlyBought = (householdId, storeId) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/recent`);
|
||||
|
||||
/**
|
||||
* Update item image
|
||||
*/
|
||||
export const updateItemImage = (
|
||||
householdId,
|
||||
storeId,
|
||||
export const updateItemWithClassification = (id, itemName, quantity, classification) => {
|
||||
return api.put(`/list/item/${id}`, {
|
||||
itemName,
|
||||
quantity,
|
||||
imageFile,
|
||||
options = {}
|
||||
) => {
|
||||
classification
|
||||
});
|
||||
};
|
||||
export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity });
|
||||
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
||||
export const getRecentlyBought = () => api.get("/list/recently-bought");
|
||||
|
||||
export const updateItemImage = (id, itemName, quantity, imageFile) => {
|
||||
const formData = new FormData();
|
||||
formData.append("item_name", itemName);
|
||||
formData.append("id", id);
|
||||
formData.append("itemName", itemName);
|
||||
formData.append("quantity", quantity);
|
||||
formData.append("image", imageFile);
|
||||
|
||||
return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, {
|
||||
return api.post("/list/update-image", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
onUploadProgress: options.onUploadProgress,
|
||||
signal: options.signal,
|
||||
timeout: options.timeoutMs,
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user