Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c8c197e17 | |||
| 35d0fc72cc | |||
|
|
68265c898e | ||
|
|
9a0a0a2715 | ||
|
|
47f306b6ac | ||
|
|
d271a0e34a | ||
|
|
cae32b9fa4 | ||
|
|
f491826a05 | ||
|
|
467ad2b036 | ||
|
|
0f01da92b6 | ||
|
|
c681312eba | ||
|
|
921952eb2c | ||
|
|
e793a59570 | ||
|
|
6b3d267abb | ||
|
|
f45473cbff | ||
|
|
d1c4fcdfe6 | ||
|
|
905f7256fa | ||
| 76817fb969 | |||
|
|
a2c08aff45 | ||
|
|
e4774ecd6a | ||
|
|
d4b1de452c | ||
|
|
d2fe873956 | ||
|
|
767def37cf | ||
|
|
5a2848ebcf | ||
|
|
cfec916392 | ||
|
|
1e63ed9726 | ||
|
|
12b514262e | ||
|
|
af0d95432f | ||
|
|
9bdf2247f4 | ||
|
|
4aff7e78f2 | ||
|
|
93e3d42edc | ||
|
|
d31fb6c79f | ||
|
|
ca0b4897cb | ||
|
|
043460ac21 | ||
|
|
74913c3435 | ||
|
|
5510401635 | ||
|
|
dc422f6127 | ||
|
|
bd945568c8 | ||
|
|
5693570f33 | ||
|
|
f6a66a37ea | ||
|
|
7c8c655cba | ||
|
|
36277a9e67 | ||
|
|
15c3ea279c | ||
|
|
254d166e84 | ||
|
|
41d08f1286 | ||
|
|
084ffe7099 | ||
|
|
104519668a | ||
|
|
033dd5dc33 | ||
|
|
86eebcc6f4 | ||
|
|
77ae5be445 | ||
|
|
ee94853084 | ||
|
|
3dd58f51e8 | ||
|
|
beb9cdcec7 | ||
|
|
9fa48e6eb3 | ||
|
|
a1beb486cb | ||
|
|
d62564fd0d | ||
|
|
c1259f0bf5 | ||
|
|
c3c0c33339 | ||
|
|
3469284e98 | ||
|
|
aa9488755f | ||
|
|
119994b602 | ||
|
|
0f9d349fa5 | ||
|
|
9cb0ac19e5 | ||
|
|
e2e9ec9eb4 | ||
|
|
05ad576206 | ||
|
|
16e60dcf63 | ||
|
|
2a9389532f | ||
|
|
9a73cea27d | ||
|
|
fec9f1ab25 | ||
|
|
a5f99ba475 | ||
|
|
ac92bed8a1 | ||
|
|
b3f607d8f8 | ||
|
|
7fb28e659f | ||
|
|
dfaab1dfcb | ||
|
|
e9b678c7be | ||
|
|
872945c747 | ||
|
|
78bbcde97f | ||
|
|
67d681114f | ||
|
|
11f23eb643 | ||
|
|
31eda793ab | ||
|
|
213134c4a5 | ||
|
|
9fc25f2274 | ||
|
|
4d5d2f0f6d | ||
|
|
ccf0c39294 | ||
|
|
fc887bdc65 | ||
|
|
1281c91c28 | ||
|
|
889914a57f | ||
|
|
fa41f12e3d | ||
|
|
aea07374d9 | ||
|
|
68976a7683 | ||
|
|
4139a07cd2 | ||
|
|
5ce4177446 | ||
|
|
0c16d22c1e | ||
|
|
bc7e212eea | ||
|
|
1300cbb0a8 | ||
|
|
ce2574c454 | ||
|
|
8d5b2d3ea3 | ||
|
|
2838cb5806 | ||
|
|
a38c29b5b5 | ||
|
|
cd06dbd9fc | ||
|
|
b5c530cfad | ||
|
|
7c58ab57f7 | ||
|
|
471ffae088 | ||
|
|
4b2bfd4be8 | ||
|
|
7adbbc1d3f | ||
|
|
cbfb835b4f | ||
| 0b0283127b |
40
.agents/skills/fiddy-verify/SKILL.md
Normal file
40
.agents/skills/fiddy-verify/SKILL.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: fiddy-verify
|
||||||
|
description: Run and report the Fiddy repository verification loop. Use when Codex is finishing changes, checking repo health, validating docs/scripts/config updates, or deciding which lint/typecheck/test/build commands are appropriate for this Express/Vite/npm project.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fiddy Verification
|
||||||
|
|
||||||
|
Use this workflow from the repo root.
|
||||||
|
|
||||||
|
## Before Running Commands
|
||||||
|
- Read `AGENTS.md`, `PROJECT_INSTRUCTIONS.md`, and any doc related to the touched area.
|
||||||
|
- Check `git status --short --branch` so user work is not mistaken for Codex changes.
|
||||||
|
- Do not print real `.env` values. Inspect keys only if environment context is needed.
|
||||||
|
- Do not run DB migrations unless the user explicitly asked for migration execution.
|
||||||
|
|
||||||
|
## Choose Checks
|
||||||
|
- Docs-only changes: validate affected Markdown links/content where practical, then run JSON/script sanity checks if package files changed.
|
||||||
|
- Root or frontend script changes: run `npm run lint`, `npm run typecheck`, and the relevant build command.
|
||||||
|
- Backend behavior changes: run `npm test`; add focused Jest/Supertest coverage for changed API behavior.
|
||||||
|
- Frontend behavior changes: run `npm run lint`, `npm run typecheck`, and focused Playwright tests when a browser flow changed.
|
||||||
|
- Dependency or lockfile changes: run `npm run audit` after install/update commands.
|
||||||
|
- Migration changes: run `npm run db:migrate:stale:check` and `npm run db:migrate:verify` only against the intended external DB environment.
|
||||||
|
|
||||||
|
## Default Safe Loop
|
||||||
|
Run these when dependencies are already installed and the touched files justify them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm run audit
|
||||||
|
npm test
|
||||||
|
npm run build:backend
|
||||||
|
npm run build:frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Report
|
||||||
|
- List exact commands run and pass/fail.
|
||||||
|
- For failures, include the short relevant error and whether it appears caused by the current changes.
|
||||||
|
- If a check is skipped, state the concrete reason.
|
||||||
|
- End with unresolved risks and the smallest useful next step.
|
||||||
@ -5,7 +5,7 @@ on:
|
|||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.nicosaya.com/nalalangan/costco-grocery-list
|
REGISTRY: git.nicosaya.com/nalalangan/grocery-app
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@ -13,26 +13,34 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22.12.0
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 BACKEND TESTS
|
# Verification gate
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Install backend dependencies
|
- name: Install dependencies
|
||||||
working-directory: backend
|
run: |
|
||||||
run: npm ci
|
npm ci
|
||||||
|
npm --prefix backend ci
|
||||||
|
npm --prefix frontend ci
|
||||||
|
|
||||||
- name: Run backend tests
|
- name: Run reliability verification
|
||||||
working-directory: backend
|
run: |
|
||||||
run: npm test --if-present
|
npm run audit
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run db:migrate:stale:check
|
||||||
|
npm run build:backend
|
||||||
|
npm run build:frontend
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 Docker Login
|
# Docker Login
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Docker login
|
- name: Docker login
|
||||||
run: |
|
run: |
|
||||||
@ -40,7 +48,7 @@ jobs:
|
|||||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 Build Backend Image
|
# Build Backend Image
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Build Backend Image
|
- name: Build Backend Image
|
||||||
run: |
|
run: |
|
||||||
@ -55,14 +63,14 @@ jobs:
|
|||||||
docker push $REGISTRY/backend:latest
|
docker push $REGISTRY/backend:latest
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# 🔹 Build Frontend Image
|
# Build Frontend Image
|
||||||
# -------------------------
|
# -------------------------
|
||||||
- name: Build Frontend Image
|
- name: Build Frontend Image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
-t $REGISTRY/frontend:${{ github.sha }} \
|
-t $REGISTRY/frontend:${{ github.sha }} \
|
||||||
-t $REGISTRY/frontend:latest \
|
-t $REGISTRY/frontend:latest \
|
||||||
-f frontend/Dockerfile frontend/
|
-f frontend/Dockerfile.dev frontend/
|
||||||
|
|
||||||
- name: Push Frontend Image
|
- name: Push Frontend Image
|
||||||
run: |
|
run: |
|
||||||
@ -75,7 +83,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install SSH key
|
- name: Install SSH key
|
||||||
run: |
|
run: |
|
||||||
@ -117,12 +125,11 @@ jobs:
|
|||||||
echo "Deployment job finished with status: $STATUS"
|
echo "Deployment job finished with status: $STATUS"
|
||||||
|
|
||||||
if [ "$STATUS" = "success" ]; then
|
if [ "$STATUS" = "success" ]; then
|
||||||
MSG="🚀 Costco App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}"
|
MSG="Costco App Deployment succeeded: $REGISTRY:${{ github.sha }}"
|
||||||
else
|
else
|
||||||
MSG="❌ Costco App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}"
|
MSG="Costco App Deployment FAILED: $REGISTRY:${{ github.sha }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -d "$MSG" \
|
curl -d "$MSG" \
|
||||||
https://ntfy.nicosaya.com/gitea
|
https://ntfy.nicosaya.com/gitea
|
||||||
|
|
||||||
|
|
||||||
157
.gitea/workflows/new-deploy.yml
Normal file
157
.gitea/workflows/new-deploy.yml
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
name: Build & Deploy Costco Grocery List
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main-new" ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.nicosaya.com/nalalangan/grocery-app
|
||||||
|
# REGISTRY: grocery-app
|
||||||
|
IMAGE_TAG: main-new
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.12.0
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Verification gate
|
||||||
|
# -------------------------
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm --prefix backend ci
|
||||||
|
npm --prefix frontend ci
|
||||||
|
|
||||||
|
- name: Run reliability verification
|
||||||
|
run: |
|
||||||
|
npm run audit
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run db:migrate:stale:check
|
||||||
|
npm run build:backend
|
||||||
|
npm run build:frontend
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Docker Login
|
||||||
|
# -------------------------
|
||||||
|
- name: Docker login
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY \
|
||||||
|
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Build Backend Image
|
||||||
|
# -------------------------
|
||||||
|
- name: Build Backend Image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-t $REGISTRY/backend:${{ github.sha }} \
|
||||||
|
-t $REGISTRY/backend:${{ env.IMAGE_TAG }} \
|
||||||
|
-f backend/Dockerfile backend/
|
||||||
|
|
||||||
|
- name: Push Backend Image
|
||||||
|
run: |
|
||||||
|
docker push $REGISTRY/backend:${{ github.sha }}
|
||||||
|
docker push $REGISTRY/backend:${{ env.IMAGE_TAG }}
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Build Frontend Image
|
||||||
|
# -------------------------
|
||||||
|
- name: Build Frontend Image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-t $REGISTRY/frontend:${{ github.sha }} \
|
||||||
|
-t $REGISTRY/frontend:${{ env.IMAGE_TAG }} \
|
||||||
|
-f frontend/Dockerfile.dev frontend/
|
||||||
|
|
||||||
|
- name: Push Frontend Image
|
||||||
|
run: |
|
||||||
|
docker push $REGISTRY/frontend:${{ github.sha }}
|
||||||
|
docker push $REGISTRY/frontend:${{ env.IMAGE_TAG }}
|
||||||
|
|
||||||
|
verify-images:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Docker login
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY \
|
||||||
|
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Verify backend image tags exist
|
||||||
|
run: |
|
||||||
|
docker manifest inspect $REGISTRY/backend:${{ github.sha }} >/dev/null
|
||||||
|
docker manifest inspect $REGISTRY/backend:${{ env.IMAGE_TAG }} >/dev/null
|
||||||
|
|
||||||
|
- name: Verify frontend image tags exist
|
||||||
|
run: |
|
||||||
|
docker manifest inspect $REGISTRY/frontend:${{ github.sha }} >/dev/null
|
||||||
|
docker manifest inspect $REGISTRY/frontend:${{ env.IMAGE_TAG }} >/dev/null
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: verify-images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install SSH key
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 1. Upload docker-compose.yml to the production directory
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
- name: Upload docker-compose.yml
|
||||||
|
run: |
|
||||||
|
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "mkdir -p /opt/costco-app-new"
|
||||||
|
scp docker-compose.new.yml \
|
||||||
|
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/costco-app-new/docker-compose.yml
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 2. Deploy using the uploaded compose file
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
- name: Deploy via SSH
|
||||||
|
run: |
|
||||||
|
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
||||||
|
cd /opt/costco-app-new
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --remove-orphans
|
||||||
|
docker image prune -f
|
||||||
|
EOF
|
||||||
|
|
||||||
|
notify:
|
||||||
|
needs: deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: always()
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Notify ntfy
|
||||||
|
run: |
|
||||||
|
STATUS="${{ needs.deploy.result }}"
|
||||||
|
echo "Deployment job finished with status: $STATUS"
|
||||||
|
|
||||||
|
if [ "$STATUS" = "success" ]; then
|
||||||
|
MSG="Grocery App Deployment succeeded: $REGISTRY:${{ github.sha }}"
|
||||||
|
else
|
||||||
|
MSG="Grocery App Deployment FAILED: $REGISTRY:${{ github.sha }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -d "$MSG" \
|
||||||
|
https://ntfy.nicosaya.com/gitea
|
||||||
|
|
||||||
208
.github/copilot-instructions.md
vendored
208
.github/copilot-instructions.md
vendored
@ -1,197 +1,21 @@
|
|||||||
# Costco Grocery List - AI Agent Instructions
|
# Copilot Compatibility Instructions
|
||||||
|
|
||||||
## Architecture Overview
|
## Precedence
|
||||||
|
- Source of truth: `PROJECT_INSTRUCTIONS.md` (repo root).
|
||||||
|
- Agent workflow constraints: `AGENTS.md` (repo root).
|
||||||
|
- Bugfix protocol: `DEBUGGING_INSTRUCTIONS.md` (repo root).
|
||||||
|
|
||||||
This is a full-stack grocery list management app with **role-based access control (RBAC)**:
|
If any guidance in this file conflicts with the root instruction files, follow the root instruction files.
|
||||||
- **Backend**: Node.js + Express + PostgreSQL (port 5000)
|
|
||||||
- **Frontend**: React 19 + TypeScript + Vite (port 3000/5173)
|
|
||||||
- **Deployment**: Docker Compose with separate dev/prod configurations
|
|
||||||
|
|
||||||
### Key Design Patterns
|
## Current stack note
|
||||||
|
This repository is currently:
|
||||||
|
- Backend: Express (`backend/`)
|
||||||
|
- Frontend: React + Vite (`frontend/`)
|
||||||
|
|
||||||
**Three-tier RBAC system** (`viewer`, `editor`, `admin`):
|
Apply architecture intent from `PROJECT_INSTRUCTIONS.md` using the current stack mapping in:
|
||||||
- `viewer`: Read-only access to grocery lists
|
- `docs/AGENTIC_CONTRACT_MAP.md`
|
||||||
- `editor`: Can add items and mark as bought
|
|
||||||
- `admin`: Full user management via admin panel
|
|
||||||
- Roles defined in [backend/models/user.model.js](backend/models/user.model.js) and mirrored in [frontend/src/constants/roles.js](frontend/src/constants/roles.js)
|
|
||||||
|
|
||||||
**Middleware chain pattern** for protected routes:
|
## Safety reminders
|
||||||
```javascript
|
- External DB only (`DATABASE_URL`), no DB container assumptions.
|
||||||
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem);
|
- No cron/worker additions unless explicitly approved.
|
||||||
```
|
- Never log secrets, receipt bytes, or full invite codes.
|
||||||
- `auth` middleware extracts JWT from `Authorization: Bearer <token>` header
|
|
||||||
- `requireRole` checks if user's role matches allowed roles
|
|
||||||
- See [backend/routes/list.routes.js](backend/routes/list.routes.js) for examples
|
|
||||||
|
|
||||||
**Frontend route protection**:
|
|
||||||
- `<PrivateRoute>`: Requires authentication, redirects to `/login` if no token
|
|
||||||
- `<RoleGuard allowed={[ROLES.ADMIN]}>`: Requires specific role(s), redirects to `/` if unauthorized
|
|
||||||
- Example in [frontend/src/App.jsx](frontend/src/App.jsx)
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
**PostgreSQL server runs externally** - not in Docker Compose. Connection configured in [backend/.env](backend/.env) via standard environment variables.
|
|
||||||
|
|
||||||
**Tables** (inferred from models, no formal migrations):
|
|
||||||
- **users**: `id`, `username`, `password` (bcrypt hashed), `name`, `role`
|
|
||||||
- **grocery_list**: `id`, `item_name`, `quantity`, `bought`, `added_by`
|
|
||||||
- **grocery_history**: Junction table tracking which users added which items
|
|
||||||
|
|
||||||
**Important patterns**:
|
|
||||||
- No migration system - schema changes are manual SQL
|
|
||||||
- Items use case-insensitive matching (`ILIKE`) to prevent duplicates
|
|
||||||
- JOINs with `ARRAY_AGG` for multi-contributor queries (see [backend/models/list.model.js](backend/models/list.model.js))
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
```bash
|
|
||||||
# Start all services with hot-reload against LOCAL database
|
|
||||||
docker-compose -f docker-compose.dev.yml up
|
|
||||||
|
|
||||||
# Backend runs nodemon (watches backend/*.js)
|
|
||||||
# Frontend runs Vite dev server with HMR on port 3000
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key dev setup details**:
|
|
||||||
- Volume mounts preserve `node_modules` in containers while syncing source code
|
|
||||||
- Backend uses `Dockerfile` (standard) with `npm run dev` override
|
|
||||||
- Frontend uses `Dockerfile.dev` with `CHOKIDAR_USEPOLLING=true` for file watching
|
|
||||||
- Both connect to **external PostgreSQL server** (configured in `backend/.env`)
|
|
||||||
- No database container in compose - DB is managed separately
|
|
||||||
|
|
||||||
### Production Build
|
|
||||||
```bash
|
|
||||||
# Local production build (for testing)
|
|
||||||
docker-compose -f docker-compose.prod.yml up --build
|
|
||||||
|
|
||||||
# Actual production uses pre-built images
|
|
||||||
docker-compose up # Pulls from private registry
|
|
||||||
```
|
|
||||||
|
|
||||||
### CI/CD Pipeline (Gitea Actions)
|
|
||||||
|
|
||||||
See [.gitea/workflows/deploy.yml](.gitea/workflows/deploy.yml) for full workflow:
|
|
||||||
|
|
||||||
**Build stage** (on push to `main`):
|
|
||||||
1. Run backend tests (`npm test --if-present`)
|
|
||||||
2. Build backend image with tags: `:latest` and `:<commit-sha>`
|
|
||||||
3. Build frontend image with tags: `:latest` and `:<commit-sha>`
|
|
||||||
4. Push both images to private registry
|
|
||||||
|
|
||||||
**Deploy stage**:
|
|
||||||
1. SSH to production server
|
|
||||||
2. Upload `docker-compose.yml` to deployment directory
|
|
||||||
3. Pull latest images and restart containers with `docker compose up -d`
|
|
||||||
4. Prune old images
|
|
||||||
|
|
||||||
**Notify stage**:
|
|
||||||
- Sends deployment status via webhook
|
|
||||||
|
|
||||||
**Required secrets**:
|
|
||||||
- `REGISTRY_USER`, `REGISTRY_PASS`: Docker registry credentials
|
|
||||||
- `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_KEY`: SSH deployment credentials
|
|
||||||
|
|
||||||
### Backend Scripts
|
|
||||||
- `npm run dev`: Start with nodemon
|
|
||||||
- `npm run build`: esbuild compilation + copy public assets to `dist/`
|
|
||||||
- `npm test`: Run Jest tests (currently no tests exist)
|
|
||||||
|
|
||||||
### Frontend Scripts
|
|
||||||
- `npm run dev`: Vite dev server (port 5173)
|
|
||||||
- `npm run build`: TypeScript compilation + Vite production build
|
|
||||||
|
|
||||||
### Docker Configurations
|
|
||||||
|
|
||||||
**docker-compose.yml** (production):
|
|
||||||
- Pulls pre-built images from private registry
|
|
||||||
- Backend on port 5000, frontend on port 3000 (nginx serves on port 80)
|
|
||||||
- Requires `backend.env` and `frontend.env` files
|
|
||||||
|
|
||||||
**docker-compose.dev.yml** (local development):
|
|
||||||
- Builds images locally from Dockerfile/Dockerfile.dev
|
|
||||||
- Volume mounts for hot-reload: `./backend:/app` and `./frontend:/app`
|
|
||||||
- Named volumes preserve `node_modules` between rebuilds
|
|
||||||
- Backend uses `backend/.env` directly
|
|
||||||
- Frontend uses `Dockerfile.dev` with polling enabled for cross-platform compatibility
|
|
||||||
|
|
||||||
**docker-compose.prod.yml** (local production testing):
|
|
||||||
- Builds images locally using production Dockerfiles
|
|
||||||
- Backend: Standard Node.js server
|
|
||||||
- Frontend: Multi-stage build with nginx serving static files
|
|
||||||
|
|
||||||
## Configuration & Environment
|
|
||||||
|
|
||||||
**Backend** ([backend/.env](backend/.env)):
|
|
||||||
- Database connection variables (host, user, password, database name)
|
|
||||||
- `JWT_SECRET`: Token signing key
|
|
||||||
- `ALLOWED_ORIGINS`: Comma-separated CORS whitelist (supports static origins + `192.168.*.*` IP ranges)
|
|
||||||
- `PORT`: Server port (default 5000)
|
|
||||||
|
|
||||||
**Frontend** (environment variables):
|
|
||||||
- `VITE_API_URL`: Backend base URL
|
|
||||||
|
|
||||||
**Config accessed via**:
|
|
||||||
- Backend: `process.env.VAR_NAME`
|
|
||||||
- Frontend: `import.meta.env.VITE_VAR_NAME` (see [frontend/src/config.ts](frontend/src/config.ts))
|
|
||||||
|
|
||||||
## Authentication Flow
|
|
||||||
|
|
||||||
1. User logs in → backend returns `{token, role, username}` ([backend/controllers/auth.controller.js](backend/controllers/auth.controller.js))
|
|
||||||
2. Frontend stores in `localStorage` and `AuthContext` ([frontend/src/context/AuthContext.jsx](frontend/src/context/AuthContext.jsx))
|
|
||||||
3. Axios interceptor auto-attaches `Authorization: Bearer <token>` header ([frontend/src/api/axios.js](frontend/src/api/axios.js))
|
|
||||||
4. Backend validates JWT on protected routes ([backend/middleware/auth.js](backend/middleware/auth.js))
|
|
||||||
5. On 401 "Invalid or expired token" response, frontend clears storage and redirects to login
|
|
||||||
|
|
||||||
## Critical Conventions
|
|
||||||
|
|
||||||
### Security Practices
|
|
||||||
- **Never expose credentials**: Do not hardcode or document actual values for `JWT_SECRET`, database passwords, API keys, or any sensitive configuration
|
|
||||||
- **No infrastructure details**: Avoid documenting specific IP addresses, domain names, deployment paths, or server locations in code or documentation
|
|
||||||
- **Environment variables**: Reference `.env` files conceptually - never include actual contents
|
|
||||||
- **Secrets in CI/CD**: Document that secrets are required, not their values
|
|
||||||
- **Code review**: Scan all changes for accidentally committed credentials before pushing
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- **No SQL injection**: Always use parameterized queries (`$1`, `$2`, etc.) with [backend/db/pool.js](backend/db/pool.js)
|
|
||||||
- **Password hashing**: Use `bcryptjs` for hashing (see [backend/controllers/auth.controller.js](backend/controllers/auth.controller.js))
|
|
||||||
- **CORS**: Dynamic origin validation in [backend/app.js](backend/app.js) allows configured origins + local IPs
|
|
||||||
- **Error responses**: Return JSON with `{message: "..."}` structure
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **Mixed JSX/TSX**: Some components are `.jsx` (JavaScript), others `.tsx` (TypeScript) - maintain existing file extensions
|
|
||||||
- **API calls**: Use centralized `api` instance from [frontend/src/api/axios.js](frontend/src/api/axios.js), not raw axios
|
|
||||||
- **Role checks**: Access role from `AuthContext`, compare with constants from [frontend/src/constants/roles.js](frontend/src/constants/roles.js)
|
|
||||||
- **Navigation**: Use React Router's `<Navigate>` for redirects, not `window.location` (except in interceptor)
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
**Add a new protected route**:
|
|
||||||
1. Backend: Add route with `auth` + `requireRole(...)` middleware
|
|
||||||
2. Frontend: Add route in [frontend/src/App.jsx](frontend/src/App.jsx) wrapped in `<PrivateRoute>` and/or `<RoleGuard>`
|
|
||||||
|
|
||||||
**Access user info in backend controller**:
|
|
||||||
```javascript
|
|
||||||
const { id, role } = req.user; // Set by auth middleware
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query grocery items with contributors**:
|
|
||||||
Use the JOIN pattern in [backend/models/list.model.js](backend/models/list.model.js) - aggregates user names via `grocery_history` table.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**Backend**:
|
|
||||||
- Jest configured at root level ([package.json](package.json))
|
|
||||||
- Currently **no test files exist** - testing infrastructure needs development
|
|
||||||
- CI/CD runs `npm test --if-present` but will pass if no tests found
|
|
||||||
- Focus area: API endpoint testing (use `supertest` with Express)
|
|
||||||
|
|
||||||
**Frontend**:
|
|
||||||
- ESLint only (see [frontend/eslint.config.js](frontend/eslint.config.js))
|
|
||||||
- No test runner configured
|
|
||||||
- Manual testing workflow in use
|
|
||||||
|
|
||||||
**To add backend tests**:
|
|
||||||
1. Create `backend/__tests__/` directory
|
|
||||||
2. Use Jest + Supertest pattern for API tests
|
|
||||||
3. Mock database calls or use test database
|
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,5 +1,6 @@
|
|||||||
# Environment variables (DO NOT COMMIT)
|
# Environment variables (DO NOT COMMIT)
|
||||||
.env
|
.env
|
||||||
|
.codex-local.env
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
@ -7,6 +8,10 @@ node_modules/
|
|||||||
# Build output (if using a bundler or React later)
|
# Build output (if using a bundler or React later)
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.npm-cache/
|
||||||
|
.playwright-browsers/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
1
.vscode-extensions/extensions.json
Normal file
1
.vscode-extensions/extensions.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
12
.vscode-user/logs/20260219T013031/cli.log
Normal file
12
.vscode-user/logs/20260219T013031/cli.log
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
2026-02-19 01:30:31.762 [error] Error: Unable to create or open registry key
|
||||||
|
at Object.setDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\storage.js:82:25)
|
||||||
|
at Module.getDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\devdeviceid.js:46:23)
|
||||||
|
at async U9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11451)
|
||||||
|
at async F9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11886)
|
||||||
|
at async e7.f (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:50696)
|
||||||
|
at async e7.run (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:49285)
|
||||||
|
at async Module.t7 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:53186)
|
||||||
|
at async kn (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/cli.js:29:16018)
|
||||||
|
2026-02-19 01:30:31.767 [info] CLI main {"_":[],"diff":false,"merge":false,"add":false,"remove":false,"goto":false,"new-window":false,"reuse-window":false,"wait":false,"user-data-dir":".vscode-user","help":false,"extensions-dir":".vscode-extensions","list-extensions":true,"show-versions":false,"pre-release":false,"update-extensions":false,"version":false,"verbose":false,"status":false,"prof-startup":false,"no-cached-data":false,"prof-v8-extensions":false,"disable-extensions":false,"disable-lcd-text":false,"disable-gpu":false,"disable-chromium-sandbox":false,"sandbox":false,"telemetry":false,"debugRenderer":false,"enable-smoke-test-driver":false,"logExtensionHostCommunication":false,"skip-release-notes":false,"skip-welcome":false,"disable-telemetry":false,"disable-updates":false,"transient":false,"use-inmemory-secretstorage":false,"disable-workspace-trust":false,"disable-crash-reporter":false,"skip-add-to-recently-opened":false,"open-url":false,"file-write":false,"file-chmod":false,"force":false,"do-not-sync":false,"do-not-include-pack-dependencies":false,"trace":false,"trace-memory-infra":false,"preserve-env":false,"force-user-env":false,"force-disable-user-env":false,"open-devtools":false,"disable-gpu-sandbox":false,"__enable-file-policy":false,"enable-coi":false,"enable-rdp-display-tracking":false,"disable-layout-restore":false,"disable-experiments":false,"no-proxy-server":false,"no-sandbox":false,"nolazy":false,"force-renderer-accessibility":false,"ignore-certificate-errors":false,"allow-insecure-localhost":false,"disable-dev-shm-usage":false,"profile-temp":false,"logsPath":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-user\\logs\\20260219T013031"}
|
||||||
|
2026-02-19 01:30:31.783 [info] Started initializing default profile extensions in extensions installation folder. file:///c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions
|
||||||
|
2026-02-19 01:30:31.817 [info] Completed initializing default profile extensions in extensions installation folder. file:///c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions
|
||||||
14
.vscode-user/logs/20260219T013038/cli.log
Normal file
14
.vscode-user/logs/20260219T013038/cli.log
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
2026-02-19 01:30:39.254 [error] Error: Unable to create or open registry key
|
||||||
|
at Object.setDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\storage.js:82:25)
|
||||||
|
at Module.getDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\devdeviceid.js:46:23)
|
||||||
|
at async U9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11451)
|
||||||
|
at async F9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11886)
|
||||||
|
at async e7.f (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:50696)
|
||||||
|
at async e7.run (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:49285)
|
||||||
|
at async Module.t7 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:53186)
|
||||||
|
at async kn (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/cli.js:29:16018)
|
||||||
|
2026-02-19 01:30:39.259 [info] CLI main {"_":[],"diff":false,"merge":false,"add":false,"remove":false,"goto":false,"new-window":false,"reuse-window":false,"wait":false,"user-data-dir":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-user","help":false,"extensions-dir":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-extensions","list-extensions":false,"show-versions":false,"install-extension":["ritwickdey.LiveServer"],"pre-release":false,"update-extensions":false,"version":false,"verbose":false,"status":false,"prof-startup":false,"no-cached-data":false,"prof-v8-extensions":false,"disable-extensions":false,"disable-lcd-text":false,"disable-gpu":false,"disable-chromium-sandbox":false,"sandbox":false,"telemetry":false,"debugRenderer":false,"enable-smoke-test-driver":false,"logExtensionHostCommunication":false,"skip-release-notes":false,"skip-welcome":false,"disable-telemetry":false,"disable-updates":false,"transient":false,"use-inmemory-secretstorage":false,"disable-workspace-trust":false,"disable-crash-reporter":false,"skip-add-to-recently-opened":false,"open-url":false,"file-write":false,"file-chmod":false,"force":false,"do-not-sync":false,"do-not-include-pack-dependencies":false,"trace":false,"trace-memory-infra":false,"preserve-env":false,"force-user-env":false,"force-disable-user-env":false,"open-devtools":false,"disable-gpu-sandbox":false,"__enable-file-policy":false,"enable-coi":false,"enable-rdp-display-tracking":false,"disable-layout-restore":false,"disable-experiments":false,"no-proxy-server":false,"no-sandbox":false,"nolazy":false,"force-renderer-accessibility":false,"ignore-certificate-errors":false,"allow-insecure-localhost":false,"disable-dev-shm-usage":false,"profile-temp":false,"logsPath":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-user\\logs\\20260219T013038"}
|
||||||
|
2026-02-19 01:30:40.046 [info] Getting Manifest... ritwickdey.liveserver
|
||||||
|
2026-02-19 01:30:40.071 [info] Installing extension: ritwickdey.liveserver {"isMachineScoped":false,"installPreReleaseVersion":false,"donotIncludePackAndDependencies":false,"profileLocation":{"$mid":1,"external":"vscode-userdata:/c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions/extensions.json","path":"/C:/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions/extensions.json","scheme":"vscode-userdata"},"isBuiltin":false,"installGivenVersion":false,"isApplicationScoped":false,"productVersion":{"version":"1.109.2","date":"2026-02-10T20:18:23.520Z"}}
|
||||||
|
2026-02-19 01:30:40.581 [info] Extension signature verification result for ritwickdey.liveserver: UnknownError. Executed: false. Duration: 5ms.
|
||||||
|
2026-02-19 01:30:40.599 [error] Error while installing the extension ritwickdey.liveserver Signature verification failed with 'UnknownError' error. vscode-userdata:/c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions/extensions.json
|
||||||
1
.vscode-user/machineid
Normal file
1
.vscode-user/machineid
Normal file
@ -0,0 +1 @@
|
|||||||
|
490a70bb-d2b1-490e-9046-37c8a08b0270
|
||||||
111
AGENTS.md
Normal file
111
AGENTS.md
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# AGENTS.md - Fiddy
|
||||||
|
|
||||||
|
## Authority
|
||||||
|
- Source of truth: `PROJECT_INSTRUCTIONS.md` in the repo root.
|
||||||
|
- Bugfix protocol: `DEBUGGING_INSTRUCTIONS.md`.
|
||||||
|
- Current-stack mapping: `docs/AGENTIC_CONTRACT_MAP.md`.
|
||||||
|
- If files conflict, follow `PROJECT_INSTRUCTIONS.md`.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
- Full-stack grocery list app with household/group behavior, RBAC, image support, and Postgres persistence.
|
||||||
|
- Backend: Express 5 CommonJS API in `backend/`.
|
||||||
|
- Frontend: React 19 + Vite SPA in `frontend/`, with partial TypeScript.
|
||||||
|
- Database: external on-prem Postgres. Do not add or assume a DB container.
|
||||||
|
- Canonical migrations live in `packages/db/migrations`.
|
||||||
|
|
||||||
|
## Important Directories
|
||||||
|
- `backend/routes`, `backend/controllers`: route registration and request/response handling.
|
||||||
|
- `backend/models`, `backend/services`, `backend/middleware`, `backend/db`: DB access, domain logic, auth, RBAC, request IDs, image handling.
|
||||||
|
- `frontend/src/api`: client API wrappers using the shared Axios instance.
|
||||||
|
- `frontend/src/context`, `frontend/src/hooks`, `frontend/src/components`, `frontend/src/pages`: UI state and screens.
|
||||||
|
- `frontend/tests`: Playwright e2e tests.
|
||||||
|
- `backend/tests`: Jest/Supertest backend tests.
|
||||||
|
- `scripts`: DB migration helpers.
|
||||||
|
- `docs`: practical maps, runbooks, architecture notes, and archived implementation history.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
- Install root tools: `npm ci`
|
||||||
|
- Install backend tools: `npm --prefix backend ci`
|
||||||
|
- Install frontend tools: `npm --prefix frontend ci`
|
||||||
|
- Use Node.js 20.19+ or 22.12+ for frontend/Vite commands.
|
||||||
|
- Configure backend env from `backend/.env.example`; never commit real `.env` values.
|
||||||
|
- For migration scripts, set `DATABASE_URL` in the shell before running root DB commands.
|
||||||
|
|
||||||
|
## Run Commands
|
||||||
|
- Dev with Docker: `docker compose -f docker-compose.dev.yml up`
|
||||||
|
- Backend only: `npm run dev:backend`
|
||||||
|
- Frontend only: `npm run dev:frontend`
|
||||||
|
- Backend default port: `5000`
|
||||||
|
- Frontend Docker-mapped port: `3010`; Vite direct default is `5173` unless overridden.
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
- Backend unit/API tests: `npm test`
|
||||||
|
- Frontend lint: `npm run lint`
|
||||||
|
- Frontend typecheck: `npm run typecheck`
|
||||||
|
- Vulnerability audit: `npm run audit`
|
||||||
|
- Backend build: `npm run build:backend`
|
||||||
|
- Frontend build: `npm run build:frontend`
|
||||||
|
- Full build: `npm run build`
|
||||||
|
- E2E tests: `npm run test:e2e`
|
||||||
|
- Migration status: `npm run db:migrate:status`
|
||||||
|
- Migration verification: `npm run db:migrate:verify`
|
||||||
|
|
||||||
|
## Environment Notes
|
||||||
|
- `backend/.env` is used by the backend and Docker dev service.
|
||||||
|
- `frontend/.env` may define `VITE_API_URL` and `VITE_ALLOWED_HOSTS`.
|
||||||
|
- Do not print, log, or commit secrets, tokens, cookies, DB URLs, receipt bytes, or full invite codes.
|
||||||
|
- Logs/audit entries for invite codes may include last4 only.
|
||||||
|
|
||||||
|
## Coding Conventions
|
||||||
|
- Preserve the current stack; do not migrate to Next.js or another framework.
|
||||||
|
- Keep Express routes/controllers thin; put DB-heavy work in models/services and authz in backend layers.
|
||||||
|
- Keep frontend network calls in `frontend/src/api` wrappers and UI state in context/hooks.
|
||||||
|
- Always send credentials through the shared Axios client when touching authenticated frontend API calls.
|
||||||
|
- Enforce RBAC server-side; client guards are UX only.
|
||||||
|
- Frontend DB-mutating actions must show toast/bubble outcome notifications.
|
||||||
|
- Progress notifications must reuse `UploadQueueContext` and `UploadToaster`.
|
||||||
|
- Keep encoding clean; do not introduce mojibake.
|
||||||
|
|
||||||
|
## Dependency Rules
|
||||||
|
- npm is the package manager; do not introduce pnpm, yarn, bun, or another build tool.
|
||||||
|
- Do not add production dependencies unless the task clearly requires it and the repo has no practical existing option.
|
||||||
|
- Do not add cron, workers, polling daemons, or background job frameworks.
|
||||||
|
|
||||||
|
## Testing Expectations
|
||||||
|
- Add or update tests when API behavior changes.
|
||||||
|
- Include negative cases for authz, membership, and invalid input where applicable.
|
||||||
|
- For UI behavior changes, prefer focused Playwright tests.
|
||||||
|
- Run the narrowest relevant checks first, then broader checks when risk warrants it.
|
||||||
|
|
||||||
|
## Done Means
|
||||||
|
- Behavior is preserved unless the task explicitly requires a change.
|
||||||
|
- Relevant tests/lint/typecheck/build commands were run or the reason they could not run is documented.
|
||||||
|
- Touched files have no known TS/lint errors.
|
||||||
|
- Migrations are in `packages/db/migrations` when schema changes are required.
|
||||||
|
- Documentation is updated for changed commands, contracts, or workflows.
|
||||||
|
|
||||||
|
## Codex Must Not
|
||||||
|
- Do not read or expose real `.env` values; inspect variable names only when needed.
|
||||||
|
- Do not touch production data or run DB migrations without explicit operator intent.
|
||||||
|
- Do not log secrets, receipt bytes, or full invite codes.
|
||||||
|
- Do not delete user work or generated artifacts unless explicitly asked.
|
||||||
|
- Do not make broad refactors, file moves, or framework changes for cleanup alone.
|
||||||
|
|
||||||
|
## Deeper Docs
|
||||||
|
- `docs/PROJECT_MAP.md`: quick repo orientation.
|
||||||
|
- `docs/DEVELOPMENT.md`: setup, run, verify, and troubleshooting.
|
||||||
|
- `docs/DB_MIGRATION_WORKFLOW.md`: migration runbook.
|
||||||
|
- `docs/PLANS.md`: template for multi-step work.
|
||||||
|
- `.agents/skills/fiddy-verify/SKILL.md`: repo-specific verification workflow for Codex.
|
||||||
|
|
||||||
|
## Response Icon Legend
|
||||||
|
- `🔄` in progress
|
||||||
|
- `✅` completed
|
||||||
|
- `🧪` verification/test result
|
||||||
|
- `📄` documentation update
|
||||||
|
- `🗄️` database or migration change
|
||||||
|
- `🚀` deploy/release step
|
||||||
|
- `⚠️` risk, blocker, or manual operator action needed
|
||||||
|
- `❌` failed command or unsuccessful attempt
|
||||||
|
- `ℹ️` informational context
|
||||||
|
- `🧭` recommendation or next-step option
|
||||||
48
DEBUGGING_INSTRUCTIONS.md
Normal file
48
DEBUGGING_INSTRUCTIONS.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Debugging Instructions - Fiddy
|
||||||
|
|
||||||
|
## Scope and authority
|
||||||
|
- This file is required for bugfix work.
|
||||||
|
- `PROJECT_INSTRUCTIONS.md` remains the source of truth for global project rules.
|
||||||
|
- For debugging tasks, ship the smallest safe fix that resolves the verified issue.
|
||||||
|
|
||||||
|
## Required bugfix workflow
|
||||||
|
1. Reproduce:
|
||||||
|
- Capture exact route/page, inputs, actor role, and expected vs actual behavior.
|
||||||
|
- Record a concrete repro sequence before changing code.
|
||||||
|
2. Localize:
|
||||||
|
- Identify the failing boundary (route/controller/model/service/client wrapper/hook/ui).
|
||||||
|
- Confirm whether failure is validation, authorization, data, or rendering.
|
||||||
|
3. Fix minimally:
|
||||||
|
- Modify only the layers needed to resolve the bug.
|
||||||
|
- Do not introduce parallel mechanisms for the same state flow.
|
||||||
|
4. Verify:
|
||||||
|
- Re-run repro.
|
||||||
|
- Run lint/tests for touched areas.
|
||||||
|
- Confirm no regression against contracts in `PROJECT_INSTRUCTIONS.md`.
|
||||||
|
|
||||||
|
## Guardrails while debugging
|
||||||
|
- External DB only:
|
||||||
|
- Use `DATABASE_URL`.
|
||||||
|
- Never add a DB container for a fix.
|
||||||
|
- No background jobs:
|
||||||
|
- Do not add cron, workers, or polling daemons.
|
||||||
|
- Security:
|
||||||
|
- Never log secrets, receipt bytes, or full invite codes.
|
||||||
|
- Invite logs/audit may include only last4.
|
||||||
|
- Authorization:
|
||||||
|
- Enforce RBAC server-side; client checks are UX only.
|
||||||
|
|
||||||
|
## Contract-specific debug checks
|
||||||
|
- Auth:
|
||||||
|
- Sessions must remain DB-backed and cookie-based (HttpOnly).
|
||||||
|
- Receipts:
|
||||||
|
- List endpoints must never include receipt bytes.
|
||||||
|
- Byte retrieval must be through dedicated endpoint only.
|
||||||
|
- Request IDs/audit:
|
||||||
|
- Ensure `request_id` appears in responses and audit trail for affected paths.
|
||||||
|
|
||||||
|
## Evidence to include with every bugfix
|
||||||
|
- Root cause summary (one short paragraph).
|
||||||
|
- Changed files list with rationale.
|
||||||
|
- Verification steps performed and outcome.
|
||||||
|
- Any residual risk, fallback, or operator action.
|
||||||
290
PROJECT_INSTRUCTIONS.md
Normal file
290
PROJECT_INSTRUCTIONS.md
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
### Docker dev runtime
|
||||||
|
- After backend/API code changes while using `docker-compose.dev.yml`, rebuild and restart only the backend service:
|
||||||
|
- `docker compose -f docker-compose.dev.yml up -d --build backend`
|
||||||
|
- After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded:
|
||||||
|
- `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend`
|
||||||
|
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include the exact browser origin, for example `http://localhost:3010` and `http://127.0.0.1:3010`.
|
||||||
|
- Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`.
|
||||||
|
- Do not print or commit real `.env` values while checking or updating local Docker env.
|
||||||
|
|
||||||
|
### No background jobs
|
||||||
|
- **No cron/worker jobs**. Any fix must work without background tasks.
|
||||||
|
|
||||||
|
### Security / logging
|
||||||
|
- **Never log secrets** (passwords, tokens, session cookies).
|
||||||
|
- **Never log receipt bytes**.
|
||||||
|
- **Never log full invite codes** - logs/audit store **last4 only**.
|
||||||
|
|
||||||
|
### Server-side authorization only
|
||||||
|
- **Server-side RBAC only.** Client checks are UX only and must not be trusted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Non-regression contracts (do not break)
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- Custom email/password auth.
|
||||||
|
- Sessions are **DB-backed** and stored in table `sessions`.
|
||||||
|
- Session cookies are **HttpOnly**.
|
||||||
|
|
||||||
|
### Receipts
|
||||||
|
- Receipt images are stored in Postgres `bytea` table `receipts`.
|
||||||
|
- **Entries list endpoints must never return receipt image bytes.**
|
||||||
|
- Receipt bytes are fetched only via a **separate endpoint** when inspecting a single item.
|
||||||
|
|
||||||
|
### Request IDs + audit
|
||||||
|
- API must generate a **`request_id`** and return it in responses.
|
||||||
|
- Audit logs must include `request_id`.
|
||||||
|
- Audit logs must never store full invite codes (store **last4 only**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Architecture contract (Backend <-> Client <-> Hooks <-> UI)
|
||||||
|
|
||||||
|
### No-assumptions rule (required)
|
||||||
|
Before making structural changes, first scan the repo and identify:
|
||||||
|
- where `app/`, `components/`, `features/`, `hooks/`, `lib/` live
|
||||||
|
- existing API routes and helpers
|
||||||
|
- patterns already in use
|
||||||
|
Do not invent files/endpoints/conventions. If something is missing, add it **minimally** and **consistently**.
|
||||||
|
|
||||||
|
### Single mechanism rule (required)
|
||||||
|
For any cross-component state propagation concern, keep **one** canonical mechanism only:
|
||||||
|
- Context **OR** custom events **OR** cache invalidation
|
||||||
|
Do not keep old and new mechanisms in parallel. Remove superseded utilities/imports/files in the same PR.
|
||||||
|
|
||||||
|
### Layering (hard boundaries)
|
||||||
|
For every domain (auth, groups, entries, receipts, etc.) follow this flow:
|
||||||
|
|
||||||
|
1) **API Route Handlers** - `app/api/.../route.ts`
|
||||||
|
- Thin: parse/validate input, call a server service, return JSON.
|
||||||
|
- No direct DB queries in route files unless there is no existing server service.
|
||||||
|
|
||||||
|
2) **Server Services (DB + authorization)** - `lib/server/*`
|
||||||
|
- Own all DB access and authorization helpers.
|
||||||
|
- Server-only modules must include: `import "server-only";`
|
||||||
|
- Prefer small domain modules: `lib/server/auth.ts`, `lib/server/groups.ts`, `lib/server/entries.ts`, `lib/server/receipts.ts`, `lib/server/session.ts`.
|
||||||
|
|
||||||
|
3) **Client API Wrappers** - `lib/client/*`
|
||||||
|
- Typed fetch helpers only (no React state).
|
||||||
|
- Centralize fetch + error normalization.
|
||||||
|
- Always send credentials (cookies) and never trust client-side RBAC.
|
||||||
|
|
||||||
|
4) **Hooks (UI-facing API layer)** - `hooks/use-*.ts`
|
||||||
|
- Hooks are the primary interface for components/pages to call APIs.
|
||||||
|
- Components should not call `fetch()` directly unless there is a strong reason.
|
||||||
|
|
||||||
|
### API conventions
|
||||||
|
- Prefer consistent JSON error shape:
|
||||||
|
- `{ error: { code: string, message: string }, request_id?: string }`
|
||||||
|
- Validate inputs at the route boundary (shape/type), authorize in server services.
|
||||||
|
- Mirror existing REST style used in the project.
|
||||||
|
|
||||||
|
### Next.js route params checklist (required)
|
||||||
|
For `app/api/**/[param]/route.ts`:
|
||||||
|
- Treat `context.params` as **async** and `await` it before reading properties.
|
||||||
|
- Example: `const { id } = await context.params;`
|
||||||
|
|
||||||
|
### Frontend structure preference
|
||||||
|
- Prefer domain-first structure: `features/<domain>/...` + `shared/...`.
|
||||||
|
- Use `components/*` only for compatibility shims during migrations (remove them after imports are migrated).
|
||||||
|
|
||||||
|
### Maintainability thresholds (refactor triggers)
|
||||||
|
- Component files > **400 lines** should be split into container/presentational parts.
|
||||||
|
- Hook files > **150 lines** should extract helper functions/services.
|
||||||
|
- Functions with more than **3 nested branches** should be extracted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Decisions / constraints (Group Settings)
|
||||||
|
- Add `GROUP_OWNER` role to group roles; migrate existing groups so the first admin becomes owner.
|
||||||
|
- Join policy default is `NOT_ACCEPTING`. Policies: `NOT_ACCEPTING`, `AUTO_ACCEPT`, `APPROVAL_REQUIRED`.
|
||||||
|
- Both owner and admins can approve join requests and manage invite links.
|
||||||
|
- Invite links:
|
||||||
|
- TTL limited to 1-7 days.
|
||||||
|
- Settings are immutable after creation (policy, single-use, etc.).
|
||||||
|
- Single-use does not override approval-required.
|
||||||
|
- Expired links are retained and can be revived.
|
||||||
|
- Single-use links are deleted after successful use.
|
||||||
|
- Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event.
|
||||||
|
- No cron/worker jobs for now (auto ownership transfer and invite rotation are paused).
|
||||||
|
- Group role icons must be consistent: owner, admin, member.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Do first (vertical slice)
|
||||||
|
1) DB migrate command + schema
|
||||||
|
2) Register/Login/Logout (custom sessions)
|
||||||
|
3) Protected dashboard page
|
||||||
|
4) Group create/join + group switcher (approval-based joins + optional join disable)
|
||||||
|
5) Entries CRUD (no receipt bytes in list)
|
||||||
|
6) Receipt upload/download endpoints
|
||||||
|
7) Settings + Reports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Definition of done
|
||||||
|
- Works via `docker-compose.dev.yml` with external DB
|
||||||
|
- Migrations applied via `npm run db:migrate`
|
||||||
|
- Tests + lint pass
|
||||||
|
- RBAC enforced server-side
|
||||||
|
- No large files
|
||||||
|
- No TypeScript warnings or lint errors in touched files
|
||||||
|
- No new cron/worker dependencies unless explicitly approved
|
||||||
|
- No orphaned utilities/hooks/contexts after refactors
|
||||||
|
- No duplicate mechanisms for the same state flow
|
||||||
|
- Text encoding remains clean in user-facing strings/docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Desktop + mobile UX checklist (required)
|
||||||
|
- Touch: long-press affordance for item-level actions when no visible button.
|
||||||
|
- Mouse: hover affordance on interactive rows/cards.
|
||||||
|
- Tap targets remain >= 40px on mobile.
|
||||||
|
- Modal overlays must close on outside click/tap.
|
||||||
|
- For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure).
|
||||||
|
- Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception.
|
||||||
|
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency.
|
||||||
|
- Add Playwright UI tests for new UI features and critical flows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Tests (required)
|
||||||
|
- Add/update tests for API behavior changes (auth, groups, entries, receipts).
|
||||||
|
- Include negative cases where applicable:
|
||||||
|
- unauthorized
|
||||||
|
- not-a-member
|
||||||
|
- invalid input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) Agent Response Legend (required)
|
||||||
|
Use emoji/icons in agent progress and final responses so status is obvious at a glance.
|
||||||
|
|
||||||
|
Legend:
|
||||||
|
- `🔄` in progress
|
||||||
|
- `✅` completed
|
||||||
|
- `🧪` test/lint/verification result
|
||||||
|
- `📄` documentation update
|
||||||
|
- `🗄️` database or migration change
|
||||||
|
- `🚀` deploy/release step
|
||||||
|
- `⚠️` risk, blocker, or manual operator action needed
|
||||||
|
- `❌` failed command or unsuccessful attempt
|
||||||
|
- `ℹ️` informational context
|
||||||
|
- `🧭` recommendation or next-step option
|
||||||
|
|
||||||
|
Usage rules:
|
||||||
|
- Include at least one status icon in each substantive agent response.
|
||||||
|
- Use one icon per bullet/line; avoid icon spam.
|
||||||
|
- Keep icon meaning consistent with this legend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12) Git Intake, Branching, Commit, and PR Discipline (required)
|
||||||
|
|
||||||
|
### Read-only intake before editing
|
||||||
|
Before editing, run this read-only intake:
|
||||||
|
- `git status --short --branch`
|
||||||
|
- `git branch -vv`
|
||||||
|
- `git log --oneline --decorate -8`
|
||||||
|
- `git ls-files --others --exclude-standard`
|
||||||
|
- Check current PR status when GitHub CLI is available.
|
||||||
|
- Check open PRs for overlapping work before editing shared or collision-prone areas.
|
||||||
|
|
||||||
|
### Branch suitability gate before editing
|
||||||
|
- Continue on the current branch only when the requested work belongs to that branch or PR.
|
||||||
|
- Start independent work from `main` after pulling latest.
|
||||||
|
- Start stacked work from the current PR branch when the work intentionally builds on that PR.
|
||||||
|
- If the current branch purpose does not match the request, stop before editing and switch or create the correct branch.
|
||||||
|
- If either `main` or the current branch could be valid, ask whether the work is independent side work or required follow-on work.
|
||||||
|
- Do not layer unrelated work on top of a dirty worktree.
|
||||||
|
- If unrelated local changes exist, pause and ask how to separate them.
|
||||||
|
|
||||||
|
### Branch creation and naming
|
||||||
|
- Create a descriptive branch before writing code.
|
||||||
|
- Preferred branch prefixes:
|
||||||
|
- `feature/<short-description>`
|
||||||
|
- `bugfix/<short-description>`
|
||||||
|
- `refactor/<short-description>`
|
||||||
|
- `chore/<short-description>`
|
||||||
|
- `spike/<short-description>`
|
||||||
|
- Do not include tracker numbers in branch names.
|
||||||
|
- Use standalone branches from `main` for independent work.
|
||||||
|
- Use stacked branches from the parent PR branch for follow-on work.
|
||||||
|
- Target standalone PRs at `main`.
|
||||||
|
- Target stacked PRs at the parent PR branch.
|
||||||
|
- Never push directly to `main`.
|
||||||
|
|
||||||
|
### Commit discipline
|
||||||
|
- Treat committing as a first-class part of the workflow: create frequent, verified checkpoint commits for completed work instead of accumulating large uncommitted changes.
|
||||||
|
- Commit after each coherent logical unit of work.
|
||||||
|
- Commit in small, logical slices (no broad mixed-purpose commits).
|
||||||
|
- Before committing:
|
||||||
|
1. Run `git diff --stat`.
|
||||||
|
2. Run relevant tests or checks when practical.
|
||||||
|
3. Stage only files that belong to the logical unit.
|
||||||
|
4. Run `git diff --cached --stat`.
|
||||||
|
5. Commit with an imperative, present-tense subject at or below 72 characters.
|
||||||
|
- 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
|
||||||
|
- Do not stage unrelated user or collaborator changes.
|
||||||
|
- Do not start a second unrelated task while the first has uncommitted work.
|
||||||
|
- If existing local changes are external/user changes, leave them untouched unless explicitly told otherwise.
|
||||||
|
- If asked to commit and external changes already exist, commit those separately on the proper branch before starting new work.
|
||||||
|
- Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas).
|
||||||
|
- Prefer frequent checkpoint commits during agentic work rather than one large end-state commit.
|
||||||
|
- Before switching tasks or stopping after a completed change, check git status and either commit the finished slice or clearly document why it remains uncommitted.
|
||||||
|
- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code).
|
||||||
|
|
||||||
|
### Push and PR coordination
|
||||||
|
- Push the branch before opening a PR.
|
||||||
|
- For this Gitea repo, use `docs/GITEA_PR_WORKFLOW.md` and `scripts/gitea-pr.js` for PR creation, lookup, and merge operations.
|
||||||
|
- PR tooling must read auth from `GITEA_TOKEN`/`GITEA_BASE_URL` shell environment or ignored `.codex-local.env` only; never commit tokens or print token values.
|
||||||
|
- Open a draft PR early for non-trivial, collision-prone, or multi-agent work once the first coherent commit exists.
|
||||||
|
- Use the PR body as the coordination record:
|
||||||
|
- `Owner:`
|
||||||
|
- `Status: proposed / in-progress / blocked / review / done`
|
||||||
|
- `Branch:`
|
||||||
|
- `Branch relationship: standalone from main / stacked on parent branch / continuing existing branch`
|
||||||
|
- `Likely modified areas:`
|
||||||
|
- `Actual modified files:`
|
||||||
|
- `Collision risk: low / medium / high`
|
||||||
|
- `Last meaningful update:`
|
||||||
|
- Collision risk levels:
|
||||||
|
- Low: isolated docs, tests, or one leaf component.
|
||||||
|
- Medium: shared stores/types, panels/components, handlers, helpers.
|
||||||
|
- High: interface contracts, broad app flows, core registries, cross-cutting behavior.
|
||||||
|
- If a branch already contains assigned feature work and has no current PR, stop before adding more feature commits. Push the branch and open a draft PR, or record the GitHub/auth blocker.
|
||||||
|
- Before writing or updating the final PR body, inspect:
|
||||||
|
- `git log <base>..HEAD`
|
||||||
|
- `git diff --stat <base>..HEAD`
|
||||||
|
- The PR should describe the cumulative branch diff against the target branch, not only the latest commit.
|
||||||
|
- Include:
|
||||||
|
- Summary of functional changes.
|
||||||
|
- Tests run, or a clear reason tests were not run.
|
||||||
|
- For broad branches, organize the summary by subsystem, workflow, or behavior area.
|
||||||
|
- Do not use auto-closing keywords such as `Closes`, `Fixes`, or `Resolves`.
|
||||||
|
- Merge PRs only after explicit operator approval, required checks, and a final `npm run pr:view -- --number <pr-number>` status check.
|
||||||
776
README.md
Normal file
776
README.md
Normal file
@ -0,0 +1,776 @@
|
|||||||
|
# Costco Grocery List
|
||||||
|
|
||||||
|
A full-stack web application for managing grocery shopping lists with role-based access control, image support, and intelligent item classification.
|
||||||
|
|
||||||
|
> Current maintainer notes: `PROJECT_INSTRUCTIONS.md` is the source of truth for project constraints. For current setup, run, and verification commands, start with `docs/DEVELOPMENT.md` and `docs/PROJECT_MAP.md`; some older sections below are historical and should be checked against current code before changing behavior.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## 📋 Table of Contents
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [System Architecture](#system-architecture)
|
||||||
|
- [Key Features](#key-features)
|
||||||
|
- [Technology Stack](#technology-stack)
|
||||||
|
- [Data Flow](#data-flow)
|
||||||
|
- [Role-Based Access Control](#role-based-access-control)
|
||||||
|
- [Getting Started](#getting-started)
|
||||||
|
- [API Documentation](#api-documentation)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Development Workflow](#development-workflow)
|
||||||
|
- [Deployment](#deployment)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Overview
|
||||||
|
|
||||||
|
The Costco Grocery List application provides a collaborative platform for managing household grocery shopping. Users can add items with photos, track quantities, mark items as purchased, and organize items by store zones. The system supports multiple users with different permission levels, making it ideal for families or shared households.
|
||||||
|
|
||||||
|
**Live Demo:** [https://costco.nicosaya.com](https://costco.nicosaya.com)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ System Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Client Layer"
|
||||||
|
A[React 19 SPA]
|
||||||
|
B[Vite Dev Server]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Application Layer"
|
||||||
|
C[Express 5 API]
|
||||||
|
D[JWT Auth Middleware]
|
||||||
|
E[RBAC Middleware]
|
||||||
|
F[Image Processing]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Data Layer"
|
||||||
|
G[(PostgreSQL)]
|
||||||
|
H[grocery_list]
|
||||||
|
I[users]
|
||||||
|
J[item_classification]
|
||||||
|
K[grocery_history]
|
||||||
|
end
|
||||||
|
|
||||||
|
A -->|HTTP/REST| C
|
||||||
|
C --> D
|
||||||
|
D --> E
|
||||||
|
E --> F
|
||||||
|
C --> G
|
||||||
|
G --> H
|
||||||
|
G --> I
|
||||||
|
G --> J
|
||||||
|
G --> K
|
||||||
|
|
||||||
|
style A fill:#61dafb
|
||||||
|
style C fill:#259dff
|
||||||
|
style G fill:#336791
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture Layers
|
||||||
|
|
||||||
|
1. **Presentation Layer (Frontend)**
|
||||||
|
- React 19 with modern hooks (useState, useEffect, useContext)
|
||||||
|
- Component-based architecture organized by feature
|
||||||
|
- CSS custom properties for theming
|
||||||
|
- Axios for HTTP requests with interceptors
|
||||||
|
|
||||||
|
2. **Business Logic Layer (Backend)**
|
||||||
|
- Express.js REST API
|
||||||
|
- JWT-based authentication
|
||||||
|
- Role-based access control (RBAC)
|
||||||
|
- Image optimization middleware (Sharp)
|
||||||
|
- Centralized error handling
|
||||||
|
|
||||||
|
3. **Data Persistence Layer**
|
||||||
|
- PostgreSQL relational database
|
||||||
|
- Normalized schema with foreign key constraints
|
||||||
|
- Junction table for item history tracking
|
||||||
|
- Binary storage for optimized images
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Key Features
|
||||||
|
|
||||||
|
### 🔐 Authentication & Authorization
|
||||||
|
- JWT token-based authentication (1 year expiration)
|
||||||
|
- Three-tier role system (Viewer, Editor, Admin)
|
||||||
|
- Secure password hashing with bcrypt
|
||||||
|
- Token-based session management
|
||||||
|
|
||||||
|
### 📝 Grocery List Management
|
||||||
|
- Add items with optional images
|
||||||
|
- Update item quantities
|
||||||
|
- Mark items as bought/unbought
|
||||||
|
- View recently bought items (24-hour window)
|
||||||
|
- Long-press to edit items (mobile-friendly)
|
||||||
|
|
||||||
|
### 🖼️ Image Support
|
||||||
|
- Upload product images
|
||||||
|
- Automatic image optimization (800x800px, JPEG 85%)
|
||||||
|
- Base64 encoding for efficient storage
|
||||||
|
- 5MB maximum file size
|
||||||
|
- Support for JPEG, PNG, GIF, WebP
|
||||||
|
|
||||||
|
### 🏪 Smart Organization
|
||||||
|
- Item classification system (type, group, zone)
|
||||||
|
- 13 predefined store zones
|
||||||
|
- Sort by zone, alphabetically, or quantity
|
||||||
|
- Visual grouping by store location
|
||||||
|
- Intelligent item suggestions
|
||||||
|
|
||||||
|
### 🔍 Search & Suggestions
|
||||||
|
- Real-time autocomplete suggestions
|
||||||
|
- Fuzzy string matching (80% similarity threshold)
|
||||||
|
- Substring detection for partial matches
|
||||||
|
- Case-insensitive search
|
||||||
|
|
||||||
|
### 👥 User Management (Admin)
|
||||||
|
- View all registered users
|
||||||
|
- Update user roles
|
||||||
|
- Delete user accounts
|
||||||
|
- User activity tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Technology Stack
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
| Technology | Version | Purpose |
|
||||||
|
|------------|---------|---------|
|
||||||
|
| React | 19.2.0 | UI framework |
|
||||||
|
| React Router | 7.9.6 | Client-side routing |
|
||||||
|
| Axios | 1.13.2 | HTTP client |
|
||||||
|
| Vite | 7.2.2 | Build tool & dev server |
|
||||||
|
| TypeScript | 5.9.3 | Type safety |
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
| Technology | Version | Purpose |
|
||||||
|
|------------|---------|---------|
|
||||||
|
| Node.js | 20.x | Runtime environment |
|
||||||
|
| Express | 5.1.0 | Web framework |
|
||||||
|
| PostgreSQL | 8.16.0 | Database |
|
||||||
|
| JWT | 9.0.2 | Authentication |
|
||||||
|
| Bcrypt | 3.0.3 | Password hashing |
|
||||||
|
| Sharp | 0.34.5 | Image processing |
|
||||||
|
| Multer | 2.0.2 | File upload handling |
|
||||||
|
|
||||||
|
### DevOps
|
||||||
|
| Technology | Purpose |
|
||||||
|
|------------|---------|
|
||||||
|
| Docker | Containerization |
|
||||||
|
| Docker Compose | Multi-container orchestration |
|
||||||
|
| Gitea Actions | CI/CD pipeline |
|
||||||
|
| Nginx | Production static file serving |
|
||||||
|
| Nodemon | Development hot-reload |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Data Flow
|
||||||
|
|
||||||
|
### Adding an Item
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Frontend
|
||||||
|
participant API
|
||||||
|
participant Auth
|
||||||
|
participant RBAC
|
||||||
|
participant Database
|
||||||
|
|
||||||
|
User->>Frontend: Enter item name & quantity
|
||||||
|
User->>Frontend: Upload image (optional)
|
||||||
|
Frontend->>API: POST /list/add (FormData)
|
||||||
|
API->>Auth: Verify JWT token
|
||||||
|
Auth->>RBAC: Check role (Editor/Admin)
|
||||||
|
RBAC->>API: Authorization granted
|
||||||
|
API->>API: Process & optimize image
|
||||||
|
API->>Database: Check if item exists
|
||||||
|
alt Item exists & unbought
|
||||||
|
Database-->>API: Return existing item
|
||||||
|
API->>Database: UPDATE quantity
|
||||||
|
else Item exists & bought
|
||||||
|
Database-->>API: Return existing item
|
||||||
|
API->>Database: SET bought=false, UPDATE quantity
|
||||||
|
else Item doesn't exist
|
||||||
|
API->>Database: INSERT new item
|
||||||
|
end
|
||||||
|
API->>Database: INSERT grocery_history record
|
||||||
|
Database-->>API: Success
|
||||||
|
API-->>Frontend: 200 OK {message, addedBy}
|
||||||
|
Frontend-->>User: Show success message
|
||||||
|
Frontend->>API: GET /list (refresh)
|
||||||
|
API->>Database: SELECT unbought items
|
||||||
|
Database-->>API: Return items
|
||||||
|
API-->>Frontend: Item list
|
||||||
|
Frontend-->>User: Update UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Frontend
|
||||||
|
participant API
|
||||||
|
participant Database
|
||||||
|
|
||||||
|
User->>Frontend: Enter credentials
|
||||||
|
Frontend->>API: POST /auth/login
|
||||||
|
API->>Database: SELECT user WHERE username
|
||||||
|
Database-->>API: User record
|
||||||
|
API->>API: Compare password hash
|
||||||
|
alt Valid credentials
|
||||||
|
API->>API: Generate JWT token
|
||||||
|
API-->>Frontend: {token, role, username}
|
||||||
|
Frontend->>Frontend: Store token in localStorage
|
||||||
|
Frontend->>Frontend: Store role in AuthContext
|
||||||
|
Frontend->>API: GET /list (with token)
|
||||||
|
API->>API: Verify token
|
||||||
|
API-->>Frontend: Protected data
|
||||||
|
else Invalid credentials
|
||||||
|
API-->>Frontend: 401 Unauthorized
|
||||||
|
Frontend-->>User: Show error message
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Item Classification Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[Add/Edit Item] --> B{Classification Exists?}
|
||||||
|
B -->|Yes| C[Display Existing]
|
||||||
|
B -->|No| D[Show Empty Form]
|
||||||
|
C --> E[User Edits]
|
||||||
|
D --> E
|
||||||
|
E --> F{Validate Classification}
|
||||||
|
F -->|Valid| G[Upsert to DB]
|
||||||
|
F -->|Invalid| H[Show Error]
|
||||||
|
G --> I[confidence=1.0, source='user']
|
||||||
|
I --> J[Update Item List]
|
||||||
|
H --> E
|
||||||
|
|
||||||
|
style G fill:#90EE90
|
||||||
|
style H fill:#FFB6C1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Role-Based Access Control
|
||||||
|
|
||||||
|
### Role Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
Admin (Full Access)
|
||||||
|
├── User Management
|
||||||
|
├── Item Management
|
||||||
|
└── View Access
|
||||||
|
|
||||||
|
Editor (Modify Access)
|
||||||
|
├── Add Items
|
||||||
|
├── Edit Items
|
||||||
|
├── Mark as Bought
|
||||||
|
└── View Access
|
||||||
|
|
||||||
|
Viewer (Read-Only)
|
||||||
|
└── View Lists Only
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Matrix
|
||||||
|
|
||||||
|
| Feature | Viewer | Editor | Admin |
|
||||||
|
|---------|--------|--------|-------|
|
||||||
|
| View grocery list | ✅ | ✅ | ✅ |
|
||||||
|
| View recently bought | ✅ | ✅ | ✅ |
|
||||||
|
| Get suggestions | ✅ | ✅ | ✅ |
|
||||||
|
| View classifications | ✅ | ✅ | ✅ |
|
||||||
|
| Add items | ❌ | ✅ | ✅ |
|
||||||
|
| Edit items | ❌ | ✅ | ✅ |
|
||||||
|
| Upload images | ❌ | ✅ | ✅ |
|
||||||
|
| Mark items bought | ❌ | ✅ | ✅ |
|
||||||
|
| Update classifications | ❌ | ✅ | ✅ |
|
||||||
|
| View all users | ❌ | ❌ | ✅ |
|
||||||
|
| Update user roles | ❌ | ❌ | ✅ |
|
||||||
|
| Delete users | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
|
### Middleware Chain
|
||||||
|
|
||||||
|
Protected routes use a middleware chain pattern:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
router.post("/add",
|
||||||
|
auth, // Verify JWT token
|
||||||
|
requireRole(ROLES.EDITOR, ROLES.ADMIN), // Check role
|
||||||
|
upload.single("image"), // Handle file upload
|
||||||
|
processImage, // Optimize image
|
||||||
|
controller.addItem // Execute business logic
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- **Node.js** 20.x or higher
|
||||||
|
- **PostgreSQL** 8.x or higher
|
||||||
|
- **Docker** (optional, recommended)
|
||||||
|
- **Git** for version control
|
||||||
|
|
||||||
|
### Local Development Setup
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone https://git.nicosaya.com/nalalangan/grocery-app.git
|
||||||
|
cd grocery-app
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure environment variables**
|
||||||
|
|
||||||
|
Create `backend/.env`:
|
||||||
|
```env
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_DATABASE=grocery_list
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
JWT_SECRET=your_secret_key_here
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=5000
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start with Docker (Recommended)**
|
||||||
|
```bash
|
||||||
|
# Development mode with hot-reload
|
||||||
|
docker-compose -f docker-compose.dev.yml up
|
||||||
|
|
||||||
|
# Access application:
|
||||||
|
# Frontend: http://localhost:3000
|
||||||
|
# Backend: http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Manual Setup (Alternative)**
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Database Setup**
|
||||||
|
|
||||||
|
Create PostgreSQL database and tables:
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE grocery_list;
|
||||||
|
|
||||||
|
-- See backend/models/*.js for table schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
### First User Registration
|
||||||
|
|
||||||
|
The first registered user should be manually promoted to admin:
|
||||||
|
```sql
|
||||||
|
UPDATE users SET role = 'admin' WHERE username = 'your_username';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 API Documentation
|
||||||
|
|
||||||
|
For detailed API documentation including all endpoints, request/response formats, and examples, see [API_DOCUMENTATION.md](./API_DOCUMENTATION.md).
|
||||||
|
|
||||||
|
### Quick Reference
|
||||||
|
|
||||||
|
**Base URL:** `http://localhost:5000/api`
|
||||||
|
|
||||||
|
**Common Endpoints:**
|
||||||
|
- `POST /auth/register` - Register new user
|
||||||
|
- `POST /auth/login` - Authenticate user
|
||||||
|
- `GET /list` - Get all unbought items
|
||||||
|
- `POST /list/add` - Add or update item
|
||||||
|
- `POST /list/mark-bought` - Mark item as bought
|
||||||
|
- `GET /list/recently-bought` - Get items bought in last 24h
|
||||||
|
- `GET /admin/users` - Get all users (Admin only)
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
All protected endpoints require a JWT token in the Authorization header:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your_jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
grocery-app/
|
||||||
|
├── .gitea/
|
||||||
|
│ └── workflows/
|
||||||
|
│ └── deploy.yml # CI/CD pipeline configuration
|
||||||
|
├── backend/
|
||||||
|
│ ├── constants/
|
||||||
|
│ │ └── classifications.js # Item type/zone definitions
|
||||||
|
│ ├── controllers/
|
||||||
|
│ │ ├── auth.controller.js # Authentication logic
|
||||||
|
│ │ ├── lists.controller.js # Grocery list logic
|
||||||
|
│ │ └── users.controller.js # User management logic
|
||||||
|
│ ├── db/
|
||||||
|
│ │ └── pool.js # PostgreSQL connection pool
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── auth.js # JWT verification
|
||||||
|
│ │ ├── rbac.js # Role-based access control
|
||||||
|
│ │ └── image.js # Image upload & processing
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── list.model.js # Grocery list database queries
|
||||||
|
│ │ └── user.model.js # User database queries
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── auth.routes.js # Authentication routes
|
||||||
|
│ │ ├── list.routes.js # Grocery list routes
|
||||||
|
│ │ ├── admin.routes.js # Admin routes
|
||||||
|
│ │ └── users.routes.js # User routes
|
||||||
|
│ ├── app.js # Express app configuration
|
||||||
|
│ ├── server.js # Server entry point
|
||||||
|
│ ├── Dockerfile # Backend container config
|
||||||
|
│ └── package.json
|
||||||
|
├── frontend/
|
||||||
|
│ ├── public/ # Static assets
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ ├── axios.js # Axios instance with interceptors
|
||||||
|
│ │ │ ├── auth.js # Auth API calls
|
||||||
|
│ │ │ ├── list.js # List API calls
|
||||||
|
│ │ │ └── users.js # User API calls
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── common/ # Reusable components
|
||||||
|
│ │ │ ├── forms/ # Form components
|
||||||
|
│ │ │ ├── items/ # Item-related components
|
||||||
|
│ │ │ ├── layout/ # Layout components
|
||||||
|
│ │ │ └── modals/ # Modal dialogs
|
||||||
|
│ │ ├── constants/
|
||||||
|
│ │ │ └── roles.js # Role constants
|
||||||
|
│ │ ├── context/
|
||||||
|
│ │ │ └── AuthContext.jsx # Authentication context
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── AdminPanel.jsx # Admin user management
|
||||||
|
│ │ │ ├── GroceryList.jsx # Main grocery list page
|
||||||
|
│ │ │ ├── Login.jsx # Login page
|
||||||
|
│ │ │ └── Register.jsx # Registration page
|
||||||
|
│ │ ├── styles/
|
||||||
|
│ │ │ ├── theme.css # CSS custom properties
|
||||||
|
│ │ │ ├── components/ # Component-specific styles
|
||||||
|
│ │ │ └── pages/ # Page-specific styles
|
||||||
|
│ │ ├── utils/
|
||||||
|
│ │ │ ├── PrivateRoute.jsx # Route protection
|
||||||
|
│ │ │ ├── RoleGuard.jsx # Role-based component guard
|
||||||
|
│ │ │ └── stringSimilarity.js # Fuzzy matching algorithm
|
||||||
|
│ │ ├── App.jsx # Root component with routing
|
||||||
|
│ │ └── main.tsx # Application entry point
|
||||||
|
│ ├── Dockerfile # Production build (nginx)
|
||||||
|
│ ├── Dockerfile.dev # Development build (Vite)
|
||||||
|
│ └── package.json
|
||||||
|
├── docker-compose.yml # Production compose file
|
||||||
|
├── docker-compose.dev.yml # Development compose file
|
||||||
|
├── docker-compose.prod.yml # Local production testing
|
||||||
|
├── API_DOCUMENTATION.md # Detailed API reference
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Directories
|
||||||
|
|
||||||
|
- **`backend/constants/`** - Classification definitions (item types, groups, zones)
|
||||||
|
- **`backend/middleware/`** - Authentication, authorization, and image processing
|
||||||
|
- **`frontend/src/components/`** - Organized into 5 categories (common, forms, items, layout, modals)
|
||||||
|
- **`frontend/src/styles/`** - Theme system with CSS custom properties
|
||||||
|
- **`.gitea/workflows/`** - CI/CD pipeline for automated deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔨 Development Workflow
|
||||||
|
|
||||||
|
### Component Organization
|
||||||
|
|
||||||
|
Frontend components are organized by feature:
|
||||||
|
|
||||||
|
```
|
||||||
|
components/
|
||||||
|
├── common/ # Reusable UI components
|
||||||
|
│ ├── ErrorMessage.jsx
|
||||||
|
│ ├── FloatingActionButton.jsx
|
||||||
|
│ ├── FormInput.jsx
|
||||||
|
│ ├── SortDropdown.jsx
|
||||||
|
│ └── UserRoleCard.jsx
|
||||||
|
├── forms/ # Form components
|
||||||
|
│ ├── AddItemForm.jsx
|
||||||
|
│ ├── ClassificationSection.jsx
|
||||||
|
│ └── ImageUploadSection.jsx
|
||||||
|
├── items/ # Item-related components
|
||||||
|
│ ├── GroceryItem.tsx
|
||||||
|
│ ├── GroceryListItem.jsx
|
||||||
|
│ └── SuggestionList.tsx
|
||||||
|
├── layout/ # Layout components
|
||||||
|
│ ├── AppLayout.jsx
|
||||||
|
│ └── Navbar.jsx
|
||||||
|
└── modals/ # Modal dialogs
|
||||||
|
├── AddImageModal.jsx
|
||||||
|
├── AddItemWithDetailsModal.jsx
|
||||||
|
├── ConfirmBuyModal.jsx
|
||||||
|
├── EditItemModal.jsx
|
||||||
|
├── ImageModal.jsx
|
||||||
|
├── ImageUploadModal.jsx
|
||||||
|
├── ItemClassificationModal.jsx
|
||||||
|
└── SimilarItemModal.jsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Each subdirectory has an `index.js` barrel export for cleaner imports.
|
||||||
|
|
||||||
|
### Theme System
|
||||||
|
|
||||||
|
The application uses CSS custom properties for consistent theming:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Theme variables defined in frontend/src/styles/theme.css */
|
||||||
|
:root {
|
||||||
|
/* Colors */
|
||||||
|
--color-primary: #0066cc;
|
||||||
|
--color-secondary: #6c757d;
|
||||||
|
--color-success: #28a745;
|
||||||
|
--color-danger: #dc3545;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', ...;
|
||||||
|
--font-size-base: 1rem;
|
||||||
|
|
||||||
|
/* And many more... */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Standards
|
||||||
|
|
||||||
|
- **Backend**: CommonJS modules, async/await for asynchronous operations
|
||||||
|
- **Frontend**: ES6 modules, functional components with hooks
|
||||||
|
- **TypeScript**: Used for type-safe components (gradually migrating)
|
||||||
|
- **Naming**: camelCase for functions/variables, PascalCase for components
|
||||||
|
- **File Structure**: Feature-based organization over type-based
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Currently, the project uses manual testing. Automated testing infrastructure is planned for future development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚢 Deployment
|
||||||
|
|
||||||
|
### CI/CD Pipeline
|
||||||
|
|
||||||
|
The application uses Gitea Actions for automated deployment:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .gitea/workflows/deploy.yml
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Build Stage:
|
||||||
|
- Install dependencies
|
||||||
|
- Run tests (if present)
|
||||||
|
- Build Docker images
|
||||||
|
- Tag with :latest and :<commit-sha>
|
||||||
|
- Push to private registry
|
||||||
|
|
||||||
|
2. Deploy Stage:
|
||||||
|
- SSH to production server
|
||||||
|
- Upload docker-compose.yml
|
||||||
|
- Pull latest images
|
||||||
|
- Restart containers
|
||||||
|
- Prune old images
|
||||||
|
|
||||||
|
3. Notify Stage:
|
||||||
|
- Send deployment status via webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Production Server
|
||||||
|
├── Nginx Reverse Proxy (Port 80/443)
|
||||||
|
│ ├── /api → Backend Container (Port 5000)
|
||||||
|
│ └── /* → Frontend Container (Port 3000)
|
||||||
|
├── Docker Compose
|
||||||
|
│ ├── backend:latest (from registry)
|
||||||
|
│ └── frontend:latest (from registry)
|
||||||
|
└── PostgreSQL (External, not containerized)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Configuration
|
||||||
|
|
||||||
|
**Production (`docker-compose.yml`):**
|
||||||
|
- Pulls pre-built images from registry
|
||||||
|
- Uses external PostgreSQL database
|
||||||
|
- Environment configured via `backend.env` and `frontend.env`
|
||||||
|
- Automatic restart on failure
|
||||||
|
|
||||||
|
**Development (`docker-compose.dev.yml`):**
|
||||||
|
- Builds images locally
|
||||||
|
- Volume mounts for hot-reload
|
||||||
|
- Uses local `.env` files
|
||||||
|
- Nodemon for backend, Vite HMR for frontend
|
||||||
|
|
||||||
|
### Deployment Process
|
||||||
|
|
||||||
|
1. **Commit and push to `main` branch**
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Your commit message"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **CI/CD automatically:**
|
||||||
|
- Runs tests
|
||||||
|
- Builds Docker images
|
||||||
|
- Pushes to registry
|
||||||
|
- Deploys to production
|
||||||
|
|
||||||
|
3. **Manual deployment (if needed):**
|
||||||
|
```bash
|
||||||
|
ssh user@production-server
|
||||||
|
cd /opt/costco-app
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --remove-orphans
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ Database Schema
|
||||||
|
|
||||||
|
### Entity Relationship Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
users ||--o{ grocery_list : creates
|
||||||
|
users ||--o{ grocery_history : adds
|
||||||
|
grocery_list ||--o{ grocery_history : tracks
|
||||||
|
grocery_list ||--o| item_classification : has
|
||||||
|
|
||||||
|
users {
|
||||||
|
int id PK
|
||||||
|
string username UK
|
||||||
|
string password
|
||||||
|
string name
|
||||||
|
string role
|
||||||
|
}
|
||||||
|
|
||||||
|
grocery_list {
|
||||||
|
int id PK
|
||||||
|
string item_name
|
||||||
|
int quantity
|
||||||
|
boolean bought
|
||||||
|
bytea item_image
|
||||||
|
string image_mime_type
|
||||||
|
int added_by FK
|
||||||
|
timestamp modified_on
|
||||||
|
}
|
||||||
|
|
||||||
|
item_classification {
|
||||||
|
int id PK,FK
|
||||||
|
string item_type
|
||||||
|
string item_group
|
||||||
|
string zone
|
||||||
|
float confidence
|
||||||
|
string source
|
||||||
|
}
|
||||||
|
|
||||||
|
grocery_history {
|
||||||
|
int id PK
|
||||||
|
int list_item_id FK
|
||||||
|
int quantity
|
||||||
|
int added_by FK
|
||||||
|
timestamp added_on
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Relationships
|
||||||
|
|
||||||
|
- **users → grocery_list**: One-to-many (creator relationship)
|
||||||
|
- **users → grocery_history**: One-to-many (contributor relationship)
|
||||||
|
- **grocery_list → grocery_history**: One-to-many (tracks all additions/modifications)
|
||||||
|
- **grocery_list → item_classification**: One-to-one (optional classification data)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please follow these guidelines:
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
### Development Guidelines
|
||||||
|
|
||||||
|
- Follow existing code style and structure
|
||||||
|
- Add comments for complex logic
|
||||||
|
- Update documentation for new features
|
||||||
|
- Test thoroughly before submitting PR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👤 Author
|
||||||
|
|
||||||
|
**Nico Saya**
|
||||||
|
- Repository: [git.nicosaya.com/nalalangan/grocery-app](https://git.nicosaya.com/nalalangan/grocery-app)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
|
- React team for the excellent framework
|
||||||
|
- Express.js community for robust server framework
|
||||||
|
- PostgreSQL for reliable data persistence
|
||||||
|
- Sharp for efficient image processing
|
||||||
|
- All contributors and users of this application
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For issues, questions, or feature requests, please:
|
||||||
|
1. Check existing issues in the repository
|
||||||
|
2. Create a new issue with detailed description
|
||||||
|
3. Include steps to reproduce (for bugs)
|
||||||
|
4. Tag appropriately (bug, enhancement, question, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** January 4, 2026
|
||||||
11
backend/.env.example
Normal file
11
backend/.env.example
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
DATABASE_URL=postgres://username:password@db-host:5432/database_name
|
||||||
|
DB_USER=
|
||||||
|
DB_PASS=
|
||||||
|
DB_HOST=
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=
|
||||||
|
PORT=5000
|
||||||
|
JWT_SECRET=change-me
|
||||||
|
ALLOWED_ORIGINS=http://localhost:3000
|
||||||
|
SESSION_COOKIE_NAME=sid
|
||||||
|
SESSION_TTL_DAYS=30
|
||||||
@ -2,6 +2,8 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache postgresql-client
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,23 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
|
const path = require("path");
|
||||||
const User = require("./models/user.model");
|
const User = require("./models/user.model");
|
||||||
|
const requestIdMiddleware = require("./middleware/request-id");
|
||||||
|
const { sendError } = require("./utils/http");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.use(requestIdMiddleware);
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim());
|
// Expose manual API test pages in non-production environments only.
|
||||||
console.log("Allowed Origins:", allowedOrigins);
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
app.use("/test", express.static(path.join(__dirname, "public")));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
|
||||||
|
.split(",")
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean);
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: function (origin, callback) {
|
origin: function (origin, callback) {
|
||||||
@ -14,17 +25,20 @@ app.use(
|
|||||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||||
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
||||||
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
||||||
callback(new Error("Not allowed by CORS"));
|
console.error(`CORS blocked origin: ${origin}`);
|
||||||
|
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
|
||||||
},
|
},
|
||||||
methods: ["GET", "POST", "PUT", "DELETE"],
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||||
|
credentials: true,
|
||||||
|
exposedHeaders: ["X-Request-Id"],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get('/', async (req, res) => {
|
app.get('/', async (req, res) => {
|
||||||
resText = `Grocery List API is running.\n` +
|
res.status(200).json({
|
||||||
`Roles available: ${Object.values(User.ROLES).join(', ')}`
|
message: "Grocery List API is running.",
|
||||||
|
roles: Object.values(User.ROLES),
|
||||||
res.status(200).type("text/plain").send(resText);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@ -40,4 +54,28 @@ app.use("/admin", adminRoutes);
|
|||||||
const usersRoutes = require("./routes/users.routes");
|
const usersRoutes = require("./routes/users.routes");
|
||||||
app.use("/users", usersRoutes);
|
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;
|
module.exports = app;
|
||||||
62
backend/build.js
Normal file
62
backend/build.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const rootDir = __dirname;
|
||||||
|
const distDir = path.join(rootDir, "dist");
|
||||||
|
|
||||||
|
const directoriesToCopy = [
|
||||||
|
"config",
|
||||||
|
"constants",
|
||||||
|
"controllers",
|
||||||
|
"db",
|
||||||
|
"middleware",
|
||||||
|
"models",
|
||||||
|
"routes",
|
||||||
|
"services",
|
||||||
|
"utils",
|
||||||
|
"public",
|
||||||
|
];
|
||||||
|
|
||||||
|
const filesToCopy = ["app.js", "server.js", "package.json", "package-lock.json"];
|
||||||
|
|
||||||
|
function copyFile(sourcePath, targetPath) {
|
||||||
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyDirectory(sourceDir, targetDir) {
|
||||||
|
if (!fs.existsSync(sourceDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
||||||
|
const sourcePath = path.join(sourceDir, entry.name);
|
||||||
|
const targetPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
copyDirectory(sourcePath, targetPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isFile()) {
|
||||||
|
copyFile(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(distDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const directory of directoriesToCopy) {
|
||||||
|
copyDirectory(path.join(rootDir, directory), path.join(distDir, directory));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of filesToCopy) {
|
||||||
|
const sourcePath = path.join(rootDir, file);
|
||||||
|
if (fs.existsSync(sourcePath)) {
|
||||||
|
copyFile(sourcePath, path.join(distDir, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Backend build copied runtime files to ${path.relative(rootDir, distDir)}`);
|
||||||
15
backend/config/constants.js
Normal file
15
backend/config/constants.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Application-wide constants
|
||||||
|
* These are non-secret configuration values shared across the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// File upload limits
|
||||||
|
MAX_FILE_SIZE_MB: 20,
|
||||||
|
MAX_FILE_SIZE_BYTES: 20 * 1024 * 1024,
|
||||||
|
|
||||||
|
// Image processing
|
||||||
|
MAX_IMAGE_DIMENSION: 800,
|
||||||
|
IMAGE_QUALITY: 85,
|
||||||
|
IMAGE_FORMAT: 'jpeg'
|
||||||
|
};
|
||||||
@ -1,44 +1,101 @@
|
|||||||
const bcrypt = require("bcryptjs");
|
const bcrypt = require("bcryptjs");
|
||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const User = require("../models/user.model");
|
const User = require("../models/user.model");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
const Session = require("../models/session.model");
|
||||||
|
const { parseCookieHeader } = require("../utils/cookies");
|
||||||
|
const { setSessionCookie, clearSessionCookie, cookieName } = require("../utils/session-cookie");
|
||||||
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
exports.register = async (req, res) => {
|
exports.register = async (req, res) => {
|
||||||
let { username, password, name } = req.body;
|
let { username, password, name } = req.body;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!username ||
|
||||||
|
!password ||
|
||||||
|
!name ||
|
||||||
|
typeof username !== "string" ||
|
||||||
|
typeof password !== "string" ||
|
||||||
|
typeof name !== "string"
|
||||||
|
) {
|
||||||
|
return sendError(res, 400, "Username, password, and name are required");
|
||||||
|
}
|
||||||
|
|
||||||
username = username.toLowerCase();
|
username = username.toLowerCase();
|
||||||
console.log(`🆕 Registration attempt for ${name} => username:${username}, password:${password}`);
|
if (password.length < 8) {
|
||||||
|
return sendError(res, 400, "Password must be at least 8 characters");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hash = await bcrypt.hash(password, 10);
|
const hash = await bcrypt.hash(password, 10);
|
||||||
const user = await User.createUser(username, hash, name);
|
const user = await User.createUser(username, hash, name);
|
||||||
console.log(`✅ User registered: ${username}`);
|
|
||||||
|
|
||||||
res.json({ message: "User registered", user });
|
res.json({ message: "User registered", user });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ message: "Registration failed", error: err });
|
logError(req, "auth.register", err);
|
||||||
|
sendError(res, 400, "Registration failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.login = async (req, res) => {
|
exports.login = async (req, res) => {
|
||||||
let { username, password } = req.body;
|
let { username, password } = req.body;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!username ||
|
||||||
|
!password ||
|
||||||
|
typeof username !== "string" ||
|
||||||
|
typeof password !== "string"
|
||||||
|
) {
|
||||||
|
return sendError(res, 400, "Username and password are required");
|
||||||
|
}
|
||||||
|
|
||||||
username = username.toLowerCase();
|
username = username.toLowerCase();
|
||||||
const user = await User.findByUsername(username);
|
const user = await User.findByUsername(username);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log(`⚠️ Login attempt -> No user found: ${username}`);
|
return sendError(res, 401, "Invalid credentials");
|
||||||
return res.status(401).json({ message: "User not found" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await bcrypt.compare(password, user.password);
|
const valid = await bcrypt.compare(password, user.password);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
console.log(`⛔ Login attempt for user ${username} with password ${password}`);
|
return sendError(res, 401, "Invalid credentials");
|
||||||
return res.status(401).json({ message: "Invalid credentials" });
|
}
|
||||||
|
|
||||||
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
|
if (!jwtSecret) {
|
||||||
|
logError(req, "auth.login.jwtSecretMissing", new Error("JWT_SECRET is not configured"));
|
||||||
|
return sendError(res, 500, "Authentication is unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ id: user.id, role: user.role },
|
{ id: user.id, role: user.role },
|
||||||
process.env.JWT_SECRET,
|
jwtSecret,
|
||||||
{ expiresIn: "1 year" }
|
{ expiresIn: "1 year" }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ token, username, role: user.role });
|
try {
|
||||||
|
const session = await Session.createSession(user.id, req.headers["user-agent"] || null);
|
||||||
|
setSessionCookie(res, session.id);
|
||||||
|
} catch (err) {
|
||||||
|
logError(req, "auth.login.createSession", err);
|
||||||
|
return sendError(res, 500, "Failed to create session");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ token, userId: user.id, username, role: user.role });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.logout = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cookies = parseCookieHeader(req.headers.cookie);
|
||||||
|
const sid = cookies[cookieName()];
|
||||||
|
|
||||||
|
if (sid) {
|
||||||
|
await Session.deleteSession(sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSessionCookie(res);
|
||||||
|
res.json({ message: "Logged out" });
|
||||||
|
} catch (err) {
|
||||||
|
logError(req, "auth.logout", err);
|
||||||
|
sendError(res, 500, "Failed to logout");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
371
backend/controllers/available-items.controller.js
Normal file
371
backend/controllers/available-items.controller.js
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
const AvailableItems = require("../models/available-item.model");
|
||||||
|
const List = require("../models/list.model.v2");
|
||||||
|
const { isValidItemType, isValidItemGroup } = require("../constants/classifications");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
|
const LEGACY_ITEM_TYPE_MAP = {
|
||||||
|
beverages: "beverage",
|
||||||
|
snacks: "snack",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseBoolean(value) {
|
||||||
|
return value === true || value === "true" || value === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoreLocationId(req) {
|
||||||
|
return req.params.locationId || req.params.storeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCatalogTableMissing(error) {
|
||||||
|
return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClassificationInput(value) {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === "null") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch (error) {
|
||||||
|
return Symbol.for("invalid-classification-json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClassificationPayload(classification) {
|
||||||
|
if (typeof classification === "string") {
|
||||||
|
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
||||||
|
return {
|
||||||
|
item_type: normalizedItemType,
|
||||||
|
item_group: null,
|
||||||
|
zone: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!classification || typeof classification !== "object" || Array.isArray(classification)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item_type =
|
||||||
|
typeof classification.item_type === "string" && classification.item_type.trim() !== ""
|
||||||
|
? classification.item_type.trim()
|
||||||
|
: null;
|
||||||
|
const item_group =
|
||||||
|
typeof classification.item_group === "string" && classification.item_group.trim() !== ""
|
||||||
|
? classification.item_group.trim()
|
||||||
|
: null;
|
||||||
|
const zone =
|
||||||
|
typeof classification.zone === "string" && classification.zone.trim() !== ""
|
||||||
|
? classification.zone.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!item_type && !item_group && !zone) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { item_type, item_group, zone };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateClassification(res, householdId, storeLocationId, classification) {
|
||||||
|
if (!classification) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item_type, item_group, zone } = classification;
|
||||||
|
|
||||||
|
if (item_type && !isValidItemType(item_type)) {
|
||||||
|
sendError(res, 400, "Invalid item_type");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !item_type) {
|
||||||
|
sendError(res, 400, "Item type is required when item group is provided");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
||||||
|
sendError(res, 400, "Invalid item_group for selected item_type");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zone) {
|
||||||
|
const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone);
|
||||||
|
if (!zoneRecord) {
|
||||||
|
sendError(res, 400, "Invalid zone");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseItemId(value) {
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getAvailableItems = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const items = await AvailableItems.listAvailableItems(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
req.query.query || ""
|
||||||
|
);
|
||||||
|
res.json({ items, catalog_ready: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (isCatalogTableMissing(error)) {
|
||||||
|
return res.json({
|
||||||
|
items: [],
|
||||||
|
catalog_ready: false,
|
||||||
|
message: "Store item management is unavailable until the latest database migration is applied.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logError(req, "availableItems.getAvailableItems", error);
|
||||||
|
sendError(res, 500, "Failed to load available items");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createAvailableItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const { item_name } = req.body;
|
||||||
|
|
||||||
|
if (!item_name || item_name.trim() === "") {
|
||||||
|
return sendError(res, 400, "Item name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedClassification = parseClassificationInput(req.body.classification);
|
||||||
|
if (parsedClassification === Symbol.for("invalid-classification-json")) {
|
||||||
|
return sendError(res, 400, "Classification payload must be valid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
||||||
|
if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageBuffer = req.processedImage?.buffer || null;
|
||||||
|
const mimeType = req.processedImage?.mimeType || null;
|
||||||
|
|
||||||
|
const item = await AvailableItems.createAvailableItem(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
item_name,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalizedClassification) {
|
||||||
|
await List.upsertClassification(householdId, storeLocationId, item.item_id, {
|
||||||
|
...normalizedClassification,
|
||||||
|
confidence: 1.0,
|
||||||
|
source: "user",
|
||||||
|
});
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: item.item_id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_CLASSIFICATION_CHANGED",
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
...normalizedClassification,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedItem = await AvailableItems.getAvailableItemById(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
item.item_id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Available item added",
|
||||||
|
item: refreshedItem,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isCatalogTableMissing(error)) {
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
503,
|
||||||
|
"Store item management is unavailable until the latest database migration is applied"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logError(req, "availableItems.createAvailableItem", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Available item already exists for this store");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to add available item");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateAvailableItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId, itemId: rawItemId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const itemId = parseItemId(rawItemId);
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
return sendError(res, 400, "Item ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasClassificationField = Object.prototype.hasOwnProperty.call(req.body, "classification");
|
||||||
|
const parsedClassification = parseClassificationInput(req.body.classification);
|
||||||
|
|
||||||
|
if (parsedClassification === Symbol.for("invalid-classification-json")) {
|
||||||
|
return sendError(res, 400, "Classification payload must be valid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
||||||
|
if (
|
||||||
|
normalizedClassification &&
|
||||||
|
(await validateClassification(res, householdId, storeLocationId, normalizedClassification))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeLocationId, itemId, {
|
||||||
|
itemName: req.body.item_name,
|
||||||
|
imageBuffer: req.processedImage?.buffer || null,
|
||||||
|
mimeType: req.processedImage?.mimeType || null,
|
||||||
|
removeImage: parseBoolean(req.body.remove_image),
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedItem) {
|
||||||
|
return sendError(res, 404, "Available item not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasClassificationField) {
|
||||||
|
if (normalizedClassification) {
|
||||||
|
await List.upsertClassification(householdId, storeLocationId, updatedItem.item_id, {
|
||||||
|
...normalizedClassification,
|
||||||
|
confidence: 1.0,
|
||||||
|
source: "user",
|
||||||
|
});
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: updatedItem.item_id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_CLASSIFICATION_CHANGED",
|
||||||
|
metadata: {
|
||||||
|
item_name: updatedItem.item_name,
|
||||||
|
...normalizedClassification,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await List.deleteClassification(householdId, storeLocationId, updatedItem.item_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedItem = await AvailableItems.getAvailableItemById(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
updatedItem.item_id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "Available item updated",
|
||||||
|
item: refreshedItem,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isCatalogTableMissing(error)) {
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
503,
|
||||||
|
"Store item management is unavailable until the latest database migration is applied"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logError(req, "availableItems.updateAvailableItem", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Available item already exists for this store");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to update available item");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteAvailableItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId, itemId: rawItemId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const itemId = parseItemId(rawItemId);
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
return sendError(res, 400, "Item ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await AvailableItems.deleteAvailableItem(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
itemId,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return sendError(res, 404, "Store item not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Store item deleted" });
|
||||||
|
} catch (error) {
|
||||||
|
if (isCatalogTableMissing(error)) {
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
503,
|
||||||
|
"Store item management is unavailable until the latest database migration is applied"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logError(req, "availableItems.deleteAvailableItem", error);
|
||||||
|
sendError(res, 500, "Failed to delete store item");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.importCurrentItems = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeLocationId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: importedCount > 0 ? "Imported current list items" : "No current list items to import",
|
||||||
|
imported_count: importedCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isCatalogTableMissing(error)) {
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
503,
|
||||||
|
"Store item management is unavailable until the latest database migration is applied"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logError(req, "availableItems.importCurrentItems", error);
|
||||||
|
sendError(res, 500, "Failed to import current list items");
|
||||||
|
}
|
||||||
|
};
|
||||||
14
backend/controllers/config.controller.js
Normal file
14
backend/controllers/config.controller.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Configuration endpoints
|
||||||
|
* Public endpoints that provide application configuration to clients
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { MAX_FILE_SIZE_MB, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants");
|
||||||
|
|
||||||
|
exports.getConfig = (req, res) => {
|
||||||
|
res.json({
|
||||||
|
maxFileSizeMB: MAX_FILE_SIZE_MB,
|
||||||
|
maxImageDimension: MAX_IMAGE_DIMENSION,
|
||||||
|
imageQuality: IMAGE_QUALITY
|
||||||
|
});
|
||||||
|
};
|
||||||
252
backend/controllers/group-invites.controller.js
Normal file
252
backend/controllers/group-invites.controller.js
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
const invitesService = require("../services/group-invites.service");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
const { logError } = require("../utils/logger");
|
||||||
|
const { inviteCodeLast4 } = require("../utils/redaction");
|
||||||
|
|
||||||
|
function getClientIp(req) {
|
||||||
|
const forwardedFor = req.headers["x-forwarded-for"];
|
||||||
|
if (typeof forwardedFor === "string" && forwardedFor.trim()) {
|
||||||
|
return forwardedFor.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return req.ip || req.socket?.remoteAddress || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRequestedGroupId(req) {
|
||||||
|
const headerGroupId = req.headers["x-group-id"] || req.headers["x-household-id"];
|
||||||
|
if (headerGroupId) {
|
||||||
|
const raw = Array.isArray(headerGroupId) ? headerGroupId[0] : headerGroupId;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (req.query?.groupId !== undefined) {
|
||||||
|
return req.query.groupId;
|
||||||
|
}
|
||||||
|
if (req.body?.groupId !== undefined) {
|
||||||
|
return req.body.groupId;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampTtlDays(value) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isInteger(parsed)) return 1;
|
||||||
|
return Math.max(1, Math.min(7, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapServiceError(req, res, error, context, extraLog = {}) {
|
||||||
|
if (error instanceof invitesService.InviteServiceError) {
|
||||||
|
return sendError(res, error.statusCode, error.message, error.code);
|
||||||
|
}
|
||||||
|
logError(req, context, error, extraLog);
|
||||||
|
return sendError(res, 500, "Failed to process invite request");
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.listInviteLinks = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
|
const groupId = await invitesService.resolveManagedGroupId(
|
||||||
|
req.user.id,
|
||||||
|
requestedGroupId
|
||||||
|
);
|
||||||
|
const links = await invitesService.listInviteLinks(req.user.id, groupId);
|
||||||
|
res.json({ links });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.listInviteLinks");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createInviteLink = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
|
const groupId = await invitesService.resolveManagedGroupId(
|
||||||
|
req.user.id,
|
||||||
|
requestedGroupId
|
||||||
|
);
|
||||||
|
const ttlDays = clampTtlDays(req.body?.ttlDays);
|
||||||
|
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||||
|
const link = await invitesService.createInviteLink(
|
||||||
|
req.user.id,
|
||||||
|
groupId,
|
||||||
|
req.body?.policy,
|
||||||
|
Boolean(req.body?.singleUse),
|
||||||
|
expiresAt,
|
||||||
|
req.request_id,
|
||||||
|
getClientIp(req),
|
||||||
|
req.headers["user-agent"] || null
|
||||||
|
);
|
||||||
|
res.status(201).json({ link });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.createInviteLink");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.listPendingJoinRequests = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
|
const groupId = await invitesService.resolveManagedGroupId(
|
||||||
|
req.user.id,
|
||||||
|
requestedGroupId
|
||||||
|
);
|
||||||
|
const requests = await invitesService.listPendingJoinRequests(req.user.id, groupId);
|
||||||
|
res.json({ requests });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.listPendingJoinRequests");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.revokeInviteLink = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
|
const groupId = await invitesService.resolveManagedGroupId(
|
||||||
|
req.user.id,
|
||||||
|
requestedGroupId
|
||||||
|
);
|
||||||
|
await invitesService.revokeInviteLink(
|
||||||
|
req.user.id,
|
||||||
|
groupId,
|
||||||
|
req.body?.linkId,
|
||||||
|
req.request_id,
|
||||||
|
getClientIp(req),
|
||||||
|
req.headers["user-agent"] || null
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.revokeInviteLink");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.reviveInviteLink = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
|
const groupId = await invitesService.resolveManagedGroupId(
|
||||||
|
req.user.id,
|
||||||
|
requestedGroupId
|
||||||
|
);
|
||||||
|
const ttlDays = clampTtlDays(req.body?.ttlDays);
|
||||||
|
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
|
||||||
|
await invitesService.reviveInviteLink(
|
||||||
|
req.user.id,
|
||||||
|
groupId,
|
||||||
|
req.body?.linkId,
|
||||||
|
expiresAt,
|
||||||
|
req.request_id,
|
||||||
|
getClientIp(req),
|
||||||
|
req.headers["user-agent"] || null
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.reviveInviteLink");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteInviteLink = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
|
const groupId = await invitesService.resolveManagedGroupId(
|
||||||
|
req.user.id,
|
||||||
|
requestedGroupId
|
||||||
|
);
|
||||||
|
await invitesService.deleteInviteLink(
|
||||||
|
req.user.id,
|
||||||
|
groupId,
|
||||||
|
req.body?.linkId,
|
||||||
|
req.request_id,
|
||||||
|
getClientIp(req),
|
||||||
|
req.headers["user-agent"] || null
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.deleteInviteLink");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getJoinPolicy = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
|
const groupId = await invitesService.resolveManagedGroupId(
|
||||||
|
req.user.id,
|
||||||
|
requestedGroupId
|
||||||
|
);
|
||||||
|
const joinPolicy = await invitesService.getGroupJoinPolicy(req.user.id, groupId);
|
||||||
|
res.json({ joinPolicy });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.getJoinPolicy");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setJoinPolicy = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
|
const groupId = await invitesService.resolveManagedGroupId(
|
||||||
|
req.user.id,
|
||||||
|
requestedGroupId
|
||||||
|
);
|
||||||
|
await invitesService.setGroupJoinPolicy(
|
||||||
|
req.user.id,
|
||||||
|
groupId,
|
||||||
|
req.body?.joinPolicy,
|
||||||
|
req.request_id,
|
||||||
|
getClientIp(req),
|
||||||
|
req.headers["user-agent"] || null
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.setJoinPolicy");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.decideJoinRequest = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedGroupId = parseRequestedGroupId(req);
|
||||||
|
const groupId = await invitesService.resolveManagedGroupId(
|
||||||
|
req.user.id,
|
||||||
|
requestedGroupId
|
||||||
|
);
|
||||||
|
const decision = await invitesService.decideJoinRequest(
|
||||||
|
req.user.id,
|
||||||
|
groupId,
|
||||||
|
req.body?.requestId,
|
||||||
|
req.body?.decision,
|
||||||
|
req.request_id,
|
||||||
|
getClientIp(req),
|
||||||
|
req.headers["user-agent"] || null
|
||||||
|
);
|
||||||
|
res.json({ request: decision });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.decideJoinRequest");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getInviteLinkSummary = async (req, res) => {
|
||||||
|
const token = req.params.token;
|
||||||
|
const inviteLast4 = inviteCodeLast4(token);
|
||||||
|
try {
|
||||||
|
const link = await invitesService.getInviteLinkSummaryByToken(
|
||||||
|
token,
|
||||||
|
req.user?.id || null
|
||||||
|
);
|
||||||
|
res.json({ link });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.getInviteLinkSummary", {
|
||||||
|
invite_last4: inviteLast4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.acceptInviteLink = async (req, res) => {
|
||||||
|
const token = req.params.token;
|
||||||
|
const inviteLast4 = inviteCodeLast4(token);
|
||||||
|
try {
|
||||||
|
const result = await invitesService.acceptInviteLink(
|
||||||
|
req.user.id,
|
||||||
|
token,
|
||||||
|
req.request_id,
|
||||||
|
getClientIp(req),
|
||||||
|
req.headers["user-agent"] || null
|
||||||
|
);
|
||||||
|
res.json({ result });
|
||||||
|
} catch (error) {
|
||||||
|
return mapServiceError(req, res, error, "groupInvites.acceptInviteLink", {
|
||||||
|
invite_last4: inviteLast4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
275
backend/controllers/households.controller.js
Normal file
275
backend/controllers/households.controller.js
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.reorderHouseholds = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rawHouseholdIds = req.body.household_ids || req.body.householdIds;
|
||||||
|
|
||||||
|
if (!Array.isArray(rawHouseholdIds)) {
|
||||||
|
return sendError(res, 400, "household_ids must be an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const householdIds = rawHouseholdIds.map((householdId) =>
|
||||||
|
Number.parseInt(householdId, 10)
|
||||||
|
);
|
||||||
|
const hasInvalidId = householdIds.some(
|
||||||
|
(householdId) => !Number.isInteger(householdId) || householdId <= 0
|
||||||
|
);
|
||||||
|
const hasDuplicates = new Set(householdIds).size !== householdIds.length;
|
||||||
|
|
||||||
|
if (hasInvalidId || hasDuplicates) {
|
||||||
|
return sendError(res, 400, "household_ids must contain unique positive household IDs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const households = await householdModel.reorderUserHouseholds(req.user.id, householdIds);
|
||||||
|
|
||||||
|
if (!households) {
|
||||||
|
return sendError(res, 400, "Household order must include every household you belong to");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "Household order updated successfully",
|
||||||
|
households,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "households.reorderHouseholds", error);
|
||||||
|
sendError(res, 500, "Failed to update household order");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get household details
|
||||||
|
exports.getHousehold = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const household = await householdModel.getHouseholdById(
|
||||||
|
req.params.householdId,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!household) {
|
||||||
|
return sendError(res, 404, "Household not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(household);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "households.getHousehold", error);
|
||||||
|
sendError(res, 500, "Failed to fetch household");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new household
|
||||||
|
exports.createHousehold = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body;
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Household name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > 100) {
|
||||||
|
return sendError(res, 400, "Household name must be 100 characters or less");
|
||||||
|
}
|
||||||
|
|
||||||
|
const household = await householdModel.createHousehold(
|
||||||
|
name.trim(),
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Household created successfully",
|
||||||
|
household
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "households.createHousehold", error);
|
||||||
|
sendError(res, 500, "Failed to create household");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update household
|
||||||
|
exports.updateHousehold = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body;
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Household name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > 100) {
|
||||||
|
return sendError(res, 400, "Household name must be 100 characters or less");
|
||||||
|
}
|
||||||
|
|
||||||
|
const household = await householdModel.updateHousehold(
|
||||||
|
req.params.householdId,
|
||||||
|
{ name: name.trim() }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "Household updated successfully",
|
||||||
|
household
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "households.updateHousehold", error);
|
||||||
|
sendError(res, 500, "Failed to update household");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete household
|
||||||
|
exports.deleteHousehold = async (req, res) => {
|
||||||
|
try {
|
||||||
|
await householdModel.deleteHousehold(req.params.householdId);
|
||||||
|
res.json({ message: "Household deleted successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "households.deleteHousehold", error);
|
||||||
|
sendError(res, 500, "Failed to delete household");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh invite code
|
||||||
|
exports.refreshInviteCode = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const household = await householdModel.refreshInviteCode(req.params.householdId);
|
||||||
|
res.json({
|
||||||
|
message: "Invite code refreshed successfully",
|
||||||
|
household
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "households.refreshInviteCode", error, {
|
||||||
|
invite_last4: inviteCodeLast4(req.body?.inviteCode),
|
||||||
|
});
|
||||||
|
sendError(res, 500, "Failed to refresh invite code");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Join household via invite code
|
||||||
|
exports.joinHousehold = async (req, res) => {
|
||||||
|
const inviteLast4 = inviteCodeLast4(req.params.inviteCode);
|
||||||
|
try {
|
||||||
|
const { inviteCode } = req.params;
|
||||||
|
if (!inviteCode) return sendError(res, 400, "Invite code is required");
|
||||||
|
|
||||||
|
const result = await householdModel.joinHousehold(
|
||||||
|
inviteCode.toUpperCase(),
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) return sendError(res, 404, "Invalid or expired invite code");
|
||||||
|
|
||||||
|
|
||||||
|
if (result.alreadyMember) {
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "You are already a member of this household",
|
||||||
|
household: { id: result.id, name: result.name }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
message: `Successfully joined ${result.name}`,
|
||||||
|
household: { id: result.id, name: result.name }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "households.joinHousehold", error, { invite_last4: inviteLast4 });
|
||||||
|
sendError(res, 500, "Failed to join household");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get household members
|
||||||
|
exports.getMembers = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const members = await householdModel.getHouseholdMembers(req.params.householdId);
|
||||||
|
res.json(members);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "households.getMembers", error);
|
||||||
|
sendError(res, 500, "Failed to fetch members");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update member role
|
||||||
|
exports.updateMemberRole = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const { role } = req.body;
|
||||||
|
|
||||||
|
if (!role || !["owner", "admin", "member"].includes(role)) {
|
||||||
|
return sendError(res, 400, "Invalid role. Must be 'owner', 'admin', or 'member'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't change own role
|
||||||
|
if (parseInt(userId) === req.user.id) {
|
||||||
|
return sendError(res, 400, "Cannot change your own role");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRole = await householdModel.getUserRole(req.params.householdId, userId);
|
||||||
|
if (!targetRole) {
|
||||||
|
return sendError(res, 404, "Member not found");
|
||||||
|
}
|
||||||
|
if (targetRole === "owner") {
|
||||||
|
return sendError(res, 403, "Owner role cannot be changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated;
|
||||||
|
if (role === "owner") {
|
||||||
|
if (req.household.role !== "owner") {
|
||||||
|
return sendError(res, 403, "Only the household owner can transfer ownership");
|
||||||
|
}
|
||||||
|
|
||||||
|
updated = await householdModel.transferOwnership(
|
||||||
|
req.params.householdId,
|
||||||
|
req.user.id,
|
||||||
|
parseInt(userId, 10)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updated = await householdModel.updateMemberRole(
|
||||||
|
req.params.householdId,
|
||||||
|
userId,
|
||||||
|
role
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: role === "owner"
|
||||||
|
? "Household ownership transferred successfully"
|
||||||
|
: "Member role updated successfully",
|
||||||
|
member: updated
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "households.updateMemberRole", error);
|
||||||
|
sendError(res, 500, "Failed to update member role");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove member
|
||||||
|
exports.removeMember = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const targetUserId = parseInt(userId);
|
||||||
|
|
||||||
|
// Allow users to remove themselves, or admins to remove others
|
||||||
|
if (targetUserId !== req.user.id && !["owner", "admin"].includes(req.household.role)) {
|
||||||
|
return sendError(res, 403, "Only admins or owners can remove other members");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRole = await householdModel.getUserRole(req.params.householdId, userId);
|
||||||
|
if (targetRole === "owner") {
|
||||||
|
return sendError(res, 403, "Owner cannot be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await householdModel.removeMember(req.params.householdId, userId);
|
||||||
|
|
||||||
|
res.json({ message: "Member removed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "households.removeMember", error);
|
||||||
|
sendError(res, 500, "Failed to remove member");
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,5 +1,7 @@
|
|||||||
const List = require("../models/list.model");
|
const List = require("../models/list.model");
|
||||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
|
|
||||||
exports.getList = async (req, res) => {
|
exports.getList = async (req, res) => {
|
||||||
@ -32,7 +34,8 @@ exports.addItem = async (req, res) => {
|
|||||||
|
|
||||||
exports.markBought = async (req, res) => {
|
exports.markBought = async (req, res) => {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
await List.setBought(req.body.id, userId);
|
const { id, quantity } = req.body;
|
||||||
|
await List.setBought(id, userId, quantity);
|
||||||
res.json({ message: "Item marked bought" });
|
res.json({ message: "Item marked bought" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,7 +60,7 @@ exports.updateItemImage = async (req, res) => {
|
|||||||
const mimeType = req.processedImage?.mimeType || null;
|
const mimeType = req.processedImage?.mimeType || null;
|
||||||
|
|
||||||
if (!imageBuffer) {
|
if (!imageBuffer) {
|
||||||
return res.status(400).json({ message: "No image provided" });
|
return sendError(res, 400, "No image provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the item with new image
|
// Update the item with new image
|
||||||
@ -89,15 +92,15 @@ exports.updateItemWithClassification = async (req, res) => {
|
|||||||
|
|
||||||
// Validate classification data
|
// Validate classification data
|
||||||
if (item_type && !isValidItemType(item_type)) {
|
if (item_type && !isValidItemType(item_type)) {
|
||||||
return res.status(400).json({ message: "Invalid item_type" });
|
return sendError(res, 400, "Invalid item_type");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
||||||
return res.status(400).json({ message: "Invalid item_group for selected item_type" });
|
return sendError(res, 400, "Invalid item_group for selected item_type");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (zone && !isValidZone(zone)) {
|
if (zone && !isValidZone(zone)) {
|
||||||
return res.status(400).json({ message: "Invalid zone" });
|
return sendError(res, 400, "Invalid zone");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert classification with confidence=1.0 and source='user'
|
// Upsert classification with confidence=1.0 and source='user'
|
||||||
@ -112,7 +115,7 @@ exports.updateItemWithClassification = async (req, res) => {
|
|||||||
|
|
||||||
res.json({ message: "Item updated successfully" });
|
res.json({ message: "Item updated successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating item with classification:", error);
|
logError(req, "listsLegacy.updateItemWithClassification", error);
|
||||||
res.status(500).json({ message: "Failed to update item" });
|
sendError(res, 500, "Failed to update item");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
484
backend/controllers/lists.controller.v2.js
Normal file
484
backend/controllers/lists.controller.v2.js
Normal file
@ -0,0 +1,484 @@
|
|||||||
|
const List = require("../models/list.model.v2");
|
||||||
|
const householdModel = require("../models/household.model");
|
||||||
|
const { isValidItemType, isValidItemGroup } = require("../constants/classifications");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
|
const LEGACY_ITEM_TYPE_MAP = {
|
||||||
|
beverages: "beverage",
|
||||||
|
snacks: "snack",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStoreLocationId(req) {
|
||||||
|
return req.params.locationId || req.params.storeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClassificationPayload(classification) {
|
||||||
|
if (typeof classification === "string") {
|
||||||
|
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
||||||
|
return {
|
||||||
|
item_type: normalizedItemType,
|
||||||
|
item_group: null,
|
||||||
|
zone: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!classification || typeof classification !== "object" || Array.isArray(classification)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item_type =
|
||||||
|
typeof classification.item_type === "string" && classification.item_type.trim() !== ""
|
||||||
|
? classification.item_type.trim()
|
||||||
|
: null;
|
||||||
|
const item_group =
|
||||||
|
typeof classification.item_group === "string" && classification.item_group.trim() !== ""
|
||||||
|
? classification.item_group.trim()
|
||||||
|
: null;
|
||||||
|
const zone =
|
||||||
|
typeof classification.zone === "string" && classification.zone.trim() !== ""
|
||||||
|
? classification.zone.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!item_type && !item_group && !zone) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { item_type, item_group, zone };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateClassification(res, householdId, storeLocationId, classification) {
|
||||||
|
const { item_type, item_group, zone } = classification;
|
||||||
|
|
||||||
|
if (item_type && !isValidItemType(item_type)) {
|
||||||
|
sendError(res, 400, "Invalid item_type");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !item_type) {
|
||||||
|
sendError(res, 400, "Item type is required when item group is provided");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
||||||
|
sendError(res, 400, "Invalid item_group for selected item_type");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zone) {
|
||||||
|
const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone);
|
||||||
|
if (!zoneRecord) {
|
||||||
|
sendError(res, 400, "Invalid zone");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getList = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const items = await List.getHouseholdStoreList(householdId, storeLocationId);
|
||||||
|
res.json({ items });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "listsV2.getList", error);
|
||||||
|
sendError(res, 500, "Failed to get list");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getItemByName = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const { item_name } = req.query;
|
||||||
|
|
||||||
|
if (!item_name) {
|
||||||
|
return sendError(res, 400, "Item name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await List.getItemByName(householdId, storeLocationId, 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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.addItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageBuffer = req.processedImage?.buffer || null;
|
||||||
|
const mimeType = req.processedImage?.mimeType || null;
|
||||||
|
|
||||||
|
const result = await List.addOrUpdateItem(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
item_name,
|
||||||
|
quantity || "1",
|
||||||
|
userId,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
notes
|
||||||
|
);
|
||||||
|
|
||||||
|
await List.addHistoryRecord(
|
||||||
|
result.listId,
|
||||||
|
result.householdStoreItemId,
|
||||||
|
result.historyQuantity ?? quantity ?? "1",
|
||||||
|
historyUserId,
|
||||||
|
storeLocationId
|
||||||
|
);
|
||||||
|
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: result.householdStoreItemId,
|
||||||
|
householdListId: result.listId,
|
||||||
|
actorUserId: historyUserId,
|
||||||
|
eventType: "ITEM_ADDED",
|
||||||
|
quantityDelta: result.historyQuantity ?? Number.parseInt(quantity || "1", 10),
|
||||||
|
quantityAfter: result.quantity,
|
||||||
|
metadata: {
|
||||||
|
item_name: result.itemName,
|
||||||
|
is_new_list_item: result.isNew,
|
||||||
|
added_by_request_user_id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: result.isNew ? "Item added" : "Item updated",
|
||||||
|
item: {
|
||||||
|
id: result.listId,
|
||||||
|
item_name: result.itemName,
|
||||||
|
quantity: result.quantity ?? quantity ?? "1",
|
||||||
|
bought: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "listsV2.addItem", error);
|
||||||
|
sendError(res, 500, "Failed to add item");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.markBought = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
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, storeLocationId, item_name);
|
||||||
|
if (!item) return sendError(res, 404, "Item not found");
|
||||||
|
|
||||||
|
const eventDetails = await List.setBought(item.id, bought, quantity_bought);
|
||||||
|
|
||||||
|
if (eventDetails) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: item.household_store_item_id,
|
||||||
|
householdListId: item.id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: eventDetails.eventType,
|
||||||
|
quantityDelta: eventDetails.quantityDelta,
|
||||||
|
quantityAfter: eventDetails.quantityAfter,
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
requested_quantity: quantity_bought || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "listsV2.markBought", error);
|
||||||
|
sendError(res, 500, "Failed to update item");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const { item_name, quantity, notes } = req.body;
|
||||||
|
|
||||||
|
if (!item_name) {
|
||||||
|
return sendError(res, 400, "Item name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
|
if (!item) {
|
||||||
|
return sendError(res, 404, "Item not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateResult = await List.updateItem(item.id, item_name, quantity, notes);
|
||||||
|
if (!updateResult) {
|
||||||
|
return sendError(res, 404, "Item not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quantity !== undefined && Number(quantity) !== Number(updateResult.previous.quantity)) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: item.household_store_item_id,
|
||||||
|
householdListId: item.id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_QUANTITY_CHANGED",
|
||||||
|
quantityDelta: Number(quantity) - Number(updateResult.previous.quantity),
|
||||||
|
quantityAfter: Number(quantity),
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
previous_quantity: updateResult.previous.quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const { item_name } = req.body;
|
||||||
|
|
||||||
|
if (!item_name) {
|
||||||
|
return sendError(res, 400, "Item name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
|
if (!item) {
|
||||||
|
return sendError(res, 404, "Item not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await List.deleteItem(item.id);
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: item.household_store_item_id,
|
||||||
|
householdListId: item.id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_DELETED",
|
||||||
|
quantityDelta: -Number(item.quantity || 0),
|
||||||
|
quantityAfter: 0,
|
||||||
|
metadata: { item_name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Item deleted" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "listsV2.deleteItem", error);
|
||||||
|
sendError(res, 500, "Failed to delete item");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getSuggestions = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const { query } = req.query;
|
||||||
|
|
||||||
|
const suggestions = await List.getSuggestions(query || "", householdId, storeLocationId);
|
||||||
|
res.json(suggestions);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "listsV2.getSuggestions", error);
|
||||||
|
sendError(res, 500, "Failed to get suggestions");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getRecentlyBought = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const items = await List.getRecentlyBoughtItems(householdId, storeLocationId);
|
||||||
|
res.json(items);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "listsV2.getRecentlyBought", error);
|
||||||
|
sendError(res, 500, "Failed to get recent items");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getClassification = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const { item_name } = req.query;
|
||||||
|
|
||||||
|
if (!item_name) {
|
||||||
|
return sendError(res, 400, "Item name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
|
if (!item) {
|
||||||
|
return res.json({ classification: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const classification = await List.getClassification(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
item.item_id
|
||||||
|
);
|
||||||
|
res.json({ classification });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "listsV2.getClassification", error);
|
||||||
|
sendError(res, 500, "Failed to get classification");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setClassification = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const { item_name, classification } = req.body;
|
||||||
|
|
||||||
|
if (!item_name) {
|
||||||
|
return sendError(res, 400, "Item name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedClassification = normalizeClassificationPayload(classification);
|
||||||
|
if (!normalizedClassification) {
|
||||||
|
return sendError(res, 400, "Classification is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
|
let itemId;
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
const itemResult = await List.ensureHouseholdStoreItem(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
item_name
|
||||||
|
);
|
||||||
|
itemId = itemResult.id;
|
||||||
|
} else {
|
||||||
|
itemId = item.item_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await List.upsertClassification(householdId, storeLocationId, itemId, {
|
||||||
|
...normalizedClassification,
|
||||||
|
confidence: 1.0,
|
||||||
|
source: "user",
|
||||||
|
});
|
||||||
|
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: itemId,
|
||||||
|
householdListId: item?.id || null,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_CLASSIFICATION_CHANGED",
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
item_type: normalizedClassification.item_type,
|
||||||
|
item_group: normalizedClassification.item_group,
|
||||||
|
zone: normalizedClassification.zone,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (normalizedClassification.zone) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: itemId,
|
||||||
|
householdListId: item?.id || null,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_ZONE_CHANGED",
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
zone: normalizedClassification.zone,
|
||||||
|
zone_id: updated.zone_id || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Classification set", classification: normalizedClassification });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "listsV2.setClassification", error);
|
||||||
|
sendError(res, 500, "Failed to set classification");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateItemImage = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const { item_name, quantity } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
const imageBuffer = req.processedImage?.buffer || null;
|
||||||
|
const mimeType = req.processedImage?.mimeType || null;
|
||||||
|
|
||||||
|
if (!imageBuffer) {
|
||||||
|
return sendError(res, 400, "No image provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
await List.addOrUpdateItem(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
412
backend/controllers/stores.controller.js
Normal file
412
backend/controllers/stores.controller.js
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
const storeModel = require("../models/store.model");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
|
function parsePositiveInteger(value) {
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHouseholdId(req) {
|
||||||
|
return req.params.householdId || req.household?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocationId(req) {
|
||||||
|
return req.params.locationId || req.params.storeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy global store catalog. Kept for system-admin compatibility.
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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") {
|
||||||
|
return sendError(res, 400, "Store with this name already exists");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to create store");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Household-owned store/location management.
|
||||||
|
exports.getHouseholdStores = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stores = await storeModel.getHouseholdStores(getHouseholdId(req));
|
||||||
|
res.json(stores);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.getHouseholdStores", error);
|
||||||
|
sendError(res, 500, "Failed to fetch household stores");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createHouseholdStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const householdId = getHouseholdId(req);
|
||||||
|
const { name, location_name, address } = req.body;
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Store name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await storeModel.createHouseholdStore(
|
||||||
|
householdId,
|
||||||
|
name,
|
||||||
|
location_name || "Default Location",
|
||||||
|
address || null,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Store location created successfully",
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.createHouseholdStore", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Store or location already exists for this household");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to create store location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateHouseholdStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body;
|
||||||
|
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||||
|
|
||||||
|
if (!householdStoreId) {
|
||||||
|
return sendError(res, 400, "Store ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Store name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await storeModel.updateHouseholdStore(getHouseholdId(req), householdStoreId, {
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Store updated successfully", store });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.updateHouseholdStore", error);
|
||||||
|
sendError(res, 500, "Failed to update store");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteHouseholdStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||||
|
if (!householdStoreId) {
|
||||||
|
return sendError(res, 400, "Store ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await storeModel.deleteHouseholdStore(getHouseholdId(req), householdStoreId);
|
||||||
|
if (!deleted) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Store deleted successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.deleteHouseholdStore", error);
|
||||||
|
if (error.message.includes("last store location")) {
|
||||||
|
return sendError(res, 400, error.message);
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to delete store");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.addLocationToStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||||
|
const { name, address } = req.body;
|
||||||
|
|
||||||
|
if (!householdStoreId) {
|
||||||
|
return sendError(res, 400, "Store ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Location name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await storeModel.addLocationToStore(
|
||||||
|
getHouseholdId(req),
|
||||||
|
householdStoreId,
|
||||||
|
name,
|
||||||
|
address || null,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Location added successfully",
|
||||||
|
store: location,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.addLocationToStore", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Location already exists for this store");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to add location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateLocation = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const { name, address, map_data } = req.body;
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await storeModel.updateLocation(getHouseholdId(req), locationId, {
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
map_data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
return sendError(res, 404, "Location not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Location updated successfully", store: location });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.updateLocation", error);
|
||||||
|
sendError(res, 500, "Failed to update location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteLocation = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await storeModel.deleteLocation(getHouseholdId(req), locationId);
|
||||||
|
if (!deleted) {
|
||||||
|
return sendError(res, 404, "Location not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Location removed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.deleteLocation", error);
|
||||||
|
if (error.message.includes("last store location")) {
|
||||||
|
return sendError(res, 400, error.message);
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to remove location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setDefaultLocation = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeModel.setDefaultLocation(getHouseholdId(req), locationId);
|
||||||
|
res.json({ message: "Default location updated successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.setDefaultLocation", error);
|
||||||
|
sendError(res, 500, "Failed to set default location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getLocationZones = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const zones = await storeModel.listLocationZones(getHouseholdId(req), locationId);
|
||||||
|
res.json({ zones });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.getLocationZones", error);
|
||||||
|
sendError(res, 500, "Failed to load zones");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createZone = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const { name, sort_order, color, map_metadata } = req.body;
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Zone name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const zone = await storeModel.createZone(getHouseholdId(req), locationId, {
|
||||||
|
name,
|
||||||
|
sort_order: Number.isInteger(sort_order) ? sort_order : Number.parseInt(sort_order, 10),
|
||||||
|
color,
|
||||||
|
map_metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ message: "Zone created successfully", zone });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.createZone", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Zone already exists for this location");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to create zone");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateZone = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const zoneId = parsePositiveInteger(req.params.zoneId);
|
||||||
|
|
||||||
|
if (!locationId || !zoneId) {
|
||||||
|
return sendError(res, 400, "Location ID and zone ID must be positive integers");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrder = req.body.sort_order;
|
||||||
|
const zone = await storeModel.updateZone(getHouseholdId(req), locationId, zoneId, {
|
||||||
|
name: req.body.name,
|
||||||
|
sort_order:
|
||||||
|
sortOrder === undefined
|
||||||
|
? undefined
|
||||||
|
: Number.isInteger(sortOrder)
|
||||||
|
? sortOrder
|
||||||
|
: Number.parseInt(sortOrder, 10),
|
||||||
|
color: req.body.color,
|
||||||
|
map_metadata: req.body.map_metadata,
|
||||||
|
is_active: req.body.is_active,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!zone) {
|
||||||
|
return sendError(res, 404, "Zone not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Zone updated successfully", zone });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.updateZone", error);
|
||||||
|
sendError(res, 500, "Failed to update zone");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteZone = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const zoneId = parsePositiveInteger(req.params.zoneId);
|
||||||
|
|
||||||
|
if (!locationId || !zoneId) {
|
||||||
|
return sendError(res, 400, "Location ID and zone ID must be positive integers");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await storeModel.deleteZone(getHouseholdId(req), locationId, zoneId);
|
||||||
|
if (!deleted) {
|
||||||
|
return sendError(res, 404, "Zone not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Zone removed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.deleteZone", error);
|
||||||
|
sendError(res, 500, "Failed to remove zone");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backward-compatible handlers for the old /stores/household routes.
|
||||||
|
exports.addStoreToHousehold = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { storeId } = req.body;
|
||||||
|
if (!storeId) {
|
||||||
|
return sendError(res, 400, "Store ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyStore = await storeModel.getStoreById(storeId);
|
||||||
|
if (!legacyStore) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await storeModel.createHouseholdStore(
|
||||||
|
getHouseholdId(req),
|
||||||
|
legacyStore.name,
|
||||||
|
"Default Location",
|
||||||
|
null,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.removeStoreFromHousehold = exports.deleteLocation;
|
||||||
|
exports.setDefaultStore = exports.setDefaultLocation;
|
||||||
@ -1,12 +1,13 @@
|
|||||||
const User = require("../models/user.model");
|
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) => {
|
exports.test = async (req, res) => {
|
||||||
console.log("User route is working");
|
|
||||||
res.json({ message: "User route is working" });
|
res.json({ message: "User route is working" });
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getAllUsers = async (req, res) => {
|
exports.getAllUsers = async (req, res) => {
|
||||||
console.log(req);
|
|
||||||
const users = await User.getAllUsers();
|
const users = await User.getAllUsers();
|
||||||
res.json(users);
|
res.json(users);
|
||||||
};
|
};
|
||||||
@ -15,18 +16,17 @@ exports.getAllUsers = async (req, res) => {
|
|||||||
exports.updateUserRole = async (req, res) => {
|
exports.updateUserRole = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id, role } = req.body;
|
const { id, role } = req.body;
|
||||||
|
|
||||||
console.log(`Updating user ${id} to role ${role}`);
|
|
||||||
if (!Object.values(User.ROLES).includes(role))
|
if (!Object.values(User.ROLES).includes(role))
|
||||||
return res.status(400).json({ error: "Invalid role" });
|
return sendError(res, 400, "Invalid role");
|
||||||
|
|
||||||
const updated = await User.updateUserRole(id, role);
|
const updated = await User.updateUserRole(id, role);
|
||||||
if (!updated)
|
if (!updated)
|
||||||
return res.status(404).json({ error: "User not found" });
|
return sendError(res, 404, "User not found");
|
||||||
|
|
||||||
res.json({ message: "Role updated", id, role });
|
res.json({ message: "Role updated", id, role });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: "Failed to update role" });
|
logError(req, "users.updateUserRole", err);
|
||||||
|
sendError(res, 500, "Failed to update role");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -36,20 +36,102 @@ exports.deleteUser = async (req, res) => {
|
|||||||
|
|
||||||
const deleted = await User.deleteUser(id);
|
const deleted = await User.deleteUser(id);
|
||||||
if (!deleted)
|
if (!deleted)
|
||||||
return res.status(404).json({ error: "User not found" });
|
return sendError(res, 404, "User not found");
|
||||||
|
|
||||||
|
|
||||||
res.json({ message: "User deleted", id });
|
res.json({ message: "User deleted", id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: "Failed to delete user" });
|
logError(req, "users.deleteUser", err);
|
||||||
|
sendError(res, 500, "Failed to delete user");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
exports.checkIfUserExists = async (req, res) => {
|
exports.checkIfUserExists = async (req, res) => {
|
||||||
const { username } = req.query;
|
const { username } = req.query;
|
||||||
const users = await User.checkIfUserExists(username);
|
const exists = await User.checkIfUserExists(username);
|
||||||
res.json(users);
|
res.json(exists);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getCurrentUser = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const user = await User.getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return sendError(res, 404, "User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(user);
|
||||||
|
} catch (err) {
|
||||||
|
logError(req, "users.getCurrentUser", err);
|
||||||
|
sendError(res, 500, "Failed to get user profile");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateCurrentUser = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { display_name } = req.body;
|
||||||
|
|
||||||
|
if (!display_name || display_name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Display name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (display_name.length > 100) {
|
||||||
|
return sendError(res, 400, "Display name must be 100 characters or less");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await User.updateUserProfile(userId, { display_name: display_name.trim() });
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return sendError(res, 404, "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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.changePassword = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { current_password, new_password } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!current_password || !new_password) {
|
||||||
|
return sendError(res, 400, "Current password and new password are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new_password.length < 6) {
|
||||||
|
return sendError(res, 400, "New password must be at least 6 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current password hash
|
||||||
|
const currentHash = await User.getUserPasswordHash(userId);
|
||||||
|
|
||||||
|
if (!currentHash) {
|
||||||
|
return sendError(res, 404, "User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isValidPassword = await bcrypt.compare(current_password, currentHash);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return sendError(res, 401, "Current password is incorrect");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const hashedPassword = await bcrypt.hash(new_password, salt);
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await User.updateUserPassword(userId, hashedPassword);
|
||||||
|
|
||||||
|
res.json({ message: "Password changed successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
logError(req, "users.changePassword", err);
|
||||||
|
sendError(res, 500, "Failed to change password");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,11 +1,21 @@
|
|||||||
const { Pool } = require("pg");
|
const { Pool } = require("pg");
|
||||||
|
|
||||||
const pool = new Pool({
|
function buildPoolConfig() {
|
||||||
|
if (process.env.DATABASE_URL) {
|
||||||
|
return {
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
password: process.env.DB_PASS,
|
password: process.env.DB_PASS,
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
port: 5432,
|
port: Number(process.env.DB_PORT || 5432),
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool(buildPoolConfig());
|
||||||
|
|
||||||
module.exports = pool;
|
module.exports = pool;
|
||||||
|
|||||||
@ -1,18 +1,57 @@
|
|||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
const Session = require("../models/session.model");
|
||||||
|
const { parseCookieHeader } = require("../utils/cookies");
|
||||||
|
const { cookieName } = require("../utils/session-cookie");
|
||||||
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
function auth(req, res, next) {
|
async function auth(req, res, next) {
|
||||||
const header = req.headers.authorization;
|
const header = req.headers.authorization || "";
|
||||||
if (!header) return res.status(401).json({ message: "Missing token" });
|
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null;
|
||||||
|
const cookies = parseCookieHeader(req.headers.cookie);
|
||||||
|
const sid = cookies[cookieName()];
|
||||||
|
|
||||||
const token = header.split(" ")[1];
|
if (token) {
|
||||||
if (!token) return res.status(401).json({ message: "Invalid token format" });
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
|
if (!jwtSecret && !sid) {
|
||||||
|
logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured"));
|
||||||
|
return sendError(res, 500, "Authentication is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jwtSecret) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, jwtSecret);
|
||||||
|
req.user = decoded; // id + role
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
if (!sid) {
|
||||||
|
return sendError(res, 401, "Invalid or expired token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
if (!sid) {
|
||||||
req.user = decoded; // id + role
|
return sendError(res, 401, "Missing authentication");
|
||||||
next();
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
res.status(401).json({ message: "Invalid or expired token" });
|
logError(req, "middleware.auth", err);
|
||||||
|
return sendError(res, 500, "Authentication check failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
141
backend/middleware/household.js
Normal file
141
backend/middleware/household.js
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
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 check location access (household must own the store location)
|
||||||
|
exports.locationAccess = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const locationId = parseInt(req.params.locationId || req.params.storeId);
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.household) {
|
||||||
|
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeModel = require("../models/store.model");
|
||||||
|
const hasLocation = await storeModel.householdHasLocation(req.household.id, locationId);
|
||||||
|
|
||||||
|
if (!hasLocation) {
|
||||||
|
return sendError(res, 403, "This household does not have access to this store location.");
|
||||||
|
}
|
||||||
|
|
||||||
|
req.storeLocation = {
|
||||||
|
id: locationId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep req.store populated so older controller code and tests can continue
|
||||||
|
// to refer to the active shopping scope as a store.
|
||||||
|
req.store = {
|
||||||
|
id: locationId
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "middleware.locationAccess", error);
|
||||||
|
sendError(res, 500, "Server error checking location 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,11 +1,13 @@
|
|||||||
const multer = require("multer");
|
const multer = require("multer");
|
||||||
const sharp = require("sharp");
|
const sharp = require("sharp");
|
||||||
|
const { MAX_FILE_SIZE_BYTES, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
|
||||||
// Configure multer for memory storage (we'll process before saving to DB)
|
// Configure multer for memory storage (we'll process before saving to DB)
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
fileSize: MAX_FILE_SIZE_BYTES,
|
||||||
},
|
},
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
// Only accept images
|
// Only accept images
|
||||||
@ -24,13 +26,13 @@ const processImage = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Compress and resize image to 800x800px, JPEG quality 85
|
// Compress and resize image using constants
|
||||||
const processedBuffer = await sharp(req.file.buffer)
|
const processedBuffer = await sharp(req.file.buffer)
|
||||||
.resize(800, 800, {
|
.resize(MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION, {
|
||||||
fit: "inside",
|
fit: "inside",
|
||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
})
|
})
|
||||||
.jpeg({ quality: 85 })
|
.jpeg({ quality: IMAGE_QUALITY })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
// Attach processed image to request
|
// Attach processed image to request
|
||||||
@ -41,7 +43,7 @@ const processImage = async (req, res, next) => {
|
|||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ message: "Error processing image: " + error.message });
|
sendError(res, 400, `Error processing image: ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
45
backend/middleware/optional-auth.js
Normal file
45
backend/middleware/optional-auth.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
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) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, jwtSecret);
|
||||||
|
req.user = decoded;
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
// Continue to the session cookie fallback below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
59
backend/middleware/rate-limit.js
Normal file
59
backend/middleware/rate-limit.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
|
||||||
|
const buckets = new Map();
|
||||||
|
|
||||||
|
function pruneExpired(now) {
|
||||||
|
for (const [key, value] of buckets.entries()) {
|
||||||
|
if (value.resetAt <= now) {
|
||||||
|
buckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientIp(req) {
|
||||||
|
const forwardedFor = req.headers["x-forwarded-for"];
|
||||||
|
if (typeof forwardedFor === "string" && forwardedFor.trim()) {
|
||||||
|
return forwardedFor.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return req.ip || req.socket?.remoteAddress || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRateLimit({ keyPrefix, windowMs, max, message, keyFn }) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (buckets.size > 5000) {
|
||||||
|
pruneExpired(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = typeof keyFn === "function" ? keyFn(req) : getClientIp(req);
|
||||||
|
const key = `${keyPrefix}:${suffix || "unknown"}`;
|
||||||
|
const existing = buckets.get(key);
|
||||||
|
const bucket =
|
||||||
|
!existing || existing.resetAt <= now
|
||||||
|
? { count: 0, resetAt: now + windowMs }
|
||||||
|
: existing;
|
||||||
|
|
||||||
|
bucket.count += 1;
|
||||||
|
buckets.set(key, bucket);
|
||||||
|
|
||||||
|
if (bucket.count > max) {
|
||||||
|
const retryAfterSeconds = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil((bucket.resetAt - now) / 1000)
|
||||||
|
);
|
||||||
|
res.setHeader("Retry-After", String(retryAfterSeconds));
|
||||||
|
return sendError(
|
||||||
|
res,
|
||||||
|
429,
|
||||||
|
message || "Too many requests. Please try again later."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRateLimit,
|
||||||
|
};
|
||||||
@ -1,8 +1,10 @@
|
|||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
|
||||||
function requireRole(...allowedRoles) {
|
function requireRole(...allowedRoles) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (!req.user) return res.status(401).json({ message: "Authentication required" });
|
if (!req.user) return sendError(res, 401, "Authentication required");
|
||||||
if (!allowedRoles.includes(req.user.role))
|
if (!allowedRoles.includes(req.user.role))
|
||||||
return res.status(403).json({ message: "Forbidden" });
|
return sendError(res, 403, "Forbidden");
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|||||||
47
backend/middleware/request-id.js
Normal file
47
backend/middleware/request-id.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
const { normalizeErrorPayload } = require("../utils/http");
|
||||||
|
|
||||||
|
function generateRequestId() {
|
||||||
|
if (typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return crypto.randomBytes(16).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
Object.prototype.toString.call(value) === "[object Object]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestIdMiddleware(req, res, next) {
|
||||||
|
const requestId = generateRequestId();
|
||||||
|
|
||||||
|
req.request_id = requestId;
|
||||||
|
res.locals.request_id = requestId;
|
||||||
|
res.setHeader("X-Request-Id", requestId);
|
||||||
|
|
||||||
|
const originalJson = res.json.bind(res);
|
||||||
|
res.json = (payload) => {
|
||||||
|
const normalizedPayload = normalizeErrorPayload(payload, res.statusCode);
|
||||||
|
|
||||||
|
if (isPlainObject(normalizedPayload)) {
|
||||||
|
if (normalizedPayload.request_id === undefined) {
|
||||||
|
return originalJson({ ...normalizedPayload, request_id: requestId });
|
||||||
|
}
|
||||||
|
return originalJson(normalizedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalJson({
|
||||||
|
data: normalizedPayload,
|
||||||
|
request_id: requestId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = requestIdMiddleware;
|
||||||
243
backend/migrations/MIGRATION_GUIDE.md
Normal file
243
backend/migrations/MIGRATION_GUIDE.md
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
# Multi-Household Architecture Migration Guide
|
||||||
|
|
||||||
|
## Pre-Migration Checklist
|
||||||
|
|
||||||
|
- [ ] **Backup Database**
|
||||||
|
```bash
|
||||||
|
pg_dump -U your_user -d grocery_list > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Test on Staging First**
|
||||||
|
- Copy production database to staging environment
|
||||||
|
- Run migration on staging
|
||||||
|
- Verify all data migrated correctly
|
||||||
|
- Test application functionality
|
||||||
|
|
||||||
|
- [ ] **Review Migration Script**
|
||||||
|
- Read through `multi_household_architecture.sql`
|
||||||
|
- Understand each step
|
||||||
|
- Note verification queries
|
||||||
|
|
||||||
|
- [ ] **Announce Maintenance Window**
|
||||||
|
- Notify users of downtime
|
||||||
|
- Schedule during low-usage period
|
||||||
|
- Estimate 15-30 minutes for migration
|
||||||
|
|
||||||
|
## Running the Migration
|
||||||
|
|
||||||
|
### 1. Connect to Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U your_user -d grocery_list
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run Migration
|
||||||
|
|
||||||
|
```sql
|
||||||
|
\i backend/migrations/multi_household_architecture.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. ✅ Create 8 new tables
|
||||||
|
2. ✅ Create default "Main Household"
|
||||||
|
3. ✅ Create default "Costco" store
|
||||||
|
4. ✅ Migrate all users to household members
|
||||||
|
5. ✅ Extract items to master catalog
|
||||||
|
6. ✅ Migrate grocery_list → household_lists
|
||||||
|
7. ✅ Migrate classifications
|
||||||
|
8. ✅ Migrate history records
|
||||||
|
9. ✅ Update user system roles
|
||||||
|
|
||||||
|
### 3. Verify Migration
|
||||||
|
|
||||||
|
Run these queries inside psql:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check household created
|
||||||
|
SELECT * FROM households;
|
||||||
|
|
||||||
|
-- Check all users migrated
|
||||||
|
SELECT u.username, u.role as system_role, hm.role as household_role
|
||||||
|
FROM users u
|
||||||
|
JOIN household_members hm ON u.id = hm.user_id
|
||||||
|
ORDER BY u.id;
|
||||||
|
|
||||||
|
-- Check item counts match
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(DISTINCT item_name) FROM grocery_list) as old_unique_items,
|
||||||
|
(SELECT COUNT(*) FROM items) as new_items;
|
||||||
|
|
||||||
|
-- Check list counts
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM grocery_list) as old_lists,
|
||||||
|
(SELECT COUNT(*) FROM household_lists) as new_lists;
|
||||||
|
|
||||||
|
-- Check classification counts
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM item_classification) as old_classifications,
|
||||||
|
(SELECT COUNT(*) FROM household_item_classifications) as new_classifications;
|
||||||
|
|
||||||
|
-- Check history counts
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM grocery_history) as old_history,
|
||||||
|
(SELECT COUNT(*) FROM household_list_history) as new_history;
|
||||||
|
|
||||||
|
-- Verify no data loss - check if all old items have corresponding new records
|
||||||
|
SELECT gl.item_name
|
||||||
|
FROM grocery_list gl
|
||||||
|
LEFT JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
|
||||||
|
LEFT JOIN household_lists hl ON hl.item_id = i.id
|
||||||
|
WHERE hl.id IS NULL;
|
||||||
|
-- Should return 0 rows
|
||||||
|
|
||||||
|
-- Check invite code
|
||||||
|
SELECT name, invite_code FROM households;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test Application
|
||||||
|
|
||||||
|
- [ ] Users can log in
|
||||||
|
- [ ] Can view "Main Household" list
|
||||||
|
- [ ] Can add items
|
||||||
|
- [ ] Can mark items as bought
|
||||||
|
- [ ] History shows correctly
|
||||||
|
- [ ] Classifications preserved
|
||||||
|
- [ ] Images display correctly
|
||||||
|
|
||||||
|
## Post-Migration Cleanup
|
||||||
|
|
||||||
|
**Only after verifying everything works correctly:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Drop old tables (CAREFUL - THIS IS IRREVERSIBLE)
|
||||||
|
DROP TABLE IF EXISTS grocery_history CASCADE;
|
||||||
|
DROP TABLE IF EXISTS item_classification CASCADE;
|
||||||
|
DROP TABLE IF EXISTS grocery_list CASCADE;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
### If Migration Fails
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Inside psql during migration
|
||||||
|
ROLLBACK;
|
||||||
|
|
||||||
|
-- Then restore from backup
|
||||||
|
\q
|
||||||
|
psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### If Issues Found After Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Drop the database and restore
|
||||||
|
dropdb grocery_list
|
||||||
|
createdb grocery_list
|
||||||
|
psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues & Solutions
|
||||||
|
|
||||||
|
### Issue: Duplicate items in items table
|
||||||
|
**Cause**: Case-insensitive matching not working
|
||||||
|
**Solution**: Check item names for leading/trailing spaces
|
||||||
|
|
||||||
|
### Issue: Foreign key constraint errors
|
||||||
|
**Cause**: User or item references not found
|
||||||
|
**Solution**: Verify all users and items exist before migrating lists
|
||||||
|
|
||||||
|
### Issue: History not showing
|
||||||
|
**Cause**: household_list_id references incorrect
|
||||||
|
**Solution**: Check JOIN conditions in history migration
|
||||||
|
|
||||||
|
### Issue: Images not displaying
|
||||||
|
**Cause**: BYTEA encoding issues
|
||||||
|
**Solution**: Verify image_mime_type correctly migrated
|
||||||
|
|
||||||
|
## Migration Timeline
|
||||||
|
|
||||||
|
- **T-0**: Begin maintenance window
|
||||||
|
- **T+2min**: Backup complete
|
||||||
|
- **T+3min**: Start migration script
|
||||||
|
- **T+8min**: Migration complete (for ~1000 items)
|
||||||
|
- **T+10min**: Run verification queries
|
||||||
|
- **T+15min**: Test application functionality
|
||||||
|
- **T+20min**: If successful, announce completion
|
||||||
|
- **T+30min**: End maintenance window
|
||||||
|
|
||||||
|
## Data Integrity Checks
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Ensure all users belong to at least one household
|
||||||
|
SELECT u.id, u.username
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN household_members hm ON u.id = hm.user_id
|
||||||
|
WHERE hm.id IS NULL;
|
||||||
|
-- Should return 0 rows
|
||||||
|
|
||||||
|
-- Ensure all household lists have valid items
|
||||||
|
SELECT hl.id
|
||||||
|
FROM household_lists hl
|
||||||
|
LEFT JOIN items i ON hl.item_id = i.id
|
||||||
|
WHERE i.id IS NULL;
|
||||||
|
-- Should return 0 rows
|
||||||
|
|
||||||
|
-- Ensure all history has valid list references
|
||||||
|
SELECT hlh.id
|
||||||
|
FROM household_list_history hlh
|
||||||
|
LEFT JOIN household_lists hl ON hlh.household_list_id = hl.id
|
||||||
|
WHERE hl.id IS NULL;
|
||||||
|
-- Should return 0 rows
|
||||||
|
|
||||||
|
-- Check for orphaned classifications
|
||||||
|
SELECT hic.id
|
||||||
|
FROM household_item_classifications hic
|
||||||
|
LEFT JOIN household_lists hl ON hic.item_id = hl.item_id
|
||||||
|
AND hic.household_id = hl.household_id
|
||||||
|
AND hic.store_id = hl.store_id
|
||||||
|
WHERE hl.id IS NULL;
|
||||||
|
-- Should return 0 rows (or classifications for removed items, which is ok)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
✅ All tables created successfully
|
||||||
|
✅ All users migrated to "Main Household"
|
||||||
|
✅ Item count matches (unique items from old → new)
|
||||||
|
✅ List count matches (all grocery_list items → household_lists)
|
||||||
|
✅ Classification count matches
|
||||||
|
✅ History count matches
|
||||||
|
✅ No NULL foreign keys
|
||||||
|
✅ Application loads without errors
|
||||||
|
✅ Users can perform all CRUD operations
|
||||||
|
✅ Images display correctly
|
||||||
|
✅ Bought items still marked as bought
|
||||||
|
✅ Recently bought still shows correctly
|
||||||
|
|
||||||
|
## Next Steps After Migration
|
||||||
|
|
||||||
|
1. ✅ Update backend models (Sprint 2)
|
||||||
|
2. ✅ Update API routes
|
||||||
|
3. ✅ Update controllers
|
||||||
|
4. ✅ Test all endpoints
|
||||||
|
5. ✅ Update frontend contexts
|
||||||
|
6. ✅ Update UI components
|
||||||
|
7. ✅ Enable multi-household features
|
||||||
|
|
||||||
|
## Support & Troubleshooting
|
||||||
|
|
||||||
|
If issues arise:
|
||||||
|
1. Check PostgreSQL logs: `/var/log/postgresql/`
|
||||||
|
2. Check application logs
|
||||||
|
3. Restore from backup if needed
|
||||||
|
4. Review migration script for errors
|
||||||
|
|
||||||
|
## Monitoring Post-Migration
|
||||||
|
|
||||||
|
For the first 24 hours after migration:
|
||||||
|
- Monitor error logs
|
||||||
|
- Watch for performance issues
|
||||||
|
- Verify user activity normal
|
||||||
|
- Check for any data inconsistencies
|
||||||
|
- Be ready to rollback if critical issues found
|
||||||
10
backend/migrations/add_display_name_column.sql
Normal file
10
backend/migrations/add_display_name_column.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- Add display_name column to users table
|
||||||
|
-- This allows users to have a friendly name separate from their username
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS display_name VARCHAR(100);
|
||||||
|
|
||||||
|
-- Set display_name to name for existing users (as default)
|
||||||
|
UPDATE users
|
||||||
|
SET display_name = name
|
||||||
|
WHERE display_name IS NULL;
|
||||||
@ -1,20 +1,8 @@
|
|||||||
# Database Migration: Add Image Support
|
|
||||||
|
|
||||||
Run these SQL commands on your PostgreSQL database:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Add image columns to grocery_list table
|
-- Add image columns to grocery_list table
|
||||||
ALTER TABLE grocery_list
|
ALTER TABLE grocery_list
|
||||||
ADD COLUMN item_image BYTEA,
|
ADD COLUMN IF NOT EXISTS item_image BYTEA,
|
||||||
ADD COLUMN image_mime_type VARCHAR(50);
|
ADD COLUMN IF NOT EXISTS image_mime_type VARCHAR(50);
|
||||||
|
|
||||||
-- Optional: Add index for faster queries when filtering by items with images
|
-- Index to speed up queries that filter by rows with images.
|
||||||
CREATE INDEX idx_grocery_list_has_image ON grocery_list ((item_image IS NOT NULL));
|
CREATE INDEX IF NOT EXISTS idx_grocery_list_has_image
|
||||||
```
|
ON grocery_list ((item_image IS NOT NULL));
|
||||||
|
|
||||||
## To Verify:
|
|
||||||
```sql
|
|
||||||
\d grocery_list
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see the new columns `item_image` and `image_mime_type`.
|
|
||||||
|
|||||||
7
backend/migrations/add_notes_column.sql
Normal file
7
backend/migrations/add_notes_column.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- Add notes column to household_lists table
|
||||||
|
-- This allows users to add custom notes/descriptions to list items
|
||||||
|
|
||||||
|
ALTER TABLE household_lists
|
||||||
|
ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item';
|
||||||
397
backend/migrations/multi_household_architecture.sql
Normal file
397
backend/migrations/multi_household_architecture.sql
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Multi-Household & Multi-Store Architecture Migration
|
||||||
|
-- ============================================================================
|
||||||
|
-- This migration transforms the single-list app into a multi-tenant system
|
||||||
|
-- supporting multiple households, each with multiple stores.
|
||||||
|
--
|
||||||
|
-- IMPORTANT: Backup your database before running this migration!
|
||||||
|
-- pg_dump grocery_list > backup_$(date +%Y%m%d).sql
|
||||||
|
--
|
||||||
|
-- Migration Strategy:
|
||||||
|
-- 1. Create new tables
|
||||||
|
-- 2. Create "Main Household" for existing users
|
||||||
|
-- 3. Migrate existing data to new structure
|
||||||
|
-- 4. Update roles (keep users.role for system admin)
|
||||||
|
-- 5. Verify data integrity
|
||||||
|
-- 6. (Manual step) Drop old tables after verification
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 1: CREATE NEW TABLES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Households table
|
||||||
|
CREATE TABLE IF NOT EXISTS households (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
invite_code VARCHAR(20) UNIQUE NOT NULL,
|
||||||
|
code_expires_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_households_invite_code ON households(invite_code);
|
||||||
|
COMMENT ON TABLE households IS 'Household groups (families, roommates, etc.)';
|
||||||
|
COMMENT ON COLUMN households.invite_code IS 'Unique code for inviting users to join household';
|
||||||
|
|
||||||
|
-- Store types table
|
||||||
|
CREATE TABLE IF NOT EXISTS stores (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
default_zones JSONB,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE stores IS 'Store types/chains (Costco, Target, Walmart, etc.)';
|
||||||
|
COMMENT ON COLUMN stores.default_zones IS 'JSON array of default zone names for this store type';
|
||||||
|
|
||||||
|
-- User-Household membership with per-household roles
|
||||||
|
CREATE TABLE IF NOT EXISTS household_members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
|
||||||
|
joined_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(household_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_household_members_user ON household_members(user_id);
|
||||||
|
CREATE INDEX idx_household_members_household ON household_members(household_id);
|
||||||
|
COMMENT ON TABLE household_members IS 'User membership in households with per-household roles';
|
||||||
|
COMMENT ON COLUMN household_members.role IS 'admin: full control, user: standard member';
|
||||||
|
|
||||||
|
-- Household-Store relationship
|
||||||
|
CREATE TABLE IF NOT EXISTS household_stores (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
added_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(household_id, store_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_household_stores_household ON household_stores(household_id);
|
||||||
|
COMMENT ON TABLE household_stores IS 'Which stores each household shops at';
|
||||||
|
|
||||||
|
-- Master item catalog (shared across all households)
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
default_image BYTEA,
|
||||||
|
default_image_mime_type VARCHAR(50),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
usage_count INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_items_name ON items(name);
|
||||||
|
CREATE INDEX idx_items_usage_count ON items(usage_count DESC);
|
||||||
|
COMMENT ON TABLE items IS 'Master item catalog shared across all households';
|
||||||
|
COMMENT ON COLUMN items.usage_count IS 'Popularity metric for suggestions';
|
||||||
|
|
||||||
|
-- Household-specific grocery lists (per store)
|
||||||
|
CREATE TABLE IF NOT EXISTS household_lists (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
|
||||||
|
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
bought BOOLEAN DEFAULT FALSE,
|
||||||
|
custom_image BYTEA,
|
||||||
|
custom_image_mime_type VARCHAR(50),
|
||||||
|
added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
modified_on TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(household_id, store_id, item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_household_lists_household_store ON household_lists(household_id, store_id);
|
||||||
|
CREATE INDEX idx_household_lists_bought ON household_lists(household_id, store_id, bought);
|
||||||
|
CREATE INDEX idx_household_lists_modified ON household_lists(modified_on DESC);
|
||||||
|
COMMENT ON TABLE household_lists IS 'Grocery lists scoped to household + store combination';
|
||||||
|
|
||||||
|
-- Household-specific item classifications (per store)
|
||||||
|
CREATE TABLE IF NOT EXISTS household_item_classifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
|
||||||
|
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
item_type VARCHAR(50),
|
||||||
|
item_group VARCHAR(100),
|
||||||
|
zone VARCHAR(100),
|
||||||
|
confidence DECIMAL(3,2) DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1),
|
||||||
|
source VARCHAR(20) DEFAULT 'user' CHECK (source IN ('user', 'ml', 'default')),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(household_id, store_id, item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_household_classifications ON household_item_classifications(household_id, store_id);
|
||||||
|
CREATE INDEX idx_household_classifications_type ON household_item_classifications(item_type);
|
||||||
|
CREATE INDEX idx_household_classifications_zone ON household_item_classifications(zone);
|
||||||
|
COMMENT ON TABLE household_item_classifications IS 'Item classifications scoped to household + store';
|
||||||
|
|
||||||
|
-- History tracking
|
||||||
|
CREATE TABLE IF NOT EXISTS household_list_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE,
|
||||||
|
quantity INTEGER NOT NULL,
|
||||||
|
added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
added_on TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_household_history_list ON household_list_history(household_list_id);
|
||||||
|
CREATE INDEX idx_household_history_user ON household_list_history(added_by);
|
||||||
|
CREATE INDEX idx_household_history_date ON household_list_history(added_on DESC);
|
||||||
|
COMMENT ON TABLE household_list_history IS 'Tracks who added items and when';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 2: CREATE DEFAULT HOUSEHOLD AND STORE
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Create default household for existing users
|
||||||
|
INSERT INTO households (name, created_by, invite_code)
|
||||||
|
SELECT
|
||||||
|
'Main Household',
|
||||||
|
(SELECT id FROM users WHERE role = 'admin' LIMIT 1), -- First admin as creator
|
||||||
|
'MAIN' || LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0') -- Random 6-digit code
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM households WHERE name = 'Main Household');
|
||||||
|
|
||||||
|
-- Create default Costco store
|
||||||
|
INSERT INTO stores (name, default_zones)
|
||||||
|
VALUES (
|
||||||
|
'Costco',
|
||||||
|
'{
|
||||||
|
"zones": [
|
||||||
|
"Entrance & Seasonal",
|
||||||
|
"Fresh Produce",
|
||||||
|
"Meat & Seafood",
|
||||||
|
"Dairy & Refrigerated",
|
||||||
|
"Deli & Prepared Foods",
|
||||||
|
"Bakery & Bread",
|
||||||
|
"Frozen Foods",
|
||||||
|
"Beverages",
|
||||||
|
"Snacks & Candy",
|
||||||
|
"Pantry & Dry Goods",
|
||||||
|
"Health & Beauty",
|
||||||
|
"Household & Cleaning",
|
||||||
|
"Other"
|
||||||
|
]
|
||||||
|
}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Link default household to default store
|
||||||
|
INSERT INTO household_stores (household_id, store_id, is_default)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM households WHERE name = 'Main Household'),
|
||||||
|
(SELECT id FROM stores WHERE name = 'Costco'),
|
||||||
|
TRUE
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM household_stores
|
||||||
|
WHERE household_id = (SELECT id FROM households WHERE name = 'Main Household')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 3: MIGRATE USERS TO HOUSEHOLD MEMBERS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Add all existing users to Main Household
|
||||||
|
-- Old admins become household admins, others become standard users
|
||||||
|
INSERT INTO household_members (household_id, user_id, role)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM households WHERE name = 'Main Household'),
|
||||||
|
id,
|
||||||
|
CASE
|
||||||
|
WHEN role = 'admin' THEN 'admin'
|
||||||
|
ELSE 'user'
|
||||||
|
END
|
||||||
|
FROM users
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM household_members hm
|
||||||
|
WHERE hm.user_id = users.id
|
||||||
|
AND hm.household_id = (SELECT id FROM households WHERE name = 'Main Household')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 4: MIGRATE ITEMS TO MASTER CATALOG
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Extract unique items from grocery_list into master items table
|
||||||
|
INSERT INTO items (name, default_image, default_image_mime_type, created_at, usage_count)
|
||||||
|
SELECT
|
||||||
|
LOWER(TRIM(item_name)) as name,
|
||||||
|
item_image,
|
||||||
|
image_mime_type,
|
||||||
|
MIN(modified_on) as created_at,
|
||||||
|
COUNT(*) as usage_count
|
||||||
|
FROM grocery_list
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM items WHERE LOWER(items.name) = LOWER(TRIM(grocery_list.item_name))
|
||||||
|
)
|
||||||
|
GROUP BY LOWER(TRIM(item_name)), item_image, image_mime_type
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 5: MIGRATE GROCERY_LIST TO HOUSEHOLD_LISTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Migrate current list to household_lists
|
||||||
|
INSERT INTO household_lists (
|
||||||
|
household_id,
|
||||||
|
store_id,
|
||||||
|
item_id,
|
||||||
|
quantity,
|
||||||
|
bought,
|
||||||
|
custom_image,
|
||||||
|
custom_image_mime_type,
|
||||||
|
added_by,
|
||||||
|
modified_on
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM households WHERE name = 'Main Household'),
|
||||||
|
(SELECT id FROM stores WHERE name = 'Costco'),
|
||||||
|
i.id,
|
||||||
|
gl.quantity,
|
||||||
|
gl.bought,
|
||||||
|
CASE WHEN gl.item_image != i.default_image THEN gl.item_image ELSE NULL END, -- Only store if different
|
||||||
|
CASE WHEN gl.item_image != i.default_image THEN gl.image_mime_type ELSE NULL END,
|
||||||
|
gl.added_by,
|
||||||
|
gl.modified_on
|
||||||
|
FROM grocery_list gl
|
||||||
|
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM household_lists hl
|
||||||
|
WHERE hl.household_id = (SELECT id FROM households WHERE name = 'Main Household')
|
||||||
|
AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco')
|
||||||
|
AND hl.item_id = i.id
|
||||||
|
)
|
||||||
|
ON CONFLICT (household_id, store_id, item_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 6: MIGRATE ITEM_CLASSIFICATION TO HOUSEHOLD_ITEM_CLASSIFICATIONS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Migrate classifications
|
||||||
|
INSERT INTO household_item_classifications (
|
||||||
|
household_id,
|
||||||
|
store_id,
|
||||||
|
item_id,
|
||||||
|
item_type,
|
||||||
|
item_group,
|
||||||
|
zone,
|
||||||
|
confidence,
|
||||||
|
source,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT id FROM households WHERE name = 'Main Household'),
|
||||||
|
(SELECT id FROM stores WHERE name = 'Costco'),
|
||||||
|
i.id,
|
||||||
|
ic.item_type,
|
||||||
|
ic.item_group,
|
||||||
|
ic.zone,
|
||||||
|
ic.confidence,
|
||||||
|
ic.source,
|
||||||
|
ic.created_at,
|
||||||
|
ic.updated_at
|
||||||
|
FROM item_classification ic
|
||||||
|
JOIN grocery_list gl ON ic.id = gl.id
|
||||||
|
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM household_item_classifications hic
|
||||||
|
WHERE hic.household_id = (SELECT id FROM households WHERE name = 'Main Household')
|
||||||
|
AND hic.store_id = (SELECT id FROM stores WHERE name = 'Costco')
|
||||||
|
AND hic.item_id = i.id
|
||||||
|
)
|
||||||
|
ON CONFLICT (household_id, store_id, item_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 7: MIGRATE GROCERY_HISTORY TO HOUSEHOLD_LIST_HISTORY
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Migrate history records
|
||||||
|
INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on)
|
||||||
|
SELECT
|
||||||
|
hl.id,
|
||||||
|
gh.quantity,
|
||||||
|
gh.added_by,
|
||||||
|
gh.added_on
|
||||||
|
FROM grocery_history gh
|
||||||
|
JOIN grocery_list gl ON gh.list_item_id = gl.id
|
||||||
|
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
|
||||||
|
JOIN household_lists hl ON hl.item_id = i.id
|
||||||
|
AND hl.household_id = (SELECT id FROM households WHERE name = 'Main Household')
|
||||||
|
AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco')
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM household_list_history hlh
|
||||||
|
WHERE hlh.household_list_id = hl.id
|
||||||
|
AND hlh.added_by = gh.added_by
|
||||||
|
AND hlh.added_on = gh.added_on
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 8: UPDATE USER ROLES (SYSTEM-WIDE)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Update system roles: admin → system_admin, others → user
|
||||||
|
UPDATE users
|
||||||
|
SET role = 'system_admin'
|
||||||
|
WHERE role = 'admin';
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET role = 'user'
|
||||||
|
WHERE role IN ('editor', 'viewer');
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- VERIFICATION QUERIES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Run these to verify migration success:
|
||||||
|
|
||||||
|
-- Check household created
|
||||||
|
-- SELECT * FROM households;
|
||||||
|
|
||||||
|
-- Check all users added to household
|
||||||
|
-- SELECT u.username, u.role as system_role, hm.role as household_role
|
||||||
|
-- FROM users u
|
||||||
|
-- JOIN household_members hm ON u.id = hm.user_id
|
||||||
|
-- ORDER BY u.id;
|
||||||
|
|
||||||
|
-- Check items migrated
|
||||||
|
-- SELECT COUNT(*) as total_items FROM items;
|
||||||
|
-- SELECT COUNT(*) as original_items FROM (SELECT DISTINCT item_name FROM grocery_list) sub;
|
||||||
|
|
||||||
|
-- Check lists migrated
|
||||||
|
-- SELECT COUNT(*) as new_lists FROM household_lists;
|
||||||
|
-- SELECT COUNT(*) as old_lists FROM grocery_list;
|
||||||
|
|
||||||
|
-- Check classifications migrated
|
||||||
|
-- SELECT COUNT(*) as new_classifications FROM household_item_classifications;
|
||||||
|
-- SELECT COUNT(*) as old_classifications FROM item_classification;
|
||||||
|
|
||||||
|
-- Check history migrated
|
||||||
|
-- SELECT COUNT(*) as new_history FROM household_list_history;
|
||||||
|
-- SELECT COUNT(*) as old_history FROM grocery_history;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- MANUAL STEPS AFTER VERIFICATION
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- After verifying data integrity, uncomment and run these to clean up:
|
||||||
|
|
||||||
|
-- DROP TABLE IF EXISTS grocery_history CASCADE;
|
||||||
|
-- DROP TABLE IF EXISTS item_classification CASCADE;
|
||||||
|
-- DROP TABLE IF EXISTS grocery_list CASCADE;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ROLLBACK (if something goes wrong)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ROLLBACK;
|
||||||
|
|
||||||
|
-- Then restore from backup:
|
||||||
|
-- psql -U your_user -d grocery_list < backup_YYYYMMDD.sql
|
||||||
99
backend/migrations/stale-sql-report.json
Normal file
99
backend/migrations/stale-sql-report.json
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"generated_at": "2026-05-25T23:06:21.741Z",
|
||||||
|
"canonical_dir": "packages\\db\\migrations",
|
||||||
|
"legacy_dir": "backend\\migrations",
|
||||||
|
"stale_sql_files": [
|
||||||
|
{
|
||||||
|
"filename": "add_display_name_column.sql",
|
||||||
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
"requires_action": false,
|
||||||
|
"backend_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
|
||||||
|
"canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
|
||||||
|
"normalized_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "add_image_columns.sql",
|
||||||
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
"requires_action": false,
|
||||||
|
"backend_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded",
|
||||||
|
"canonical_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded",
|
||||||
|
"normalized_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "add_modified_on_column.sql",
|
||||||
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
"requires_action": false,
|
||||||
|
"backend_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
|
||||||
|
"canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
|
||||||
|
"normalized_sha256": "cf4f5dcd2e470954499fc5a191428401bda033d2d32f4851b5674530e56e9b08"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "add_notes_column.sql",
|
||||||
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
"requires_action": false,
|
||||||
|
"backend_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
|
||||||
|
"canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
|
||||||
|
"normalized_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "create_item_classification_table.sql",
|
||||||
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
"requires_action": false,
|
||||||
|
"backend_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
|
||||||
|
"canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
|
||||||
|
"normalized_sha256": "473e804290863e92ae4d732d4a241be96e827c3194139e32172f6012caf60c50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "multi_household_architecture.sql",
|
||||||
|
"status": "STALE_DUPLICATE_OF_CANONICAL",
|
||||||
|
"requires_action": false,
|
||||||
|
"backend_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
|
||||||
|
"canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
|
||||||
|
"normalized_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"canonical_only_sql_files": [
|
||||||
|
{
|
||||||
|
"filename": "20260328_010000_add_household_store_available_items.sql",
|
||||||
|
"status": "CANONICAL_ONLY",
|
||||||
|
"requires_action": false,
|
||||||
|
"canonical_sha256": "58eaf6b526e0317edd45083ba64432fb973ab4a489c0bfd320c422ee501a6206"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "20260329_010000_add_household_store_items.sql",
|
||||||
|
"status": "CANONICAL_ONLY",
|
||||||
|
"requires_action": false,
|
||||||
|
"canonical_sha256": "4421515183150c388b19dde66e682807269fbc31414cc1ccfc095abab3788188"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "20260329_020000_fix_household_item_classification_upsert.sql",
|
||||||
|
"status": "CANONICAL_ONLY",
|
||||||
|
"requires_action": false,
|
||||||
|
"canonical_sha256": "8c86cde57bf98b0c9bf5340d685150e89a2fdb873d1bda83893506b2b2478e62"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "create_sessions_table.sql",
|
||||||
|
"status": "CANONICAL_ONLY",
|
||||||
|
"requires_action": false,
|
||||||
|
"canonical_sha256": "d46e5147eb113042e9c2856d17b38715e66a486ee4d7c6450c960145791bc030"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "zz_group_invites_and_join_policies.sql",
|
||||||
|
"status": "CANONICAL_ONLY",
|
||||||
|
"requires_action": false,
|
||||||
|
"canonical_sha256": "47e31807356c6682a926aa0d9fd9c46b9edf0b8a586d6c39a36c931e5de5ca5b"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"legacy_non_sql_files": [
|
||||||
|
"MIGRATION_GUIDE.md",
|
||||||
|
"stale-sql-report.json"
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"stale_total": 6,
|
||||||
|
"stale_only_in_backend_total": 0,
|
||||||
|
"stale_duplicate_total": 6,
|
||||||
|
"stale_diverged_total": 0,
|
||||||
|
"action_required_total": 0,
|
||||||
|
"canonical_only_total": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
333
backend/models/available-item.model.js
Normal file
333
backend/models/available-item.model.js
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
const pool = require("../db/pool");
|
||||||
|
const List = require("./list.model.v2");
|
||||||
|
|
||||||
|
function normalizeItemName(itemName) {
|
||||||
|
return String(itemName || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHouseholdStoreItemRecord(householdId, storeLocationId, itemId) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`WITH latest_list_items AS (
|
||||||
|
SELECT DISTINCT ON (hl.household_store_item_id)
|
||||||
|
hl.household_store_item_id,
|
||||||
|
hl.image_id,
|
||||||
|
hl.custom_image,
|
||||||
|
hl.custom_image_mime_type,
|
||||||
|
hl.modified_on,
|
||||||
|
hl.id
|
||||||
|
FROM household_lists hl
|
||||||
|
WHERE hl.household_id = $1
|
||||||
|
AND hl.store_location_id = $2
|
||||||
|
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
hsi.id AS item_id,
|
||||||
|
hsi.name AS item_name,
|
||||||
|
ENCODE(
|
||||||
|
COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image),
|
||||||
|
'base64'
|
||||||
|
) AS item_image,
|
||||||
|
COALESCE(
|
||||||
|
catalog_img.mime_type,
|
||||||
|
hsi.custom_image_mime_type,
|
||||||
|
list_img.mime_type,
|
||||||
|
lli.custom_image_mime_type
|
||||||
|
) AS image_mime_type,
|
||||||
|
hic.item_type,
|
||||||
|
hic.item_group,
|
||||||
|
COALESCE(slz.name, hic.zone) AS zone,
|
||||||
|
slz.sort_order AS zone_sort_order
|
||||||
|
FROM household_store_items hsi
|
||||||
|
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||||
|
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
|
||||||
|
LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id
|
||||||
|
LEFT JOIN household_item_classifications hic
|
||||||
|
ON hic.household_id = hsi.household_id
|
||||||
|
AND hic.store_location_id = hsi.store_location_id
|
||||||
|
AND hic.household_store_item_id = hsi.id
|
||||||
|
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||||
|
WHERE hsi.household_id = $1
|
||||||
|
AND hsi.store_location_id = $2
|
||||||
|
AND hsi.id = $3`,
|
||||||
|
[householdId, storeLocationId, itemId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateHouseholdStoreItem(householdId, storeLocationId, itemName) {
|
||||||
|
const normalizedName = normalizeItemName(itemName);
|
||||||
|
const existing = await pool.query(
|
||||||
|
`SELECT id, name
|
||||||
|
FROM household_store_items
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND normalized_name = $3`,
|
||||||
|
[householdId, storeLocationId, normalizedName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rowCount > 0) {
|
||||||
|
return {
|
||||||
|
itemId: existing.rows[0].id,
|
||||||
|
itemName: existing.rows[0].name,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await pool.query(
|
||||||
|
`INSERT INTO household_store_items
|
||||||
|
(household_id, store_location_id, name, normalized_name, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
RETURNING id, name`,
|
||||||
|
[householdId, storeLocationId, normalizedName, normalizedName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId: created.rows[0].id,
|
||||||
|
itemName: created.rows[0].name,
|
||||||
|
isNew: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.listAvailableItems = async (householdId, storeLocationId, query = "") => {
|
||||||
|
const trimmedQuery = String(query || "").trim();
|
||||||
|
const values = [householdId, storeLocationId];
|
||||||
|
let filterClause = "";
|
||||||
|
|
||||||
|
if (trimmedQuery) {
|
||||||
|
values.push(`%${trimmedQuery}%`);
|
||||||
|
filterClause = "AND hsi.name ILIKE $3";
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`WITH latest_list_items AS (
|
||||||
|
SELECT DISTINCT ON (hl.household_store_item_id)
|
||||||
|
hl.household_store_item_id,
|
||||||
|
hl.image_id,
|
||||||
|
hl.custom_image,
|
||||||
|
hl.custom_image_mime_type,
|
||||||
|
hl.modified_on,
|
||||||
|
hl.id
|
||||||
|
FROM household_lists hl
|
||||||
|
WHERE hl.household_id = $1
|
||||||
|
AND hl.store_location_id = $2
|
||||||
|
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
hsi.id AS item_id,
|
||||||
|
hsi.name AS item_name,
|
||||||
|
ENCODE(
|
||||||
|
COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image),
|
||||||
|
'base64'
|
||||||
|
) AS item_image,
|
||||||
|
COALESCE(
|
||||||
|
catalog_img.mime_type,
|
||||||
|
hsi.custom_image_mime_type,
|
||||||
|
list_img.mime_type,
|
||||||
|
lli.custom_image_mime_type
|
||||||
|
) AS image_mime_type,
|
||||||
|
hic.item_type,
|
||||||
|
hic.item_group,
|
||||||
|
COALESCE(slz.name, hic.zone) AS zone,
|
||||||
|
slz.sort_order AS zone_sort_order,
|
||||||
|
(
|
||||||
|
hsi.image_id IS NOT NULL
|
||||||
|
OR hsi.custom_image IS NOT NULL
|
||||||
|
OR hic.household_store_item_id IS NOT NULL
|
||||||
|
) AS has_managed_settings
|
||||||
|
FROM household_store_items hsi
|
||||||
|
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||||
|
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
|
||||||
|
LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id
|
||||||
|
LEFT JOIN household_item_classifications hic
|
||||||
|
ON hic.household_id = hsi.household_id
|
||||||
|
AND hic.store_location_id = hsi.store_location_id
|
||||||
|
AND hic.household_store_item_id = hsi.id
|
||||||
|
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||||
|
WHERE hsi.household_id = $1
|
||||||
|
AND hsi.store_location_id = $2
|
||||||
|
${filterClause}
|
||||||
|
ORDER BY hsi.name ASC
|
||||||
|
LIMIT 100`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getAvailableItemById = async (householdId, storeLocationId, itemId) =>
|
||||||
|
getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
|
||||||
|
|
||||||
|
exports.getAvailableItemImageByName = async (householdId, storeLocationId, itemName) => {
|
||||||
|
const normalizedName = normalizeItemName(itemName);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
hsi.id AS item_id,
|
||||||
|
hsi.name AS item_name,
|
||||||
|
COALESCE(img.image, hsi.custom_image) AS custom_image,
|
||||||
|
COALESCE(img.mime_type, hsi.custom_image_mime_type) AS custom_image_mime_type
|
||||||
|
FROM household_store_items hsi
|
||||||
|
LEFT JOIN household_item_images img ON img.id = hsi.image_id
|
||||||
|
WHERE hsi.household_id = $1
|
||||||
|
AND hsi.store_location_id = $2
|
||||||
|
AND hsi.normalized_name = $3`,
|
||||||
|
[householdId, storeLocationId, normalizedName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createAvailableItem = async (
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
itemName,
|
||||||
|
imageBuffer = null,
|
||||||
|
mimeType = null,
|
||||||
|
userId = null
|
||||||
|
) => {
|
||||||
|
const { itemId, isNew } = await findOrCreateHouseholdStoreItem(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
itemName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (imageBuffer && mimeType) {
|
||||||
|
await List.setCatalogItemImage(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
itemId,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: itemId,
|
||||||
|
actorUserId: userId,
|
||||||
|
eventType: "ITEM_ADDED",
|
||||||
|
metadata: { source: "catalog" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateAvailableItem = async (householdId, storeLocationId, itemId, updates = {}) => {
|
||||||
|
const {
|
||||||
|
itemName,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
removeImage = false,
|
||||||
|
userId = null,
|
||||||
|
} = updates;
|
||||||
|
|
||||||
|
const assignments = ["updated_at = NOW()"];
|
||||||
|
const values = [householdId, storeLocationId, itemId];
|
||||||
|
let parameterIndex = values.length;
|
||||||
|
|
||||||
|
if (itemName !== undefined && String(itemName).trim() !== "") {
|
||||||
|
const normalizedName = normalizeItemName(itemName);
|
||||||
|
parameterIndex += 1;
|
||||||
|
assignments.push(`name = $${parameterIndex}`);
|
||||||
|
values.push(normalizedName);
|
||||||
|
|
||||||
|
parameterIndex += 1;
|
||||||
|
assignments.push(`normalized_name = $${parameterIndex}`);
|
||||||
|
values.push(normalizedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeImage) {
|
||||||
|
assignments.push("image_id = NULL", "custom_image = NULL", "custom_image_mime_type = NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE household_store_items
|
||||||
|
SET ${assignments.join(", ")}
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND id = $3
|
||||||
|
RETURNING id`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!removeImage && imageBuffer && mimeType) {
|
||||||
|
await List.setCatalogItemImage(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
result.rows[0].id,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getHouseholdStoreItemRecord(householdId, storeLocationId, result.rows[0].id);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteAvailableItem = async (householdId, storeLocationId, itemId, userId = null) => {
|
||||||
|
const item = await getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM household_store_items
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND id = $3`,
|
||||||
|
[householdId, storeLocationId, itemId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount > 0) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: itemId,
|
||||||
|
actorUserId: userId,
|
||||||
|
eventType: "ITEM_DELETED",
|
||||||
|
metadata: { item_name: item?.item_name || null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.importCurrentListItems = async (householdId, storeLocationId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO household_store_items
|
||||||
|
(household_id, store_location_id, name, normalized_name, image_id, updated_at)
|
||||||
|
SELECT DISTINCT ON (hl.household_store_item_id)
|
||||||
|
hl.household_id,
|
||||||
|
hl.store_location_id,
|
||||||
|
hsi.name,
|
||||||
|
hsi.normalized_name,
|
||||||
|
hsi.image_id,
|
||||||
|
NOW()
|
||||||
|
FROM household_lists hl
|
||||||
|
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||||
|
WHERE hl.household_id = $1
|
||||||
|
AND hl.store_location_id = $2
|
||||||
|
ON CONFLICT (household_id, store_location_id, normalized_name) DO NOTHING
|
||||||
|
RETURNING id`,
|
||||||
|
[householdId, storeLocationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rowCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.hasAvailableItems = async (householdId, storeLocationId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM household_store_items
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[householdId, storeLocationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
458
backend/models/group-invites.model.js
Normal file
458
backend/models/group-invites.model.js
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
const pool = require("../db/pool");
|
||||||
|
|
||||||
|
function getExecutor(client) {
|
||||||
|
return client || pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTransaction(handler) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const result = await handler(client);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getManageableGroupsForUser(userId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT household_id AS group_id
|
||||||
|
FROM household_members
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND role IN ('owner', 'admin')`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserGroupRole(groupId, userId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT role
|
||||||
|
FROM household_members
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND user_id = $2`,
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
return result.rows[0]?.role || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroupById(groupId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT id, name
|
||||||
|
FROM households
|
||||||
|
WHERE id = $1`,
|
||||||
|
[groupId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listInviteLinks(groupId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT
|
||||||
|
id,
|
||||||
|
group_id,
|
||||||
|
created_by,
|
||||||
|
token,
|
||||||
|
policy,
|
||||||
|
single_use,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
revoked_at,
|
||||||
|
created_at
|
||||||
|
FROM group_invite_links
|
||||||
|
WHERE group_id = $1
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[groupId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInviteLink(
|
||||||
|
{ groupId, createdBy, token, policy, singleUse, expiresAt },
|
||||||
|
client
|
||||||
|
) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`INSERT INTO group_invite_links (
|
||||||
|
group_id,
|
||||||
|
created_by,
|
||||||
|
token,
|
||||||
|
policy,
|
||||||
|
single_use,
|
||||||
|
expires_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
group_id,
|
||||||
|
created_by,
|
||||||
|
token,
|
||||||
|
policy,
|
||||||
|
single_use,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
revoked_at,
|
||||||
|
created_at`,
|
||||||
|
[groupId, createdBy, token, policy, singleUse, expiresAt]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInviteLinkById(groupId, linkId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT
|
||||||
|
id,
|
||||||
|
group_id,
|
||||||
|
created_by,
|
||||||
|
token,
|
||||||
|
policy,
|
||||||
|
single_use,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
revoked_at,
|
||||||
|
created_at
|
||||||
|
FROM group_invite_links
|
||||||
|
WHERE group_id = $1
|
||||||
|
AND id = $2`,
|
||||||
|
[groupId, linkId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeInviteLink(groupId, linkId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`UPDATE group_invite_links
|
||||||
|
SET revoked_at = NOW()
|
||||||
|
WHERE group_id = $1
|
||||||
|
AND id = $2
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
group_id,
|
||||||
|
created_by,
|
||||||
|
token,
|
||||||
|
policy,
|
||||||
|
single_use,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
revoked_at,
|
||||||
|
created_at`,
|
||||||
|
[groupId, linkId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reviveInviteLink(groupId, linkId, expiresAt, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`UPDATE group_invite_links
|
||||||
|
SET used_at = NULL,
|
||||||
|
revoked_at = NULL,
|
||||||
|
expires_at = $3
|
||||||
|
WHERE group_id = $1
|
||||||
|
AND id = $2
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
group_id,
|
||||||
|
created_by,
|
||||||
|
token,
|
||||||
|
policy,
|
||||||
|
single_use,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
revoked_at,
|
||||||
|
created_at`,
|
||||||
|
[groupId, linkId, expiresAt]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteInviteLink(groupId, linkId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`DELETE FROM group_invite_links
|
||||||
|
WHERE group_id = $1
|
||||||
|
AND id = $2
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
group_id,
|
||||||
|
created_by,
|
||||||
|
token,
|
||||||
|
policy,
|
||||||
|
single_use,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
revoked_at,
|
||||||
|
created_at`,
|
||||||
|
[groupId, linkId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInviteLinkSummaryByToken(token, client, forUpdate = false) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT
|
||||||
|
gil.id,
|
||||||
|
gil.group_id,
|
||||||
|
gil.created_by,
|
||||||
|
gil.token,
|
||||||
|
gil.policy,
|
||||||
|
gil.single_use,
|
||||||
|
gil.expires_at,
|
||||||
|
gil.used_at,
|
||||||
|
gil.revoked_at,
|
||||||
|
gil.created_at,
|
||||||
|
h.name AS group_name,
|
||||||
|
gs.join_policy AS current_join_policy
|
||||||
|
FROM group_invite_links gil
|
||||||
|
JOIN households h ON h.id = gil.group_id
|
||||||
|
LEFT JOIN group_settings gs ON gs.group_id = gil.group_id
|
||||||
|
WHERE gil.token = $1
|
||||||
|
${forUpdate ? "FOR UPDATE OF gil" : ""}`,
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isGroupMember(groupId, userId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM household_members
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND user_id = $2`,
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPendingJoinRequest(groupId, userId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT id, group_id, user_id, status, created_at, updated_at
|
||||||
|
FROM group_join_requests
|
||||||
|
WHERE group_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
AND status = 'PENDING'`,
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPendingJoinRequests(groupId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT
|
||||||
|
gjr.id,
|
||||||
|
gjr.group_id,
|
||||||
|
gjr.user_id,
|
||||||
|
gjr.status,
|
||||||
|
gjr.created_at,
|
||||||
|
gjr.updated_at,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.display_name
|
||||||
|
FROM group_join_requests gjr
|
||||||
|
JOIN users u ON u.id = gjr.user_id
|
||||||
|
WHERE gjr.group_id = $1
|
||||||
|
AND gjr.status = 'PENDING'
|
||||||
|
ORDER BY gjr.created_at ASC`,
|
||||||
|
[groupId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPendingJoinRequestById(groupId, requestId, client, forUpdate = false) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT
|
||||||
|
gjr.id,
|
||||||
|
gjr.group_id,
|
||||||
|
gjr.user_id,
|
||||||
|
gjr.status,
|
||||||
|
gjr.decided_by,
|
||||||
|
gjr.decided_at,
|
||||||
|
gjr.created_at,
|
||||||
|
gjr.updated_at,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.display_name
|
||||||
|
FROM group_join_requests gjr
|
||||||
|
JOIN users u ON u.id = gjr.user_id
|
||||||
|
WHERE gjr.group_id = $1
|
||||||
|
AND gjr.id = $2
|
||||||
|
AND gjr.status = 'PENDING'
|
||||||
|
${forUpdate ? "FOR UPDATE OF gjr" : ""}`,
|
||||||
|
[groupId, requestId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrTouchPendingJoinRequest(groupId, userId, client) {
|
||||||
|
const executor = getExecutor(client);
|
||||||
|
const existing = await executor.query(
|
||||||
|
`UPDATE group_join_requests
|
||||||
|
SET updated_at = NOW()
|
||||||
|
WHERE group_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
AND status = 'PENDING'
|
||||||
|
RETURNING id, group_id, user_id, status, created_at, updated_at`,
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
return existing.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inserted = await executor.query(
|
||||||
|
`INSERT INTO group_join_requests (group_id, user_id, status)
|
||||||
|
VALUES ($1, $2, 'PENDING')
|
||||||
|
RETURNING id, group_id, user_id, status, created_at, updated_at`,
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
return inserted.rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "23505") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const fallback = await executor.query(
|
||||||
|
`SELECT id, group_id, user_id, status, created_at, updated_at
|
||||||
|
FROM group_join_requests
|
||||||
|
WHERE group_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
AND status = 'PENDING'
|
||||||
|
LIMIT 1`,
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
return fallback.rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateJoinRequestDecision(groupId, requestId, status, decidedBy, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`UPDATE group_join_requests
|
||||||
|
SET status = $3,
|
||||||
|
decided_by = $4,
|
||||||
|
decided_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE group_id = $1
|
||||||
|
AND id = $2
|
||||||
|
AND status = 'PENDING'
|
||||||
|
RETURNING id, group_id, user_id, status, decided_by, decided_at, created_at, updated_at`,
|
||||||
|
[groupId, requestId, status, decidedBy]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGroupMember(groupId, userId, role = "member", client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`INSERT INTO household_members (household_id, user_id, role)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (household_id, user_id) DO NOTHING
|
||||||
|
RETURNING id`,
|
||||||
|
[groupId, userId, role]
|
||||||
|
);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeSingleUseInvite(linkId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`UPDATE group_invite_links
|
||||||
|
SET used_at = NOW(),
|
||||||
|
revoked_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[linkId]
|
||||||
|
);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroupSettings(groupId, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`SELECT group_id, join_policy
|
||||||
|
FROM group_settings
|
||||||
|
WHERE group_id = $1`,
|
||||||
|
[groupId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertGroupSettings(groupId, joinPolicy, client) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`INSERT INTO group_settings (group_id, join_policy)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (group_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
join_policy = EXCLUDED.join_policy,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING group_id, join_policy`,
|
||||||
|
[groupId, joinPolicy]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId,
|
||||||
|
actorUserId,
|
||||||
|
actorRole,
|
||||||
|
eventType,
|
||||||
|
requestId,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
success = true,
|
||||||
|
errorCode = null,
|
||||||
|
metadata = {},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
) {
|
||||||
|
const result = await getExecutor(client).query(
|
||||||
|
`INSERT INTO group_audit_log (
|
||||||
|
group_id,
|
||||||
|
actor_user_id,
|
||||||
|
actor_role,
|
||||||
|
event_type,
|
||||||
|
request_id,
|
||||||
|
ip,
|
||||||
|
user_agent,
|
||||||
|
success,
|
||||||
|
error_code,
|
||||||
|
metadata
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
groupId,
|
||||||
|
actorUserId,
|
||||||
|
actorRole,
|
||||||
|
eventType,
|
||||||
|
requestId,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
success,
|
||||||
|
errorCode,
|
||||||
|
JSON.stringify(metadata || {}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
addGroupMember,
|
||||||
|
createGroupAuditLog,
|
||||||
|
createInviteLink,
|
||||||
|
createOrTouchPendingJoinRequest,
|
||||||
|
consumeSingleUseInvite,
|
||||||
|
deleteInviteLink,
|
||||||
|
getGroupById,
|
||||||
|
getGroupSettings,
|
||||||
|
getInviteLinkById,
|
||||||
|
getInviteLinkSummaryByToken,
|
||||||
|
getManageableGroupsForUser,
|
||||||
|
getPendingJoinRequestById,
|
||||||
|
getPendingJoinRequest,
|
||||||
|
getUserGroupRole,
|
||||||
|
isGroupMember,
|
||||||
|
listPendingJoinRequests,
|
||||||
|
listInviteLinks,
|
||||||
|
revokeInviteLink,
|
||||||
|
reviveInviteLink,
|
||||||
|
updateJoinRequestDecision,
|
||||||
|
upsertGroupSettings,
|
||||||
|
withTransaction,
|
||||||
|
};
|
||||||
283
backend/models/household.model.js
Normal file
283
backend/models/household.model.js
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
const pool = require("../db/pool");
|
||||||
|
|
||||||
|
async function queryUserHouseholds(db, userId) {
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT
|
||||||
|
h.id,
|
||||||
|
h.name,
|
||||||
|
h.invite_code,
|
||||||
|
h.created_at,
|
||||||
|
hm.role,
|
||||||
|
hm.joined_at,
|
||||||
|
hm.household_sort_order,
|
||||||
|
(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.household_sort_order ASC NULLS LAST, hm.joined_at DESC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all households a user belongs to
|
||||||
|
exports.getUserHouseholds = async (userId) => {
|
||||||
|
return queryUserHouseholds(pool, userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.reorderUserHouseholds = async (userId, householdIds) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const membershipResult = await client.query(
|
||||||
|
`SELECT household_id
|
||||||
|
FROM household_members
|
||||||
|
WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentIds = membershipResult.rows.map((row) => Number(row.household_id));
|
||||||
|
const currentIdSet = new Set(currentIds);
|
||||||
|
|
||||||
|
if (
|
||||||
|
householdIds.length !== currentIds.length ||
|
||||||
|
householdIds.some((householdId) => !currentIdSet.has(householdId))
|
||||||
|
) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, householdId] of householdIds.entries()) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE household_members
|
||||||
|
SET household_sort_order = $1
|
||||||
|
WHERE user_id = $2
|
||||||
|
AND household_id = $3`,
|
||||||
|
[index, userId, householdId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const households = await queryUserHouseholds(client, userId);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return households;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get household by ID (with member check)
|
||||||
|
exports.getHouseholdById = async (householdId, userId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
h.id,
|
||||||
|
h.name,
|
||||||
|
h.invite_code,
|
||||||
|
h.created_at,
|
||||||
|
h.created_by,
|
||||||
|
hm.role as user_role,
|
||||||
|
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
|
||||||
|
FROM households h
|
||||||
|
LEFT JOIN household_members hm ON h.id = hm.household_id AND hm.user_id = $2
|
||||||
|
WHERE h.id = $1`,
|
||||||
|
[householdId, userId]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new household
|
||||||
|
exports.createHousehold = async (name, createdBy) => {
|
||||||
|
// Generate random 6-digit invite code
|
||||||
|
const inviteCode = 'H' + Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO households (name, created_by, invite_code)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, name, invite_code, created_at`,
|
||||||
|
[name, createdBy, inviteCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add creator as admin
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO household_members (household_id, user_id, role)
|
||||||
|
VALUES ($1, $2, 'owner')`,
|
||||||
|
[result.rows[0].id, createdBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update household
|
||||||
|
exports.updateHousehold = async (householdId, updates) => {
|
||||||
|
const { name } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE households
|
||||||
|
SET name = COALESCE($1, name)
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, name, invite_code, created_at`,
|
||||||
|
[name, householdId]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete household
|
||||||
|
exports.deleteHousehold = async (householdId) => {
|
||||||
|
await pool.query('DELETE FROM households WHERE id = $1', [householdId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh invite code
|
||||||
|
exports.refreshInviteCode = async (householdId) => {
|
||||||
|
const inviteCode = 'H' + Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE households
|
||||||
|
SET invite_code = $1, code_expires_at = NULL
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, name, invite_code`,
|
||||||
|
[inviteCode, householdId]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Join household via invite code
|
||||||
|
exports.joinHousehold = async (inviteCode, userId) => {
|
||||||
|
const householdResult = await pool.query(
|
||||||
|
`SELECT id, name FROM households
|
||||||
|
WHERE invite_code = $1
|
||||||
|
AND (code_expires_at IS NULL OR code_expires_at > NOW())`,
|
||||||
|
[inviteCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
if (householdResult.rows.length === 0) return null;
|
||||||
|
|
||||||
|
const household = householdResult.rows[0];
|
||||||
|
|
||||||
|
const existingMember = await pool.query(
|
||||||
|
`SELECT id FROM household_members
|
||||||
|
WHERE household_id = $1 AND user_id = $2`,
|
||||||
|
[household.id, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingMember.rows.length > 0) return { ...household, alreadyMember: true };
|
||||||
|
|
||||||
|
|
||||||
|
// Add as user role
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO household_members (household_id, user_id, role)
|
||||||
|
VALUES ($1, $2, 'member')`,
|
||||||
|
[household.id, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...household, alreadyMember: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get household members
|
||||||
|
exports.getHouseholdMembers = async (householdId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u.display_name,
|
||||||
|
hm.role,
|
||||||
|
hm.joined_at
|
||||||
|
FROM household_members hm
|
||||||
|
JOIN users u ON hm.user_id = u.id
|
||||||
|
WHERE hm.household_id = $1
|
||||||
|
ORDER BY
|
||||||
|
CASE hm.role
|
||||||
|
WHEN 'owner' THEN 1
|
||||||
|
WHEN 'admin' THEN 2
|
||||||
|
WHEN 'member' THEN 3
|
||||||
|
END,
|
||||||
|
hm.joined_at ASC`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update member role
|
||||||
|
exports.updateMemberRole = async (householdId, userId, newRole) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE household_members
|
||||||
|
SET role = $1
|
||||||
|
WHERE household_id = $2 AND user_id = $3
|
||||||
|
RETURNING user_id, role`,
|
||||||
|
[newRole, householdId, userId]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transfer household ownership from one member to another atomically.
|
||||||
|
exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUserId) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const demoteResult = await client.query(
|
||||||
|
`UPDATE household_members
|
||||||
|
SET role = 'admin'
|
||||||
|
WHERE household_id = $1 AND user_id = $2
|
||||||
|
RETURNING user_id, role`,
|
||||||
|
[householdId, currentOwnerUserId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (demoteResult.rows.length === 0) {
|
||||||
|
throw new Error("CURRENT_OWNER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const promoteResult = await client.query(
|
||||||
|
`UPDATE household_members
|
||||||
|
SET role = 'owner'
|
||||||
|
WHERE household_id = $1 AND user_id = $2
|
||||||
|
RETURNING user_id, role`,
|
||||||
|
[householdId, nextOwnerUserId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (promoteResult.rows.length === 0) {
|
||||||
|
throw new Error("TARGET_MEMBER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return promoteResult.rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove member from household
|
||||||
|
exports.removeMember = async (householdId, userId) => {
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM household_members
|
||||||
|
WHERE household_id = $1 AND user_id = $2`,
|
||||||
|
[householdId, userId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user's role in household
|
||||||
|
exports.getUserRole = async (householdId, userId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT role FROM household_members
|
||||||
|
WHERE household_id = $1 AND user_id = $2`,
|
||||||
|
[householdId, userId]
|
||||||
|
);
|
||||||
|
return result.rows[0]?.role || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if user is household member
|
||||||
|
exports.isHouseholdMember = async (householdId, userId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT 1 FROM household_members
|
||||||
|
WHERE household_id = $1 AND user_id = $2`,
|
||||||
|
[householdId, userId]
|
||||||
|
);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
};
|
||||||
@ -10,18 +10,23 @@ exports.getUnboughtItems = async () => {
|
|||||||
gl.bought,
|
gl.bought,
|
||||||
ENCODE(gl.item_image, 'base64') as item_image,
|
ENCODE(gl.item_image, 'base64') as item_image,
|
||||||
gl.image_mime_type,
|
gl.image_mime_type,
|
||||||
ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users,
|
(
|
||||||
|
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT gh.added_by
|
||||||
|
FROM grocery_history gh
|
||||||
|
WHERE gh.list_item_id = gl.id
|
||||||
|
ORDER BY gh.added_by
|
||||||
|
) gh
|
||||||
|
JOIN users u ON gh.added_by = u.id
|
||||||
|
) as added_by_users,
|
||||||
gl.modified_on as last_added_on,
|
gl.modified_on as last_added_on,
|
||||||
ic.item_type,
|
ic.item_type,
|
||||||
ic.item_group,
|
ic.item_group,
|
||||||
ic.zone
|
ic.zone
|
||||||
FROM grocery_list gl
|
FROM grocery_list gl
|
||||||
LEFT JOIN users creator ON gl.added_by = creator.id
|
|
||||||
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id
|
|
||||||
LEFT JOIN users gh_user ON gh.added_by = gh_user.id
|
|
||||||
LEFT JOIN item_classification ic ON gl.id = ic.id
|
LEFT JOIN item_classification ic ON gl.id = ic.id
|
||||||
WHERE gl.bought = FALSE
|
WHERE gl.bought = FALSE
|
||||||
GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on, ic.item_type, ic.item_group, ic.zone
|
|
||||||
ORDER BY gl.id ASC`
|
ORDER BY gl.id ASC`
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
@ -29,7 +34,30 @@ exports.getUnboughtItems = async () => {
|
|||||||
|
|
||||||
exports.getItemByName = async (itemName) => {
|
exports.getItemByName = async (itemName) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
"SELECT * FROM grocery_list WHERE item_name ILIKE $1",
|
`SELECT
|
||||||
|
gl.id,
|
||||||
|
LOWER(gl.item_name) AS item_name,
|
||||||
|
gl.quantity,
|
||||||
|
gl.bought,
|
||||||
|
ENCODE(gl.item_image, 'base64') as item_image,
|
||||||
|
gl.image_mime_type,
|
||||||
|
(
|
||||||
|
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT gh.added_by
|
||||||
|
FROM grocery_history gh
|
||||||
|
WHERE gh.list_item_id = gl.id
|
||||||
|
ORDER BY gh.added_by
|
||||||
|
) gh
|
||||||
|
JOIN users u ON gh.added_by = u.id
|
||||||
|
) as added_by_users,
|
||||||
|
gl.modified_on as last_added_on,
|
||||||
|
ic.item_type,
|
||||||
|
ic.item_group,
|
||||||
|
ic.zone
|
||||||
|
FROM grocery_list gl
|
||||||
|
LEFT JOIN item_classification ic ON gl.id = ic.id
|
||||||
|
WHERE gl.item_name ILIKE $1`,
|
||||||
[itemName]
|
[itemName]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -38,9 +66,11 @@ exports.getItemByName = async (itemName) => {
|
|||||||
|
|
||||||
|
|
||||||
exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, mimeType = null) => {
|
exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null, mimeType = null) => {
|
||||||
|
const lowerItemName = itemName.toLowerCase();
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
"SELECT id, bought FROM grocery_list WHERE item_name ILIKE $1",
|
"SELECT id, bought FROM grocery_list WHERE item_name ILIKE $1",
|
||||||
[itemName]
|
[lowerItemName]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rowCount > 0) {
|
if (result.rowCount > 0) {
|
||||||
@ -73,18 +103,38 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null,
|
|||||||
`INSERT INTO grocery_list
|
`INSERT INTO grocery_list
|
||||||
(item_name, quantity, added_by, item_image, image_mime_type)
|
(item_name, quantity, added_by, item_image, image_mime_type)
|
||||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
[itemName, quantity, userId, imageBuffer, mimeType]
|
[lowerItemName, quantity, userId, imageBuffer, mimeType]
|
||||||
);
|
);
|
||||||
return insert.rows[0].id;
|
return insert.rows[0].id;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
exports.setBought = async (id, userId) => {
|
exports.setBought = async (id, userId, quantityBought) => {
|
||||||
|
// Get current item
|
||||||
|
const item = await pool.query(
|
||||||
|
"SELECT quantity FROM grocery_list WHERE id = $1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!item.rows[0]) return;
|
||||||
|
|
||||||
|
const currentQuantity = item.rows[0].quantity;
|
||||||
|
const remainingQuantity = currentQuantity - quantityBought;
|
||||||
|
|
||||||
|
if (remainingQuantity <= 0) {
|
||||||
|
// Mark as bought if all quantity is purchased
|
||||||
await pool.query(
|
await pool.query(
|
||||||
"UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
"UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// Reduce quantity if partial purchase
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE grocery_list SET quantity = $1, modified_on = NOW() WHERE id = $2",
|
||||||
|
[remainingQuantity, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -99,13 +149,12 @@ exports.addHistoryRecord = async (itemId, quantity, userId) => {
|
|||||||
|
|
||||||
exports.getSuggestions = async (query) => {
|
exports.getSuggestions = async (query) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT DISTINCT item_name
|
`SELECT DISTINCT LOWER(item_name) as item_name
|
||||||
FROM grocery_list
|
FROM grocery_list
|
||||||
WHERE item_name ILIKE $1
|
WHERE item_name ILIKE $1
|
||||||
LIMIT 10`,
|
LIMIT 10`,
|
||||||
[`%${query}%`]
|
[`%${query}%`]
|
||||||
);
|
);
|
||||||
res = result.rows;
|
|
||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -118,15 +167,20 @@ exports.getRecentlyBoughtItems = async () => {
|
|||||||
gl.bought,
|
gl.bought,
|
||||||
ENCODE(gl.item_image, 'base64') as item_image,
|
ENCODE(gl.item_image, 'base64') as item_image,
|
||||||
gl.image_mime_type,
|
gl.image_mime_type,
|
||||||
ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users,
|
(
|
||||||
|
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT gh.added_by
|
||||||
|
FROM grocery_history gh
|
||||||
|
WHERE gh.list_item_id = gl.id
|
||||||
|
ORDER BY gh.added_by
|
||||||
|
) gh
|
||||||
|
JOIN users u ON gh.added_by = u.id
|
||||||
|
) as added_by_users,
|
||||||
gl.modified_on as last_added_on
|
gl.modified_on as last_added_on
|
||||||
FROM grocery_list gl
|
FROM grocery_list gl
|
||||||
LEFT JOIN users creator ON gl.added_by = creator.id
|
|
||||||
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id
|
|
||||||
LEFT JOIN users gh_user ON gh.added_by = gh_user.id
|
|
||||||
WHERE gl.bought = TRUE
|
WHERE gl.bought = TRUE
|
||||||
AND gl.modified_on >= NOW() - INTERVAL '24 hours'
|
AND gl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||||
GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on
|
|
||||||
ORDER BY gl.modified_on DESC`
|
ORDER BY gl.modified_on DESC`
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
|||||||
663
backend/models/list.model.v2.js
Normal file
663
backend/models/list.model.v2.js
Normal file
@ -0,0 +1,663 @@
|
|||||||
|
const pool = require("../db/pool");
|
||||||
|
|
||||||
|
function normalizeItemName(itemName) {
|
||||||
|
return String(itemName || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPositiveInteger(value, fallback = 1) {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_ADDED_BY_USERS_SQL = `
|
||||||
|
(
|
||||||
|
SELECT ARRAY_AGG(
|
||||||
|
active_added_by_users.user_label
|
||||||
|
ORDER BY active_added_by_users.last_added_on DESC, active_added_by_users.user_label
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label,
|
||||||
|
MAX(active_history.added_on) AS last_added_on
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
hlh.*,
|
||||||
|
COALESCE(
|
||||||
|
SUM(hlh.quantity) OVER (
|
||||||
|
PARTITION BY hlh.household_list_id
|
||||||
|
ORDER BY hlh.added_on DESC, hlh.id DESC
|
||||||
|
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) AS newer_quantity
|
||||||
|
FROM household_list_history hlh
|
||||||
|
WHERE hlh.household_list_id = hl.id
|
||||||
|
) active_history
|
||||||
|
JOIN users u ON active_history.added_by = u.id
|
||||||
|
WHERE active_history.newer_quantity < GREATEST(hl.quantity, 0)
|
||||||
|
GROUP BY user_label
|
||||||
|
) active_added_by_users
|
||||||
|
) AS added_by_users`;
|
||||||
|
|
||||||
|
async function getHouseholdStoreItemByNormalizedName(householdId, storeLocationId, normalizedName) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name, normalized_name, image_id
|
||||||
|
FROM household_store_items
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND normalized_name = $3`,
|
||||||
|
[householdId, storeLocationId, normalizedName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createItemImage({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
householdListId = null,
|
||||||
|
imageScope,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId = null,
|
||||||
|
}) {
|
||||||
|
if (!imageBuffer || !mimeType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO household_item_images
|
||||||
|
(
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
household_store_item_id,
|
||||||
|
household_list_id,
|
||||||
|
image_scope,
|
||||||
|
image,
|
||||||
|
mime_type,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
householdListId,
|
||||||
|
imageScope,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.ensureHouseholdStoreItem = async (householdId, storeLocationId, itemName) => {
|
||||||
|
const normalizedName = normalizeItemName(itemName);
|
||||||
|
let item = await getHouseholdStoreItemByNormalizedName(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
normalizedName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO household_store_items
|
||||||
|
(household_id, store_location_id, name, normalized_name, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
RETURNING id, name, normalized_name, image_id`,
|
||||||
|
[householdId, storeLocationId, normalizedName, normalizedName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getHouseholdStoreList = async (householdId, storeLocationId, includeHistory = true) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
hl.id,
|
||||||
|
hl.household_store_item_id AS item_id,
|
||||||
|
hl.household_store_item_id,
|
||||||
|
hsi.name AS item_name,
|
||||||
|
hl.quantity,
|
||||||
|
hl.bought,
|
||||||
|
hl.notes,
|
||||||
|
ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
|
||||||
|
COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||||
|
${includeHistory ? `${ACTIVE_ADDED_BY_USERS_SQL},` : "NULL AS added_by_users,"}
|
||||||
|
hl.modified_on AS last_added_on,
|
||||||
|
hic.item_type,
|
||||||
|
hic.item_group,
|
||||||
|
COALESCE(slz.name, hic.zone) AS zone,
|
||||||
|
slz.sort_order AS zone_sort_order
|
||||||
|
FROM household_lists hl
|
||||||
|
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||||
|
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
|
||||||
|
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||||
|
LEFT JOIN household_item_classifications hic
|
||||||
|
ON hic.household_id = hl.household_id
|
||||||
|
AND hic.store_location_id = hl.store_location_id
|
||||||
|
AND hic.household_store_item_id = hl.household_store_item_id
|
||||||
|
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||||
|
WHERE hl.household_id = $1
|
||||||
|
AND hl.store_location_id = $2
|
||||||
|
AND hl.bought = FALSE
|
||||||
|
ORDER BY slz.sort_order ASC NULLS LAST, hsi.name ASC`,
|
||||||
|
[householdId, storeLocationId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getItemByName = async (householdId, storeLocationId, itemName) => {
|
||||||
|
const normalizedName = normalizeItemName(itemName);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
hl.id,
|
||||||
|
hl.household_id,
|
||||||
|
hl.store_location_id,
|
||||||
|
hl.household_store_item_id AS item_id,
|
||||||
|
hl.household_store_item_id,
|
||||||
|
hsi.name AS item_name,
|
||||||
|
hl.quantity,
|
||||||
|
hl.bought,
|
||||||
|
hl.notes,
|
||||||
|
ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
|
||||||
|
COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||||
|
${ACTIVE_ADDED_BY_USERS_SQL},
|
||||||
|
hl.modified_on AS last_added_on,
|
||||||
|
hic.item_type,
|
||||||
|
hic.item_group,
|
||||||
|
COALESCE(slz.name, hic.zone) AS zone,
|
||||||
|
slz.sort_order AS zone_sort_order
|
||||||
|
FROM household_lists hl
|
||||||
|
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||||
|
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
|
||||||
|
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||||
|
LEFT JOIN household_item_classifications hic
|
||||||
|
ON hic.household_id = hl.household_id
|
||||||
|
AND hic.store_location_id = hl.store_location_id
|
||||||
|
AND hic.household_store_item_id = hl.household_store_item_id
|
||||||
|
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||||
|
WHERE hl.household_id = $1
|
||||||
|
AND hl.store_location_id = $2
|
||||||
|
AND hsi.normalized_name = $3`,
|
||||||
|
[householdId, storeLocationId, normalizedName]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.addOrUpdateItem = async (
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
itemName,
|
||||||
|
quantity,
|
||||||
|
userId,
|
||||||
|
imageBuffer = null,
|
||||||
|
mimeType = null,
|
||||||
|
notes = undefined
|
||||||
|
) => {
|
||||||
|
const nextQuantity = toPositiveInteger(quantity);
|
||||||
|
const householdStoreItem = await exports.ensureHouseholdStoreItem(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
itemName
|
||||||
|
);
|
||||||
|
const listResult = await pool.query(
|
||||||
|
`SELECT id, bought, quantity
|
||||||
|
FROM household_lists
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND household_store_item_id = $3`,
|
||||||
|
[householdId, storeLocationId, householdStoreItem.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (listResult.rowCount > 0) {
|
||||||
|
const listId = listResult.rows[0].id;
|
||||||
|
const previousQuantity = toPositiveInteger(listResult.rows[0].quantity, 0);
|
||||||
|
const wasBought = Boolean(listResult.rows[0].bought);
|
||||||
|
const historyQuantity =
|
||||||
|
!wasBought && nextQuantity > previousQuantity
|
||||||
|
? nextQuantity - previousQuantity
|
||||||
|
: nextQuantity;
|
||||||
|
|
||||||
|
let imageId = null;
|
||||||
|
if (imageBuffer && mimeType) {
|
||||||
|
imageId = await createItemImage({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: householdStoreItem.id,
|
||||||
|
householdListId: listId,
|
||||||
|
imageScope: "list",
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageId) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE household_lists
|
||||||
|
SET quantity = $1,
|
||||||
|
bought = FALSE,
|
||||||
|
image_id = $2,
|
||||||
|
custom_image = NULL,
|
||||||
|
custom_image_mime_type = NULL,
|
||||||
|
notes = COALESCE($3, notes),
|
||||||
|
modified_on = NOW()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[nextQuantity, imageId, notes, listId]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE household_lists
|
||||||
|
SET quantity = $1,
|
||||||
|
bought = FALSE,
|
||||||
|
notes = COALESCE($2, notes),
|
||||||
|
modified_on = NOW()
|
||||||
|
WHERE id = $3`,
|
||||||
|
[nextQuantity, notes, listId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
listId,
|
||||||
|
itemId: householdStoreItem.id,
|
||||||
|
householdStoreItemId: householdStoreItem.id,
|
||||||
|
itemName: householdStoreItem.name,
|
||||||
|
quantity: nextQuantity,
|
||||||
|
previousQuantity,
|
||||||
|
historyQuantity,
|
||||||
|
wasBought,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const insert = await pool.query(
|
||||||
|
`INSERT INTO household_lists
|
||||||
|
(household_id, store_location_id, household_store_item_id, quantity, added_by, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id`,
|
||||||
|
[householdId, storeLocationId, householdStoreItem.id, nextQuantity, userId, notes || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (imageBuffer && mimeType) {
|
||||||
|
const imageId = await createItemImage({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: householdStoreItem.id,
|
||||||
|
householdListId: insert.rows[0].id,
|
||||||
|
imageScope: "list",
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE household_lists
|
||||||
|
SET image_id = $1,
|
||||||
|
custom_image = NULL,
|
||||||
|
custom_image_mime_type = NULL
|
||||||
|
WHERE id = $2`,
|
||||||
|
[imageId, insert.rows[0].id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
listId: insert.rows[0].id,
|
||||||
|
itemId: householdStoreItem.id,
|
||||||
|
householdStoreItemId: householdStoreItem.id,
|
||||||
|
itemName: householdStoreItem.name,
|
||||||
|
quantity: nextQuantity,
|
||||||
|
previousQuantity: 0,
|
||||||
|
historyQuantity: nextQuantity,
|
||||||
|
wasBought: false,
|
||||||
|
isNew: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setBought = async (listId, bought, quantityBought = null) => {
|
||||||
|
const item = await pool.query(
|
||||||
|
`SELECT id, household_id, store_location_id, household_store_item_id, quantity, bought
|
||||||
|
FROM household_lists
|
||||||
|
WHERE id = $1`,
|
||||||
|
[listId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!item.rows[0]) return null;
|
||||||
|
|
||||||
|
const current = item.rows[0];
|
||||||
|
const currentQuantity = toPositiveInteger(current.quantity, 0);
|
||||||
|
|
||||||
|
if (bought === false) {
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
|
||||||
|
[listId]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
eventType: "ITEM_UNBOUGHT",
|
||||||
|
quantityDelta: null,
|
||||||
|
quantityAfter: currentQuantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedQuantity = toPositiveInteger(quantityBought, 0);
|
||||||
|
if (requestedQuantity > 0) {
|
||||||
|
const boughtQuantity = Math.min(requestedQuantity, currentQuantity);
|
||||||
|
const remainingQuantity = currentQuantity - boughtQuantity;
|
||||||
|
|
||||||
|
if (remainingQuantity <= 0) {
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||||
|
[listId]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
|
||||||
|
[remainingQuantity, listId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
eventType: "ITEM_BOUGHT",
|
||||||
|
quantityDelta: -boughtQuantity,
|
||||||
|
quantityAfter: Math.max(remainingQuantity, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||||
|
[listId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
eventType: "ITEM_BOUGHT",
|
||||||
|
quantityDelta: -currentQuantity,
|
||||||
|
quantityAfter: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.addHistoryRecord = async (
|
||||||
|
listId,
|
||||||
|
householdStoreItemId,
|
||||||
|
quantity,
|
||||||
|
userId,
|
||||||
|
storeLocationId = null
|
||||||
|
) => {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO household_list_history
|
||||||
|
(household_list_id, store_location_id, household_store_item_id, quantity, added_by, added_on)
|
||||||
|
VALUES (
|
||||||
|
$1,
|
||||||
|
COALESCE($5, (SELECT store_location_id FROM household_lists WHERE id = $1)),
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
NOW()
|
||||||
|
)`,
|
||||||
|
[listId, householdStoreItemId, quantity, userId, storeLocationId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.recordItemEvent = async ({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
householdListId = null,
|
||||||
|
actorUserId = null,
|
||||||
|
eventType,
|
||||||
|
quantityDelta = null,
|
||||||
|
quantityAfter = null,
|
||||||
|
metadata = {},
|
||||||
|
}) => {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO household_item_events
|
||||||
|
(
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
household_store_item_id,
|
||||||
|
household_list_id,
|
||||||
|
actor_user_id,
|
||||||
|
event_type,
|
||||||
|
quantity_delta,
|
||||||
|
quantity_after,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
householdListId,
|
||||||
|
actorUserId,
|
||||||
|
eventType,
|
||||||
|
quantityDelta,
|
||||||
|
quantityAfter,
|
||||||
|
JSON.stringify(metadata || {}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getSuggestions = async (query, householdId, storeLocationId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT DISTINCT
|
||||||
|
hsi.name AS item_name,
|
||||||
|
CASE WHEN hl.id IS NOT NULL AND hl.bought = FALSE THEN 0 ELSE 1 END AS sort_order
|
||||||
|
FROM household_store_items hsi
|
||||||
|
LEFT JOIN household_lists hl
|
||||||
|
ON hl.household_store_item_id = hsi.id
|
||||||
|
AND hl.household_id = $2
|
||||||
|
AND hl.store_location_id = $3
|
||||||
|
WHERE hsi.household_id = $2
|
||||||
|
AND hsi.store_location_id = $3
|
||||||
|
AND hsi.name ILIKE $1
|
||||||
|
ORDER BY sort_order, hsi.name
|
||||||
|
LIMIT 10`,
|
||||||
|
[`%${query}%`, householdId, storeLocationId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getRecentlyBoughtItems = async (householdId, storeLocationId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
hl.id,
|
||||||
|
hl.household_store_item_id AS item_id,
|
||||||
|
hl.household_store_item_id,
|
||||||
|
hsi.name AS item_name,
|
||||||
|
hl.quantity,
|
||||||
|
hl.bought,
|
||||||
|
ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
|
||||||
|
COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||||
|
${ACTIVE_ADDED_BY_USERS_SQL},
|
||||||
|
hl.modified_on AS last_added_on
|
||||||
|
FROM household_lists hl
|
||||||
|
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||||
|
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
|
||||||
|
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||||
|
WHERE hl.household_id = $1
|
||||||
|
AND hl.store_location_id = $2
|
||||||
|
AND hl.bought = TRUE
|
||||||
|
AND hl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||||
|
ORDER BY hl.modified_on DESC`,
|
||||||
|
[householdId, storeLocationId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getZoneByName = async (householdId, storeLocationId, zoneName) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name, sort_order
|
||||||
|
FROM store_location_zones
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND normalized_name = $3
|
||||||
|
AND is_active = TRUE`,
|
||||||
|
[householdId, storeLocationId, normalizeItemName(zoneName)]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getClassification = async (householdId, storeLocationId, itemId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
hic.item_type,
|
||||||
|
hic.item_group,
|
||||||
|
COALESCE(slz.name, hic.zone) AS zone,
|
||||||
|
hic.confidence,
|
||||||
|
hic.source
|
||||||
|
FROM household_item_classifications hic
|
||||||
|
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||||
|
WHERE hic.household_id = $1
|
||||||
|
AND hic.store_location_id = $2
|
||||||
|
AND hic.household_store_item_id = $3`,
|
||||||
|
[householdId, storeLocationId, itemId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.upsertClassification = async (householdId, storeLocationId, itemId, classification) => {
|
||||||
|
const { item_type, item_group, zone, confidence, source } = classification;
|
||||||
|
const zoneRecord = zone ? await exports.getZoneByName(householdId, storeLocationId, zone) : null;
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO household_item_classifications
|
||||||
|
(
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
household_store_item_id,
|
||||||
|
item_type,
|
||||||
|
item_group,
|
||||||
|
zone,
|
||||||
|
zone_id,
|
||||||
|
confidence,
|
||||||
|
source
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (household_id, store_location_id, household_store_item_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
item_type = EXCLUDED.item_type,
|
||||||
|
item_group = EXCLUDED.item_group,
|
||||||
|
zone = EXCLUDED.zone,
|
||||||
|
zone_id = EXCLUDED.zone_id,
|
||||||
|
confidence = EXCLUDED.confidence,
|
||||||
|
source = EXCLUDED.source,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
itemId,
|
||||||
|
item_type,
|
||||||
|
item_group,
|
||||||
|
zone,
|
||||||
|
zoneRecord?.id || null,
|
||||||
|
confidence,
|
||||||
|
source,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteClassification = async (householdId, storeLocationId, itemId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM household_item_classifications
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND household_store_item_id = $3`,
|
||||||
|
[householdId, storeLocationId, itemId]
|
||||||
|
);
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateItem = async (listId, itemName, quantity, notes) => {
|
||||||
|
const existing = await pool.query(
|
||||||
|
`SELECT id, household_id, store_location_id, household_store_item_id, quantity, notes
|
||||||
|
FROM household_lists
|
||||||
|
WHERE id = $1`,
|
||||||
|
[listId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rowCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const values = [listId];
|
||||||
|
let paramCount = 1;
|
||||||
|
|
||||||
|
if (quantity !== undefined) {
|
||||||
|
paramCount += 1;
|
||||||
|
updates.push(`quantity = $${paramCount}`);
|
||||||
|
values.push(quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes !== undefined) {
|
||||||
|
paramCount += 1;
|
||||||
|
updates.push(`notes = $${paramCount}`);
|
||||||
|
values.push(notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push("modified_on = NOW()");
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE household_lists SET ${updates.join(", ")} WHERE id = $1 RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
previous: existing.rows[0],
|
||||||
|
updated: result.rows[0],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteItem = async (listId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM household_lists
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, household_id, store_location_id, household_store_item_id, quantity`,
|
||||||
|
[listId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setCatalogItemImage = async (
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId = null
|
||||||
|
) => {
|
||||||
|
const imageId = await createItemImage({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
imageScope: "catalog",
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE household_store_items
|
||||||
|
SET image_id = $1,
|
||||||
|
custom_image = NULL,
|
||||||
|
custom_image_mime_type = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $2
|
||||||
|
AND store_location_id = $3
|
||||||
|
AND id = $4`,
|
||||||
|
[imageId, householdId, storeLocationId, householdStoreItemId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return imageId;
|
||||||
|
};
|
||||||
123
backend/models/session.model.js
Normal file
123
backend/models/session.model.js
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
const pool = require("../db/pool");
|
||||||
|
const { SESSION_TTL_DAYS } = require("../utils/session-cookie");
|
||||||
|
|
||||||
|
const INSERT_SESSION_SQL = `INSERT INTO sessions (id, user_id, expires_at, user_agent)
|
||||||
|
VALUES ($1, $2, NOW() + ($3 || ' days')::interval, $4)
|
||||||
|
RETURNING id, user_id, created_at, expires_at`;
|
||||||
|
const SELECT_ACTIVE_SESSION_SQL = `SELECT
|
||||||
|
s.id,
|
||||||
|
s.user_id,
|
||||||
|
s.expires_at,
|
||||||
|
u.username,
|
||||||
|
u.role
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.id = $1
|
||||||
|
AND s.expires_at > NOW()`;
|
||||||
|
|
||||||
|
let ensureSessionsTablePromise = null;
|
||||||
|
|
||||||
|
function generateSessionId() {
|
||||||
|
if (typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID().replace(/-/g, "") + crypto.randomBytes(8).toString("hex");
|
||||||
|
}
|
||||||
|
return crypto.randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUndefinedTableError(error) {
|
||||||
|
return error && error.code === "42P01";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSessionsTable() {
|
||||||
|
if (!ensureSessionsTablePromise) {
|
||||||
|
ensureSessionsTablePromise = (async () => {
|
||||||
|
await pool.query(`CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id VARCHAR(128) PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
user_agent TEXT
|
||||||
|
);`);
|
||||||
|
await pool.query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);"
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);"
|
||||||
|
);
|
||||||
|
})().catch((error) => {
|
||||||
|
ensureSessionsTablePromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureSessionsTablePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertSession(id, userId, userAgent) {
|
||||||
|
const result = await pool.query(INSERT_SESSION_SQL, [
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
String(SESSION_TTL_DAYS),
|
||||||
|
userAgent,
|
||||||
|
]);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.createSession = async (userId, userAgent = null) => {
|
||||||
|
const id = generateSessionId();
|
||||||
|
try {
|
||||||
|
return await insertSession(id, userId, userAgent);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUndefinedTableError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureSessionsTable();
|
||||||
|
return insertSession(id, userId, userAgent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getActiveSessionWithUser = async (sessionId) => {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await pool.query(SELECT_ACTIVE_SESSION_SQL, [sessionId]);
|
||||||
|
} catch (error) {
|
||||||
|
if (isUndefinedTableError(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = result.rows[0] || null;
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE sessions
|
||||||
|
SET last_seen_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUndefinedTableError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteSession = async (sessionId) => {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM sessions WHERE id = $1`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUndefinedTableError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
559
backend/models/store.model.js
Normal file
559
backend/models/store.model.js
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
const pool = require("../db/pool");
|
||||||
|
const { ZONE_FLOW } = require("../constants/classifications");
|
||||||
|
|
||||||
|
const DEFAULT_LOCATION_NAME = "Default Location";
|
||||||
|
|
||||||
|
function normalizeName(value) {
|
||||||
|
return String(value || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayLocationName(storeName, locationName) {
|
||||||
|
if (!locationName || locationName === DEFAULT_LOCATION_NAME) {
|
||||||
|
return storeName;
|
||||||
|
}
|
||||||
|
return `${storeName} - ${locationName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLocationRow(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
id: row.location_id,
|
||||||
|
display_name: row.display_name || displayLocationName(row.name, row.location_name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryLocationById(db, householdId, locationId) {
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT
|
||||||
|
sl.id AS location_id,
|
||||||
|
sl.id,
|
||||||
|
sl.household_id,
|
||||||
|
sl.household_store_id,
|
||||||
|
hcs.name,
|
||||||
|
sl.name AS location_name,
|
||||||
|
sl.address,
|
||||||
|
sl.is_default,
|
||||||
|
sl.map_data,
|
||||||
|
sl.created_at,
|
||||||
|
sl.updated_at,
|
||||||
|
CASE
|
||||||
|
WHEN sl.name = $3 THEN hcs.name
|
||||||
|
ELSE hcs.name || ' - ' || sl.name
|
||||||
|
END AS display_name
|
||||||
|
FROM store_locations sl
|
||||||
|
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
|
||||||
|
WHERE sl.household_id = $1
|
||||||
|
AND sl.id = $2`,
|
||||||
|
[householdId, locationId, DEFAULT_LOCATION_NAME]
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapLocationRow(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDefaultZones(db, householdId, locationId) {
|
||||||
|
for (let index = 0; index < ZONE_FLOW.length; index += 1) {
|
||||||
|
const zoneName = ZONE_FLOW[index];
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO store_location_zones
|
||||||
|
(household_id, store_location_id, name, normalized_name, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (store_location_id, normalized_name) DO NOTHING`,
|
||||||
|
[householdId, locationId, zoneName, normalizeName(zoneName), (index + 1) * 10]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy global store catalog. Kept for system-admin compatibility only.
|
||||||
|
exports.getAllStores = async () => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name, default_zones, created_at
|
||||||
|
FROM stores
|
||||||
|
ORDER BY name ASC`
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteStore = async (storeId) => {
|
||||||
|
const usage = await pool.query(
|
||||||
|
`SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`,
|
||||||
|
[storeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(usage.rows[0].count, 10) > 0) {
|
||||||
|
throw new Error("Cannot delete store that is in use by households");
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("DELETE FROM stores WHERE id = $1", [storeId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Household-owned store locations.
|
||||||
|
exports.getHouseholdStores = async (householdId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
sl.id AS location_id,
|
||||||
|
sl.id,
|
||||||
|
sl.household_id,
|
||||||
|
sl.household_store_id,
|
||||||
|
hcs.name,
|
||||||
|
sl.name AS location_name,
|
||||||
|
sl.address,
|
||||||
|
sl.is_default,
|
||||||
|
sl.map_data,
|
||||||
|
sl.created_at,
|
||||||
|
sl.updated_at,
|
||||||
|
CASE
|
||||||
|
WHEN sl.name = $2 THEN hcs.name
|
||||||
|
ELSE hcs.name || ' - ' || sl.name
|
||||||
|
END AS display_name
|
||||||
|
FROM store_locations sl
|
||||||
|
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
|
||||||
|
WHERE sl.household_id = $1
|
||||||
|
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
|
||||||
|
[householdId, DEFAULT_LOCATION_NAME]
|
||||||
|
);
|
||||||
|
return result.rows.map(mapLocationRow);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createHouseholdStore = async (
|
||||||
|
householdId,
|
||||||
|
name,
|
||||||
|
locationName = DEFAULT_LOCATION_NAME,
|
||||||
|
address = null,
|
||||||
|
createdBy = null
|
||||||
|
) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
const normalizedStoreName = normalizeName(name);
|
||||||
|
const normalizedLocationName = normalizeName(locationName || DEFAULT_LOCATION_NAME);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const storeResult = await client.query(
|
||||||
|
`INSERT INTO household_custom_stores
|
||||||
|
(household_id, name, normalized_name, created_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT (household_id, normalized_name)
|
||||||
|
DO UPDATE SET name = EXCLUDED.name, updated_at = NOW()
|
||||||
|
RETURNING id, name`,
|
||||||
|
[householdId, name.trim(), normalizedStoreName, createdBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasDefault = await client.query(
|
||||||
|
`SELECT 1 FROM store_locations
|
||||||
|
WHERE household_id = $1 AND is_default = TRUE
|
||||||
|
LIMIT 1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationResult = await client.query(
|
||||||
|
`INSERT INTO store_locations
|
||||||
|
(household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
|
ON CONFLICT (household_store_id, normalized_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
address = COALESCE(EXCLUDED.address, store_locations.address),
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
storeResult.rows[0].id,
|
||||||
|
(locationName || DEFAULT_LOCATION_NAME).trim(),
|
||||||
|
normalizedLocationName,
|
||||||
|
address || null,
|
||||||
|
hasDefault.rowCount === 0,
|
||||||
|
createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedDefaultZones(client, householdId, locationResult.rows[0].id);
|
||||||
|
|
||||||
|
const location = await queryLocationById(client, householdId, locationResult.rows[0].id);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return location;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateHouseholdStore = async (householdId, householdStoreId, updates = {}) => {
|
||||||
|
const { name } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE household_custom_stores
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
normalized_name = COALESCE($2, normalized_name),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $3
|
||||||
|
AND id = $4
|
||||||
|
RETURNING id, household_id, name, created_at, updated_at`,
|
||||||
|
[
|
||||||
|
name?.trim() || null,
|
||||||
|
name ? normalizeName(name) : null,
|
||||||
|
householdId,
|
||||||
|
householdStoreId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteHouseholdStore = async (householdId, householdStoreId) => {
|
||||||
|
const countResult = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS count
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const storeLocationCount = countResult.rows[0]?.count || 0;
|
||||||
|
const targetLocations = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS count
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND household_store_id = $2`,
|
||||||
|
[householdId, householdStoreId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (storeLocationCount <= targetLocations.rows[0]?.count) {
|
||||||
|
throw new Error("Cannot remove the last store location for a household");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM household_custom_stores
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2`,
|
||||||
|
[householdId, householdStoreId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.addLocationToStore = async (
|
||||||
|
householdId,
|
||||||
|
householdStoreId,
|
||||||
|
name,
|
||||||
|
address = null,
|
||||||
|
createdBy = null
|
||||||
|
) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const storeResult = await client.query(
|
||||||
|
`SELECT id FROM household_custom_stores
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2`,
|
||||||
|
[householdId, householdStoreId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (storeResult.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDefault = await client.query(
|
||||||
|
`SELECT 1 FROM store_locations
|
||||||
|
WHERE household_id = $1 AND is_default = TRUE
|
||||||
|
LIMIT 1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationResult = await client.query(
|
||||||
|
`INSERT INTO store_locations
|
||||||
|
(household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
householdStoreId,
|
||||||
|
name.trim(),
|
||||||
|
normalizeName(name),
|
||||||
|
address || null,
|
||||||
|
hasDefault.rowCount === 0,
|
||||||
|
createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedDefaultZones(client, householdId, locationResult.rows[0].id);
|
||||||
|
const location = await queryLocationById(client, householdId, locationResult.rows[0].id);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return location;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateLocation = async (householdId, locationId, updates = {}) => {
|
||||||
|
const { name, address, map_data } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
normalized_name = COALESCE($2, normalized_name),
|
||||||
|
address = COALESCE($3, address),
|
||||||
|
map_data = COALESCE($4::jsonb, map_data),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $5
|
||||||
|
AND id = $6
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
name?.trim() || null,
|
||||||
|
name ? normalizeName(name) : null,
|
||||||
|
address === undefined ? null : address,
|
||||||
|
map_data ? JSON.stringify(map_data) : null,
|
||||||
|
householdId,
|
||||||
|
locationId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) return null;
|
||||||
|
return queryLocationById(pool, householdId, locationId);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteLocation = async (householdId, locationId) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const countResult = await client.query(
|
||||||
|
`SELECT COUNT(*)::int AS count
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((countResult.rows[0]?.count || 0) <= 1) {
|
||||||
|
throw new Error("Cannot remove the last store location for a household");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await client.query(
|
||||||
|
`DELETE FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2
|
||||||
|
RETURNING is_default`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deleted.rowCount === 0) {
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted.rows[0].is_default) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET is_default = TRUE, updated_at = NOW()
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
)`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setDefaultLocation = async (householdId, locationId) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET is_default = FALSE, updated_at = NOW()
|
||||||
|
WHERE household_id = $1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET is_default = TRUE, updated_at = NOW()
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2
|
||||||
|
RETURNING id`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error("Location not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.householdHasLocation = async (householdId, locationId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getLocationById = async (householdId, locationId) =>
|
||||||
|
queryLocationById(pool, householdId, locationId);
|
||||||
|
|
||||||
|
exports.listLocationZones = async (householdId, locationId, includeInactive = false) => {
|
||||||
|
const values = [householdId, locationId];
|
||||||
|
const inactiveClause = includeInactive ? "" : "AND is_active = TRUE";
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name, sort_order, color, map_metadata, is_active, created_at, updated_at
|
||||||
|
FROM store_location_zones
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
${inactiveClause}
|
||||||
|
ORDER BY sort_order ASC, name ASC`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getZoneByName = async (householdId, locationId, zoneName) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name, sort_order, color, is_active
|
||||||
|
FROM store_location_zones
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND normalized_name = $3
|
||||||
|
AND is_active = TRUE`,
|
||||||
|
[householdId, locationId, normalizeName(zoneName)]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createZone = async (householdId, locationId, zone) => {
|
||||||
|
const { name, sort_order, color, map_metadata } = zone;
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO store_location_zones
|
||||||
|
(household_id, store_location_id, name, normalized_name, sort_order, color, map_metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7::jsonb, '{}'::jsonb))
|
||||||
|
ON CONFLICT (store_location_id, normalized_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
color = EXCLUDED.color,
|
||||||
|
map_metadata = EXCLUDED.map_metadata,
|
||||||
|
is_active = TRUE,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, name, sort_order, color, map_metadata, is_active`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
locationId,
|
||||||
|
name.trim(),
|
||||||
|
normalizeName(name),
|
||||||
|
Number.isInteger(sort_order) ? sort_order : 0,
|
||||||
|
color || null,
|
||||||
|
map_metadata ? JSON.stringify(map_metadata) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateZone = async (householdId, locationId, zoneId, updates = {}) => {
|
||||||
|
const { name, sort_order, color, map_metadata, is_active } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE store_location_zones
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
normalized_name = COALESCE($2, normalized_name),
|
||||||
|
sort_order = COALESCE($3, sort_order),
|
||||||
|
color = COALESCE($4, color),
|
||||||
|
map_metadata = COALESCE($5::jsonb, map_metadata),
|
||||||
|
is_active = COALESCE($6, is_active),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $7
|
||||||
|
AND store_location_id = $8
|
||||||
|
AND id = $9
|
||||||
|
RETURNING id, name, sort_order, color, map_metadata, is_active`,
|
||||||
|
[
|
||||||
|
name?.trim() || null,
|
||||||
|
name ? normalizeName(name) : null,
|
||||||
|
Number.isInteger(sort_order) ? sort_order : null,
|
||||||
|
color === undefined ? null : color,
|
||||||
|
map_metadata ? JSON.stringify(map_metadata) : null,
|
||||||
|
typeof is_active === "boolean" ? is_active : null,
|
||||||
|
householdId,
|
||||||
|
locationId,
|
||||||
|
zoneId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteZone = async (householdId, locationId, zoneId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE store_location_zones
|
||||||
|
SET is_active = FALSE, updated_at = NOW()
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND id = $3`,
|
||||||
|
[householdId, locationId, zoneId]
|
||||||
|
);
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backward-compatible check for legacy routes. Prefer householdHasLocation.
|
||||||
|
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.rowCount > 0;
|
||||||
|
};
|
||||||
@ -1,33 +1,70 @@
|
|||||||
const pool = require("../db/pool");
|
const pool = require("../db/pool");
|
||||||
|
|
||||||
exports.ROLES = {
|
exports.ROLES = {
|
||||||
VIEWER: "viewer",
|
SYSTEM_ADMIN: "system_admin",
|
||||||
EDITOR: "editor",
|
USER: "user",
|
||||||
ADMIN: "admin",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.findByUsername = async (username) => {
|
exports.findByUsername = async (username) => {
|
||||||
query = `SELECT * FROM users WHERE username = ${username}`;
|
|
||||||
const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]);
|
const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]);
|
||||||
console.log(query);
|
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.createUser = async (username, hashedPassword, name) => {
|
exports.createUser = async (username, hashedPassword, name) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO users (username, password, name, role)
|
`INSERT INTO users (username, password, name, role)
|
||||||
VALUES ($1, $2, $3, $4)`,
|
VALUES ($1, $2, $3, $4)
|
||||||
[username, hashedPassword, name, this.ROLES.VIEWER]
|
RETURNING id, username, name, role`,
|
||||||
|
[username, hashedPassword, name, exports.ROLES.USER]
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
exports.getAllUsers = async () => {
|
exports.getAllUsers = async () => {
|
||||||
const result = await pool.query("SELECT id, username, name, role FROM users ORDER BY id ASC");
|
const result = await pool.query("SELECT id, username, name, role, display_name FROM users ORDER BY id ASC");
|
||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.getUserById = async (id) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT id, username, name, role, display_name FROM users WHERE id = $1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateUserProfile = async (id, updates) => {
|
||||||
|
const { display_name } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE users
|
||||||
|
SET display_name = COALESCE($1, display_name)
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, username, name, role, display_name`,
|
||||||
|
[display_name, id]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateUserPassword = async (id, hashedPassword) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE users
|
||||||
|
SET password = $1
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id`,
|
||||||
|
[hashedPassword, id]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getUserPasswordHash = async (id) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT password FROM users WHERE id = $1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0]?.password;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
exports.updateUserRole = async (id, role) => {
|
exports.updateUserRole = async (id, role) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
|
|||||||
2208
backend/package-lock.json
generated
2208
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,13 +10,12 @@
|
|||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cpx": "^1.5.0",
|
|
||||||
"esbuild": "^0.25.5",
|
"esbuild": "^0.25.5",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"rimraf": "^6.0.1"
|
"rimraf": "^6.0.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rimraf dist && node build.js && cpx \"public/**/*\" dist/public",
|
"build": "rimraf dist && node build.js",
|
||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
backend/public/TEST_SUITE_README.md
Normal file
43
backend/public/TEST_SUITE_README.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# API Test Suite
|
||||||
|
|
||||||
|
The test suite has been reorganized into separate files for better maintainability:
|
||||||
|
|
||||||
|
## New Modular Structure (✅ Complete)
|
||||||
|
- **api-tests.html** - Main HTML file
|
||||||
|
- **test-config.js** - Global state management
|
||||||
|
- **test-definitions.js** - All 62 test cases across 8 categories
|
||||||
|
- **test-runner.js** - Test execution logic
|
||||||
|
- **test-ui.js** - UI manipulation functions
|
||||||
|
- **test-styles.css** - All CSS styles
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
1. Start the dev server: `docker-compose -f docker-compose.dev.yml up`
|
||||||
|
2. Navigate to: `http://localhost:5000/test/api-tests.html`
|
||||||
|
3. Configure credentials (default: admin/admin123)
|
||||||
|
4. Click "▶ Run All Tests"
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- ✅ 62 comprehensive tests
|
||||||
|
- ✅ Collapsible test cards (collapsed by default)
|
||||||
|
- ✅ Expected field validation with visual indicators
|
||||||
|
- ✅ Color-coded HTTP status badges
|
||||||
|
- ✅ Auto-expansion on test run
|
||||||
|
- ✅ Expand/Collapse all buttons
|
||||||
|
- ✅ Real-time pass/fail/error states
|
||||||
|
- ✅ Summary dashboard
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
```
|
||||||
|
backend/public/
|
||||||
|
├── api-tests.html # Main entry point (use this)
|
||||||
|
├── test-config.js # State management (19 lines)
|
||||||
|
├── test-definitions.js # Test cases (450+ lines)
|
||||||
|
├── test-runner.js # Test execution (160+ lines)
|
||||||
|
├── test-ui.js # UI functions (90+ lines)
|
||||||
|
└── test-styles.css # All styles (310+ lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Old File
|
||||||
|
- **api-test.html** - Original monolithic version (kept for reference)
|
||||||
|
|
||||||
|
Total: ~1030 lines split into 6 clean, modular files
|
||||||
1037
backend/public/api-test.html
Normal file
1037
backend/public/api-test.html
Normal file
File diff suppressed because it is too large
Load Diff
63
backend/public/api-tests.html
Normal file
63
backend/public/api-tests.html
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>API Test Suite - Grocery List</title>
|
||||||
|
<link rel="stylesheet" href="test-styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🧪 API Test Suite</h1>
|
||||||
|
<p style="color: #666; margin-bottom: 20px;">Multi-Household Grocery List API Testing</p>
|
||||||
|
|
||||||
|
<div class="config">
|
||||||
|
<h3 style="margin-bottom: 15px;">Configuration</h3>
|
||||||
|
<div class="config-row">
|
||||||
|
<label>API URL:</label>
|
||||||
|
<input type="text" id="apiUrl" value="http://localhost:5000" />
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<label>Username:</label>
|
||||||
|
<input type="text" id="username" value="admin" />
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<label>Password:</label>
|
||||||
|
<input type="password" id="password" value="admin123" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="runAllTests(event)">▶ Run All Tests</button>
|
||||||
|
<button onclick="clearResults()">🗑 Clear Results</button>
|
||||||
|
<button onclick="expandAllTests()">📂 Expand All</button>
|
||||||
|
<button onclick="collapseAllTests()">📁 Collapse All</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary" id="summary" style="display: none;">
|
||||||
|
<div class="summary-item total">
|
||||||
|
<div class="summary-value" id="totalTests">0</div>
|
||||||
|
<div class="summary-label">Total Tests</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item pass">
|
||||||
|
<div class="summary-value" id="passedTests">0</div>
|
||||||
|
<div class="summary-label">Passed</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item fail">
|
||||||
|
<div class="summary-value" id="failedTests">0</div>
|
||||||
|
<div class="summary-label">Failed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="testResults"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="test-config.js"></script>
|
||||||
|
<script src="test-definitions.js"></script>
|
||||||
|
<script src="test-runner.js"></script>
|
||||||
|
<script src="test-ui.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
19
backend/public/test-config.js
Normal file
19
backend/public/test-config.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// Global state
|
||||||
|
let authToken = null;
|
||||||
|
let householdId = null;
|
||||||
|
let storeId = null;
|
||||||
|
let testUserId = null;
|
||||||
|
let createdHouseholdId = null;
|
||||||
|
let secondHouseholdId = null;
|
||||||
|
let inviteCode = null;
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
function resetState() {
|
||||||
|
authToken = null;
|
||||||
|
householdId = null;
|
||||||
|
storeId = null;
|
||||||
|
testUserId = null;
|
||||||
|
createdHouseholdId = null;
|
||||||
|
secondHouseholdId = null;
|
||||||
|
inviteCode = null;
|
||||||
|
}
|
||||||
826
backend/public/test-definitions.js
Normal file
826
backend/public/test-definitions.js
Normal file
@ -0,0 +1,826 @@
|
|||||||
|
// Test definitions - 108 tests across 14 categories
|
||||||
|
const tests = [
|
||||||
|
{
|
||||||
|
category: "Authentication",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Login with valid credentials",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/auth/login",
|
||||||
|
auth: false,
|
||||||
|
body: () => ({ username: document.getElementById('username').value, password: document.getElementById('password').value }),
|
||||||
|
expect: (res) => res.token && res.role,
|
||||||
|
expectedFields: ['token', 'username', 'role'],
|
||||||
|
onSuccess: (res) => { authToken = res.token; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Login with invalid credentials",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/auth/login",
|
||||||
|
auth: false,
|
||||||
|
body: { username: "wronguser", password: "wrongpass" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 401,
|
||||||
|
expectedFields: ['message']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Access protected route without token",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: false,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 401
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Households",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get user's households",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
expect: (res) => Array.isArray(res),
|
||||||
|
onSuccess: (res) => { if (res.length > 0) householdId = res[0].id; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Create new household",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
body: { name: `Test Household ${Date.now()}` },
|
||||||
|
expect: (res) => res.household && res.household.invite_code,
|
||||||
|
expectedFields: ['message', 'household', 'household.id', 'household.name', 'household.invite_code']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Get household details",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
expect: (res) => res.id === householdId,
|
||||||
|
expectedFields: ['id', 'name', 'invite_code', 'created_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update household name",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${householdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
body: { name: `Updated Household ${Date.now()}` },
|
||||||
|
expect: (res) => res.household,
|
||||||
|
expectedFields: ['message', 'household', 'household.id', 'household.name']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Refresh invite code",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${householdId}/invite/refresh`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
expect: (res) => res.household && res.household.invite_code,
|
||||||
|
expectedFields: ['message', 'household', 'household.invite_code']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Join household with invalid code",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/households/join/INVALID123",
|
||||||
|
auth: true,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Create household with empty name (validation)",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
body: { name: "" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400,
|
||||||
|
expectedFields: ['error']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Members",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get household members",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}/members`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length > 0,
|
||||||
|
onSuccess: (res) => { testUserId = res[0].user_id; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update member role (non-admin attempting)",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${householdId}/members/${testUserId}/role`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !testUserId,
|
||||||
|
body: { role: "user" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400 || status === 403
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Stores",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get all stores catalog",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/stores",
|
||||||
|
auth: true,
|
||||||
|
expect: (res) => Array.isArray(res),
|
||||||
|
onSuccess: (res) => { if (res.length > 0) storeId = res[0].id; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Get household stores",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/stores/household/${householdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
expect: (res) => Array.isArray(res)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Add store to household",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/stores/household/${householdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: () => ({ storeId: storeId, isDefault: true }),
|
||||||
|
expect: (res) => res.store,
|
||||||
|
expectedFields: ['message', 'store', 'store.id', 'store.name']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Set default store",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/stores/household/${householdId}/${storeId}/default`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Add invalid store to household",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/stores/household/${householdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
body: { storeId: 99999 },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Advanced Household Tests",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Create household for complex workflows",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
body: { name: `Workflow Test ${Date.now()}` },
|
||||||
|
expect: (res) => res.household && res.household.id,
|
||||||
|
onSuccess: (res) => {
|
||||||
|
createdHouseholdId = res.household.id;
|
||||||
|
inviteCode = res.household.invite_code;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify invite code format (7 chars)",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expect: (res) => res.invite_code && res.invite_code.length === 7 && res.invite_code.startsWith('H')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Get household with no stores added yet",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length === 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update household with very long name (validation)",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
body: { name: "A".repeat(101) },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Refresh invite code changes value",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}/invite/refresh`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !inviteCode,
|
||||||
|
expect: (res) => res.household && res.household.invite_code !== inviteCode,
|
||||||
|
onSuccess: (res) => { inviteCode = res.household.invite_code; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Join same household twice (idempotent)",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/join/${inviteCode}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !inviteCode,
|
||||||
|
expect: (res, status) => status === 200 && res.message.includes("already a member")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Get non-existent household",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/households/99999",
|
||||||
|
auth: true,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update non-existent household",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: "/households/99999",
|
||||||
|
auth: true,
|
||||||
|
body: { name: "Test" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 403 || status === 404
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Member Management Edge Cases",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get members for created household",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}/members`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length >= 1 && res[0].role === 'admin'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update own role (should fail)",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}/members/${testUserId}/role`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !testUserId,
|
||||||
|
body: { role: "user" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400 && res.error && res.error.includes("own role")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update role with invalid value",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}/members/1/role`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
body: { role: "superadmin" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Remove non-existent member",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}/members/99999`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404 || status === 500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Store Management Advanced",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Add multiple stores to household",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !storeId,
|
||||||
|
body: () => ({ storeId: storeId, isDefault: false }),
|
||||||
|
expect: (res) => res.store
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Add same store twice (duplicate check)",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !storeId,
|
||||||
|
body: () => ({ storeId: storeId, isDefault: false }),
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400 || status === 409 || status === 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Set default store for household",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}/default`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !storeId,
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify default store is first in list",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !storeId,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length > 0 && res[0].is_default === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Set non-existent store as default",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}/99999/default`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404 || status === 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Remove store from household",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !storeId,
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify store removed from household",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length === 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Data Integrity & Cleanup",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Create second household for testing",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
body: { name: `Second Test ${Date.now()}` },
|
||||||
|
expect: (res) => res.household && res.household.id,
|
||||||
|
onSuccess: (res) => { secondHouseholdId = res.household.id; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify user belongs to multiple households",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length >= 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete created test household",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify deleted household is gone",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404 || status === 403
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete second test household",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/households/${secondHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !secondHouseholdId,
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify households list updated",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
expect: (res) => Array.isArray(res)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "List Operations",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get grocery list for household+store",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
expect: (res) => Array.isArray(res),
|
||||||
|
expectedFields: ['items']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Add item to list",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Test API Item",
|
||||||
|
quantity: "2 units"
|
||||||
|
},
|
||||||
|
expect: (res) => res.item,
|
||||||
|
expectedFields: ['item', 'item.id', 'item.item_name', 'item.quantity']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Add duplicate item (should update quantity)",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Test API Item",
|
||||||
|
quantity: "3 units"
|
||||||
|
},
|
||||||
|
expect: (res) => res.item && res.item.quantity === "3 units"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mark item as bought",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Test API Item",
|
||||||
|
bought: true
|
||||||
|
},
|
||||||
|
expect: (res) => res.message,
|
||||||
|
expectedFields: ['message']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unmark item (set bought to false)",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Test API Item",
|
||||||
|
bought: false
|
||||||
|
},
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update item details",
|
||||||
|
method: "PUT",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Test API Item",
|
||||||
|
quantity: "5 units",
|
||||||
|
notes: "Updated via API test"
|
||||||
|
},
|
||||||
|
expect: (res) => res.item,
|
||||||
|
expectedFields: ['item', 'item.quantity', 'item.notes']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Get suggestions based on history",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/suggestions?query=test`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
expect: (res) => Array.isArray(res)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Get recently bought items",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/recent`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
expect: (res) => Array.isArray(res)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete item from list",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Test API Item"
|
||||||
|
},
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Try to add item with empty name",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "",
|
||||||
|
quantity: "1"
|
||||||
|
},
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Item Classifications",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get item classification",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification?item_name=Milk`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
expect: (res) => res.classification !== undefined,
|
||||||
|
expectedFields: ['classification']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Set item classification",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Test Classified Item",
|
||||||
|
classification: "dairy"
|
||||||
|
},
|
||||||
|
expect: (res) => res.message || res.classification
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update item classification",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Test Classified Item",
|
||||||
|
classification: "produce"
|
||||||
|
},
|
||||||
|
expect: (res) => res.message || res.classification
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify classification persists",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification?item_name=Test Classified Item`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
expect: (res) => res.classification === "produce"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Account Management",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get current user profile",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/users/me",
|
||||||
|
auth: true,
|
||||||
|
expect: (res) => res.username,
|
||||||
|
expectedFields: ['id', 'username', 'name', 'display_name', 'role'],
|
||||||
|
onSuccess: (res) => { testUserId = res.id; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update display name",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: "/users/me/display-name",
|
||||||
|
auth: true,
|
||||||
|
body: {
|
||||||
|
display_name: "Test Display Name"
|
||||||
|
},
|
||||||
|
expect: (res) => res.message,
|
||||||
|
expectedFields: ['message']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify display name updated",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/users/me",
|
||||||
|
auth: true,
|
||||||
|
expect: (res) => res.display_name === "Test Display Name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Clear display name (set to null)",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: "/users/me/display-name",
|
||||||
|
auth: true,
|
||||||
|
body: {
|
||||||
|
display_name: null
|
||||||
|
},
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update password",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: "/users/me/password",
|
||||||
|
auth: true,
|
||||||
|
body: () => ({
|
||||||
|
currentPassword: document.getElementById('password').value,
|
||||||
|
newPassword: document.getElementById('password').value
|
||||||
|
}),
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Try to update password with wrong current password",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: "/users/me/password",
|
||||||
|
auth: true,
|
||||||
|
body: {
|
||||||
|
currentPassword: "wrongpassword",
|
||||||
|
newPassword: "newpass123"
|
||||||
|
},
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 401
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Config Endpoints",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get classifications list",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/config/classifications",
|
||||||
|
auth: false,
|
||||||
|
expect: (res) => Array.isArray(res),
|
||||||
|
expectedFields: ['[0].value', '[0].label', '[0].color']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Get system config",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/config",
|
||||||
|
auth: false,
|
||||||
|
expect: (res) => res.classifications,
|
||||||
|
expectedFields: ['classifications']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Advanced List Scenarios",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Add multiple items rapidly",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Rapid Test Item 1",
|
||||||
|
quantity: "1"
|
||||||
|
},
|
||||||
|
expect: (res) => res.item
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Add second rapid item",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Rapid Test Item 2",
|
||||||
|
quantity: "1"
|
||||||
|
},
|
||||||
|
expect: (res) => res.item
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify list contains both items",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
expect: (res) => res.items && res.items.length >= 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mark both items as bought",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Rapid Test Item 1",
|
||||||
|
bought: true
|
||||||
|
},
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mark second item as bought",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Rapid Test Item 2",
|
||||||
|
bought: true
|
||||||
|
},
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify recent items includes bought items",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/recent`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length > 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete first rapid test item",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Rapid Test Item 1"
|
||||||
|
},
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete second rapid test item",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Rapid Test Item 2"
|
||||||
|
},
|
||||||
|
expect: (res) => res.message
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Edge Cases & Error Handling",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Access non-existent household",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/households/99999",
|
||||||
|
auth: true,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 403 || status === 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Access non-existent store in household",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/99999/list`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 403 || status === 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Try to update non-existent item",
|
||||||
|
method: "PUT",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Non Existent Item 999",
|
||||||
|
quantity: "1"
|
||||||
|
},
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Try to delete non-existent item",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Non Existent Item 999"
|
||||||
|
},
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid classification value",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: {
|
||||||
|
item_name: "Test Item",
|
||||||
|
classification: "invalid_category_xyz"
|
||||||
|
},
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty household name on creation",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
body: {
|
||||||
|
name: ""
|
||||||
|
},
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
147
backend/public/test-runner.js
Normal file
147
backend/public/test-runner.js
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
async function makeRequest(test) {
|
||||||
|
const apiUrl = document.getElementById('apiUrl').value;
|
||||||
|
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
|
||||||
|
const url = `${apiUrl}${endpoint}`;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
method: test.method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (test.auth && authToken) {
|
||||||
|
options.headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (test.body) {
|
||||||
|
options.body = JSON.stringify(typeof test.body === 'function' ? test.body() : test.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
return { data, status: response.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest(categoryIdx, testIdx) {
|
||||||
|
const test = tests[categoryIdx].tests[testIdx];
|
||||||
|
const testId = `test-${categoryIdx}-${testIdx}`;
|
||||||
|
const testEl = document.getElementById(testId);
|
||||||
|
const contentEl = document.getElementById(`${testId}-content`);
|
||||||
|
const toggleEl = document.getElementById(`${testId}-toggle`);
|
||||||
|
const resultEl = testEl.querySelector('.test-result');
|
||||||
|
|
||||||
|
if (test.skip && test.skip()) {
|
||||||
|
testEl.querySelector('.test-status').textContent = 'SKIPPED';
|
||||||
|
testEl.querySelector('.test-status').className = 'test-status pending';
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
resultEl.className = 'test-result';
|
||||||
|
resultEl.innerHTML = '⚠️ Prerequisites not met';
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
|
||||||
|
testEl.className = 'test-case running';
|
||||||
|
testEl.querySelector('.test-status').textContent = 'RUNNING';
|
||||||
|
testEl.querySelector('.test-status').className = 'test-status running';
|
||||||
|
resultEl.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, status } = await makeRequest(test);
|
||||||
|
|
||||||
|
const expectFail = test.expectFail || false;
|
||||||
|
const passed = test.expect(data, status);
|
||||||
|
|
||||||
|
const success = expectFail ? !passed || status >= 400 : passed;
|
||||||
|
|
||||||
|
testEl.className = success ? 'test-case pass' : 'test-case fail';
|
||||||
|
testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL';
|
||||||
|
testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`;
|
||||||
|
|
||||||
|
// Determine status code class
|
||||||
|
let statusClass = 'status-5xx';
|
||||||
|
if (status >= 200 && status < 300) statusClass = 'status-2xx';
|
||||||
|
else if (status >= 300 && status < 400) statusClass = 'status-3xx';
|
||||||
|
else if (status >= 400 && status < 500) statusClass = 'status-4xx';
|
||||||
|
|
||||||
|
// Check expected fields if defined
|
||||||
|
let expectedFieldsHTML = '';
|
||||||
|
if (test.expectedFields) {
|
||||||
|
const fieldChecks = test.expectedFields.map(field => {
|
||||||
|
const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined;
|
||||||
|
const icon = exists ? '✓' : '✗';
|
||||||
|
const className = exists ? 'pass' : 'fail';
|
||||||
|
return `<div class="field-check ${className}">${icon} ${field}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
expectedFieldsHTML = `
|
||||||
|
<div class="expected-section">
|
||||||
|
<div class="expected-label">Expected Fields:</div>
|
||||||
|
${fieldChecks}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
resultEl.className = 'test-result';
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<span class="response-status ${statusClass}">HTTP ${status}</span>
|
||||||
|
<span style="color: #666;">${success ? '✓ Test passed' : '✗ Test failed'}</span>
|
||||||
|
</div>
|
||||||
|
${expectedFieldsHTML}
|
||||||
|
<div style="color: #666; font-size: 12px; margin-bottom: 4px;">Response:</div>
|
||||||
|
<div>${JSON.stringify(data, null, 2)}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (success && test.onSuccess) {
|
||||||
|
test.onSuccess(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success ? 'pass' : 'fail';
|
||||||
|
} catch (error) {
|
||||||
|
testEl.className = 'test-case fail';
|
||||||
|
testEl.querySelector('.test-status').textContent = 'ERROR';
|
||||||
|
testEl.querySelector('.test-status').className = 'test-status fail';
|
||||||
|
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
resultEl.className = 'test-error';
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div style="font-weight: bold; margin-bottom: 8px;">❌ Network/Request Error</div>
|
||||||
|
<div>${error.message}</div>
|
||||||
|
${error.stack ? `<div style="margin-top: 8px; font-size: 11px; opacity: 0.7;">${error.stack}</div>` : ''}
|
||||||
|
`;
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAllTests(event) {
|
||||||
|
resetState();
|
||||||
|
|
||||||
|
const button = event.target;
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = '⏳ Running Tests...';
|
||||||
|
|
||||||
|
let totalTests = 0;
|
||||||
|
let passedTests = 0;
|
||||||
|
let failedTests = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < tests.length; i++) {
|
||||||
|
for (let j = 0; j < tests[i].tests.length; j++) {
|
||||||
|
const result = await runTest(i, j);
|
||||||
|
if (result !== 'skip') {
|
||||||
|
totalTests++;
|
||||||
|
if (result === 'pass') passedTests++;
|
||||||
|
if (result === 'fail') failedTests++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('summary').style.display = 'flex';
|
||||||
|
document.getElementById('totalTests').textContent = totalTests;
|
||||||
|
document.getElementById('passedTests').textContent = passedTests;
|
||||||
|
document.getElementById('failedTests').textContent = failedTests;
|
||||||
|
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = '▶ Run All Tests';
|
||||||
|
}
|
||||||
666
backend/public/test-script.js
Normal file
666
backend/public/test-script.js
Normal file
@ -0,0 +1,666 @@
|
|||||||
|
let authToken = null;
|
||||||
|
let householdId = null;
|
||||||
|
let storeId = null;
|
||||||
|
let testUserId = null;
|
||||||
|
let createdHouseholdId = null;
|
||||||
|
let secondHouseholdId = null;
|
||||||
|
let inviteCode = null;
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
{
|
||||||
|
category: "Authentication",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Login with valid credentials",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/auth/login",
|
||||||
|
auth: false,
|
||||||
|
body: () => ({ username: document.getElementById('username').value, password: document.getElementById('password').value }),
|
||||||
|
expect: (res) => res.token && res.role,
|
||||||
|
expectedFields: ['token', 'username', 'role'],
|
||||||
|
onSuccess: (res) => { authToken = res.token; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Login with invalid credentials",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/auth/login",
|
||||||
|
auth: false,
|
||||||
|
body: { username: "wronguser", password: "wrongpass" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 401,
|
||||||
|
expectedFields: ['message']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Access protected route without token",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: false,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 401
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Households",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get user's households",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
expect: (res) => Array.isArray(res),
|
||||||
|
onSuccess: (res) => { if (res.length > 0) householdId = res[0].id; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Create new household",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
body: { name: `Test Household ${Date.now()}` },
|
||||||
|
expect: (res) => res.household && res.household.invite_code,
|
||||||
|
expectedFields: ['message', 'household', 'household.id', 'household.name', 'household.invite_code']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Get household details",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
expect: (res) => res.id === householdId,
|
||||||
|
expectedFields: ['id', 'name', 'invite_code', 'created_at']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update household name",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${householdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
body: { name: `Updated Household ${Date.now()}` },
|
||||||
|
expect: (res) => res.household,
|
||||||
|
expectedFields: ['message', 'household', 'household.id', 'household.name']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Refresh invite code",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${householdId}/invite/refresh`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
expect: (res) => res.household && res.household.invite_code,
|
||||||
|
expectedFields: ['message', 'household', 'household.invite_code']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Join household with invalid code",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/households/join/INVALID123",
|
||||||
|
auth: true,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Create household with empty name (validation)",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
body: { name: "" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400,
|
||||||
|
expectedFields: ['error']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Members",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get household members",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${householdId}/members`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length > 0,
|
||||||
|
onSuccess: (res) => { testUserId = res[0].user_id; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update member role (non-admin attempting)",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${householdId}/members/${testUserId}/role`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !testUserId,
|
||||||
|
body: { role: "user" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400 || status === 403
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Stores",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get all stores catalog",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/stores",
|
||||||
|
auth: true,
|
||||||
|
expect: (res) => Array.isArray(res),
|
||||||
|
onSuccess: (res) => { if (res.length > 0) storeId = res[0].id; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Get household stores",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/stores/household/${householdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
expect: (res) => Array.isArray(res)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Add store to household",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/stores/household/${householdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
body: () => ({ storeId: storeId, isDefault: true }),
|
||||||
|
expect: (res) => res.store,
|
||||||
|
expectedFields: ['message', 'store', 'store.id', 'store.name']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Set default store",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/stores/household/${householdId}/${storeId}/default`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId || !storeId,
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Add invalid store to household",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/stores/household/${householdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !householdId,
|
||||||
|
body: { storeId: 99999 },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Advanced Household Tests",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Create household for complex workflows",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
body: { name: `Workflow Test ${Date.now()}` },
|
||||||
|
expect: (res) => res.household && res.household.id,
|
||||||
|
onSuccess: (res) => {
|
||||||
|
createdHouseholdId = res.household.id;
|
||||||
|
inviteCode = res.household.invite_code;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify invite code format (7 chars)",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expect: (res) => res.invite_code && res.invite_code.length === 7 && res.invite_code.startsWith('H')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Get household with no stores added yet",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length === 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update household with very long name (validation)",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
body: { name: "A".repeat(101) },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Refresh invite code changes value",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}/invite/refresh`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !inviteCode,
|
||||||
|
expect: (res) => res.household && res.household.invite_code !== inviteCode,
|
||||||
|
onSuccess: (res) => { inviteCode = res.household.invite_code; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Join same household twice (idempotent)",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/households/join/${inviteCode}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !inviteCode,
|
||||||
|
expect: (res, status) => status === 200 && res.message.includes("already a member")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Get non-existent household",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/households/99999",
|
||||||
|
auth: true,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update non-existent household",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: "/households/99999",
|
||||||
|
auth: true,
|
||||||
|
body: { name: "Test" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 403 || status === 404
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Member Management Edge Cases",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Get members for created household",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}/members`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length >= 1 && res[0].role === 'admin'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update own role (should fail)",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}/members/${testUserId}/role`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !testUserId,
|
||||||
|
body: { role: "user" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400 && res.error && res.error.includes("own role")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update role with invalid value",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}/members/1/role`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
body: { role: "superadmin" },
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Remove non-existent member",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}/members/99999`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404 || status === 500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Store Management Advanced",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Add multiple stores to household",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !storeId,
|
||||||
|
body: () => ({ storeId: storeId, isDefault: false }),
|
||||||
|
expect: (res) => res.store
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Add same store twice (duplicate check)",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !storeId,
|
||||||
|
body: () => ({ storeId: storeId, isDefault: false }),
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 400 || status === 409 || status === 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Set default store for household",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}/default`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !storeId,
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify default store is first in list",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !storeId,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length > 0 && res[0].is_default === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Set non-existent store as default",
|
||||||
|
method: "PATCH",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}/99999/default`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404 || status === 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Remove store from household",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId || !storeId,
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify store removed from household",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length === 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Data Integrity & Cleanup",
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: "Create second household for testing",
|
||||||
|
method: "POST",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
body: { name: `Second Test ${Date.now()}` },
|
||||||
|
expect: (res) => res.household && res.household.id,
|
||||||
|
onSuccess: (res) => { secondHouseholdId = res.household.id; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify user belongs to multiple households",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
expect: (res) => Array.isArray(res) && res.length >= 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete created test household",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify deleted household is gone",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: () => `/households/${createdHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !createdHouseholdId,
|
||||||
|
expectFail: true,
|
||||||
|
expect: (res, status) => status === 404 || status === 403
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete second test household",
|
||||||
|
method: "DELETE",
|
||||||
|
endpoint: () => `/households/${secondHouseholdId}`,
|
||||||
|
auth: true,
|
||||||
|
skip: () => !secondHouseholdId,
|
||||||
|
expect: (res) => res.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify households list updated",
|
||||||
|
method: "GET",
|
||||||
|
endpoint: "/households",
|
||||||
|
auth: true,
|
||||||
|
expect: (res) => Array.isArray(res)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function makeRequest(test) {
|
||||||
|
const apiUrl = document.getElementById('apiUrl').value;
|
||||||
|
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
|
||||||
|
const url = `${apiUrl}${endpoint}`;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
method: test.method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (test.auth && authToken) {
|
||||||
|
options.headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (test.body) {
|
||||||
|
options.body = JSON.stringify(typeof test.body === 'function' ? test.body() : test.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
return { data, status: response.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest(categoryIdx, testIdx) {
|
||||||
|
const test = tests[categoryIdx].tests[testIdx];
|
||||||
|
const testId = `test-${categoryIdx}-${testIdx}`;
|
||||||
|
const testEl = document.getElementById(testId);
|
||||||
|
const contentEl = document.getElementById(`${testId}-content`);
|
||||||
|
const toggleEl = document.getElementById(`${testId}-toggle`);
|
||||||
|
const resultEl = testEl.querySelector('.test-result');
|
||||||
|
|
||||||
|
// Auto-expand when running
|
||||||
|
contentEl.classList.add('expanded');
|
||||||
|
toggleEl.classList.add('expanded');
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
resultEl.className = 'test-result';
|
||||||
|
resultEl.innerHTML = '⚠️ Prerequisites not met';
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
|
||||||
|
testEl.className = 'test-case running';
|
||||||
|
testEl.querySelector('.test-status').textContent = 'RUNNING';
|
||||||
|
testEl.querySelector('.test-status').className = 'test-status running';
|
||||||
|
resultEl.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, status } = await makeRequest(test);
|
||||||
|
|
||||||
|
const expectFail = test.expectFail || false;
|
||||||
|
const passed = test.expect(data, status);
|
||||||
|
|
||||||
|
const success = expectFail ? !passed || status >= 400 : passed;
|
||||||
|
|
||||||
|
testEl.className = success ? 'test-case pass' : 'test-case fail';
|
||||||
|
testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL';
|
||||||
|
testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`;
|
||||||
|
|
||||||
|
// Determine status code class
|
||||||
|
let statusClass = 'status-5xx';
|
||||||
|
if (status >= 200 && status < 300) statusClass = 'status-2xx';
|
||||||
|
else if (status >= 300 && status < 400) statusClass = 'status-3xx';
|
||||||
|
else if (status >= 400 && status < 500) statusClass = 'status-4xx';
|
||||||
|
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
resultEl.className = 'test-result';
|
||||||
|
|
||||||
|
// Check expected fields if defined
|
||||||
|
let expectedFieldsHTML = '';
|
||||||
|
if (test.expectedFields) {
|
||||||
|
const fieldChecks = test.expectedFields.map(field => {
|
||||||
|
const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined;
|
||||||
|
const icon = exists ? '✓' : '✗';
|
||||||
|
const className = exists ? 'pass' : 'fail';
|
||||||
|
return `<div class="field-check ${className}">${icon} ${field}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
expectedFieldsHTML = `
|
||||||
|
<div class="expected-section">
|
||||||
|
<div class="expected-label">Expected Fields:</div>
|
||||||
|
${fieldChecks}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<span class="response-status ${statusClass}">HTTP ${status}</span>
|
||||||
|
<span style="color: #666;">${success ? '✓ Test passed' : '✗ Test failed'}</span>
|
||||||
|
</div>
|
||||||
|
${expectedFieldsHTML}
|
||||||
|
<div style="color: #666; font-size: 12px; margin-bottom: 4px;">Response:</div>
|
||||||
|
<div>${JSON.stringify(data, null, 2)}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (success && test.onSuccess) {
|
||||||
|
test.onSuccess(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success ? 'pass' : 'fail';
|
||||||
|
} catch (error) {
|
||||||
|
testEl.className = 'test-case fail';
|
||||||
|
testEl.querySelector('.test-status').textContent = 'ERROR';
|
||||||
|
testEl.querySelector('.test-status').className = 'test-status fail';
|
||||||
|
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
resultEl.className = 'test-error';
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div style="font-weight: bold; margin-bottom: 8px;">❌ Network/Request Error</div>
|
||||||
|
<div>${error.message}</div>
|
||||||
|
${error.stack ? `<div style="margin-top: 8px; font-size: 11px; opacity: 0.7;">${error.stack}</div>` : ''}
|
||||||
|
`;
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAllTests(event) {
|
||||||
|
authToken = null;
|
||||||
|
householdId = null;
|
||||||
|
storeId = null;
|
||||||
|
testUserId = null;
|
||||||
|
createdHouseholdId = null;
|
||||||
|
secondHouseholdId = null;
|
||||||
|
inviteCode = null;
|
||||||
|
|
||||||
|
const button = event.target;
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = '⏳ Running Tests...';
|
||||||
|
|
||||||
|
let totalTests = 0;
|
||||||
|
let passedTests = 0;
|
||||||
|
let failedTests = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < tests.length; i++) {
|
||||||
|
for (let j = 0; j < tests[i].tests.length; j++) {
|
||||||
|
const result = await runTest(i, j);
|
||||||
|
if (result !== 'skip') {
|
||||||
|
totalTests++;
|
||||||
|
if (result === 'pass') passedTests++;
|
||||||
|
if (result === 'fail') failedTests++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('summary').style.display = 'flex';
|
||||||
|
document.getElementById('totalTests').textContent = totalTests;
|
||||||
|
document.getElementById('passedTests').textContent = passedTests;
|
||||||
|
document.getElementById('failedTests').textContent = failedTests;
|
||||||
|
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = '▶ Run All Tests';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTest(testId) {
|
||||||
|
const content = document.getElementById(`${testId}-content`);
|
||||||
|
const toggle = document.getElementById(`${testId}-toggle`);
|
||||||
|
|
||||||
|
if (content.classList.contains('expanded')) {
|
||||||
|
content.classList.remove('expanded');
|
||||||
|
toggle.classList.remove('expanded');
|
||||||
|
} else {
|
||||||
|
content.classList.add('expanded');
|
||||||
|
toggle.classList.add('expanded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAllTests() {
|
||||||
|
document.querySelectorAll('.test-content').forEach(content => {
|
||||||
|
content.classList.add('expanded');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.toggle-icon').forEach(icon => {
|
||||||
|
icon.classList.add('expanded');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAllTests() {
|
||||||
|
document.querySelectorAll('.test-content').forEach(content => {
|
||||||
|
content.classList.remove('expanded');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.toggle-icon').forEach(icon => {
|
||||||
|
icon.classList.remove('expanded');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
renderTests();
|
||||||
|
document.getElementById('summary').style.display = 'none';
|
||||||
|
authToken = null;
|
||||||
|
householdId = null;
|
||||||
|
storeId = null;
|
||||||
|
testUserId = null;
|
||||||
|
createdHouseholdId = null;
|
||||||
|
secondHouseholdId = null;
|
||||||
|
inviteCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTests() {
|
||||||
|
const container = document.getElementById('testResults');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
tests.forEach((category, catIdx) => {
|
||||||
|
const categoryDiv = document.createElement('div');
|
||||||
|
categoryDiv.className = 'test-category';
|
||||||
|
|
||||||
|
const categoryHeader = document.createElement('h2');
|
||||||
|
categoryHeader.textContent = category.category;
|
||||||
|
categoryDiv.appendChild(categoryHeader);
|
||||||
|
|
||||||
|
category.tests.forEach((test, testIdx) => {
|
||||||
|
const testDiv = document.createElement('div');
|
||||||
|
testDiv.className = 'test-case';
|
||||||
|
testDiv.id = `test-${catIdx}-${testIdx}`;
|
||||||
|
|
||||||
|
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
|
||||||
|
|
||||||
|
testDiv.innerHTML = `
|
||||||
|
<div class="test-header" onclick="toggleTest('${testDiv.id}')">
|
||||||
|
<div class="test-name">
|
||||||
|
<span class="toggle-icon" id="${testDiv.id}-toggle">▶</span>
|
||||||
|
${test.name}
|
||||||
|
</div>
|
||||||
|
<div class="test-status pending">PENDING</div>
|
||||||
|
</div>
|
||||||
|
<div class="test-content" id="${testDiv.id}-content">
|
||||||
|
<div class="test-details">
|
||||||
|
<strong>${test.method}</strong> ${endpoint}
|
||||||
|
${test.expectFail ? ' <span style="color: #dc3545; font-weight: 600;">(Expected to fail)</span>' : ''}
|
||||||
|
${test.auth ? ' <span style="color: #0066cc; font-weight: 600;">🔒 Requires Auth</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="test-result" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
categoryDiv.appendChild(testDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(categoryDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
renderTests();
|
||||||
309
backend/public/test-styles.css
Normal file
309
backend/public/test-styles.css
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-row label {
|
||||||
|
min-width: 100px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #0066cc;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #0052a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-category {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-category h2 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-case {
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 4px solid #ddd;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-case.running {
|
||||||
|
border-left-color: #ffa500;
|
||||||
|
background: #fff8e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-case.pass {
|
||||||
|
border-left-color: #28a745;
|
||||||
|
background: #e8f5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-case.fail {
|
||||||
|
border-left-color: #dc3545;
|
||||||
|
background: #ffebee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-header:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
margin: -5px;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-content.expanded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-status {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-status.pending {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-status.running {
|
||||||
|
background: #ffa500;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-status.pass {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-status.fail {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-details {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expected-section {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #f0f7ff;
|
||||||
|
border-left: 3px solid #2196f3;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expected-label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1976d2;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-check {
|
||||||
|
margin: 2px 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-check.pass {
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-check.fail {
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-error {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fff5f5;
|
||||||
|
border: 1px solid #ffcdd2;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #c62828;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-2xx {
|
||||||
|
background: #c8e6c9;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-3xx {
|
||||||
|
background: #fff9c4;
|
||||||
|
color: #f57f17;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-4xx {
|
||||||
|
background: #ffccbc;
|
||||||
|
color: #d84315;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-5xx {
|
||||||
|
background: #ffcdd2;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item.total {
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item.pass {
|
||||||
|
background: #e8f5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item.fail {
|
||||||
|
background: #ffebee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
85
backend/public/test-ui.js
Normal file
85
backend/public/test-ui.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
function toggleTest(testId) {
|
||||||
|
const content = document.getElementById(`${testId}-content`);
|
||||||
|
const toggle = document.getElementById(`${testId}-toggle`);
|
||||||
|
|
||||||
|
if (content.classList.contains('expanded')) {
|
||||||
|
content.classList.remove('expanded');
|
||||||
|
toggle.classList.remove('expanded');
|
||||||
|
} else {
|
||||||
|
content.classList.add('expanded');
|
||||||
|
toggle.classList.add('expanded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAllTests() {
|
||||||
|
document.querySelectorAll('.test-content').forEach(content => {
|
||||||
|
content.classList.add('expanded');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.toggle-icon').forEach(icon => {
|
||||||
|
icon.classList.add('expanded');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAllTests() {
|
||||||
|
document.querySelectorAll('.test-content').forEach(content => {
|
||||||
|
content.classList.remove('expanded');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.toggle-icon').forEach(icon => {
|
||||||
|
icon.classList.remove('expanded');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
renderTests();
|
||||||
|
document.getElementById('summary').style.display = 'none';
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTests() {
|
||||||
|
const container = document.getElementById('testResults');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
tests.forEach((category, catIdx) => {
|
||||||
|
const categoryDiv = document.createElement('div');
|
||||||
|
categoryDiv.className = 'test-category';
|
||||||
|
|
||||||
|
const categoryHeader = document.createElement('h2');
|
||||||
|
categoryHeader.textContent = category.category;
|
||||||
|
categoryDiv.appendChild(categoryHeader);
|
||||||
|
|
||||||
|
category.tests.forEach((test, testIdx) => {
|
||||||
|
const testDiv = document.createElement('div');
|
||||||
|
testDiv.className = 'test-case';
|
||||||
|
testDiv.id = `test-${catIdx}-${testIdx}`;
|
||||||
|
|
||||||
|
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
|
||||||
|
|
||||||
|
testDiv.innerHTML = `
|
||||||
|
<div class="test-header" onclick="toggleTest('${testDiv.id}')">
|
||||||
|
<div class="test-name">
|
||||||
|
<span class="toggle-icon" id="${testDiv.id}-toggle">▶</span>
|
||||||
|
${test.name}
|
||||||
|
</div>
|
||||||
|
<div class="test-status pending">PENDING</div>
|
||||||
|
</div>
|
||||||
|
<div class="test-content" id="${testDiv.id}-content">
|
||||||
|
<div class="test-details">
|
||||||
|
<strong>${test.method}</strong> ${endpoint}
|
||||||
|
${test.expectFail ? ' <span style="color: #dc3545; font-weight: 600;">(Expected to fail)</span>' : ''}
|
||||||
|
${test.auth ? ' <span style="color: #0066cc; font-weight: 600;">🔒 Requires Auth</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="test-result" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
categoryDiv.appendChild(testDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(categoryDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
renderTests();
|
||||||
|
});
|
||||||
@ -4,8 +4,9 @@ const requireRole = require("../middleware/rbac");
|
|||||||
const usersController = require("../controllers/users.controller");
|
const usersController = require("../controllers/users.controller");
|
||||||
const { ROLES } = require("../models/user.model");
|
const { ROLES } = require("../models/user.model");
|
||||||
|
|
||||||
router.get("/users", auth, requireRole(ROLES.ADMIN), usersController.getAllUsers);
|
// router.get("/users", auth, (req, res, next) => next(), usersController.getAllUsers);
|
||||||
router.put("/users", auth, requireRole(ROLES.ADMIN), usersController.updateUserRole);
|
router.get("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.getAllUsers);
|
||||||
router.delete("/users", auth, requireRole(ROLES.ADMIN), usersController.deleteUser);
|
router.put("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.updateUserRole);
|
||||||
|
router.delete("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.deleteUser);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -1,13 +1,30 @@
|
|||||||
const router = require("express").Router();
|
const router = require("express").Router();
|
||||||
const controller = require("../controllers/auth.controller");
|
const controller = require("../controllers/auth.controller");
|
||||||
|
const User = require("../models/user.model");
|
||||||
|
const { createRateLimit } = require("../middleware/rate-limit");
|
||||||
|
|
||||||
router.post("/register", controller.register);
|
const loginRateLimit = createRateLimit({
|
||||||
router.post("/login", controller.login);
|
keyPrefix: "auth:login",
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 25,
|
||||||
|
message: "Too many login attempts. Please try again later.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerRateLimit = createRateLimit({
|
||||||
|
keyPrefix: "auth:register",
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
message: "Too many registration attempts. Please try again later.",
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/register", registerRateLimit, controller.register);
|
||||||
|
router.post("/login", loginRateLimit, controller.login);
|
||||||
|
router.post("/logout", controller.logout);
|
||||||
router.post("/", async (req, res) => {
|
router.post("/", async (req, res) => {
|
||||||
resText = `Grocery List API is running.\n` +
|
res.status(200).json({
|
||||||
`Roles available: ${Object.values(User.ROLES).join(', ')}`
|
message: "Auth API is running.",
|
||||||
|
roles: Object.values(User.ROLES),
|
||||||
res.status(200).type("text/plain").send(resText);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
8
backend/routes/config.routes.js
Normal file
8
backend/routes/config.routes.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
const configController = require("../controllers/config.controller");
|
||||||
|
|
||||||
|
// Public endpoint - no authentication required
|
||||||
|
router.get("/", configController.getConfig);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
79
backend/routes/group-invites.routes.js
Normal file
79
backend/routes/group-invites.routes.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
const router = require("express").Router();
|
||||||
|
const auth = require("../middleware/auth");
|
||||||
|
const optionalAuth = require("../middleware/optional-auth");
|
||||||
|
const { createRateLimit } = require("../middleware/rate-limit");
|
||||||
|
const controller = require("../controllers/group-invites.controller");
|
||||||
|
|
||||||
|
const inviteSummaryIpRateLimit = createRateLimit({
|
||||||
|
keyPrefix: "invite:summary:ip",
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 120,
|
||||||
|
message: "Too many invite link summary requests. Please try again later.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteAcceptIpRateLimit = createRateLimit({
|
||||||
|
keyPrefix: "invite:accept:ip",
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 60,
|
||||||
|
message: "Too many invite acceptance attempts. Please try again later.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteWriteUserRateLimit = createRateLimit({
|
||||||
|
keyPrefix: "invite:write:user",
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 60,
|
||||||
|
message: "Too many write operations. Please try again later.",
|
||||||
|
keyFn: (req) => (req.user?.id ? `user:${req.user.id}` : "anon"),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/groups/invites", auth, controller.listInviteLinks);
|
||||||
|
router.post("/groups/invites", auth, inviteWriteUserRateLimit, controller.createInviteLink);
|
||||||
|
router.get("/groups/join-requests", auth, controller.listPendingJoinRequests);
|
||||||
|
router.post(
|
||||||
|
"/groups/join-requests/decision",
|
||||||
|
auth,
|
||||||
|
inviteWriteUserRateLimit,
|
||||||
|
controller.decideJoinRequest
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/groups/invites/revoke",
|
||||||
|
auth,
|
||||||
|
inviteWriteUserRateLimit,
|
||||||
|
controller.revokeInviteLink
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/groups/invites/revive",
|
||||||
|
auth,
|
||||||
|
inviteWriteUserRateLimit,
|
||||||
|
controller.reviveInviteLink
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/groups/invites/delete",
|
||||||
|
auth,
|
||||||
|
inviteWriteUserRateLimit,
|
||||||
|
controller.deleteInviteLink
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get("/groups/join-policy", auth, controller.getJoinPolicy);
|
||||||
|
router.post(
|
||||||
|
"/groups/join-policy",
|
||||||
|
auth,
|
||||||
|
inviteWriteUserRateLimit,
|
||||||
|
controller.setJoinPolicy
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/invite-links/:token",
|
||||||
|
inviteSummaryIpRateLimit,
|
||||||
|
optionalAuth,
|
||||||
|
controller.getInviteLinkSummary
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/invite-links/:token",
|
||||||
|
auth,
|
||||||
|
inviteAcceptIpRateLimit,
|
||||||
|
inviteWriteUserRateLimit,
|
||||||
|
controller.acceptInviteLink
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
432
backend/routes/households.routes.js
Normal file
432
backend/routes/households.routes.js
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
const controller = require("../controllers/households.controller");
|
||||||
|
const listsController = require("../controllers/lists.controller.v2");
|
||||||
|
const availableItemsController = require("../controllers/available-items.controller");
|
||||||
|
const storesController = require("../controllers/stores.controller");
|
||||||
|
const auth = require("../middleware/auth");
|
||||||
|
const {
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
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.patch("/order", auth, controller.reorderHouseholds);
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Household-owned stores and locations
|
||||||
|
router.get(
|
||||||
|
"/:householdId/stores",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storesController.getHouseholdStores
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/stores",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.createHouseholdStore
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/stores/:householdStoreId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.updateHouseholdStore
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/stores/:householdStoreId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.deleteHouseholdStore
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/stores/:householdStoreId/locations",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.addLocationToStore
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/locations/:locationId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.updateLocation
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/locations/:locationId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.deleteLocation
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/locations/:locationId/default",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.setDefaultLocation
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/zones",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
storesController.getLocationZones
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/zones",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.createZone
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/locations/:locationId/zones/:zoneId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.updateZone
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/locations/:locationId/zones/:zoneId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.deleteZone
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/available-items",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
availableItemsController.getAvailableItems
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/available-items",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
availableItemsController.createAvailableItem
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/locations/:locationId/available-items/:itemId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
availableItemsController.updateAvailableItem
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/locations/:locationId/available-items/:itemId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
availableItemsController.deleteAvailableItem
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/available-items/import-current",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
availableItemsController.importCurrentItems
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:householdId/stores/:storeId/available-items",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storeAccess,
|
||||||
|
availableItemsController.getAvailableItems
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/stores/:storeId/available-items",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storeAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
availableItemsController.createAvailableItem
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/stores/:storeId/available-items/:itemId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storeAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
availableItemsController.updateAvailableItem
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/stores/:storeId/available-items/:itemId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storeAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
availableItemsController.deleteAvailableItem
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/stores/:storeId/available-items/import-current",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storeAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
availableItemsController.importCurrentItems
|
||||||
|
);
|
||||||
|
|
||||||
|
// Member management routes
|
||||||
|
router.get(
|
||||||
|
"/:householdId/members",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
controller.getMembers
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/members/:userId/role",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
controller.updateMemberRole
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/members/:userId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
controller.removeMember
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== List Operations Routes ====================
|
||||||
|
// All list routes require household access AND store access
|
||||||
|
|
||||||
|
// Get grocery list
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/list",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.getList
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/list/item",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.getItemByName
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/list/add",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
listsController.addItem
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/locations/:locationId/list/item",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.markBought
|
||||||
|
);
|
||||||
|
router.put(
|
||||||
|
"/:householdId/locations/:locationId/list/item",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.updateItem
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/locations/:locationId/list/item",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.deleteItem
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/list/suggestions",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.getSuggestions
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/list/recent",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.getRecentlyBought
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/list/classification",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.getClassification
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/list/classification",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.setClassification
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/list/update-image",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
listsController.updateItemImage
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
48
backend/routes/stores.routes.js
Normal file
48
backend/routes/stores.routes.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
const controller = require("../controllers/stores.controller");
|
||||||
|
const auth = require("../middleware/auth");
|
||||||
|
const {
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
requireSystemAdmin,
|
||||||
|
} = require("../middleware/household");
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
router.get("/", auth, controller.getAllStores);
|
||||||
|
|
||||||
|
// Household store management
|
||||||
|
router.get(
|
||||||
|
"/household/:householdId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
controller.getHouseholdStores
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/household/:householdId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
controller.addStoreToHousehold
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/household/:householdId/:storeId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
controller.removeStoreFromHousehold
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/household/:householdId/:storeId/default",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
controller.setDefaultStore
|
||||||
|
);
|
||||||
|
|
||||||
|
// System admin routes
|
||||||
|
router.post("/", auth, requireSystemAdmin, controller.createStore);
|
||||||
|
router.patch("/:storeId", auth, requireSystemAdmin, controller.updateStore);
|
||||||
|
router.delete("/:storeId", auth, requireSystemAdmin, controller.deleteStore);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -3,8 +3,23 @@ const auth = require("../middleware/auth");
|
|||||||
const requireRole = require("../middleware/rbac");
|
const requireRole = require("../middleware/rbac");
|
||||||
const usersController = require("../controllers/users.controller");
|
const usersController = require("../controllers/users.controller");
|
||||||
const { ROLES } = require("../models/user.model");
|
const { ROLES } = require("../models/user.model");
|
||||||
|
const { createRateLimit } = require("../middleware/rate-limit");
|
||||||
|
|
||||||
router.get("/exists", usersController.checkIfUserExists);
|
const userExistsRateLimit = createRateLimit({
|
||||||
router.get("/test", usersController.test);
|
keyPrefix: "users:exists",
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 60,
|
||||||
|
message: "Too many availability checks. Please try again later.",
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/exists", userExistsRateLimit, usersController.checkIfUserExists);
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
router.get("/test", usersController.test);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current user profile routes (authenticated)
|
||||||
|
router.get("/me", auth, usersController.getCurrentUser);
|
||||||
|
router.patch("/me", auth, usersController.updateCurrentUser);
|
||||||
|
router.post("/me/change-password", auth, usersController.changePassword);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
675
backend/services/group-invites.service.js
Normal file
675
backend/services/group-invites.service.js
Normal file
@ -0,0 +1,675 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
const net = require("net");
|
||||||
|
const invitesModel = require("../models/group-invites.model");
|
||||||
|
const { inviteCodeLast4 } = require("../utils/redaction");
|
||||||
|
|
||||||
|
const JOIN_POLICIES = Object.freeze({
|
||||||
|
NOT_ACCEPTING: "NOT_ACCEPTING",
|
||||||
|
AUTO_ACCEPT: "AUTO_ACCEPT",
|
||||||
|
APPROVAL_REQUIRED: "APPROVAL_REQUIRED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const JOIN_RESULTS = Object.freeze({
|
||||||
|
JOINED: "JOINED",
|
||||||
|
PENDING: "PENDING",
|
||||||
|
ALREADY_MEMBER: "ALREADY_MEMBER",
|
||||||
|
});
|
||||||
|
|
||||||
|
class InviteServiceError extends Error {
|
||||||
|
constructor(code, message, statusCode = 400) {
|
||||||
|
super(message);
|
||||||
|
this.name = "InviteServiceError";
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIp(ip) {
|
||||||
|
if (!ip || typeof ip !== "string") return null;
|
||||||
|
const trimmed = ip.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return net.isIP(trimmed) ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureJoinPolicy(policy) {
|
||||||
|
if (Object.values(JOIN_POLICIES).includes(policy)) {
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
throw new InviteServiceError(
|
||||||
|
"INVALID_JOIN_POLICY",
|
||||||
|
"Invalid join policy",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePositiveInteger(value, fieldName) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new InviteServiceError("INVALID_INPUT", `${fieldName} is required`, 400);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDate(value, fieldName) {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
throw new InviteServiceError("INVALID_INPUT", `${fieldName} is invalid`, 400);
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGroupAndManagerRole(userId, groupId, client) {
|
||||||
|
const group = await invitesModel.getGroupById(groupId, client);
|
||||||
|
if (!group) {
|
||||||
|
throw new InviteServiceError("GROUP_NOT_FOUND", "Group not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorRole = await invitesModel.getUserGroupRole(groupId, userId, client);
|
||||||
|
if (!["owner", "admin"].includes(actorRole)) {
|
||||||
|
throw new InviteServiceError(
|
||||||
|
"FORBIDDEN",
|
||||||
|
"Admin or owner role required",
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { actorRole, group };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveManagedGroupId(userId, requestedGroupId) {
|
||||||
|
if (requestedGroupId !== undefined && requestedGroupId !== null) {
|
||||||
|
return ensurePositiveInteger(requestedGroupId, "groupId");
|
||||||
|
}
|
||||||
|
|
||||||
|
const manageableGroups = await invitesModel.getManageableGroupsForUser(userId);
|
||||||
|
if (manageableGroups.length === 0) {
|
||||||
|
throw new InviteServiceError(
|
||||||
|
"FORBIDDEN",
|
||||||
|
"Admin or owner role required",
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manageableGroups.length > 1) {
|
||||||
|
throw new InviteServiceError(
|
||||||
|
"GROUP_ID_REQUIRED",
|
||||||
|
"Group ID is required when you manage multiple groups",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return manageableGroups[0].group_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInviteLink(
|
||||||
|
userId,
|
||||||
|
groupId,
|
||||||
|
policy,
|
||||||
|
singleUse,
|
||||||
|
expiresAt,
|
||||||
|
requestId,
|
||||||
|
ip,
|
||||||
|
userAgent
|
||||||
|
) {
|
||||||
|
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||||
|
const resolvedPolicy = ensureJoinPolicy(policy);
|
||||||
|
const resolvedExpiresAt = ensureDate(expiresAt, "expiresAt");
|
||||||
|
|
||||||
|
return invitesModel.withTransaction(async (client) => {
|
||||||
|
const { actorRole } = await ensureGroupAndManagerRole(
|
||||||
|
userId,
|
||||||
|
resolvedGroupId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
let link = null;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const token = crypto.randomBytes(16).toString("hex");
|
||||||
|
try {
|
||||||
|
link = await invitesModel.createInviteLink(
|
||||||
|
{
|
||||||
|
groupId: resolvedGroupId,
|
||||||
|
createdBy: userId,
|
||||||
|
token,
|
||||||
|
policy: resolvedPolicy,
|
||||||
|
singleUse: Boolean(singleUse),
|
||||||
|
expiresAt: resolvedExpiresAt,
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "23505") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new InviteServiceError(
|
||||||
|
"INVITE_CREATE_FAILED",
|
||||||
|
"Unable to create invite link",
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await invitesModel.createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId: resolvedGroupId,
|
||||||
|
actorUserId: userId,
|
||||||
|
actorRole,
|
||||||
|
eventType: "GROUP_INVITE_CREATED",
|
||||||
|
requestId,
|
||||||
|
ip: normalizeIp(ip),
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
metadata: {
|
||||||
|
inviteCodeLast4: inviteCodeLast4(link.token),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
return link;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listInviteLinks(userId, groupId) {
|
||||||
|
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||||
|
await ensureGroupAndManagerRole(userId, resolvedGroupId);
|
||||||
|
return invitesModel.listInviteLinks(resolvedGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPendingJoinRequests(userId, groupId) {
|
||||||
|
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||||
|
await ensureGroupAndManagerRole(userId, resolvedGroupId);
|
||||||
|
return invitesModel.listPendingJoinRequests(resolvedGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeInviteLink(
|
||||||
|
userId,
|
||||||
|
groupId,
|
||||||
|
linkId,
|
||||||
|
requestId,
|
||||||
|
ip,
|
||||||
|
userAgent
|
||||||
|
) {
|
||||||
|
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||||
|
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
|
||||||
|
|
||||||
|
return invitesModel.withTransaction(async (client) => {
|
||||||
|
const { actorRole } = await ensureGroupAndManagerRole(
|
||||||
|
userId,
|
||||||
|
resolvedGroupId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
const link = await invitesModel.revokeInviteLink(
|
||||||
|
resolvedGroupId,
|
||||||
|
resolvedLinkId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
if (!link) {
|
||||||
|
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await invitesModel.createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId: resolvedGroupId,
|
||||||
|
actorUserId: userId,
|
||||||
|
actorRole,
|
||||||
|
eventType: "GROUP_INVITE_REVOKED",
|
||||||
|
requestId,
|
||||||
|
ip: normalizeIp(ip),
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
metadata: {
|
||||||
|
inviteCodeLast4: inviteCodeLast4(link.token),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reviveInviteLink(
|
||||||
|
userId,
|
||||||
|
groupId,
|
||||||
|
linkId,
|
||||||
|
expiresAt,
|
||||||
|
requestId,
|
||||||
|
ip,
|
||||||
|
userAgent
|
||||||
|
) {
|
||||||
|
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||||
|
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
|
||||||
|
const resolvedExpiresAt = ensureDate(expiresAt, "expiresAt");
|
||||||
|
|
||||||
|
return invitesModel.withTransaction(async (client) => {
|
||||||
|
const { actorRole } = await ensureGroupAndManagerRole(
|
||||||
|
userId,
|
||||||
|
resolvedGroupId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
const link = await invitesModel.reviveInviteLink(
|
||||||
|
resolvedGroupId,
|
||||||
|
resolvedLinkId,
|
||||||
|
resolvedExpiresAt,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
if (!link) {
|
||||||
|
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await invitesModel.createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId: resolvedGroupId,
|
||||||
|
actorUserId: userId,
|
||||||
|
actorRole,
|
||||||
|
eventType: "GROUP_INVITE_REVIVED",
|
||||||
|
requestId,
|
||||||
|
ip: normalizeIp(ip),
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
metadata: {
|
||||||
|
inviteCodeLast4: inviteCodeLast4(link.token),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteInviteLink(
|
||||||
|
userId,
|
||||||
|
groupId,
|
||||||
|
linkId,
|
||||||
|
requestId,
|
||||||
|
ip,
|
||||||
|
userAgent
|
||||||
|
) {
|
||||||
|
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||||
|
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
|
||||||
|
|
||||||
|
return invitesModel.withTransaction(async (client) => {
|
||||||
|
const { actorRole } = await ensureGroupAndManagerRole(
|
||||||
|
userId,
|
||||||
|
resolvedGroupId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
const link = await invitesModel.deleteInviteLink(
|
||||||
|
resolvedGroupId,
|
||||||
|
resolvedLinkId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
if (!link) {
|
||||||
|
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await invitesModel.createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId: resolvedGroupId,
|
||||||
|
actorUserId: userId,
|
||||||
|
actorRole,
|
||||||
|
eventType: "GROUP_INVITE_DELETED",
|
||||||
|
requestId,
|
||||||
|
ip: normalizeIp(ip),
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
metadata: {
|
||||||
|
inviteCodeLast4: inviteCodeLast4(link.token),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decideJoinRequest(
|
||||||
|
userId,
|
||||||
|
groupId,
|
||||||
|
requestId,
|
||||||
|
decision,
|
||||||
|
requestIdForAudit,
|
||||||
|
ip,
|
||||||
|
userAgent
|
||||||
|
) {
|
||||||
|
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||||
|
const resolvedRequestId = ensurePositiveInteger(requestId, "requestId");
|
||||||
|
const normalizedDecision = typeof decision === "string" ? decision.trim().toUpperCase() : "";
|
||||||
|
|
||||||
|
if (!["APPROVE", "DENY"].includes(normalizedDecision)) {
|
||||||
|
throw new InviteServiceError("INVALID_INPUT", "Decision is required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return invitesModel.withTransaction(async (client) => {
|
||||||
|
const { actorRole } = await ensureGroupAndManagerRole(
|
||||||
|
userId,
|
||||||
|
resolvedGroupId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
const pendingRequest = await invitesModel.getPendingJoinRequestById(
|
||||||
|
resolvedGroupId,
|
||||||
|
resolvedRequestId,
|
||||||
|
client,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pendingRequest) {
|
||||||
|
throw new InviteServiceError(
|
||||||
|
"JOIN_REQUEST_NOT_FOUND",
|
||||||
|
"Pending join request not found",
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedDecision === "APPROVE") {
|
||||||
|
const isExistingMember = await invitesModel.isGroupMember(
|
||||||
|
resolvedGroupId,
|
||||||
|
pendingRequest.user_id,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
if (!isExistingMember) {
|
||||||
|
await invitesModel.addGroupMember(
|
||||||
|
resolvedGroupId,
|
||||||
|
pendingRequest.user_id,
|
||||||
|
"member",
|
||||||
|
client
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvedRequest = await invitesModel.updateJoinRequestDecision(
|
||||||
|
resolvedGroupId,
|
||||||
|
resolvedRequestId,
|
||||||
|
"APPROVED",
|
||||||
|
userId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
await invitesModel.createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId: resolvedGroupId,
|
||||||
|
actorUserId: userId,
|
||||||
|
actorRole,
|
||||||
|
eventType: "GROUP_JOIN_REQUEST_APPROVED",
|
||||||
|
requestId: requestIdForAudit,
|
||||||
|
ip: normalizeIp(ip),
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
metadata: {
|
||||||
|
joinRequestId: approvedRequest.id,
|
||||||
|
targetUserId: approvedRequest.user_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
return approvedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deniedRequest = await invitesModel.updateJoinRequestDecision(
|
||||||
|
resolvedGroupId,
|
||||||
|
resolvedRequestId,
|
||||||
|
"DENIED",
|
||||||
|
userId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
await invitesModel.createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId: resolvedGroupId,
|
||||||
|
actorUserId: userId,
|
||||||
|
actorRole,
|
||||||
|
eventType: "GROUP_JOIN_REQUEST_DENIED",
|
||||||
|
requestId: requestIdForAudit,
|
||||||
|
ip: normalizeIp(ip),
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
metadata: {
|
||||||
|
joinRequestId: deniedRequest.id,
|
||||||
|
targetUserId: deniedRequest.user_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
return deniedRequest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInviteStatus(link) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (link.single_use && link.used_at) return "USED";
|
||||||
|
if (link.revoked_at) return "REVOKED";
|
||||||
|
if (new Date(link.expires_at).getTime() <= now) return "EXPIRED";
|
||||||
|
return "ACTIVE";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInviteLinkSummaryByToken(token, userId = null) {
|
||||||
|
if (!token || typeof token !== "string") {
|
||||||
|
throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await invitesModel.getInviteLinkSummaryByToken(token.trim());
|
||||||
|
if (!summary) {
|
||||||
|
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewerStatus = null;
|
||||||
|
if (userId) {
|
||||||
|
const isMember = await invitesModel.isGroupMember(summary.group_id, userId);
|
||||||
|
if (isMember) {
|
||||||
|
viewerStatus = JOIN_RESULTS.ALREADY_MEMBER;
|
||||||
|
} else {
|
||||||
|
const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId);
|
||||||
|
if (pending) {
|
||||||
|
viewerStatus = JOIN_RESULTS.PENDING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePolicy = summary.current_join_policy || summary.policy;
|
||||||
|
return {
|
||||||
|
id: summary.id,
|
||||||
|
group_id: summary.group_id,
|
||||||
|
group_name: summary.group_name,
|
||||||
|
token: summary.token,
|
||||||
|
policy: summary.policy,
|
||||||
|
current_join_policy: summary.current_join_policy || null,
|
||||||
|
active_policy: activePolicy,
|
||||||
|
single_use: summary.single_use,
|
||||||
|
expires_at: summary.expires_at,
|
||||||
|
used_at: summary.used_at,
|
||||||
|
revoked_at: summary.revoked_at,
|
||||||
|
created_at: summary.created_at,
|
||||||
|
status: getInviteStatus(summary),
|
||||||
|
...(viewerStatus ? { viewerStatus } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptInviteLink(userId, token, requestId, ip, userAgent) {
|
||||||
|
if (!userId) {
|
||||||
|
throw new InviteServiceError("UNAUTHORIZED", "Authentication required", 401);
|
||||||
|
}
|
||||||
|
if (!token || typeof token !== "string") {
|
||||||
|
throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return invitesModel.withTransaction(async (client) => {
|
||||||
|
const summary = await invitesModel.getInviteLinkSummaryByToken(
|
||||||
|
token.trim(),
|
||||||
|
client,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
if (!summary) {
|
||||||
|
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = {
|
||||||
|
id: summary.group_id,
|
||||||
|
name: summary.group_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
const memberExists = await invitesModel.isGroupMember(summary.group_id, userId, client);
|
||||||
|
if (memberExists) {
|
||||||
|
return { status: JOIN_RESULTS.ALREADY_MEMBER, group };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId, client);
|
||||||
|
if (pending) {
|
||||||
|
return { status: JOIN_RESULTS.PENDING, group };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (summary.revoked_at) {
|
||||||
|
throw new InviteServiceError(
|
||||||
|
"INVITE_REVOKED",
|
||||||
|
"This invite link has been revoked",
|
||||||
|
410
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (new Date(summary.expires_at).getTime() <= now) {
|
||||||
|
throw new InviteServiceError(
|
||||||
|
"INVITE_EXPIRED",
|
||||||
|
"This invite link has expired",
|
||||||
|
410
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (summary.single_use && summary.used_at) {
|
||||||
|
throw new InviteServiceError(
|
||||||
|
"INVITE_USED",
|
||||||
|
"This invite link has already been used",
|
||||||
|
410
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePolicy =
|
||||||
|
summary.current_join_policy || summary.policy || JOIN_POLICIES.NOT_ACCEPTING;
|
||||||
|
if (activePolicy === JOIN_POLICIES.NOT_ACCEPTING) {
|
||||||
|
throw new InviteServiceError(
|
||||||
|
"JOIN_NOT_ACCEPTING",
|
||||||
|
"This group is not accepting new members",
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorRole = (await invitesModel.getUserGroupRole(summary.group_id, userId, client)) || "guest";
|
||||||
|
|
||||||
|
if (activePolicy === JOIN_POLICIES.AUTO_ACCEPT) {
|
||||||
|
const inserted = await invitesModel.addGroupMember(
|
||||||
|
summary.group_id,
|
||||||
|
userId,
|
||||||
|
"member",
|
||||||
|
client
|
||||||
|
);
|
||||||
|
if (!inserted) {
|
||||||
|
return { status: JOIN_RESULTS.ALREADY_MEMBER, group };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.single_use) {
|
||||||
|
await invitesModel.consumeSingleUseInvite(summary.id, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
await invitesModel.createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId: summary.group_id,
|
||||||
|
actorUserId: userId,
|
||||||
|
actorRole,
|
||||||
|
eventType: "GROUP_INVITE_USED",
|
||||||
|
requestId,
|
||||||
|
ip: normalizeIp(ip),
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
metadata: {
|
||||||
|
inviteCodeLast4: inviteCodeLast4(summary.token),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
return { status: JOIN_RESULTS.JOINED, group };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePolicy === JOIN_POLICIES.APPROVAL_REQUIRED) {
|
||||||
|
await invitesModel.createOrTouchPendingJoinRequest(summary.group_id, userId, client);
|
||||||
|
|
||||||
|
if (summary.single_use) {
|
||||||
|
await invitesModel.consumeSingleUseInvite(summary.id, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
await invitesModel.createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId: summary.group_id,
|
||||||
|
actorUserId: userId,
|
||||||
|
actorRole,
|
||||||
|
eventType: "GROUP_INVITE_REQUESTED",
|
||||||
|
requestId,
|
||||||
|
ip: normalizeIp(ip),
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
metadata: {
|
||||||
|
inviteCodeLast4: inviteCodeLast4(summary.token),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
return { status: JOIN_RESULTS.PENDING, group };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InviteServiceError("INVALID_JOIN_POLICY", "Invalid join policy", 400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroupJoinPolicy(userId, groupId) {
|
||||||
|
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||||
|
await ensureGroupAndManagerRole(userId, resolvedGroupId);
|
||||||
|
const settings = await invitesModel.getGroupSettings(resolvedGroupId);
|
||||||
|
return settings?.join_policy || JOIN_POLICIES.NOT_ACCEPTING;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setGroupJoinPolicy(
|
||||||
|
userId,
|
||||||
|
groupId,
|
||||||
|
joinPolicy,
|
||||||
|
requestId,
|
||||||
|
ip,
|
||||||
|
userAgent
|
||||||
|
) {
|
||||||
|
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||||
|
const resolvedJoinPolicy = ensureJoinPolicy(joinPolicy);
|
||||||
|
|
||||||
|
return invitesModel.withTransaction(async (client) => {
|
||||||
|
const { actorRole } = await ensureGroupAndManagerRole(
|
||||||
|
userId,
|
||||||
|
resolvedGroupId,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
await invitesModel.upsertGroupSettings(resolvedGroupId, resolvedJoinPolicy, client);
|
||||||
|
|
||||||
|
await invitesModel.createGroupAuditLog(
|
||||||
|
{
|
||||||
|
groupId: resolvedGroupId,
|
||||||
|
actorUserId: userId,
|
||||||
|
actorRole,
|
||||||
|
eventType: "GROUP_JOIN_POLICY_UPDATED",
|
||||||
|
requestId,
|
||||||
|
ip: normalizeIp(ip),
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
metadata: {
|
||||||
|
joinPolicy: resolvedJoinPolicy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
InviteServiceError,
|
||||||
|
JOIN_POLICIES,
|
||||||
|
JOIN_RESULTS,
|
||||||
|
acceptInviteLink,
|
||||||
|
createInviteLink,
|
||||||
|
decideJoinRequest,
|
||||||
|
deleteInviteLink,
|
||||||
|
getGroupJoinPolicy,
|
||||||
|
getInviteLinkSummaryByToken,
|
||||||
|
listPendingJoinRequests,
|
||||||
|
listInviteLinks,
|
||||||
|
resolveManagedGroupId,
|
||||||
|
revokeInviteLink,
|
||||||
|
reviveInviteLink,
|
||||||
|
setGroupJoinPolicy,
|
||||||
|
};
|
||||||
118
backend/tests/auth.middleware.test.js
Normal file
118
backend/tests/auth.middleware.test.js
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
jest.mock("jsonwebtoken", () => ({
|
||||||
|
verify: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../models/session.model", () => ({
|
||||||
|
getActiveSessionWithUser: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../utils/logger", () => ({
|
||||||
|
logError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const Session = require("../models/session.model");
|
||||||
|
const auth = require("../middleware/auth");
|
||||||
|
|
||||||
|
function createResponse() {
|
||||||
|
const res = {};
|
||||||
|
res.status = jest.fn().mockReturnValue(res);
|
||||||
|
res.json = jest.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("auth middleware", () => {
|
||||||
|
const originalJwtSecret = process.env.JWT_SECRET;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.JWT_SECRET = "test-secret";
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (originalJwtSecret === undefined) {
|
||||||
|
delete process.env.JWT_SECRET;
|
||||||
|
} else {
|
||||||
|
process.env.JWT_SECRET = originalJwtSecret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses a valid bearer token without reading the session cookie", async () => {
|
||||||
|
jwt.verify.mockReturnValue({ id: 5, role: "admin" });
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
headers: {
|
||||||
|
authorization: "Bearer valid-token",
|
||||||
|
cookie: "sid=session-id",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
await auth(req, res, next);
|
||||||
|
|
||||||
|
expect(jwt.verify).toHaveBeenCalledWith("valid-token", "test-secret");
|
||||||
|
expect(Session.getActiveSessionWithUser).not.toHaveBeenCalled();
|
||||||
|
expect(req.user).toEqual({ id: 5, role: "admin" });
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to a valid session cookie when the bearer token is stale", async () => {
|
||||||
|
jwt.verify.mockImplementation(() => {
|
||||||
|
throw new Error("stale token");
|
||||||
|
});
|
||||||
|
Session.getActiveSessionWithUser.mockResolvedValue({
|
||||||
|
id: "session-id",
|
||||||
|
user_id: 7,
|
||||||
|
role: "user",
|
||||||
|
username: "shopper",
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
headers: {
|
||||||
|
authorization: "Bearer stale-token",
|
||||||
|
cookie: "sid=session-id",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
await auth(req, res, next);
|
||||||
|
|
||||||
|
expect(Session.getActiveSessionWithUser).toHaveBeenCalledWith("session-id");
|
||||||
|
expect(req.user).toEqual({
|
||||||
|
id: 7,
|
||||||
|
role: "user",
|
||||||
|
username: "shopper",
|
||||||
|
});
|
||||||
|
expect(req.session_id).toBe("session-id");
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a stale bearer token when no session cookie is present", async () => {
|
||||||
|
jwt.verify.mockImplementation(() => {
|
||||||
|
throw new Error("stale token");
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
headers: {
|
||||||
|
authorization: "Bearer stale-token",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
await auth(req, res, next);
|
||||||
|
|
||||||
|
expect(Session.getActiveSessionWithUser).not.toHaveBeenCalled();
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: {
|
||||||
|
code: "unauthorized",
|
||||||
|
message: "Invalid or expired token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
130
backend/tests/available-item.model.test.js
Normal file
130
backend/tests/available-item.model.test.js
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
jest.mock("../db/pool", () => ({
|
||||||
|
query: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../models/list.model.v2", () => ({
|
||||||
|
recordItemEvent: jest.fn(),
|
||||||
|
setCatalogItemImage: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pool = require("../db/pool");
|
||||||
|
const List = require("../models/list.model.v2");
|
||||||
|
const AvailableItems = require("../models/available-item.model");
|
||||||
|
|
||||||
|
describe("available-item.model", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pool.query.mockReset();
|
||||||
|
List.recordItemEvent.mockReset();
|
||||||
|
List.setCatalogItemImage.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists household store items", async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
item_id: 55,
|
||||||
|
item_name: "milk",
|
||||||
|
item_image: null,
|
||||||
|
image_mime_type: null,
|
||||||
|
item_type: null,
|
||||||
|
item_group: null,
|
||||||
|
zone: null,
|
||||||
|
has_managed_settings: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await AvailableItems.listAvailableItems(1, 2);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
item_id: 55,
|
||||||
|
item_name: "milk",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("FROM household_store_items hsi"),
|
||||||
|
[1, 2]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a household store item when needed", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 77, name: "granola" }] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ item_id: 77, item_name: "granola" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await AvailableItems.createAvailableItem(1, 2, "Granola");
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ item_id: 77, item_name: "granola" }));
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.stringContaining("INSERT INTO household_store_items"),
|
||||||
|
[1, 2, "granola", "granola"]
|
||||||
|
);
|
||||||
|
expect(List.recordItemEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: "ITEM_ADDED",
|
||||||
|
householdId: 1,
|
||||||
|
storeLocationId: 2,
|
||||||
|
householdStoreItemId: 77,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates household store item images and returns refreshed data", async () => {
|
||||||
|
const imageBuffer = Buffer.from("abc");
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ item_id: 55, item_name: "milk", item_image: "YWJj", image_mime_type: "image/jpeg" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await AvailableItems.updateAvailableItem(1, 2, 55, {
|
||||||
|
imageBuffer,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ item_id: 55, image_mime_type: "image/jpeg" }));
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.stringContaining("UPDATE household_store_items"),
|
||||||
|
[1, 2, 55]
|
||||||
|
);
|
||||||
|
expect(List.setCatalogItemImage).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
55,
|
||||||
|
imageBuffer,
|
||||||
|
"image/jpeg",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletes the household store item", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ item_id: 55, item_name: "milk" }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
|
const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55);
|
||||||
|
|
||||||
|
expect(deleted).toBe(true);
|
||||||
|
expect(pool.query).toHaveBeenLastCalledWith(
|
||||||
|
expect.stringContaining("DELETE FROM household_store_items"),
|
||||||
|
[1, 2, 55]
|
||||||
|
);
|
||||||
|
expect(List.recordItemEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: "ITEM_DELETED",
|
||||||
|
householdId: 1,
|
||||||
|
storeLocationId: 2,
|
||||||
|
householdStoreItemId: 55,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
210
backend/tests/available-items.controller.test.js
Normal file
210
backend/tests/available-items.controller.test.js
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
jest.mock("../models/available-item.model", () => ({
|
||||||
|
createAvailableItem: jest.fn(),
|
||||||
|
deleteAvailableItem: jest.fn(),
|
||||||
|
getAvailableItemById: jest.fn(),
|
||||||
|
importCurrentListItems: jest.fn(),
|
||||||
|
listAvailableItems: jest.fn(),
|
||||||
|
updateAvailableItem: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../models/list.model.v2", () => ({
|
||||||
|
deleteClassification: jest.fn(),
|
||||||
|
getZoneByName: jest.fn(),
|
||||||
|
recordItemEvent: jest.fn(),
|
||||||
|
upsertClassification: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../utils/logger", () => ({
|
||||||
|
logError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const AvailableItems = require("../models/available-item.model");
|
||||||
|
const List = require("../models/list.model.v2");
|
||||||
|
const controller = require("../controllers/available-items.controller");
|
||||||
|
|
||||||
|
function createResponse() {
|
||||||
|
const res = {};
|
||||||
|
res.status = jest.fn().mockReturnValue(res);
|
||||||
|
res.json = jest.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("available-items.controller", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
AvailableItems.createAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
|
||||||
|
AvailableItems.getAvailableItemById.mockResolvedValue({
|
||||||
|
item_id: 99,
|
||||||
|
item_name: "milk",
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
});
|
||||||
|
AvailableItems.updateAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
|
||||||
|
AvailableItems.deleteAvailableItem.mockResolvedValue(true);
|
||||||
|
AvailableItems.importCurrentListItems.mockResolvedValue(2);
|
||||||
|
AvailableItems.listAvailableItems.mockResolvedValue([]);
|
||||||
|
List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" });
|
||||||
|
List.recordItemEvent.mockResolvedValue(undefined);
|
||||||
|
List.upsertClassification.mockResolvedValue({ zone_id: 5 });
|
||||||
|
List.deleteClassification.mockResolvedValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates an available item and persists classification metadata", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: JSON.stringify({
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
processedImage: null,
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.createAvailableItem(req, res);
|
||||||
|
|
||||||
|
expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null, 7);
|
||||||
|
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
99,
|
||||||
|
expect.objectContaining({
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid item_group values", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: JSON.stringify({
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Bread",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.createAvailableItem(req, res);
|
||||||
|
|
||||||
|
expect(AvailableItems.createAvailableItem).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Invalid item_group for selected item_type",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears classification on update when classification is explicitly empty", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2", itemId: "99" },
|
||||||
|
body: {
|
||||||
|
classification: "null",
|
||||||
|
},
|
||||||
|
processedImage: null,
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.updateAvailableItem(req, res);
|
||||||
|
|
||||||
|
expect(List.deleteClassification).toHaveBeenCalledWith("1", "2", 99);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imports current list items and reports the import count", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.importCurrentItems(req, res);
|
||||||
|
|
||||||
|
expect(AvailableItems.importCurrentListItems).toHaveBeenCalledWith("1", "2");
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
imported_count: 2,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletes a store item", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2", itemId: "99" },
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.deleteAvailableItem(req, res);
|
||||||
|
|
||||||
|
expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99, 7);
|
||||||
|
expect(List.deleteClassification).not.toHaveBeenCalled();
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns an empty catalog payload when the available items table is missing", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
query: {},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
AvailableItems.listAvailableItems.mockRejectedValueOnce({
|
||||||
|
code: "42P01",
|
||||||
|
message: 'relation "household_store_items" does not exist',
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.getAvailableItems(req, res);
|
||||||
|
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
items: [],
|
||||||
|
catalog_ready: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a setup error when creating while the available items table is missing", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
},
|
||||||
|
processedImage: null,
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
AvailableItems.createAvailableItem.mockRejectedValueOnce({
|
||||||
|
code: "42P01",
|
||||||
|
message: 'relation "household_store_items" does not exist',
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.createAvailableItem(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(503);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: expect.stringContaining("latest database migration"),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
148
backend/tests/available-items.routes.test.js
Normal file
148
backend/tests/available-items.routes.test.js
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
jest.mock("../middleware/auth", () => (req, res, next) => {
|
||||||
|
req.user = { id: 42, role: "user" };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("../middleware/household", () => ({
|
||||||
|
householdAccess: (req, res, next) => {
|
||||||
|
req.household = {
|
||||||
|
id: Number.parseInt(req.params.householdId, 10),
|
||||||
|
role: req.headers["x-household-role"] || "user",
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
locationAccess: (req, res, next) => {
|
||||||
|
req.storeLocation = { id: Number.parseInt(req.params.locationId, 10) };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireHouseholdAdmin: (req, res, next) => {
|
||||||
|
if (["owner", "admin"].includes(req.household?.role)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return res.status(403).json({
|
||||||
|
error: { code: "FORBIDDEN", message: "Admin role required" },
|
||||||
|
request_id: req.request_id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
storeAccess: (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../middleware/image", () => ({
|
||||||
|
upload: {
|
||||||
|
single: () => (req, res, next) => next(),
|
||||||
|
},
|
||||||
|
processImage: (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../controllers/households.controller", () => ({
|
||||||
|
createHousehold: jest.fn(),
|
||||||
|
deleteHousehold: jest.fn(),
|
||||||
|
getHousehold: jest.fn(),
|
||||||
|
getMembers: jest.fn(),
|
||||||
|
getUserHouseholds: jest.fn(),
|
||||||
|
joinHousehold: jest.fn(),
|
||||||
|
refreshInviteCode: jest.fn(),
|
||||||
|
removeMember: jest.fn(),
|
||||||
|
reorderHouseholds: jest.fn(),
|
||||||
|
updateHousehold: jest.fn(),
|
||||||
|
updateMemberRole: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../controllers/lists.controller.v2", () => ({
|
||||||
|
addItem: jest.fn(),
|
||||||
|
deleteItem: jest.fn(),
|
||||||
|
getClassification: jest.fn(),
|
||||||
|
getItemByName: jest.fn(),
|
||||||
|
getList: jest.fn(),
|
||||||
|
getRecentlyBought: jest.fn(),
|
||||||
|
getSuggestions: jest.fn(),
|
||||||
|
markBought: jest.fn(),
|
||||||
|
setClassification: jest.fn(),
|
||||||
|
updateItem: jest.fn(),
|
||||||
|
updateItemImage: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../controllers/available-items.controller", () => ({
|
||||||
|
createAvailableItem: jest.fn((req, res) => res.status(201).json({ message: "created" })),
|
||||||
|
deleteAvailableItem: jest.fn((req, res) => res.json({ message: "deleted" })),
|
||||||
|
getAvailableItems: jest.fn((req, res) => res.json({ items: [] })),
|
||||||
|
importCurrentItems: jest.fn((req, res) => res.json({ imported_count: 1 })),
|
||||||
|
updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../controllers/stores.controller", () => ({
|
||||||
|
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
|
||||||
|
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
|
||||||
|
createZone: jest.fn((req, res) => res.status(201).json({ message: "zone" })),
|
||||||
|
deleteHouseholdStore: jest.fn((req, res) => res.json({ message: "deleted store" })),
|
||||||
|
deleteLocation: jest.fn((req, res) => res.json({ message: "deleted location" })),
|
||||||
|
deleteZone: jest.fn((req, res) => res.json({ message: "deleted zone" })),
|
||||||
|
getHouseholdStores: jest.fn((req, res) => res.json([])),
|
||||||
|
getLocationZones: jest.fn((req, res) => res.json({ zones: [] })),
|
||||||
|
setDefaultLocation: jest.fn((req, res) => res.json({ message: "default" })),
|
||||||
|
updateHouseholdStore: jest.fn((req, res) => res.json({ message: "updated store" })),
|
||||||
|
updateLocation: jest.fn((req, res) => res.json({ message: "updated location" })),
|
||||||
|
updateZone: jest.fn((req, res) => res.json({ message: "updated zone" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const express = require("express");
|
||||||
|
const request = require("supertest");
|
||||||
|
const router = require("../routes/households.routes");
|
||||||
|
const availableItemsController = require("../controllers/available-items.controller");
|
||||||
|
|
||||||
|
describe("available-items routes", () => {
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/households", router);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members can read available items", async () => {
|
||||||
|
const response = await request(app).get("/households/1/stores/2/available-items");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(availableItemsController.getAvailableItems).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members cannot mutate available items", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/households/1/stores/2/available-items")
|
||||||
|
.set("x-household-role", "user")
|
||||||
|
.send({ item_name: "milk" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(availableItemsController.createAvailableItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admins can create available items", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/households/1/stores/2/available-items")
|
||||||
|
.set("x-household-role", "admin")
|
||||||
|
.send({ item_name: "milk" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members can create available items on location-scoped routes", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/households/1/locations/2/available-items")
|
||||||
|
.set("x-household-role", "member")
|
||||||
|
.send({ item_name: "milk" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members cannot delete available items on location-scoped routes", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.delete("/households/1/locations/2/available-items/3")
|
||||||
|
.set("x-household-role", "member");
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(availableItemsController.deleteAvailableItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
110
backend/tests/group-invites.routes.test.js
Normal file
110
backend/tests/group-invites.routes.test.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
jest.mock("../middleware/auth", () => (req, res, next) => {
|
||||||
|
req.user = { id: 42, role: "user" };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("../middleware/optional-auth", () => (req, res, next) => next());
|
||||||
|
|
||||||
|
jest.mock("../services/group-invites.service", () => {
|
||||||
|
const actual = jest.requireActual("../services/group-invites.service");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
acceptInviteLink: jest.fn(),
|
||||||
|
createInviteLink: jest.fn(),
|
||||||
|
deleteInviteLink: jest.fn(),
|
||||||
|
decideJoinRequest: jest.fn(),
|
||||||
|
getGroupJoinPolicy: jest.fn(),
|
||||||
|
getInviteLinkSummaryByToken: jest.fn(),
|
||||||
|
listPendingJoinRequests: jest.fn(),
|
||||||
|
listInviteLinks: jest.fn(),
|
||||||
|
resolveManagedGroupId: jest.fn(),
|
||||||
|
revokeInviteLink: jest.fn(),
|
||||||
|
reviveInviteLink: jest.fn(),
|
||||||
|
setGroupJoinPolicy: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = require("supertest");
|
||||||
|
const invitesService = require("../services/group-invites.service");
|
||||||
|
const app = require("../app");
|
||||||
|
|
||||||
|
describe("group invites routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
invitesService.resolveManagedGroupId.mockResolvedValue(1);
|
||||||
|
invitesService.listInviteLinks.mockResolvedValue([]);
|
||||||
|
invitesService.listPendingJoinRequests.mockResolvedValue([]);
|
||||||
|
invitesService.createInviteLink.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
token: "abcd",
|
||||||
|
status: "ACTIVE",
|
||||||
|
});
|
||||||
|
invitesService.getInviteLinkSummaryByToken.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
token: "abcd",
|
||||||
|
group_name: "Test Group",
|
||||||
|
status: "ACTIVE",
|
||||||
|
active_policy: "AUTO_ACCEPT",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin-only checks are enforced on invite management routes", async () => {
|
||||||
|
invitesService.createInviteLink.mockRejectedValue(
|
||||||
|
new invitesService.InviteServiceError(
|
||||||
|
"FORBIDDEN",
|
||||||
|
"Admin or owner role required",
|
||||||
|
403
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app).post("/api/groups/invites").send({
|
||||||
|
policy: "AUTO_ACCEPT",
|
||||||
|
singleUse: false,
|
||||||
|
ttlDays: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.error.code).toBe("FORBIDDEN");
|
||||||
|
expect(response.body.request_id).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("request_id is present in invite responses", async () => {
|
||||||
|
const response = await request(app).get("/api/invite-links/abcd1234");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.request_id).toBeTruthy();
|
||||||
|
expect(response.body.link).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pending join requests can be listed with request_id", async () => {
|
||||||
|
invitesService.listPendingJoinRequests.mockResolvedValue([
|
||||||
|
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(app).get("/api/groups/join-requests");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.request_id).toBeTruthy();
|
||||||
|
expect(response.body.requests).toEqual([
|
||||||
|
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decision route maps service validation errors", async () => {
|
||||||
|
invitesService.decideJoinRequest.mockRejectedValue(
|
||||||
|
new invitesService.InviteServiceError(
|
||||||
|
"JOIN_REQUEST_NOT_FOUND",
|
||||||
|
"Pending join request not found",
|
||||||
|
404
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/api/groups/join-requests/decision")
|
||||||
|
.send({ requestId: 99, decision: "APPROVE" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.request_id).toBeTruthy();
|
||||||
|
expect(response.body.error.code).toBe("JOIN_REQUEST_NOT_FOUND");
|
||||||
|
});
|
||||||
|
});
|
||||||
309
backend/tests/group-invites.service.test.js
Normal file
309
backend/tests/group-invites.service.test.js
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
jest.mock("../models/group-invites.model", () => ({
|
||||||
|
addGroupMember: jest.fn(),
|
||||||
|
createGroupAuditLog: jest.fn(),
|
||||||
|
createInviteLink: jest.fn(),
|
||||||
|
createOrTouchPendingJoinRequest: jest.fn(),
|
||||||
|
consumeSingleUseInvite: jest.fn(),
|
||||||
|
deleteInviteLink: jest.fn(),
|
||||||
|
getGroupById: jest.fn(),
|
||||||
|
getGroupSettings: jest.fn(),
|
||||||
|
getInviteLinkById: jest.fn(),
|
||||||
|
getInviteLinkSummaryByToken: jest.fn(),
|
||||||
|
getManageableGroupsForUser: jest.fn(),
|
||||||
|
getPendingJoinRequestById: jest.fn(),
|
||||||
|
getPendingJoinRequest: jest.fn(),
|
||||||
|
getUserGroupRole: jest.fn(),
|
||||||
|
isGroupMember: jest.fn(),
|
||||||
|
listPendingJoinRequests: jest.fn(),
|
||||||
|
listInviteLinks: jest.fn(),
|
||||||
|
revokeInviteLink: jest.fn(),
|
||||||
|
reviveInviteLink: jest.fn(),
|
||||||
|
updateJoinRequestDecision: jest.fn(),
|
||||||
|
upsertGroupSettings: jest.fn(),
|
||||||
|
withTransaction: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const invitesModel = require("../models/group-invites.model");
|
||||||
|
const invitesService = require("../services/group-invites.service");
|
||||||
|
|
||||||
|
function inviteSummary(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 30,
|
||||||
|
group_id: 10,
|
||||||
|
group_name: "Test Group",
|
||||||
|
token: "1234567890abcdef1234567890fedcba",
|
||||||
|
policy: "AUTO_ACCEPT",
|
||||||
|
current_join_policy: "AUTO_ACCEPT",
|
||||||
|
single_use: false,
|
||||||
|
expires_at: "2030-01-01T00:00:00.000Z",
|
||||||
|
used_at: null,
|
||||||
|
revoked_at: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("group invites service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
invitesModel.withTransaction.mockImplementation(async (handler) => handler({}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create link success writes audit with request_id and token last4 only", async () => {
|
||||||
|
invitesModel.getGroupById.mockResolvedValue({ id: 1, name: "G1" });
|
||||||
|
invitesModel.getUserGroupRole.mockResolvedValue("admin");
|
||||||
|
invitesModel.createInviteLink.mockResolvedValue({
|
||||||
|
id: 55,
|
||||||
|
group_id: 1,
|
||||||
|
token: "1234567890abcdef1234567890fedcba",
|
||||||
|
policy: "AUTO_ACCEPT",
|
||||||
|
single_use: true,
|
||||||
|
expires_at: "2030-01-01T00:00:00.000Z",
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = await invitesService.createInviteLink(
|
||||||
|
7,
|
||||||
|
1,
|
||||||
|
"AUTO_ACCEPT",
|
||||||
|
true,
|
||||||
|
"2030-01-01T00:00:00.000Z",
|
||||||
|
"req-123",
|
||||||
|
"127.0.0.1",
|
||||||
|
"ua"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(link.id).toBe(55);
|
||||||
|
expect(invitesModel.createGroupAuditLog).toHaveBeenCalledTimes(1);
|
||||||
|
const auditPayload = invitesModel.createGroupAuditLog.mock.calls[0][0];
|
||||||
|
expect(auditPayload.requestId).toBe("req-123");
|
||||||
|
expect(auditPayload.metadata).toEqual({ inviteCodeLast4: "dcba" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accept auto-accept adds membership", async () => {
|
||||||
|
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
|
||||||
|
invitesModel.isGroupMember.mockResolvedValue(false);
|
||||||
|
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
|
||||||
|
invitesModel.getUserGroupRole.mockResolvedValue(null);
|
||||||
|
invitesModel.addGroupMember.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await invitesService.acceptInviteLink(
|
||||||
|
99,
|
||||||
|
"token-1",
|
||||||
|
"req-1",
|
||||||
|
"127.0.0.1",
|
||||||
|
"ua"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: "JOINED",
|
||||||
|
group: { id: 10, name: "Test Group" },
|
||||||
|
});
|
||||||
|
expect(invitesModel.addGroupMember).toHaveBeenCalled();
|
||||||
|
expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe(
|
||||||
|
"GROUP_INVITE_USED"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accept manual policy creates pending request", async () => {
|
||||||
|
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(
|
||||||
|
inviteSummary({
|
||||||
|
current_join_policy: "APPROVAL_REQUIRED",
|
||||||
|
single_use: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
invitesModel.isGroupMember.mockResolvedValue(false);
|
||||||
|
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
|
||||||
|
invitesModel.getUserGroupRole.mockResolvedValue(null);
|
||||||
|
invitesModel.createOrTouchPendingJoinRequest.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
status: "PENDING",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await invitesService.acceptInviteLink(
|
||||||
|
99,
|
||||||
|
"token-2",
|
||||||
|
"req-2",
|
||||||
|
"127.0.0.1",
|
||||||
|
"ua"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: "PENDING",
|
||||||
|
group: { id: 10, name: "Test Group" },
|
||||||
|
});
|
||||||
|
expect(invitesModel.createOrTouchPendingJoinRequest).toHaveBeenCalled();
|
||||||
|
expect(invitesModel.consumeSingleUseInvite).toHaveBeenCalledWith(30, {});
|
||||||
|
expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe(
|
||||||
|
"GROUP_INVITE_REQUESTED"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["INVITE_EXPIRED", inviteSummary({ expires_at: "2020-01-01T00:00:00.000Z" })],
|
||||||
|
["INVITE_REVOKED", inviteSummary({ revoked_at: "2026-01-01T00:00:00.000Z" })],
|
||||||
|
[
|
||||||
|
"INVITE_USED",
|
||||||
|
inviteSummary({ single_use: true, used_at: "2026-01-01T00:00:00.000Z" }),
|
||||||
|
],
|
||||||
|
])("rejects %s links", async (expectedCode, summary) => {
|
||||||
|
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(summary);
|
||||||
|
invitesModel.isGroupMember.mockResolvedValue(false);
|
||||||
|
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
invitesService.acceptInviteLink(99, "token-3", "req-3", "127.0.0.1", "ua")
|
||||||
|
).rejects.toMatchObject({ code: expectedCode });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accept returns ALREADY_MEMBER before pending checks", async () => {
|
||||||
|
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
|
||||||
|
invitesModel.isGroupMember.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await invitesService.acceptInviteLink(
|
||||||
|
99,
|
||||||
|
"token-4",
|
||||||
|
"req-4",
|
||||||
|
"127.0.0.1",
|
||||||
|
"ua"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe("ALREADY_MEMBER");
|
||||||
|
expect(invitesModel.getPendingJoinRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accept returns PENDING when request already exists", async () => {
|
||||||
|
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
|
||||||
|
invitesModel.isGroupMember.mockResolvedValue(false);
|
||||||
|
invitesModel.getPendingJoinRequest.mockResolvedValue({
|
||||||
|
id: 5,
|
||||||
|
status: "PENDING",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await invitesService.acceptInviteLink(
|
||||||
|
99,
|
||||||
|
"token-5",
|
||||||
|
"req-5",
|
||||||
|
"127.0.0.1",
|
||||||
|
"ua"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe("PENDING");
|
||||||
|
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listPendingJoinRequests requires manager role and returns pending requests", async () => {
|
||||||
|
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
|
||||||
|
invitesModel.getUserGroupRole.mockResolvedValue("owner");
|
||||||
|
invitesModel.listPendingJoinRequests.mockResolvedValue([
|
||||||
|
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await invitesService.listPendingJoinRequests(99, 10);
|
||||||
|
|
||||||
|
expect(invitesModel.listPendingJoinRequests).toHaveBeenCalledWith(10);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("approve join request adds membership, updates request, and audits decision", async () => {
|
||||||
|
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
|
||||||
|
invitesModel.getUserGroupRole.mockResolvedValue("admin");
|
||||||
|
invitesModel.getPendingJoinRequestById.mockResolvedValue({
|
||||||
|
id: 77,
|
||||||
|
group_id: 10,
|
||||||
|
user_id: 55,
|
||||||
|
username: "pending-user",
|
||||||
|
status: "PENDING",
|
||||||
|
});
|
||||||
|
invitesModel.isGroupMember.mockResolvedValue(false);
|
||||||
|
invitesModel.addGroupMember.mockResolvedValue(true);
|
||||||
|
invitesModel.updateJoinRequestDecision.mockResolvedValue({
|
||||||
|
id: 77,
|
||||||
|
group_id: 10,
|
||||||
|
user_id: 55,
|
||||||
|
status: "APPROVED",
|
||||||
|
decided_by: 99,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await invitesService.decideJoinRequest(
|
||||||
|
99,
|
||||||
|
10,
|
||||||
|
77,
|
||||||
|
"APPROVE",
|
||||||
|
"req-approve",
|
||||||
|
"127.0.0.1",
|
||||||
|
"ua"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invitesModel.getPendingJoinRequestById).toHaveBeenCalledWith(
|
||||||
|
10,
|
||||||
|
77,
|
||||||
|
{},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(invitesModel.addGroupMember).toHaveBeenCalledWith(10, 55, "member", {});
|
||||||
|
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
|
||||||
|
10,
|
||||||
|
77,
|
||||||
|
"APPROVED",
|
||||||
|
99,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
|
||||||
|
eventType: "GROUP_JOIN_REQUEST_APPROVED",
|
||||||
|
requestId: "req-approve",
|
||||||
|
metadata: {
|
||||||
|
joinRequestId: 77,
|
||||||
|
targetUserId: 55,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.status).toBe("APPROVED");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deny join request updates request and audits decision", async () => {
|
||||||
|
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
|
||||||
|
invitesModel.getUserGroupRole.mockResolvedValue("owner");
|
||||||
|
invitesModel.getPendingJoinRequestById.mockResolvedValue({
|
||||||
|
id: 78,
|
||||||
|
group_id: 10,
|
||||||
|
user_id: 56,
|
||||||
|
status: "PENDING",
|
||||||
|
});
|
||||||
|
invitesModel.updateJoinRequestDecision.mockResolvedValue({
|
||||||
|
id: 78,
|
||||||
|
group_id: 10,
|
||||||
|
user_id: 56,
|
||||||
|
status: "DENIED",
|
||||||
|
decided_by: 99,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await invitesService.decideJoinRequest(
|
||||||
|
99,
|
||||||
|
10,
|
||||||
|
78,
|
||||||
|
"DENY",
|
||||||
|
"req-deny",
|
||||||
|
"127.0.0.1",
|
||||||
|
"ua"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
|
||||||
|
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
|
||||||
|
10,
|
||||||
|
78,
|
||||||
|
"DENIED",
|
||||||
|
99,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
|
||||||
|
eventType: "GROUP_JOIN_REQUEST_DENIED",
|
||||||
|
requestId: "req-deny",
|
||||||
|
metadata: {
|
||||||
|
joinRequestId: 78,
|
||||||
|
targetUserId: 56,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.status).toBe("DENIED");
|
||||||
|
});
|
||||||
|
});
|
||||||
75
backend/tests/household.model.test.js
Normal file
75
backend/tests/household.model.test.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
jest.mock("../db/pool", () => ({
|
||||||
|
connect: jest.fn(),
|
||||||
|
query: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pool = require("../db/pool");
|
||||||
|
const Household = require("../models/household.model");
|
||||||
|
|
||||||
|
describe("household.model household ordering", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads households using the user's saved sort order", async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 2, name: "Second", household_sort_order: 0 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const households = await Household.getUserHouseholds(9);
|
||||||
|
|
||||||
|
expect(households).toEqual([{ id: 2, name: "Second", household_sort_order: 0 }]);
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("ORDER BY hm.household_sort_order ASC NULLS LAST"),
|
||||||
|
[9]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("persists a full household order for the current user", async () => {
|
||||||
|
const client = {
|
||||||
|
query: jest.fn()
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
.mockResolvedValueOnce({ rows: [{ household_id: 1 }, { household_id: 2 }] })
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 2 }, { id: 1 }] })
|
||||||
|
.mockResolvedValueOnce({}),
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
pool.connect.mockResolvedValueOnce(client);
|
||||||
|
|
||||||
|
const households = await Household.reorderUserHouseholds(9, [2, 1]);
|
||||||
|
|
||||||
|
expect(households).toEqual([{ id: 2 }, { id: 1 }]);
|
||||||
|
expect(client.query).toHaveBeenNthCalledWith(1, "BEGIN");
|
||||||
|
expect(client.query).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
expect.stringContaining("SET household_sort_order = $1"),
|
||||||
|
[0, 9, 2]
|
||||||
|
);
|
||||||
|
expect(client.query).toHaveBeenNthCalledWith(
|
||||||
|
4,
|
||||||
|
expect.stringContaining("SET household_sort_order = $1"),
|
||||||
|
[1, 9, 1]
|
||||||
|
);
|
||||||
|
expect(client.query).toHaveBeenLastCalledWith("COMMIT");
|
||||||
|
expect(client.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an order that does not match the user's memberships", async () => {
|
||||||
|
const client = {
|
||||||
|
query: jest.fn()
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
.mockResolvedValueOnce({ rows: [{ household_id: 1 }] })
|
||||||
|
.mockResolvedValueOnce({}),
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
pool.connect.mockResolvedValueOnce(client);
|
||||||
|
|
||||||
|
const households = await Household.reorderUserHouseholds(9, [1, 2]);
|
||||||
|
|
||||||
|
expect(households).toBeNull();
|
||||||
|
expect(client.query).toHaveBeenNthCalledWith(3, "ROLLBACK");
|
||||||
|
expect(client.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
158
backend/tests/households.controller.test.js
Normal file
158
backend/tests/households.controller.test.js
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
jest.mock("../models/household.model", () => ({
|
||||||
|
getUserRole: jest.fn(),
|
||||||
|
reorderUserHouseholds: jest.fn(),
|
||||||
|
transferOwnership: jest.fn(),
|
||||||
|
updateMemberRole: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../utils/logger", () => ({
|
||||||
|
logError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const householdModel = require("../models/household.model");
|
||||||
|
const controller = require("../controllers/households.controller");
|
||||||
|
|
||||||
|
function createResponse() {
|
||||||
|
const res = {};
|
||||||
|
res.status = jest.fn().mockReturnValue(res);
|
||||||
|
res.json = jest.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("households.controller updateMemberRole", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
householdModel.getUserRole.mockResolvedValue("member");
|
||||||
|
householdModel.transferOwnership.mockResolvedValue({ user_id: 7, role: "owner" });
|
||||||
|
householdModel.updateMemberRole.mockResolvedValue({ user_id: 7, role: "admin" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("owner can transfer household ownership", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "3", userId: "7" },
|
||||||
|
body: { role: "owner" },
|
||||||
|
user: { id: 1 },
|
||||||
|
household: { id: 3, role: "owner" },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.updateMemberRole(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.transferOwnership).toHaveBeenCalledWith("3", 1, 7);
|
||||||
|
expect(householdModel.updateMemberRole).not.toHaveBeenCalled();
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
message: "Household ownership transferred successfully",
|
||||||
|
member: { user_id: 7, role: "owner" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin cannot transfer household ownership", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "3", userId: "7" },
|
||||||
|
body: { role: "owner" },
|
||||||
|
user: { id: 1 },
|
||||||
|
household: { id: 3, role: "admin" },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.updateMemberRole(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.transferOwnership).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Only the household owner can transfer ownership",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("owner can still update a member to admin without transfer flow", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "3", userId: "7" },
|
||||||
|
body: { role: "admin" },
|
||||||
|
user: { id: 1 },
|
||||||
|
household: { id: 3, role: "owner" },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.updateMemberRole(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.updateMemberRole).toHaveBeenCalledWith("3", "7", "admin");
|
||||||
|
expect(householdModel.transferOwnership).not.toHaveBeenCalled();
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
message: "Member role updated successfully",
|
||||||
|
member: { user_id: 7, role: "admin" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("households.controller reorderHouseholds", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
householdModel.reorderUserHouseholds.mockResolvedValue([
|
||||||
|
{ id: 3, name: "Third" },
|
||||||
|
{ id: 1, name: "First" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates the current user's household order", async () => {
|
||||||
|
const req = {
|
||||||
|
body: { household_ids: [3, 1] },
|
||||||
|
user: { id: 9 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.reorderHouseholds(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.reorderUserHouseholds).toHaveBeenCalledWith(9, [3, 1]);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
message: "Household order updated successfully",
|
||||||
|
households: [
|
||||||
|
{ id: 3, name: "Third" },
|
||||||
|
{ id: 1, name: "First" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects duplicate household IDs", async () => {
|
||||||
|
const req = {
|
||||||
|
body: { household_ids: [3, 3] },
|
||||||
|
user: { id: 9 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.reorderHouseholds(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.reorderUserHouseholds).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "household_ids must contain unique positive household IDs",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects orders that do not match current memberships", async () => {
|
||||||
|
householdModel.reorderUserHouseholds.mockResolvedValue(null);
|
||||||
|
const req = {
|
||||||
|
body: { household_ids: [999] },
|
||||||
|
user: { id: 9 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.reorderHouseholds(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Household order must include every household you belong to",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
180
backend/tests/list.model.v2.test.js
Normal file
180
backend/tests/list.model.v2.test.js
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
jest.mock("../db/pool", () => ({
|
||||||
|
query: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pool = require("../db/pool");
|
||||||
|
const List = require("../models/list.model.v2");
|
||||||
|
|
||||||
|
describe("list.model.v2 addOrUpdateItem", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pool.query.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns household store item metadata when creating a new list item", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88 }] });
|
||||||
|
|
||||||
|
const result = await List.addOrUpdateItem(1, 2, "Milk", 3, 7);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
listId: 88,
|
||||||
|
itemId: 55,
|
||||||
|
householdStoreItemId: 55,
|
||||||
|
itemName: "milk",
|
||||||
|
quantity: 3,
|
||||||
|
previousQuantity: 0,
|
||||||
|
historyQuantity: 3,
|
||||||
|
wasBought: false,
|
||||||
|
isNew: true,
|
||||||
|
});
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.stringContaining("FROM household_store_items"),
|
||||||
|
[1, 2, "milk"]
|
||||||
|
);
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.stringContaining("INSERT INTO household_store_items"),
|
||||||
|
[1, 2, "milk", "milk"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns household store item metadata when updating an existing list item", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false, quantity: 2 }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
|
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
listId: 88,
|
||||||
|
itemId: 55,
|
||||||
|
householdStoreItemId: 55,
|
||||||
|
itemName: "milk",
|
||||||
|
quantity: 4,
|
||||||
|
previousQuantity: 2,
|
||||||
|
historyQuantity: 2,
|
||||||
|
wasBought: false,
|
||||||
|
isNew: false,
|
||||||
|
});
|
||||||
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
expect.stringContaining("UPDATE household_lists"),
|
||||||
|
[4, undefined, 88]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the full requested quantity when reopening a bought list item", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: true, quantity: 2 }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
|
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
listId: 88,
|
||||||
|
quantity: 4,
|
||||||
|
previousQuantity: 2,
|
||||||
|
historyQuantity: 4,
|
||||||
|
wasBought: true,
|
||||||
|
isNew: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("limits added_by_users to history entries that account for current quantity", async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({ rowCount: 0, rows: [] });
|
||||||
|
|
||||||
|
await List.getHouseholdStoreList(1, 2);
|
||||||
|
|
||||||
|
const sql = pool.query.mock.calls[0][0];
|
||||||
|
expect(sql).toContain("ORDER BY hlh.added_on DESC, hlh.id DESC");
|
||||||
|
expect(sql).toContain("active_history.newer_quantity < GREATEST(hl.quantity, 0)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("list.model.v2 classification helpers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pool.query.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gets classification using household, location, and household-store item ids", async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
confidence: 1,
|
||||||
|
source: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await List.getClassification(1, 2, 55);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
confidence: 1,
|
||||||
|
source: "user",
|
||||||
|
});
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("household_store_item_id = $3"),
|
||||||
|
[1, 2, 55]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upserts classification using household-location item conflict target", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ id: 12, name: "Dairy & Refrigerated", sort_order: 60 }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
household_id: 1,
|
||||||
|
store_location_id: 2,
|
||||||
|
household_store_item_id: 55,
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
zone_id: 12,
|
||||||
|
confidence: 1,
|
||||||
|
source: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await List.upsertClassification(1, 2, 55, {
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
confidence: 1,
|
||||||
|
source: "user",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
household_id: 1,
|
||||||
|
store_location_id: 2,
|
||||||
|
household_store_item_id: 55,
|
||||||
|
item_type: "dairy",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(pool.query).toHaveBeenLastCalledWith(
|
||||||
|
expect.stringContaining("ON CONFLICT (household_id, store_location_id, household_store_item_id)"),
|
||||||
|
[1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 12, 1, "user"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
428
backend/tests/lists.controller.v2.test.js
Normal file
428
backend/tests/lists.controller.v2.test.js
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
jest.mock("../models/list.model.v2", () => ({
|
||||||
|
addHistoryRecord: jest.fn(),
|
||||||
|
addOrUpdateItem: jest.fn(),
|
||||||
|
ensureHouseholdStoreItem: jest.fn(),
|
||||||
|
getItemByName: jest.fn(),
|
||||||
|
getZoneByName: jest.fn(),
|
||||||
|
recordItemEvent: jest.fn(),
|
||||||
|
upsertClassification: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../models/household.model", () => ({
|
||||||
|
isHouseholdMember: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../utils/logger", () => ({
|
||||||
|
logError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const List = require("../models/list.model.v2");
|
||||||
|
const householdModel = require("../models/household.model");
|
||||||
|
const controller = require("../controllers/lists.controller.v2");
|
||||||
|
|
||||||
|
function createResponse() {
|
||||||
|
const res = {};
|
||||||
|
res.status = jest.fn().mockReturnValue(res);
|
||||||
|
res.json = jest.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("lists.controller.v2 addItem", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
List.addOrUpdateItem.mockResolvedValue({
|
||||||
|
listId: 42,
|
||||||
|
itemId: 99,
|
||||||
|
householdStoreItemId: 99,
|
||||||
|
itemName: "milk",
|
||||||
|
isNew: true,
|
||||||
|
});
|
||||||
|
List.addHistoryRecord.mockResolvedValue(undefined);
|
||||||
|
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
||||||
|
List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" });
|
||||||
|
List.recordItemEvent.mockResolvedValue(undefined);
|
||||||
|
List.upsertClassification.mockResolvedValue({ zone_id: 5 });
|
||||||
|
householdModel.isHouseholdMember.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records history for selected added_for_user_id when member is valid", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: { item_name: "milk", quantity: "1", added_for_user_id: "9" },
|
||||||
|
user: { id: 7 },
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.addItem(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
|
||||||
|
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||||
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 9, "2");
|
||||||
|
expect(List.recordItemEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: "ITEM_ADDED",
|
||||||
|
householdId: "1",
|
||||||
|
storeLocationId: "2",
|
||||||
|
householdStoreItemId: 99,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records history using request user when added_for_user_id is not provided", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: { item_name: "milk", quantity: "1" },
|
||||||
|
user: { id: 7 },
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.addItem(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||||
|
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||||
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7, "2");
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records duplicate-add history with the added quantity instead of the new total", async () => {
|
||||||
|
List.addOrUpdateItem.mockResolvedValueOnce({
|
||||||
|
listId: 42,
|
||||||
|
itemId: 99,
|
||||||
|
householdStoreItemId: 99,
|
||||||
|
itemName: "milk",
|
||||||
|
quantity: 3,
|
||||||
|
previousQuantity: 1,
|
||||||
|
historyQuantity: 2,
|
||||||
|
isNew: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: { item_name: "milk", quantity: "3" },
|
||||||
|
user: { id: 7 },
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.addItem(req, res);
|
||||||
|
|
||||||
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, 2, 7, "2");
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
item: expect.objectContaining({
|
||||||
|
quantity: 3,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records history using request user when added_for_user_id is blank", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: { item_name: "milk", quantity: "1", added_for_user_id: " " },
|
||||||
|
user: { id: 7 },
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.addItem(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||||
|
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||||
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7, "2");
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid added_for_user_id", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: { item_name: "milk", quantity: "1", added_for_user_id: "abc" },
|
||||||
|
user: { id: 7 },
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.addItem(req, res);
|
||||||
|
|
||||||
|
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
|
||||||
|
expect(List.addHistoryRecord).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Added-for user ID must be a positive integer",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects malformed numeric-looking added_for_user_id", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: { item_name: "milk", quantity: "1", added_for_user_id: "9abc" },
|
||||||
|
user: { id: 7 },
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.addItem(req, res);
|
||||||
|
|
||||||
|
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
|
||||||
|
expect(List.addHistoryRecord).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Added-for user ID must be a positive integer",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects added_for_user_id when target user is not household member", async () => {
|
||||||
|
householdModel.isHouseholdMember.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: { item_name: "milk", quantity: "1", added_for_user_id: "11" },
|
||||||
|
user: { id: 7 },
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.addItem(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 11);
|
||||||
|
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
|
||||||
|
expect(List.addHistoryRecord).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Selected user is not a member of this household",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lists.controller.v2 setClassification", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
||||||
|
List.upsertClassification.mockResolvedValue({ zone_id: 5 });
|
||||||
|
List.recordItemEvent.mockResolvedValue(undefined);
|
||||||
|
List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" });
|
||||||
|
List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts object classification with type, group, and zone", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: {
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
99,
|
||||||
|
expect.objectContaining({
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
confidence: 1.0,
|
||||||
|
source: "user",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Classification set",
|
||||||
|
classification: {
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Milk",
|
||||||
|
zone: "Dairy & Refrigerated",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts zone-only classification updates", async () => {
|
||||||
|
List.getZoneByName.mockResolvedValueOnce({ id: 6, name: "Checkout Area" });
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: {
|
||||||
|
zone: "Checkout Area",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
99,
|
||||||
|
expect.objectContaining({
|
||||||
|
item_type: null,
|
||||||
|
item_group: null,
|
||||||
|
zone: "Checkout Area",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid item_type", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: {
|
||||||
|
item_type: "invalid-type",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Invalid item_type",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid item_group for selected item_type", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: {
|
||||||
|
item_type: "dairy",
|
||||||
|
item_group: "Bread",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Invalid item_group for selected item_type",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid zone", async () => {
|
||||||
|
List.getZoneByName.mockResolvedValueOnce(null);
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: {
|
||||||
|
zone: "Space Aisle",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Invalid zone",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts legacy string classification values", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "milk",
|
||||||
|
classification: "beverages",
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
99,
|
||||||
|
expect.objectContaining({
|
||||||
|
item_type: "beverage",
|
||||||
|
item_group: null,
|
||||||
|
zone: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a household store item when classification target is not yet on the list", async () => {
|
||||||
|
List.getItemByName.mockResolvedValueOnce(null);
|
||||||
|
List.getZoneByName.mockResolvedValueOnce({ id: 7, name: "Snacks & Candy" });
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: {
|
||||||
|
item_name: "granola",
|
||||||
|
classification: {
|
||||||
|
zone: "Snacks & Candy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { id: 7 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.setClassification(req, res);
|
||||||
|
|
||||||
|
expect(List.ensureHouseholdStoreItem).toHaveBeenCalledWith("1", "2", "granola");
|
||||||
|
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
99,
|
||||||
|
expect.objectContaining({
|
||||||
|
zone: "Snacks & Candy",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
164
backend/tests/store-locations.routes.test.js
Normal file
164
backend/tests/store-locations.routes.test.js
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
jest.mock("../middleware/auth", () => (req, res, next) => {
|
||||||
|
req.user = { id: 42, role: "user" };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("../middleware/household", () => ({
|
||||||
|
householdAccess: (req, res, next) => {
|
||||||
|
req.household = {
|
||||||
|
id: Number.parseInt(req.params.householdId, 10),
|
||||||
|
role: req.headers["x-household-role"] || "member",
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
locationAccess: (req, res, next) => {
|
||||||
|
req.storeLocation = { id: Number.parseInt(req.params.locationId, 10) };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireHouseholdAdmin: (req, res, next) => {
|
||||||
|
if (["owner", "admin"].includes(req.household?.role)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return res.status(403).json({
|
||||||
|
error: { code: "FORBIDDEN", message: "Admin role required" },
|
||||||
|
request_id: req.request_id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
storeAccess: (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../middleware/image", () => ({
|
||||||
|
upload: {
|
||||||
|
single: () => (req, res, next) => next(),
|
||||||
|
},
|
||||||
|
processImage: (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../controllers/households.controller", () => ({
|
||||||
|
createHousehold: jest.fn(),
|
||||||
|
deleteHousehold: jest.fn(),
|
||||||
|
getHousehold: jest.fn(),
|
||||||
|
getMembers: jest.fn(),
|
||||||
|
getUserHouseholds: jest.fn(),
|
||||||
|
joinHousehold: jest.fn(),
|
||||||
|
refreshInviteCode: jest.fn(),
|
||||||
|
removeMember: jest.fn(),
|
||||||
|
reorderHouseholds: jest.fn((req, res) => res.json({ message: "ordered" })),
|
||||||
|
updateHousehold: jest.fn(),
|
||||||
|
updateMemberRole: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../controllers/lists.controller.v2", () => ({
|
||||||
|
addItem: jest.fn(),
|
||||||
|
deleteItem: jest.fn(),
|
||||||
|
getClassification: jest.fn(),
|
||||||
|
getItemByName: jest.fn(),
|
||||||
|
getList: jest.fn(),
|
||||||
|
getRecentlyBought: jest.fn(),
|
||||||
|
getSuggestions: jest.fn(),
|
||||||
|
markBought: jest.fn(),
|
||||||
|
setClassification: jest.fn(),
|
||||||
|
updateItem: jest.fn(),
|
||||||
|
updateItemImage: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../controllers/available-items.controller", () => ({
|
||||||
|
createAvailableItem: jest.fn(),
|
||||||
|
deleteAvailableItem: jest.fn(),
|
||||||
|
getAvailableItems: jest.fn(),
|
||||||
|
importCurrentItems: jest.fn(),
|
||||||
|
updateAvailableItem: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../controllers/stores.controller", () => ({
|
||||||
|
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
|
||||||
|
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
|
||||||
|
createZone: jest.fn((req, res) => res.status(201).json({ message: "zone" })),
|
||||||
|
deleteHouseholdStore: jest.fn((req, res) => res.json({ message: "deleted store" })),
|
||||||
|
deleteLocation: jest.fn((req, res) => res.json({ message: "deleted location" })),
|
||||||
|
deleteZone: jest.fn((req, res) => res.json({ message: "deleted zone" })),
|
||||||
|
getHouseholdStores: jest.fn((req, res) => res.json([{ id: 2, name: "Costco" }])),
|
||||||
|
getLocationZones: jest.fn((req, res) => res.json({ zones: [] })),
|
||||||
|
setDefaultLocation: jest.fn((req, res) => res.json({ message: "default" })),
|
||||||
|
updateHouseholdStore: jest.fn((req, res) => res.json({ message: "updated store" })),
|
||||||
|
updateLocation: jest.fn((req, res) => res.json({ message: "updated location" })),
|
||||||
|
updateZone: jest.fn((req, res) => res.json({ message: "updated zone" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const express = require("express");
|
||||||
|
const request = require("supertest");
|
||||||
|
const router = require("../routes/households.routes");
|
||||||
|
const householdsController = require("../controllers/households.controller");
|
||||||
|
const storesController = require("../controllers/stores.controller");
|
||||||
|
|
||||||
|
describe("store location routes", () => {
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/households", router);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members can list household store locations", async () => {
|
||||||
|
const response = await request(app).get("/households/1/stores");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(storesController.getHouseholdStores).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("users can reorder their household switcher list", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.patch("/households/order")
|
||||||
|
.send({ household_ids: [3, 1, 2] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(householdsController.reorderHouseholds).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members cannot create household stores", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/households/1/stores")
|
||||||
|
.set("x-household-role", "member")
|
||||||
|
.send({ name: "Costco" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(storesController.createHouseholdStore).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admins can create household stores", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/households/1/stores")
|
||||||
|
.set("x-household-role", "admin")
|
||||||
|
.send({ name: "Costco", location_name: "Fontana" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(storesController.createHouseholdStore).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members can list zones but cannot create zones", async () => {
|
||||||
|
const listResponse = await request(app)
|
||||||
|
.get("/households/1/locations/2/zones")
|
||||||
|
.set("x-household-role", "member");
|
||||||
|
const createResponse = await request(app)
|
||||||
|
.post("/households/1/locations/2/zones")
|
||||||
|
.set("x-household-role", "member")
|
||||||
|
.send({ name: "Produce", sort_order: 10 });
|
||||||
|
|
||||||
|
expect(listResponse.status).toBe(200);
|
||||||
|
expect(createResponse.status).toBe(403);
|
||||||
|
expect(storesController.getLocationZones).toHaveBeenCalled();
|
||||||
|
expect(storesController.createZone).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admins can update zone order", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.patch("/households/1/locations/2/zones/9")
|
||||||
|
.set("x-household-role", "admin")
|
||||||
|
.send({ sort_order: 20 });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(storesController.updateZone).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
backend/utils/cookies.js
Normal file
25
backend/utils/cookies.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
function parseCookieHeader(cookieHeader) {
|
||||||
|
const cookies = {};
|
||||||
|
if (!cookieHeader || typeof cookieHeader !== "string") return cookies;
|
||||||
|
|
||||||
|
const segments = cookieHeader.split(";");
|
||||||
|
for (const segment of segments) {
|
||||||
|
const index = segment.indexOf("=");
|
||||||
|
if (index === -1) continue;
|
||||||
|
const key = segment.slice(0, index).trim();
|
||||||
|
const value = segment.slice(index + 1).trim();
|
||||||
|
if (!key) continue;
|
||||||
|
try {
|
||||||
|
cookies[key] = decodeURIComponent(value);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore malformed cookie values instead of throwing.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseCookieHeader,
|
||||||
|
};
|
||||||
116
backend/utils/http.js
Normal file
116
backend/utils/http.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
function isPlainObject(value) {
|
||||||
|
return (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
Object.prototype.toString.call(value) === "[object Object]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorCodeFromStatus(statusCode) {
|
||||||
|
switch (statusCode) {
|
||||||
|
case 400:
|
||||||
|
return "bad_request";
|
||||||
|
case 401:
|
||||||
|
return "unauthorized";
|
||||||
|
case 403:
|
||||||
|
return "forbidden";
|
||||||
|
case 404:
|
||||||
|
return "not_found";
|
||||||
|
case 409:
|
||||||
|
return "conflict";
|
||||||
|
case 413:
|
||||||
|
return "payload_too_large";
|
||||||
|
case 415:
|
||||||
|
return "unsupported_media_type";
|
||||||
|
case 422:
|
||||||
|
return "unprocessable_entity";
|
||||||
|
case 429:
|
||||||
|
return "rate_limited";
|
||||||
|
case 500:
|
||||||
|
return "internal_error";
|
||||||
|
default:
|
||||||
|
return statusCode >= 500 ? "internal_error" : "request_error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeErrorPayload(payload, statusCode) {
|
||||||
|
if (statusCode < 400) return payload;
|
||||||
|
|
||||||
|
if (typeof payload === "string") {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: errorCodeFromStatus(statusCode),
|
||||||
|
message: payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(payload)) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: errorCodeFromStatus(statusCode),
|
||||||
|
message: "Request failed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(payload.error)) {
|
||||||
|
const code = payload.error.code || errorCodeFromStatus(statusCode);
|
||||||
|
const message = payload.error.message || "Request failed";
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
error: {
|
||||||
|
...payload.error,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.error === "string") {
|
||||||
|
const { error, ...rest } = payload;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
error: {
|
||||||
|
code: errorCodeFromStatus(statusCode),
|
||||||
|
message: error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.message === "string") {
|
||||||
|
const { message, ...rest } = payload;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
error: {
|
||||||
|
code: errorCodeFromStatus(statusCode),
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
error: {
|
||||||
|
code: errorCodeFromStatus(statusCode),
|
||||||
|
message: "Request failed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendError(res, statusCode, message, code, extra = {}) {
|
||||||
|
return res.status(statusCode).json({
|
||||||
|
...extra,
|
||||||
|
error: {
|
||||||
|
code: code || errorCodeFromStatus(statusCode),
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
errorCodeFromStatus,
|
||||||
|
normalizeErrorPayload,
|
||||||
|
sendError,
|
||||||
|
};
|
||||||
20
backend/utils/logger.js
Normal file
20
backend/utils/logger.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const { safeErrorMessage } = require("./redaction");
|
||||||
|
|
||||||
|
function formatExtra(extra = {}) {
|
||||||
|
return Object.entries(extra)
|
||||||
|
.filter(([, value]) => value !== undefined && value !== null && value !== "")
|
||||||
|
.map(([key, value]) => `${key}=${String(value)}`)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function logError(req, context, error, extra = {}) {
|
||||||
|
const requestId = req?.request_id || "unknown";
|
||||||
|
const message = safeErrorMessage(error);
|
||||||
|
const extraText = formatExtra(extra);
|
||||||
|
const suffix = extraText ? ` ${extraText}` : "";
|
||||||
|
console.error(`[${context}] request_id=${requestId} message=${message}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
logError,
|
||||||
|
};
|
||||||
20
backend/utils/redaction.js
Normal file
20
backend/utils/redaction.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
function inviteCodeLast4(inviteCode) {
|
||||||
|
if (!inviteCode || typeof inviteCode !== "string") return "none";
|
||||||
|
const trimmed = inviteCode.trim();
|
||||||
|
if (!trimmed) return "none";
|
||||||
|
return trimmed.slice(-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeErrorMessage(error) {
|
||||||
|
if (!error) return "unknown_error";
|
||||||
|
if (typeof error === "string") return error;
|
||||||
|
if (typeof error.message === "string" && error.message.trim()) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "unknown_error";
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inviteCodeLast4,
|
||||||
|
safeErrorMessage,
|
||||||
|
};
|
||||||
36
backend/utils/session-cookie.js
Normal file
36
backend/utils/session-cookie.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "sid";
|
||||||
|
const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS || 30);
|
||||||
|
|
||||||
|
function sessionMaxAgeMs() {
|
||||||
|
return SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieName() {
|
||||||
|
return SESSION_COOKIE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionCookie(res, sessionId) {
|
||||||
|
res.cookie(cookieName(), sessionId, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: sessionMaxAgeMs(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionCookie(res) {
|
||||||
|
res.clearCookie(cookieName(), {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SESSION_TTL_DAYS,
|
||||||
|
clearSessionCookie,
|
||||||
|
cookieName,
|
||||||
|
setSessionCookie,
|
||||||
|
};
|
||||||
6
debug.log
Normal file
6
debug.log
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
[0219/013019.369:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
|
||||||
|
[0219/013019.648:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
|
||||||
|
[0219/013030.696:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
|
||||||
|
[0219/013038.475:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
|
||||||
|
[0219/013103.277:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
|
||||||
|
[0219/014227.547:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
|
||||||
@ -1,8 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Quick script to rebuild Docker Compose dev environment
|
set -euo pipefail
|
||||||
|
|
||||||
echo "Stopping containers and removing volumes..."
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
docker-compose -f docker-compose.dev.yml down -v
|
exec "$SCRIPT_DIR/rebuild-dev.sh" "$@"
|
||||||
|
|
||||||
echo "Rebuilding and starting containers..."
|
|
||||||
docker-compose -f docker-compose.dev.yml up --build
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ services:
|
|||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- frontend_node_modules:/app/node_modules
|
- frontend_node_modules:/app/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "3000:5173"
|
- "3010:5173"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
20
docker-compose.new.yml
Normal file
20
docker-compose.new.yml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: git.nicosaya.com/nalalangan/grocery-app/backend:main-new
|
||||||
|
# image: grocery-app/backend:main-new
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- ./backend.env
|
||||||
|
ports:
|
||||||
|
- "5001:5000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: git.nicosaya.com/nalalangan/grocery-app/frontend:main-new
|
||||||
|
# image: grocery-app/frontend:main-new
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- ./frontend.env
|
||||||
|
ports:
|
||||||
|
- "3001:5173"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
@ -1,16 +0,0 @@
|
|||||||
services:
|
|
||||||
frontend:
|
|
||||||
build: ./frontend
|
|
||||||
environment:
|
|
||||||
- MODE_ENV=production
|
|
||||||
ports:
|
|
||||||
- "3000:80"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
|
|
||||||
backend:
|
|
||||||
build: ./backend
|
|
||||||
ports:
|
|
||||||
- "5000:5000"
|
|
||||||
env_file:
|
|
||||||
- ./backend/.env
|
|
||||||
@ -1,8 +1,6 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
image: git.nicosaya.com/nalalangan/costco-grocery-list/backend:latest
|
image: git.nicosaya.com/nalalangan/grocery-app/backend:latest
|
||||||
restart: always
|
restart: always
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend.env
|
- ./backend.env
|
||||||
@ -10,7 +8,7 @@ services:
|
|||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: git.nicosaya.com/nalalangan/costco-grocery-list/frontend:latest
|
image: git.nicosaya.com/nalalangan/grocery-app/frontend:latest
|
||||||
restart: always
|
restart: always
|
||||||
env_file:
|
env_file:
|
||||||
- ./frontend.env
|
- ./frontend.env
|
||||||
|
|||||||
49
docs/AGENTIC_CONTRACT_MAP.md
Normal file
49
docs/AGENTIC_CONTRACT_MAP.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Agentic Contract Map (Current Stack)
|
||||||
|
|
||||||
|
This file maps `PROJECT_INSTRUCTIONS.md` architecture intent to the current repository stack.
|
||||||
|
|
||||||
|
## Current stack
|
||||||
|
- Backend: Express (`backend/`)
|
||||||
|
- Frontend: React + Vite (`frontend/`)
|
||||||
|
|
||||||
|
## Contract mapping
|
||||||
|
|
||||||
|
### API Route Handlers (`app/api/**/route.ts` intent)
|
||||||
|
Current equivalent:
|
||||||
|
- `backend/routes/*.js`
|
||||||
|
- `backend/controllers/*.js`
|
||||||
|
|
||||||
|
Expectation:
|
||||||
|
- Keep these thin for parsing/validation and response shape.
|
||||||
|
- Delegate DB and authorization-heavy logic to model/service layers.
|
||||||
|
|
||||||
|
### Server Services (`lib/server/*` intent)
|
||||||
|
Current equivalent:
|
||||||
|
- `backend/models/*.js`
|
||||||
|
- `backend/middleware/*.js`
|
||||||
|
- `backend/db/*`
|
||||||
|
|
||||||
|
Expectation:
|
||||||
|
- Concentrate DB access and authorization logic in these backend layers.
|
||||||
|
- Avoid raw DB usage directly in route files unless no service/model exists.
|
||||||
|
|
||||||
|
### Client Wrappers (`lib/client/*` intent)
|
||||||
|
Current equivalent:
|
||||||
|
- `frontend/src/api/*.js`
|
||||||
|
|
||||||
|
Expectation:
|
||||||
|
- Centralize fetch/axios calls and error normalization here.
|
||||||
|
- Always send credentials/authorization headers as required.
|
||||||
|
|
||||||
|
### Hooks (`hooks/use-*.ts` intent)
|
||||||
|
Current equivalent:
|
||||||
|
- `frontend/src/context/*`
|
||||||
|
- `frontend/src/utils/*` for route guards
|
||||||
|
|
||||||
|
Expectation:
|
||||||
|
- Keep components free of direct raw network calls where possible.
|
||||||
|
- Favor one canonical state propagation mechanism per concern.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- This map does not force a framework migration.
|
||||||
|
- It defines how to apply the contract consistently in the existing codebase.
|
||||||
70
docs/DB_MIGRATION_WORKFLOW.md
Normal file
70
docs/DB_MIGRATION_WORKFLOW.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# DB Migration Workflow (External Postgres)
|
||||||
|
|
||||||
|
This project uses an external on-prem Postgres database. Migration files are canonical in:
|
||||||
|
|
||||||
|
- `packages/db/migrations`
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
- `DATABASE_URL` is set and points to the on-prem Postgres instance.
|
||||||
|
- `psql` is installed and available in PATH.
|
||||||
|
- You are in repo root.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
- Apply pending migrations:
|
||||||
|
- `npm run db:migrate`
|
||||||
|
- Show migration status:
|
||||||
|
- `npm run db:migrate:status`
|
||||||
|
- Fail if pending migrations exist:
|
||||||
|
- `npm run db:migrate:verify`
|
||||||
|
- Create a new migration file:
|
||||||
|
- `npm run db:migrate:new -- <migration-name>`
|
||||||
|
- Track stale legacy SQL in `backend/migrations`:
|
||||||
|
- `npm run db:migrate:stale`
|
||||||
|
- Fail when legacy SQL needs operator attention:
|
||||||
|
- `npm run db:migrate:stale:check`
|
||||||
|
|
||||||
|
## Active migration set
|
||||||
|
Migration files are applied in lexicographic filename order from `packages/db/migrations`.
|
||||||
|
|
||||||
|
`backend/migrations` is legacy reference-only and not part of canonical execution.
|
||||||
|
Duplicate reference copies are reported by the stale tracker, but the check fails only
|
||||||
|
when a legacy SQL file exists only in `backend/migrations` or diverges from its
|
||||||
|
canonical file.
|
||||||
|
`packages/db/migrations/stale-files.json` is the source of truth for canonical files that are intentionally stale/ignored.
|
||||||
|
|
||||||
|
Current baseline files:
|
||||||
|
- `add_display_name_column.sql`
|
||||||
|
- `add_image_columns.sql`
|
||||||
|
- `add_modified_on_column.sql`
|
||||||
|
- `add_notes_column.sql`
|
||||||
|
- `create_item_classification_table.sql`
|
||||||
|
- `create_sessions_table.sql`
|
||||||
|
- `multi_household_architecture.sql`
|
||||||
|
|
||||||
|
## Tracking table
|
||||||
|
Applied migrations are recorded in:
|
||||||
|
|
||||||
|
- `schema_migrations(filename text unique, applied_at timestamptz)`
|
||||||
|
|
||||||
|
## Expected operator flow
|
||||||
|
1. Check status:
|
||||||
|
- `npm run db:migrate:status`
|
||||||
|
2. If a new implementation needs schema changes, create a new file:
|
||||||
|
- `npm run db:migrate:new -- <migration-name>`
|
||||||
|
3. Apply pending:
|
||||||
|
- `npm run db:migrate`
|
||||||
|
4. Verify clean state:
|
||||||
|
- `npm run db:migrate:verify`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- `DATABASE_URL is required`:
|
||||||
|
- Export/set `DATABASE_URL` in your environment.
|
||||||
|
- `psql executable was not found in PATH`:
|
||||||
|
- Install PostgreSQL client tools and retry.
|
||||||
|
- SQL failure:
|
||||||
|
- Fix migration SQL and rerun; only successful files are recorded in `schema_migrations`.
|
||||||
|
- Skip known stale SQL files for a specific environment:
|
||||||
|
- Set `DB_MIGRATE_SKIP_FILES` to a comma-separated filename list.
|
||||||
|
- Example: `DB_MIGRATE_SKIP_FILES=add_modified_on_column.sql,add_image_columns.sql`
|
||||||
|
- Temporarily include files listed in `stale-files.json`:
|
||||||
|
- Set `DB_MIGRATE_INCLUDE_STALE=true` before running migration commands.
|
||||||
99
docs/DEVELOPMENT.md
Normal file
99
docs/DEVELOPMENT.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
Use this as the practical setup and verification guide. `PROJECT_INSTRUCTIONS.md` remains the source of truth for constraints.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Node.js 20.19+ or 22.12+ for the current Vite toolchain.
|
||||||
|
- npm.
|
||||||
|
- PostgreSQL client tools if running migration scripts (`psql` must be on `PATH`).
|
||||||
|
- Access to the external Postgres database through `DATABASE_URL` or backend DB variables.
|
||||||
|
- Docker is optional for local app containers.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
Run installs separately because this repo does not define npm workspaces.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm --prefix backend ci
|
||||||
|
npm --prefix frontend ci
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
- Copy `backend/.env.example` to `backend/.env` for backend runtime configuration.
|
||||||
|
- Copy `frontend/.env.example` to `frontend/.env` if the frontend needs non-default API or host settings.
|
||||||
|
- Do not commit real `.env` files.
|
||||||
|
- Root DB migration scripts read `DATABASE_URL` from the shell environment; they do not load `backend/.env` automatically.
|
||||||
|
|
||||||
|
Important variables:
|
||||||
|
|
||||||
|
| Variable | Used by | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `DATABASE_URL` | backend, root migration scripts | Preferred external Postgres connection string. |
|
||||||
|
| `DB_USER`, `DB_PASS`, `DB_HOST`, `DB_PORT`, `DB_NAME` | backend fallback | Used only when `DATABASE_URL` is absent. |
|
||||||
|
| `JWT_SECRET` | backend auth | Required for token/session-compatible auth paths. |
|
||||||
|
| `ALLOWED_ORIGINS` | backend CORS | Comma-separated allowed frontend origins. |
|
||||||
|
| `SESSION_COOKIE_NAME`, `SESSION_TTL_DAYS` | backend cookies | Optional; defaults are defined in code. |
|
||||||
|
| `VITE_API_URL` | frontend | Defaults to `http://localhost:5000`. |
|
||||||
|
| `VITE_ALLOWED_HOSTS` | Vite dev server | Comma-separated host allowlist. |
|
||||||
|
| `PLAYWRIGHT_BASE_URL` | Playwright | Defaults to `http://localhost:3010`; the e2e runner starts Vite on this URL. |
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
Docker dev stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
Separate terminals:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:backend
|
||||||
|
npm run dev:frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Default local endpoints:
|
||||||
|
- Backend: `http://localhost:5000`
|
||||||
|
- Frontend through Docker compose port mapping: `http://localhost:3010`
|
||||||
|
- Frontend direct Vite default: `http://localhost:5173`
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
Root entry points:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm run audit
|
||||||
|
npm run build:backend
|
||||||
|
npm run build:frontend
|
||||||
|
npm run build
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run test:e2e` uses `frontend/scripts/run-playwright.mjs` to start Vite, run
|
||||||
|
Playwright, and shut Vite down cleanly. Pass Playwright flags after `--`, for
|
||||||
|
example `npm run test:e2e -- --reporter=list --workers=1`.
|
||||||
|
|
||||||
|
Migration checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate:status
|
||||||
|
npm run db:migrate:verify
|
||||||
|
npm run db:migrate:stale:check
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not run `npm run db:migrate` against a shared or production database unless that is the explicit operator task.
|
||||||
|
|
||||||
|
## Common Troubleshooting
|
||||||
|
- `DATABASE_URL is required`: export/set `DATABASE_URL` in the shell before root migration commands.
|
||||||
|
- `psql executable was not found in PATH`: install PostgreSQL client tools.
|
||||||
|
- Frontend cannot reach backend: confirm `VITE_API_URL`, backend port `5000`, and backend `ALLOWED_ORIGINS`.
|
||||||
|
- Playwright starts the frontend but API calls fail: start the backend separately or use the Docker dev stack.
|
||||||
|
- CORS blocked origin: add the exact frontend origin to `ALLOWED_ORIGINS`.
|
||||||
|
|
||||||
|
## Before Finishing Work
|
||||||
|
- Re-read `AGENTS.md` and any relevant deeper doc.
|
||||||
|
- Run the smallest useful checks first.
|
||||||
|
- For API behavior changes, add/update Jest tests with negative cases.
|
||||||
|
- For user-facing UI behavior changes, add/update focused Playwright coverage.
|
||||||
|
- Summarize any command that failed, whether it appears pre-existing, and the unresolved risk.
|
||||||
71
docs/GITEA_PR_WORKFLOW.md
Normal file
71
docs/GITEA_PR_WORKFLOW.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# Gitea PR Workflow
|
||||||
|
|
||||||
|
Use this workflow when creating or merging PRs for this repo. It is designed for Codex and local operators to use the same commands without storing secrets in git.
|
||||||
|
|
||||||
|
## One-time token setup
|
||||||
|
|
||||||
|
Create a Gitea access token with repository pull request permissions, then set it only in the shell or user environment:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GITEA_BASE_URL = "http://192.168.7.78:3000"
|
||||||
|
$env:GITEA_TOKEN = "<token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
For Codex sandbox sessions, use the ignored local env file when inherited environment variables are not visible:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
@"
|
||||||
|
GITEA_BASE_URL=http://192.168.7.78:3000
|
||||||
|
GITEA_TOKEN=<token>
|
||||||
|
"@ | Set-Content .codex-local.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not commit tokens, paste them into docs, or print them in logs.
|
||||||
|
|
||||||
|
Check auth:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run pr:auth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create a PR
|
||||||
|
|
||||||
|
1. Push the branch first.
|
||||||
|
2. Inspect the cumulative branch diff against the PR target:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git log <base>..HEAD --oneline --decorate
|
||||||
|
git diff --stat <base>..HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Write the PR body to a temporary local file. Include the coordination record required by `PROJECT_INSTRUCTIONS.md`.
|
||||||
|
4. Create the PR:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run pr:create -- --base <base> --title "<title>" --body-file <body-file>
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper checks for an existing open PR with the same base/head and returns it instead of creating a duplicate.
|
||||||
|
If the PR body needs to be changed after creation, update it from a body file:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run pr:update -- --number <pr-number> --body-file <body-file>
|
||||||
|
```
|
||||||
|
|
||||||
|
For stacked work, pass the parent PR branch as `<base>`. For standalone work, pass `main`.
|
||||||
|
|
||||||
|
## View or merge a PR
|
||||||
|
|
||||||
|
View:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run pr:view -- --number <pr-number>
|
||||||
|
```
|
||||||
|
|
||||||
|
Merge after explicit operator approval and required checks:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run pr:merge -- --number <pr-number> --method merge --delete-branch --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper refuses to merge without `--yes`. Use `--method squash` or `--method rebase` only when that is the intended repo workflow.
|
||||||
36
docs/PLANS.md
Normal file
36
docs/PLANS.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Planning Template
|
||||||
|
|
||||||
|
Use this template for multi-step features, refactors, risky bugfixes, or DB work. Keep plans short and update them as evidence changes.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
- What user-visible or operator-visible outcome should be true?
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Relevant files, routes, data tables, docs, tests, and prior decisions.
|
||||||
|
- Current behavior and desired behavior.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- External DB only; migrations go in `packages/db/migrations`.
|
||||||
|
- No cron, workers, or background jobs.
|
||||||
|
- RBAC must be enforced server-side.
|
||||||
|
- No secrets, receipt bytes, or full invite codes in logs.
|
||||||
|
- Preserve current behavior outside the target area.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
1. Recon and evidence.
|
||||||
|
2. Minimal design.
|
||||||
|
3. Implementation slice.
|
||||||
|
4. Tests and verification.
|
||||||
|
5. Documentation update.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Commands to run.
|
||||||
|
- Manual checks needed.
|
||||||
|
- Negative cases to cover.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
- Files or migrations that would need reverting.
|
||||||
|
- Data or operator action needed, if any.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
- Only questions that materially affect architecture, runtime behavior, public APIs, data storage, security, deployment, package manager, or dependency footprint.
|
||||||
61
docs/PROJECT_MAP.md
Normal file
61
docs/PROJECT_MAP.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Project Map
|
||||||
|
|
||||||
|
This is the fast orientation map for Fiddy.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Backend: Express 5 on Node 20, CommonJS modules, PostgreSQL via `pg`.
|
||||||
|
- Frontend: React 19 + Vite, mostly JSX with partial TypeScript.
|
||||||
|
- Package manager: npm.
|
||||||
|
- Database: external on-prem Postgres. Migrations are canonical in `packages/db/migrations`.
|
||||||
|
|
||||||
|
## Root
|
||||||
|
- `PROJECT_INSTRUCTIONS.md`: source-of-truth constraints and delivery contract.
|
||||||
|
- `AGENTS.md`: concise Codex/human working guide.
|
||||||
|
- `DEBUGGING_INSTRUCTIONS.md`: required bugfix workflow.
|
||||||
|
- `package.json`: root DB, test, lint, typecheck, build, and e2e command entry points.
|
||||||
|
- `docker-compose.dev.yml`: local app containers with backend env loaded from `backend/.env`.
|
||||||
|
- `.gitea/workflows`: deploy workflows for `main` and `main-new`.
|
||||||
|
- `.github/copilot-instructions.md`: compatibility shim that points back to root instructions.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
- `backend/server.js`: starts the Express app.
|
||||||
|
- `backend/app.js`: middleware, CORS, route mounting, and error handling.
|
||||||
|
- `backend/build.js`: copies runtime backend files into `backend/dist` for the existing backend build script.
|
||||||
|
- `backend/routes`: Express routers.
|
||||||
|
- `backend/controllers`: request handlers.
|
||||||
|
- `backend/models`: database query modules.
|
||||||
|
- `backend/services`: domain service logic where present.
|
||||||
|
- `backend/middleware`: auth, optional auth, RBAC, request IDs, rate limiting, and image upload processing.
|
||||||
|
- `backend/utils`: logging, HTTP response helpers, cookies, redaction.
|
||||||
|
- `backend/tests`: Jest/Supertest tests run from the root Jest config.
|
||||||
|
- `backend/migrations`: legacy/reference SQL only; do not add new canonical migrations here.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
- `frontend/src/main.tsx`: browser entry point.
|
||||||
|
- `frontend/src/App.jsx`: top-level routing and providers.
|
||||||
|
- `frontend/src/config.ts`: API base URL.
|
||||||
|
- `frontend/src/api`: API wrappers and shared Axios client.
|
||||||
|
- `frontend/src/context`: app state providers.
|
||||||
|
- `frontend/src/hooks`: reusable UI-facing hooks.
|
||||||
|
- `frontend/src/pages`: route-level pages.
|
||||||
|
- `frontend/src/components`: shared and domain UI components.
|
||||||
|
- `frontend/src/styles`: global, page, component, and theme CSS.
|
||||||
|
- `frontend/tests`: Playwright e2e tests.
|
||||||
|
|
||||||
|
## Database and Scripts
|
||||||
|
- `packages/db/migrations`: canonical SQL migration set.
|
||||||
|
- `packages/db/migrations/stale-files.json`: known skipped/stale migration filenames.
|
||||||
|
- `scripts/db-migrate*.js`: migration apply/status/verify/new/stale helpers.
|
||||||
|
- `docs/DB_MIGRATION_WORKFLOW.md`: operator runbook for DB changes.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- `docs/DEVELOPMENT.md`: setup, run, verification, and troubleshooting.
|
||||||
|
- `docs/AGENTIC_CONTRACT_MAP.md`: maps Next.js-oriented architecture language to the current Express/Vite stack.
|
||||||
|
- `docs/PLANS.md`: lightweight template for multi-step work.
|
||||||
|
- `docs/architecture`, `docs/features`, `docs/guides`, `docs/migration`: deeper reference docs.
|
||||||
|
- `docs/archive`: historical implementation notes; useful context, not necessarily current.
|
||||||
|
|
||||||
|
## Known Maintainability Hotspots
|
||||||
|
- `frontend/src/pages/GroceryList.jsx` is large and should be split only during focused UI work.
|
||||||
|
- `frontend/src/components/manage/ManageHousehold.jsx`, `backend/services/group-invites.service.js`, and `backend/models/group-invites.model.js` are also large enough to review carefully before editing.
|
||||||
|
- Some older README/guides may describe pre-session or pre-household behavior; verify against current code and root instructions before relying on them.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user