Compare commits
No commits in common. "67d681114fe372efc7bf03db06739c81906a1a39" and "1281c91c28d6437ef09062f4a0c62e89ba4252c3" have entirely different histories.
67d681114f
...
1281c91c28
@ -1,129 +0,0 @@
|
|||||||
name: Build & Deploy Costco Grocery List
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main-new" ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: git.nicosaya.com/nalalangan/costco-grocery-list
|
|
||||||
IMAGE_TAG: main-new
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# 🔹 BACKEND TESTS
|
|
||||||
# -------------------------
|
|
||||||
- name: Install backend dependencies
|
|
||||||
working-directory: backend
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run backend tests
|
|
||||||
working-directory: backend
|
|
||||||
run: npm test --if-present
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# 🔹 Docker Login
|
|
||||||
# -------------------------
|
|
||||||
- name: Docker login
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY \
|
|
||||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# 🔹 Build Backend Image
|
|
||||||
# -------------------------
|
|
||||||
- name: Build Backend Image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t $REGISTRY/backend:${{ github.sha }} \
|
|
||||||
-t $REGISTRY/backend:${{ env.IMAGE_TAG }} \
|
|
||||||
-f backend/Dockerfile backend/
|
|
||||||
|
|
||||||
- name: Push Backend Image
|
|
||||||
run: |
|
|
||||||
docker push $REGISTRY/backend:${{ github.sha }}
|
|
||||||
docker push $REGISTRY/backend:${{ env.IMAGE_TAG }}
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# 🔹 Build Frontend Image
|
|
||||||
# -------------------------
|
|
||||||
- name: Build Frontend Image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t $REGISTRY/frontend:${{ github.sha }} \
|
|
||||||
-t $REGISTRY/frontend:${{ env.IMAGE_TAG }} \
|
|
||||||
-f frontend/Dockerfile.dev frontend/
|
|
||||||
|
|
||||||
- name: Push Frontend Image
|
|
||||||
run: |
|
|
||||||
docker push $REGISTRY/frontend:${{ github.sha }}
|
|
||||||
docker push $REGISTRY/frontend:${{ env.IMAGE_TAG }}
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Install SSH key
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
# 1. Upload docker-compose.yml to the production directory
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
- name: Upload docker-compose.yml
|
|
||||||
run: |
|
|
||||||
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "mkdir -p /opt/costco-app-new"
|
|
||||||
scp docker-compose.new.yml \
|
|
||||||
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/costco-app-new/docker-compose.yml
|
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
# 2. Deploy using the uploaded compose file
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
- name: Deploy via SSH
|
|
||||||
run: |
|
|
||||||
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
|
||||||
cd /opt/costco-app-new
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d --remove-orphans
|
|
||||||
docker image prune -f
|
|
||||||
EOF
|
|
||||||
|
|
||||||
notify:
|
|
||||||
needs: deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: always()
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Notify ntfy
|
|
||||||
run: |
|
|
||||||
STATUS="${{ needs.deploy.result }}"
|
|
||||||
echo "Deployment job finished with status: $STATUS"
|
|
||||||
|
|
||||||
if [ "$STATUS" = "success" ]; then
|
|
||||||
MSG="🚀 Costco App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}"
|
|
||||||
else
|
|
||||||
MSG="❌ Costco App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
curl -d "$MSG" \
|
|
||||||
https://ntfy.nicosaya.com/gitea
|
|
||||||
|
|
||||||
|
|
||||||
173
.github/copilot-instructions.md
vendored
173
.github/copilot-instructions.md
vendored
@ -7,142 +7,40 @@ This is a full-stack grocery list management app with **role-based access contro
|
|||||||
- **Frontend**: React 19 + TypeScript + Vite (port 3000/5173)
|
- **Frontend**: React 19 + TypeScript + Vite (port 3000/5173)
|
||||||
- **Deployment**: Docker Compose with separate dev/prod configurations
|
- **Deployment**: Docker Compose with separate dev/prod configurations
|
||||||
|
|
||||||
## Mobile-First Design Principles
|
|
||||||
|
|
||||||
**CRITICAL**: All UI components MUST be designed for both mobile and desktop from the start.
|
|
||||||
|
|
||||||
**Responsive Design Requirements**:
|
|
||||||
- Use relative units (`rem`, `em`, `%`, `vh/vw`) over fixed pixels where possible
|
|
||||||
- Implement mobile breakpoints: `480px`, `768px`, `1024px`
|
|
||||||
- Test layouts at: 320px (small phone), 375px (phone), 768px (tablet), 1024px+ (desktop)
|
|
||||||
- Avoid horizontal scrolling on mobile devices
|
|
||||||
- Touch targets minimum 44x44px for mobile usability
|
|
||||||
- Use `max-width` with `margin: 0 auto` for content containers
|
|
||||||
- Stack elements vertically on mobile, use flexbox/grid for larger screens
|
|
||||||
- Hide/collapse navigation into hamburger menus on mobile
|
|
||||||
- Ensure modals/dropdowns work well on small screens
|
|
||||||
|
|
||||||
**Common Patterns**:
|
|
||||||
```css
|
|
||||||
/* Mobile-first approach */
|
|
||||||
.container {
|
|
||||||
padding: 1rem;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.container {
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Design Patterns
|
### Key Design Patterns
|
||||||
|
|
||||||
**Dual RBAC System** - Two separate role hierarchies:
|
**Three-tier RBAC system** (`viewer`, `editor`, `admin`):
|
||||||
|
- `viewer`: Read-only access to grocery lists
|
||||||
**1. System Roles** (users.role column):
|
- `editor`: Can add items and mark as bought
|
||||||
- `system_admin`: Access to Admin Panel for system-wide management (stores, users)
|
- `admin`: Full user management via admin panel
|
||||||
- `user`: Regular system user (default for new registrations)
|
- 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)
|
||||||
- Defined in [backend/models/user.model.js](backend/models/user.model.js)
|
|
||||||
- Used for Admin Panel access control
|
|
||||||
|
|
||||||
**2. Household Roles** (household_members.role column):
|
|
||||||
- `admin`: Can manage household members, change roles, delete household
|
|
||||||
- `user`: Can add/edit items, mark as bought (standard member permissions)
|
|
||||||
- Defined per household membership
|
|
||||||
- Used for household-level permissions (item management, member management)
|
|
||||||
|
|
||||||
**Important**: Always distinguish between system role and household role:
|
|
||||||
- **System role**: From `AuthContext` or `req.user.role` - controls Admin Panel access
|
|
||||||
- **Household role**: From `activeHousehold.role` or `household_members.role` - controls household operations
|
|
||||||
|
|
||||||
**Middleware chain pattern** for protected routes:
|
**Middleware chain pattern** for protected routes:
|
||||||
```javascript
|
```javascript
|
||||||
// System-level protection
|
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem);
|
||||||
router.get("/stores", auth, requireRole("system_admin"), controller.getAllStores);
|
|
||||||
|
|
||||||
// Household-level checks done in controller
|
|
||||||
router.post("/lists/:householdId/items", auth, controller.addItem);
|
|
||||||
```
|
```
|
||||||
- `auth` middleware extracts JWT from `Authorization: Bearer <token>` header
|
- `auth` middleware extracts JWT from `Authorization: Bearer <token>` header
|
||||||
- `requireRole` checks system role only
|
- `requireRole` checks if user's role matches allowed roles
|
||||||
- Household role checks happen in controllers using `household.model.js` methods
|
- See [backend/routes/list.routes.js](backend/routes/list.routes.js) for examples
|
||||||
|
|
||||||
**Frontend route protection**:
|
**Frontend route protection**:
|
||||||
- `<PrivateRoute>`: Requires authentication, redirects to `/login` if no token
|
- `<PrivateRoute>`: Requires authentication, redirects to `/login` if no token
|
||||||
- `<RoleGuard allowed={[ROLES.SYSTEM_ADMIN]}>`: Requires system_admin role for Admin Panel
|
- `<RoleGuard allowed={[ROLES.ADMIN]}>`: Requires specific role(s), redirects to `/` if unauthorized
|
||||||
- Household permissions: Check `activeHousehold.role` in components (not route-level)
|
|
||||||
- Example in [frontend/src/App.jsx](frontend/src/App.jsx)
|
- Example in [frontend/src/App.jsx](frontend/src/App.jsx)
|
||||||
|
|
||||||
**Multi-Household Architecture**:
|
|
||||||
- Users can belong to multiple households
|
|
||||||
- Each household has its own grocery lists, stores, and item classifications
|
|
||||||
- `HouseholdContext` manages active household selection
|
|
||||||
- All list operations are scoped to the active household
|
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
**PostgreSQL server runs externally** - not in Docker Compose. Connection configured in [backend/.env](backend/.env) via standard environment variables.
|
**PostgreSQL server runs externally** - not in Docker Compose. Connection configured in [backend/.env](backend/.env) via standard environment variables.
|
||||||
|
|
||||||
**Core Tables**:
|
**Tables** (inferred from models, no formal migrations):
|
||||||
|
- **users**: `id`, `username`, `password` (bcrypt hashed), `name`, `role`
|
||||||
**users** - System users
|
- **grocery_list**: `id`, `item_name`, `quantity`, `bought`, `added_by`
|
||||||
- `id` (PK), `username`, `password` (bcrypt), `name`, `display_name`
|
- **grocery_history**: Junction table tracking which users added which items
|
||||||
- `role`: `system_admin` | `user` (default: `viewer` - legacy)
|
|
||||||
- System-level authentication and authorization
|
|
||||||
|
|
||||||
**households** - Household entities
|
|
||||||
- `id` (PK), `name`, `invite_code`, `created_by`, `created_at`
|
|
||||||
- Each household is independent with own lists and members
|
|
||||||
|
|
||||||
**household_members** - Junction table (users ↔ households)
|
|
||||||
- `id` (PK), `household_id` (FK), `user_id` (FK), `role`, `joined_at`
|
|
||||||
- `role`: `admin` | `user` (household-level permissions)
|
|
||||||
- One user can belong to multiple households with different roles
|
|
||||||
|
|
||||||
**items** - Master item catalog
|
|
||||||
- `id` (PK), `name`, `default_image`, `default_image_mime_type`, `usage_count`
|
|
||||||
- Shared across all households, case-insensitive unique names
|
|
||||||
|
|
||||||
**stores** - Store definitions (system-wide)
|
|
||||||
- `id` (PK), `name`, `default_zones` (JSONB array)
|
|
||||||
- Managed by system_admin in Admin Panel
|
|
||||||
|
|
||||||
**household_stores** - Stores available to each household
|
|
||||||
- `id` (PK), `household_id` (FK), `store_id` (FK), `is_default`
|
|
||||||
- Links households to stores they use
|
|
||||||
|
|
||||||
**household_lists** - Grocery list items per household
|
|
||||||
- `id` (PK), `household_id` (FK), `store_id` (FK), `item_id` (FK)
|
|
||||||
- `quantity`, `bought`, `custom_image`, `custom_image_mime_type`
|
|
||||||
- `added_by`, `modified_on`
|
|
||||||
- Scoped to household + store combination
|
|
||||||
|
|
||||||
**household_list_history** - Tracks quantity contributions
|
|
||||||
- `id` (PK), `household_list_id` (FK), `quantity`, `added_by`, `added_on`
|
|
||||||
- Multi-contributor tracking (who added how much)
|
|
||||||
|
|
||||||
**household_item_classifications** - Item classifications per household/store
|
|
||||||
- `id` (PK), `household_id`, `store_id`, `item_id`
|
|
||||||
- `item_type`, `item_group`, `zone`, `confidence`, `source`
|
|
||||||
- Household-specific overrides of global classifications
|
|
||||||
|
|
||||||
**item_classification** - Global item classifications
|
|
||||||
- `id` (PK), `item_type`, `item_group`, `zone`, `confidence`, `source`
|
|
||||||
- System-wide defaults for item categorization
|
|
||||||
|
|
||||||
**Legacy Tables** (deprecated, may still exist):
|
|
||||||
- `grocery_list`, `grocery_history` - Old single-household implementation
|
|
||||||
|
|
||||||
**Important patterns**:
|
**Important patterns**:
|
||||||
- No formal migration system - schema changes are manual SQL
|
- No migration system - schema changes are manual SQL
|
||||||
- Items use case-insensitive matching (`ILIKE`) to prevent duplicates
|
- Items use case-insensitive matching (`ILIKE`) to prevent duplicates
|
||||||
- JOINs with `ARRAY_AGG` for multi-contributor queries (see [backend/models/list.model.v2.js](backend/models/list.model.v2.js))
|
- JOINs with `ARRAY_AGG` for multi-contributor queries (see [backend/models/list.model.js](backend/models/list.model.js))
|
||||||
- All list operations require `household_id` parameter for scoping
|
|
||||||
- Image storage: `bytea` columns for images with separate MIME type columns
|
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
@ -239,16 +137,11 @@ See [.gitea/workflows/deploy.yml](.gitea/workflows/deploy.yml) for full workflow
|
|||||||
|
|
||||||
## Authentication Flow
|
## Authentication Flow
|
||||||
|
|
||||||
1. User logs in → backend returns `{token, userId, role, username}` ([backend/controllers/auth.controller.js](backend/controllers/auth.controller.js))
|
1. User logs in → backend returns `{token, role, username}` ([backend/controllers/auth.controller.js](backend/controllers/auth.controller.js))
|
||||||
- `role` is the **system role** (`system_admin` or `user`)
|
|
||||||
2. Frontend stores in `localStorage` and `AuthContext` ([frontend/src/context/AuthContext.jsx](frontend/src/context/AuthContext.jsx))
|
2. Frontend stores in `localStorage` and `AuthContext` ([frontend/src/context/AuthContext.jsx](frontend/src/context/AuthContext.jsx))
|
||||||
3. `HouseholdContext` loads user's households and sets active household
|
3. Axios interceptor auto-attaches `Authorization: Bearer <token>` header ([frontend/src/api/axios.js](frontend/src/api/axios.js))
|
||||||
- Active household includes `household.role` (the **household role**)
|
4. Backend validates JWT on protected routes ([backend/middleware/auth.js](backend/middleware/auth.js))
|
||||||
4. Axios interceptor auto-attaches `Authorization: Bearer <token>` header ([frontend/src/api/axios.js](frontend/src/api/axios.js))
|
5. On 401 "Invalid or expired token" response, frontend clears storage and redirects to login
|
||||||
5. Backend validates JWT on protected routes ([backend/middleware/auth.js](backend/middleware/auth.js))
|
|
||||||
- Sets `req.user = { id, role, username }` with **system role**
|
|
||||||
6. Controllers check household membership/role using [backend/models/household.model.js](backend/models/household.model.js)
|
|
||||||
7. On 401 "Invalid or expired token" response, frontend clears storage and redirects to login
|
|
||||||
|
|
||||||
## Critical Conventions
|
## Critical Conventions
|
||||||
|
|
||||||
@ -274,36 +167,16 @@ See [.gitea/workflows/deploy.yml](.gitea/workflows/deploy.yml) for full workflow
|
|||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
**Add a new protected route**:
|
**Add a new protected route**:
|
||||||
1. Backend: Add route with `auth` middleware (+ `requireRole(...)` if system role check needed)
|
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 `<RoleGuard>` for Admin Panel)
|
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**:
|
**Access user info in backend controller**:
|
||||||
```javascript
|
```javascript
|
||||||
const { id, role } = req.user; // Set by auth middleware (system role)
|
const { id, role } = req.user; // Set by auth middleware
|
||||||
const userId = req.user.id;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check household permissions in backend controller**:
|
|
||||||
```javascript
|
|
||||||
const householdRole = await household.getUserRole(householdId, userId);
|
|
||||||
if (!householdRole) return res.status(403).json({ message: "Not a member of this household" });
|
|
||||||
if (householdRole !== 'admin') return res.status(403).json({ message: "Household admin required" });
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check household permissions in frontend**:
|
|
||||||
```javascript
|
|
||||||
const { activeHousehold } = useContext(HouseholdContext);
|
|
||||||
const householdRole = activeHousehold?.role; // 'admin' or 'user'
|
|
||||||
|
|
||||||
// Allow all members except viewers (no viewer role in households)
|
|
||||||
const canManageItems = householdRole && householdRole !== 'viewer'; // Usually just check if role exists
|
|
||||||
|
|
||||||
// Admin-only actions
|
|
||||||
const canManageMembers = householdRole === 'admin';
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Query grocery items with contributors**:
|
**Query grocery items with contributors**:
|
||||||
Use the JOIN pattern in [backend/models/list.model.v2.js](backend/models/list.model.v2.js) - aggregates user names via `household_list_history` table.
|
Use the JOIN pattern in [backend/models/list.model.js](backend/models/list.model.js) - aggregates user names via `grocery_history` table.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,10 @@
|
|||||||
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 app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Serve static files from public directory
|
|
||||||
app.use('/test', express.static(path.join(__dirname, 'public')));
|
|
||||||
|
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim());
|
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim());
|
||||||
console.log("Allowed Origins:", allowedOrigins);
|
console.log("Allowed Origins:", allowedOrigins);
|
||||||
app.use(
|
app.use(
|
||||||
@ -18,10 +14,9 @@ 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);
|
||||||
console.error(`🚫 CORS blocked origin: ${origin}`);
|
callback(new Error("Not allowed by CORS"));
|
||||||
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
|
|
||||||
},
|
},
|
||||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
methods: ["GET", "POST", "PUT", "DELETE"],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -48,10 +43,4 @@ app.use("/users", usersRoutes);
|
|||||||
const configRoutes = require("./routes/config.routes");
|
const configRoutes = require("./routes/config.routes");
|
||||||
app.use("/config", configRoutes);
|
app.use("/config", configRoutes);
|
||||||
|
|
||||||
const householdsRoutes = require("./routes/households.routes");
|
|
||||||
app.use("/households", householdsRoutes);
|
|
||||||
|
|
||||||
const storesRoutes = require("./routes/stores.routes");
|
|
||||||
app.use("/stores", storesRoutes);
|
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
@ -40,5 +40,5 @@ exports.login = async (req, res) => {
|
|||||||
{ expiresIn: "1 year" }
|
{ expiresIn: "1 year" }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ token, userId: user.id, username, role: user.role });
|
res.json({ token, username, role: user.role });
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,207 +0,0 @@
|
|||||||
const householdModel = require("../models/household.model");
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
console.error("Get user households error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch households" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get household details
|
|
||||||
exports.getHousehold = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const household = await householdModel.getHouseholdById(
|
|
||||||
req.params.householdId,
|
|
||||||
req.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!household) {
|
|
||||||
return res.status(404).json({ error: "Household not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(household);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Get household error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch household" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create new household
|
|
||||||
exports.createHousehold = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { name } = req.body;
|
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
|
||||||
return res.status(400).json({ error: "Household name is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name.length > 100) {
|
|
||||||
return res.status(400).json({ error: "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) {
|
|
||||||
console.error("Create household error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to create household" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update household
|
|
||||||
exports.updateHousehold = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { name } = req.body;
|
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
|
||||||
return res.status(400).json({ error: "Household name is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name.length > 100) {
|
|
||||||
return res.status(400).json({ error: "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) {
|
|
||||||
console.error("Update household error:", error);
|
|
||||||
res.status(500).json({ error: "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) {
|
|
||||||
console.error("Delete household error:", error);
|
|
||||||
res.status(500).json({ error: "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) {
|
|
||||||
console.error("Refresh invite code error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to refresh invite code" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Join household via invite code
|
|
||||||
exports.joinHousehold = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { inviteCode } = req.params;
|
|
||||||
if (!inviteCode) return res.status(400).json({ error: "Invite code is required" });
|
|
||||||
|
|
||||||
const result = await householdModel.joinHousehold(
|
|
||||||
inviteCode.toUpperCase(),
|
|
||||||
req.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result) return res.status(404).json({ error: "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) {
|
|
||||||
console.error("Join household error:", error);
|
|
||||||
res.status(500).json({ error: "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) {
|
|
||||||
console.error("Get members error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch members" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update member role
|
|
||||||
exports.updateMemberRole = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId } = req.params;
|
|
||||||
const { role } = req.body;
|
|
||||||
|
|
||||||
if (!role || !['admin', 'user'].includes(role)) {
|
|
||||||
return res.status(400).json({ error: "Invalid role. Must be 'admin' or 'user'" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Can't change own role
|
|
||||||
if (parseInt(userId) === req.user.id) {
|
|
||||||
return res.status(400).json({ error: "Cannot change your own role" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await householdModel.updateMemberRole(
|
|
||||||
req.params.householdId,
|
|
||||||
userId,
|
|
||||||
role
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: "Member role updated successfully",
|
|
||||||
member: updated
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Update member role error:", error);
|
|
||||||
res.status(500).json({ error: "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 && req.household.role !== 'admin') {
|
|
||||||
return res.status(403).json({
|
|
||||||
error: "Only admins can remove other members"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await householdModel.removeMember(req.params.householdId, userId);
|
|
||||||
|
|
||||||
res.json({ message: "Member removed successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Remove member error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to remove member" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,324 +0,0 @@
|
|||||||
const List = require("../models/list.model.v2");
|
|
||||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list items for household and store
|
|
||||||
* GET /households/:householdId/stores/:storeId/list
|
|
||||||
*/
|
|
||||||
exports.getList = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const items = await List.getHouseholdStoreList(householdId, storeId);
|
|
||||||
res.json({ items });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting list:", error);
|
|
||||||
res.status(500).json({ message: "Failed to get list" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get specific item by name
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.getItemByName = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const { item_name } = req.query;
|
|
||||||
|
|
||||||
if (!item_name) {
|
|
||||||
return res.status(400).json({ message: "Item name is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
if (!item) {
|
|
||||||
return res.status(404).json({ message: "Item not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(item);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting item:", error);
|
|
||||||
res.status(500).json({ message: "Failed to get item" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add or update item in household store list
|
|
||||||
* POST /households/:householdId/stores/:storeId/list/add
|
|
||||||
*/
|
|
||||||
exports.addItem = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const { item_name, quantity, notes } = req.body;
|
|
||||||
const userId = req.user.id;
|
|
||||||
|
|
||||||
if (!item_name || item_name.trim() === "") {
|
|
||||||
return res.status(400).json({ message: "Item name is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get processed image if uploaded
|
|
||||||
const imageBuffer = req.processedImage?.buffer || null;
|
|
||||||
const mimeType = req.processedImage?.mimeType || null;
|
|
||||||
|
|
||||||
const result = await List.addOrUpdateItem(
|
|
||||||
householdId,
|
|
||||||
storeId,
|
|
||||||
item_name,
|
|
||||||
quantity || "1",
|
|
||||||
userId,
|
|
||||||
imageBuffer,
|
|
||||||
mimeType,
|
|
||||||
notes
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add history record
|
|
||||||
await List.addHistoryRecord(result.listId, quantity || "1", userId);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: result.isNew ? "Item added" : "Item updated",
|
|
||||||
item: {
|
|
||||||
id: result.listId,
|
|
||||||
item_name: result.itemName,
|
|
||||||
quantity: quantity || "1",
|
|
||||||
bought: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error adding item:", error);
|
|
||||||
res.status(500).json({ message: "Failed to add item" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark item as bought or unbought
|
|
||||||
* PATCH /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.markBought = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const { item_name, bought, quantity_bought } = req.body;
|
|
||||||
|
|
||||||
if (!item_name) return res.status(400).json({ message: "Item name is required" });
|
|
||||||
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
console.log('requesting mark ', { item, householdId, storeId, item_name, bought, quantity_bought });
|
|
||||||
if (!item) return res.status(404).json({ message: "Item not found" });
|
|
||||||
|
|
||||||
|
|
||||||
// Update bought status (with optional partial purchase)
|
|
||||||
await List.setBought(item.id, bought, quantity_bought);
|
|
||||||
|
|
||||||
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error marking bought:", error);
|
|
||||||
res.status(500).json({ message: "Failed to update item" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update item details (quantity, notes)
|
|
||||||
* PUT /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.updateItem = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const { item_name, quantity, notes } = req.body;
|
|
||||||
|
|
||||||
if (!item_name) {
|
|
||||||
return res.status(400).json({ message: "Item name is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the list item
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
if (!item) {
|
|
||||||
return res.status(404).json({ message: "Item not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update item
|
|
||||||
await List.updateItem(item.id, item_name, quantity, notes);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: "Item updated",
|
|
||||||
item: {
|
|
||||||
id: item.id,
|
|
||||||
item_name,
|
|
||||||
quantity,
|
|
||||||
notes
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating item:", error);
|
|
||||||
res.status(500).json({ message: "Failed to update item" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete item from list
|
|
||||||
* DELETE /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.deleteItem = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const { item_name } = req.body;
|
|
||||||
|
|
||||||
if (!item_name) {
|
|
||||||
return res.status(400).json({ message: "Item name is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the list item
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
if (!item) {
|
|
||||||
return res.status(404).json({ message: "Item not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
await List.deleteItem(item.id);
|
|
||||||
|
|
||||||
res.json({ message: "Item deleted" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting item:", error);
|
|
||||||
res.status(500).json({ message: "Failed to delete item" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get item suggestions based on query
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/suggestions
|
|
||||||
*/
|
|
||||||
exports.getSuggestions = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const { query } = req.query;
|
|
||||||
|
|
||||||
const suggestions = await List.getSuggestions(query || "", householdId, storeId);
|
|
||||||
res.json(suggestions);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting suggestions:", error);
|
|
||||||
res.status(500).json({ message: "Failed to get suggestions" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recently bought items
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/recent
|
|
||||||
*/
|
|
||||||
exports.getRecentlyBought = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const items = await List.getRecentlyBoughtItems(householdId, storeId);
|
|
||||||
res.json(items);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting recent items:", error);
|
|
||||||
res.status(500).json({ message: "Failed to get recent items" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get item classification
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/classification
|
|
||||||
*/
|
|
||||||
exports.getClassification = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const { item_name } = req.query;
|
|
||||||
|
|
||||||
if (!item_name) {
|
|
||||||
return res.status(400).json({ message: "Item name is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get item ID from name
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
if (!item) {
|
|
||||||
return res.json({ classification: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const classification = await List.getClassification(householdId, item.item_id);
|
|
||||||
res.json({ classification });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting classification:", error);
|
|
||||||
res.status(500).json({ message: "Failed to get classification" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set/update item classification
|
|
||||||
* POST /households/:householdId/stores/:storeId/list/classification
|
|
||||||
*/
|
|
||||||
exports.setClassification = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const { item_name, classification } = req.body;
|
|
||||||
|
|
||||||
if (!item_name) {
|
|
||||||
return res.status(400).json({ message: "Item name is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!classification) {
|
|
||||||
return res.status(400).json({ message: "Classification is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate classification
|
|
||||||
const validClassifications = ['produce', 'dairy', 'meat', 'bakery', 'frozen', 'pantry', 'snacks', 'beverages', 'household', 'other'];
|
|
||||||
if (!validClassifications.includes(classification)) {
|
|
||||||
return res.status(400).json({ message: "Invalid classification value" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get item - add to master items if not exists
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
let itemId;
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
// Item doesn't exist in list, need to get from items table or create
|
|
||||||
const itemResult = await List.addOrUpdateItem(
|
|
||||||
householdId,
|
|
||||||
storeId,
|
|
||||||
item_name,
|
|
||||||
"1",
|
|
||||||
req.user.id,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
itemId = itemResult.itemId;
|
|
||||||
} else {
|
|
||||||
itemId = item.item_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set classification (using item_type field for simplicity)
|
|
||||||
await List.upsertClassification(householdId, itemId, {
|
|
||||||
item_type: classification,
|
|
||||||
item_group: null,
|
|
||||||
zone: null
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: "Classification set", classification });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error setting classification:", error);
|
|
||||||
res.status(500).json({ message: "Failed to set classification" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update item image
|
|
||||||
* POST /households/:householdId/stores/:storeId/list/update-image
|
|
||||||
*/
|
|
||||||
exports.updateItemImage = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const { item_name, quantity } = req.body;
|
|
||||||
const userId = req.user.id;
|
|
||||||
|
|
||||||
// Get processed image
|
|
||||||
const imageBuffer = req.processedImage?.buffer || null;
|
|
||||||
const mimeType = req.processedImage?.mimeType || null;
|
|
||||||
|
|
||||||
if (!imageBuffer) {
|
|
||||||
return res.status(400).json({ message: "No image provided" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the item with new image
|
|
||||||
await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType);
|
|
||||||
|
|
||||||
res.json({ message: "Image updated successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating image:", error);
|
|
||||||
res.status(500).json({ message: "Failed to update image" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
const storeModel = require("../models/store.model");
|
|
||||||
|
|
||||||
// Get all available stores
|
|
||||||
exports.getAllStores = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const stores = await storeModel.getAllStores();
|
|
||||||
res.json(stores);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Get all stores error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch stores" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get stores for household
|
|
||||||
exports.getHouseholdStores = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const stores = await storeModel.getHouseholdStores(req.params.householdId);
|
|
||||||
res.json(stores);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Get household stores error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch household stores" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add store to household
|
|
||||||
exports.addStoreToHousehold = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { storeId, isDefault } = req.body;
|
|
||||||
// console.log("Adding store to household:", { householdId: req.params.householdId, storeId, isDefault });
|
|
||||||
if (!storeId) {
|
|
||||||
return res.status(400).json({ error: "Store ID is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = await storeModel.getStoreById(storeId);
|
|
||||||
if (!store) return res.status(404).json({ error: "Store not found" });
|
|
||||||
const foundStores = await storeModel.getHouseholdStores(req.params.householdId);
|
|
||||||
// if (foundStores.length == 0) isDefault = 'true';
|
|
||||||
|
|
||||||
await storeModel.addStoreToHousehold(
|
|
||||||
req.params.householdId,
|
|
||||||
storeId,
|
|
||||||
foundStores.length == 0 ? true : isDefault || false
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
message: "Store added to household successfully",
|
|
||||||
store
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Add store to household error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to add store to household" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove store from household
|
|
||||||
exports.removeStoreFromHousehold = async (req, res) => {
|
|
||||||
try {
|
|
||||||
await storeModel.removeStoreFromHousehold(
|
|
||||||
req.params.householdId,
|
|
||||||
req.params.storeId
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ message: "Store removed from household successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Remove store from household error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to remove store from household" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set default store
|
|
||||||
exports.setDefaultStore = async (req, res) => {
|
|
||||||
try {
|
|
||||||
await storeModel.setDefaultStore(
|
|
||||||
req.params.householdId,
|
|
||||||
req.params.storeId
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ message: "Default store updated successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Set default store error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to set default store" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create store (system admin only)
|
|
||||||
exports.createStore = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { name, default_zones } = req.body;
|
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
|
||||||
return res.status(400).json({ error: "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) {
|
|
||||||
console.error("Create store error:", error);
|
|
||||||
if (error.code === '23505') { // Unique violation
|
|
||||||
return res.status(400).json({ error: "Store with this name already exists" });
|
|
||||||
}
|
|
||||||
res.status(500).json({ error: "Failed to create store" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update store (system admin only)
|
|
||||||
exports.updateStore = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { name, default_zones } = req.body;
|
|
||||||
|
|
||||||
const store = await storeModel.updateStore(req.params.storeId, {
|
|
||||||
name: name?.trim(),
|
|
||||||
default_zones
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!store) {
|
|
||||||
return res.status(404).json({ error: "Store not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: "Store updated successfully",
|
|
||||||
store
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Update store error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to update store" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete store (system admin only)
|
|
||||||
exports.deleteStore = async (req, res) => {
|
|
||||||
try {
|
|
||||||
await storeModel.deleteStore(req.params.storeId);
|
|
||||||
res.json({ message: "Store deleted successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Delete store error:", error);
|
|
||||||
if (error.message.includes('in use')) {
|
|
||||||
return res.status(400).json({ error: error.message });
|
|
||||||
}
|
|
||||||
res.status(500).json({ error: "Failed to delete store" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -7,6 +7,7 @@ exports.test = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,110 +0,0 @@
|
|||||||
const householdModel = require("../models/household.model");
|
|
||||||
|
|
||||||
// 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 res.status(400).json({ error: "Household ID required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is member of household
|
|
||||||
const isMember = await householdModel.isHouseholdMember(householdId, userId);
|
|
||||||
|
|
||||||
if (!isMember) {
|
|
||||||
return res.status(403).json({
|
|
||||||
error: "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) {
|
|
||||||
console.error("Household access check error:", error);
|
|
||||||
res.status(500).json({ error: "Server error checking household access" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Middleware to require specific household role(s)
|
|
||||||
exports.requireHouseholdRole = (...allowedRoles) => {
|
|
||||||
return (req, res, next) => {
|
|
||||||
if (!req.household) {
|
|
||||||
return res.status(500).json({
|
|
||||||
error: "Household context not set. Use householdAccess middleware first."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowedRoles.includes(req.household.role)) {
|
|
||||||
return res.status(403).json({
|
|
||||||
error: `Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Middleware to require admin role in household
|
|
||||||
exports.requireHouseholdAdmin = exports.requireHouseholdRole('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 res.status(400).json({ error: "Store ID required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.household) {
|
|
||||||
return res.status(500).json({
|
|
||||||
error: "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 res.status(403).json({
|
|
||||||
error: "This household does not have access to this store."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach store info to request
|
|
||||||
req.store = {
|
|
||||||
id: storeId
|
|
||||||
};
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Store access check error:", error);
|
|
||||||
res.status(500).json({ error: "Server error checking store access" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Middleware to require system admin role
|
|
||||||
exports.requireSystemAdmin = (req, res, next) => {
|
|
||||||
if (!req.user) {
|
|
||||||
return res.status(401).json({ error: "Authentication required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.user.role !== 'system_admin') {
|
|
||||||
return res.status(403).json({
|
|
||||||
error: "Access denied. System administrator privileges required."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
# Multi-Household Architecture Migration Guide
|
|
||||||
|
|
||||||
## Pre-Migration Checklist
|
|
||||||
|
|
||||||
- [ ] **Backup Database**
|
|
||||||
```bash
|
|
||||||
pg_dump -U your_user -d grocery_list > backup_$(date +%Y%m%d_%H%M%S).sql
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Test on Staging First**
|
|
||||||
- Copy production database to staging environment
|
|
||||||
- Run migration on staging
|
|
||||||
- Verify all data migrated correctly
|
|
||||||
- Test application functionality
|
|
||||||
|
|
||||||
- [ ] **Review Migration Script**
|
|
||||||
- Read through `multi_household_architecture.sql`
|
|
||||||
- Understand each step
|
|
||||||
- Note verification queries
|
|
||||||
|
|
||||||
- [ ] **Announce Maintenance Window**
|
|
||||||
- Notify users of downtime
|
|
||||||
- Schedule during low-usage period
|
|
||||||
- Estimate 15-30 minutes for migration
|
|
||||||
|
|
||||||
## Running the Migration
|
|
||||||
|
|
||||||
### 1. Connect to Database
|
|
||||||
|
|
||||||
```bash
|
|
||||||
psql -U your_user -d grocery_list
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Run Migration
|
|
||||||
|
|
||||||
```sql
|
|
||||||
\i backend/migrations/multi_household_architecture.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
The script will:
|
|
||||||
1. ✅ Create 8 new tables
|
|
||||||
2. ✅ Create default "Main Household"
|
|
||||||
3. ✅ Create default "Costco" store
|
|
||||||
4. ✅ Migrate all users to household members
|
|
||||||
5. ✅ Extract items to master catalog
|
|
||||||
6. ✅ Migrate grocery_list → household_lists
|
|
||||||
7. ✅ Migrate classifications
|
|
||||||
8. ✅ Migrate history records
|
|
||||||
9. ✅ Update user system roles
|
|
||||||
|
|
||||||
### 3. Verify Migration
|
|
||||||
|
|
||||||
Run these queries inside psql:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Check household created
|
|
||||||
SELECT * FROM households;
|
|
||||||
|
|
||||||
-- Check all users migrated
|
|
||||||
SELECT u.username, u.role as system_role, hm.role as household_role
|
|
||||||
FROM users u
|
|
||||||
JOIN household_members hm ON u.id = hm.user_id
|
|
||||||
ORDER BY u.id;
|
|
||||||
|
|
||||||
-- Check item counts match
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(DISTINCT item_name) FROM grocery_list) as old_unique_items,
|
|
||||||
(SELECT COUNT(*) FROM items) as new_items;
|
|
||||||
|
|
||||||
-- Check list counts
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM grocery_list) as old_lists,
|
|
||||||
(SELECT COUNT(*) FROM household_lists) as new_lists;
|
|
||||||
|
|
||||||
-- Check classification counts
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM item_classification) as old_classifications,
|
|
||||||
(SELECT COUNT(*) FROM household_item_classifications) as new_classifications;
|
|
||||||
|
|
||||||
-- Check history counts
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM grocery_history) as old_history,
|
|
||||||
(SELECT COUNT(*) FROM household_list_history) as new_history;
|
|
||||||
|
|
||||||
-- Verify no data loss - check if all old items have corresponding new records
|
|
||||||
SELECT gl.item_name
|
|
||||||
FROM grocery_list gl
|
|
||||||
LEFT JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
|
|
||||||
LEFT JOIN household_lists hl ON hl.item_id = i.id
|
|
||||||
WHERE hl.id IS NULL;
|
|
||||||
-- Should return 0 rows
|
|
||||||
|
|
||||||
-- Check invite code
|
|
||||||
SELECT name, invite_code FROM households;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Test Application
|
|
||||||
|
|
||||||
- [ ] Users can log in
|
|
||||||
- [ ] Can view "Main Household" list
|
|
||||||
- [ ] Can add items
|
|
||||||
- [ ] Can mark items as bought
|
|
||||||
- [ ] History shows correctly
|
|
||||||
- [ ] Classifications preserved
|
|
||||||
- [ ] Images display correctly
|
|
||||||
|
|
||||||
## Post-Migration Cleanup
|
|
||||||
|
|
||||||
**Only after verifying everything works correctly:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Drop old tables (CAREFUL - THIS IS IRREVERSIBLE)
|
|
||||||
DROP TABLE IF EXISTS grocery_history CASCADE;
|
|
||||||
DROP TABLE IF EXISTS item_classification CASCADE;
|
|
||||||
DROP TABLE IF EXISTS grocery_list CASCADE;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
### If Migration Fails
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Inside psql during migration
|
|
||||||
ROLLBACK;
|
|
||||||
|
|
||||||
-- Then restore from backup
|
|
||||||
\q
|
|
||||||
psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### If Issues Found After Migration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Drop the database and restore
|
|
||||||
dropdb grocery_list
|
|
||||||
createdb grocery_list
|
|
||||||
psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Issues & Solutions
|
|
||||||
|
|
||||||
### Issue: Duplicate items in items table
|
|
||||||
**Cause**: Case-insensitive matching not working
|
|
||||||
**Solution**: Check item names for leading/trailing spaces
|
|
||||||
|
|
||||||
### Issue: Foreign key constraint errors
|
|
||||||
**Cause**: User or item references not found
|
|
||||||
**Solution**: Verify all users and items exist before migrating lists
|
|
||||||
|
|
||||||
### Issue: History not showing
|
|
||||||
**Cause**: household_list_id references incorrect
|
|
||||||
**Solution**: Check JOIN conditions in history migration
|
|
||||||
|
|
||||||
### Issue: Images not displaying
|
|
||||||
**Cause**: BYTEA encoding issues
|
|
||||||
**Solution**: Verify image_mime_type correctly migrated
|
|
||||||
|
|
||||||
## Migration Timeline
|
|
||||||
|
|
||||||
- **T-0**: Begin maintenance window
|
|
||||||
- **T+2min**: Backup complete
|
|
||||||
- **T+3min**: Start migration script
|
|
||||||
- **T+8min**: Migration complete (for ~1000 items)
|
|
||||||
- **T+10min**: Run verification queries
|
|
||||||
- **T+15min**: Test application functionality
|
|
||||||
- **T+20min**: If successful, announce completion
|
|
||||||
- **T+30min**: End maintenance window
|
|
||||||
|
|
||||||
## Data Integrity Checks
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Ensure all users belong to at least one household
|
|
||||||
SELECT u.id, u.username
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN household_members hm ON u.id = hm.user_id
|
|
||||||
WHERE hm.id IS NULL;
|
|
||||||
-- Should return 0 rows
|
|
||||||
|
|
||||||
-- Ensure all household lists have valid items
|
|
||||||
SELECT hl.id
|
|
||||||
FROM household_lists hl
|
|
||||||
LEFT JOIN items i ON hl.item_id = i.id
|
|
||||||
WHERE i.id IS NULL;
|
|
||||||
-- Should return 0 rows
|
|
||||||
|
|
||||||
-- Ensure all history has valid list references
|
|
||||||
SELECT hlh.id
|
|
||||||
FROM household_list_history hlh
|
|
||||||
LEFT JOIN household_lists hl ON hlh.household_list_id = hl.id
|
|
||||||
WHERE hl.id IS NULL;
|
|
||||||
-- Should return 0 rows
|
|
||||||
|
|
||||||
-- Check for orphaned classifications
|
|
||||||
SELECT hic.id
|
|
||||||
FROM household_item_classifications hic
|
|
||||||
LEFT JOIN household_lists hl ON hic.item_id = hl.item_id
|
|
||||||
AND hic.household_id = hl.household_id
|
|
||||||
AND hic.store_id = hl.store_id
|
|
||||||
WHERE hl.id IS NULL;
|
|
||||||
-- Should return 0 rows (or classifications for removed items, which is ok)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
✅ All tables created successfully
|
|
||||||
✅ All users migrated to "Main Household"
|
|
||||||
✅ Item count matches (unique items from old → new)
|
|
||||||
✅ List count matches (all grocery_list items → household_lists)
|
|
||||||
✅ Classification count matches
|
|
||||||
✅ History count matches
|
|
||||||
✅ No NULL foreign keys
|
|
||||||
✅ Application loads without errors
|
|
||||||
✅ Users can perform all CRUD operations
|
|
||||||
✅ Images display correctly
|
|
||||||
✅ Bought items still marked as bought
|
|
||||||
✅ Recently bought still shows correctly
|
|
||||||
|
|
||||||
## Next Steps After Migration
|
|
||||||
|
|
||||||
1. ✅ Update backend models (Sprint 2)
|
|
||||||
2. ✅ Update API routes
|
|
||||||
3. ✅ Update controllers
|
|
||||||
4. ✅ Test all endpoints
|
|
||||||
5. ✅ Update frontend contexts
|
|
||||||
6. ✅ Update UI components
|
|
||||||
7. ✅ Enable multi-household features
|
|
||||||
|
|
||||||
## Support & Troubleshooting
|
|
||||||
|
|
||||||
If issues arise:
|
|
||||||
1. Check PostgreSQL logs: `/var/log/postgresql/`
|
|
||||||
2. Check application logs
|
|
||||||
3. Restore from backup if needed
|
|
||||||
4. Review migration script for errors
|
|
||||||
|
|
||||||
## Monitoring Post-Migration
|
|
||||||
|
|
||||||
For the first 24 hours after migration:
|
|
||||||
- Monitor error logs
|
|
||||||
- Watch for performance issues
|
|
||||||
- Verify user activity normal
|
|
||||||
- Check for any data inconsistencies
|
|
||||||
- Be ready to rollback if critical issues found
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
-- Add notes column to household_lists table
|
|
||||||
-- This allows users to add custom notes/descriptions to list items
|
|
||||||
|
|
||||||
ALTER TABLE household_lists
|
|
||||||
ADD COLUMN IF NOT EXISTS notes TEXT;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item';
|
|
||||||
@ -1,397 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- Multi-Household & Multi-Store Architecture Migration
|
|
||||||
-- ============================================================================
|
|
||||||
-- This migration transforms the single-list app into a multi-tenant system
|
|
||||||
-- supporting multiple households, each with multiple stores.
|
|
||||||
--
|
|
||||||
-- IMPORTANT: Backup your database before running this migration!
|
|
||||||
-- pg_dump grocery_list > backup_$(date +%Y%m%d).sql
|
|
||||||
--
|
|
||||||
-- Migration Strategy:
|
|
||||||
-- 1. Create new tables
|
|
||||||
-- 2. Create "Main Household" for existing users
|
|
||||||
-- 3. Migrate existing data to new structure
|
|
||||||
-- 4. Update roles (keep users.role for system admin)
|
|
||||||
-- 5. Verify data integrity
|
|
||||||
-- 6. (Manual step) Drop old tables after verification
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- STEP 1: CREATE NEW TABLES
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Households table
|
|
||||||
CREATE TABLE IF NOT EXISTS households (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
invite_code VARCHAR(20) UNIQUE NOT NULL,
|
|
||||||
code_expires_at TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_households_invite_code ON households(invite_code);
|
|
||||||
COMMENT ON TABLE households IS 'Household groups (families, roommates, etc.)';
|
|
||||||
COMMENT ON COLUMN households.invite_code IS 'Unique code for inviting users to join household';
|
|
||||||
|
|
||||||
-- Store types table
|
|
||||||
CREATE TABLE IF NOT EXISTS stores (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(50) NOT NULL UNIQUE,
|
|
||||||
default_zones JSONB,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE stores IS 'Store types/chains (Costco, Target, Walmart, etc.)';
|
|
||||||
COMMENT ON COLUMN stores.default_zones IS 'JSON array of default zone names for this store type';
|
|
||||||
|
|
||||||
-- User-Household membership with per-household roles
|
|
||||||
CREATE TABLE IF NOT EXISTS household_members (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
|
|
||||||
joined_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
UNIQUE(household_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_household_members_user ON household_members(user_id);
|
|
||||||
CREATE INDEX idx_household_members_household ON household_members(household_id);
|
|
||||||
COMMENT ON TABLE household_members IS 'User membership in households with per-household roles';
|
|
||||||
COMMENT ON COLUMN household_members.role IS 'admin: full control, user: standard member';
|
|
||||||
|
|
||||||
-- Household-Store relationship
|
|
||||||
CREATE TABLE IF NOT EXISTS household_stores (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
|
||||||
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
|
|
||||||
is_default BOOLEAN DEFAULT FALSE,
|
|
||||||
added_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
UNIQUE(household_id, store_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_household_stores_household ON household_stores(household_id);
|
|
||||||
COMMENT ON TABLE household_stores IS 'Which stores each household shops at';
|
|
||||||
|
|
||||||
-- Master item catalog (shared across all households)
|
|
||||||
CREATE TABLE IF NOT EXISTS items (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
default_image BYTEA,
|
|
||||||
default_image_mime_type VARCHAR(50),
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
usage_count INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_items_name ON items(name);
|
|
||||||
CREATE INDEX idx_items_usage_count ON items(usage_count DESC);
|
|
||||||
COMMENT ON TABLE items IS 'Master item catalog shared across all households';
|
|
||||||
COMMENT ON COLUMN items.usage_count IS 'Popularity metric for suggestions';
|
|
||||||
|
|
||||||
-- Household-specific grocery lists (per store)
|
|
||||||
CREATE TABLE IF NOT EXISTS household_lists (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
|
||||||
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
|
|
||||||
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
quantity INTEGER NOT NULL DEFAULT 1,
|
|
||||||
bought BOOLEAN DEFAULT FALSE,
|
|
||||||
custom_image BYTEA,
|
|
||||||
custom_image_mime_type VARCHAR(50),
|
|
||||||
added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
modified_on TIMESTAMP DEFAULT NOW(),
|
|
||||||
UNIQUE(household_id, store_id, item_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_household_lists_household_store ON household_lists(household_id, store_id);
|
|
||||||
CREATE INDEX idx_household_lists_bought ON household_lists(household_id, store_id, bought);
|
|
||||||
CREATE INDEX idx_household_lists_modified ON household_lists(modified_on DESC);
|
|
||||||
COMMENT ON TABLE household_lists IS 'Grocery lists scoped to household + store combination';
|
|
||||||
|
|
||||||
-- Household-specific item classifications (per store)
|
|
||||||
CREATE TABLE IF NOT EXISTS household_item_classifications (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
|
||||||
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
|
|
||||||
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
item_type VARCHAR(50),
|
|
||||||
item_group VARCHAR(100),
|
|
||||||
zone VARCHAR(100),
|
|
||||||
confidence DECIMAL(3,2) DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1),
|
|
||||||
source VARCHAR(20) DEFAULT 'user' CHECK (source IN ('user', 'ml', 'default')),
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
UNIQUE(household_id, store_id, item_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_household_classifications ON household_item_classifications(household_id, store_id);
|
|
||||||
CREATE INDEX idx_household_classifications_type ON household_item_classifications(item_type);
|
|
||||||
CREATE INDEX idx_household_classifications_zone ON household_item_classifications(zone);
|
|
||||||
COMMENT ON TABLE household_item_classifications IS 'Item classifications scoped to household + store';
|
|
||||||
|
|
||||||
-- History tracking
|
|
||||||
CREATE TABLE IF NOT EXISTS household_list_history (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE,
|
|
||||||
quantity INTEGER NOT NULL,
|
|
||||||
added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
added_on TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_household_history_list ON household_list_history(household_list_id);
|
|
||||||
CREATE INDEX idx_household_history_user ON household_list_history(added_by);
|
|
||||||
CREATE INDEX idx_household_history_date ON household_list_history(added_on DESC);
|
|
||||||
COMMENT ON TABLE household_list_history IS 'Tracks who added items and when';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- STEP 2: CREATE DEFAULT HOUSEHOLD AND STORE
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Create default household for existing users
|
|
||||||
INSERT INTO households (name, created_by, invite_code)
|
|
||||||
SELECT
|
|
||||||
'Main Household',
|
|
||||||
(SELECT id FROM users WHERE role = 'admin' LIMIT 1), -- First admin as creator
|
|
||||||
'MAIN' || LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0') -- Random 6-digit code
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM households WHERE name = 'Main Household');
|
|
||||||
|
|
||||||
-- Create default Costco store
|
|
||||||
INSERT INTO stores (name, default_zones)
|
|
||||||
VALUES (
|
|
||||||
'Costco',
|
|
||||||
'{
|
|
||||||
"zones": [
|
|
||||||
"Entrance & Seasonal",
|
|
||||||
"Fresh Produce",
|
|
||||||
"Meat & Seafood",
|
|
||||||
"Dairy & Refrigerated",
|
|
||||||
"Deli & Prepared Foods",
|
|
||||||
"Bakery & Bread",
|
|
||||||
"Frozen Foods",
|
|
||||||
"Beverages",
|
|
||||||
"Snacks & Candy",
|
|
||||||
"Pantry & Dry Goods",
|
|
||||||
"Health & Beauty",
|
|
||||||
"Household & Cleaning",
|
|
||||||
"Other"
|
|
||||||
]
|
|
||||||
}'::jsonb
|
|
||||||
)
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Link default household to default store
|
|
||||||
INSERT INTO household_stores (household_id, store_id, is_default)
|
|
||||||
SELECT
|
|
||||||
(SELECT id FROM households WHERE name = 'Main Household'),
|
|
||||||
(SELECT id FROM stores WHERE name = 'Costco'),
|
|
||||||
TRUE
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM household_stores
|
|
||||||
WHERE household_id = (SELECT id FROM households WHERE name = 'Main Household')
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- STEP 3: MIGRATE USERS TO HOUSEHOLD MEMBERS
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Add all existing users to Main Household
|
|
||||||
-- Old admins become household admins, others become standard users
|
|
||||||
INSERT INTO household_members (household_id, user_id, role)
|
|
||||||
SELECT
|
|
||||||
(SELECT id FROM households WHERE name = 'Main Household'),
|
|
||||||
id,
|
|
||||||
CASE
|
|
||||||
WHEN role = 'admin' THEN 'admin'
|
|
||||||
ELSE 'user'
|
|
||||||
END
|
|
||||||
FROM users
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM household_members hm
|
|
||||||
WHERE hm.user_id = users.id
|
|
||||||
AND hm.household_id = (SELECT id FROM households WHERE name = 'Main Household')
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- STEP 4: MIGRATE ITEMS TO MASTER CATALOG
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Extract unique items from grocery_list into master items table
|
|
||||||
INSERT INTO items (name, default_image, default_image_mime_type, created_at, usage_count)
|
|
||||||
SELECT
|
|
||||||
LOWER(TRIM(item_name)) as name,
|
|
||||||
item_image,
|
|
||||||
image_mime_type,
|
|
||||||
MIN(modified_on) as created_at,
|
|
||||||
COUNT(*) as usage_count
|
|
||||||
FROM grocery_list
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM items WHERE LOWER(items.name) = LOWER(TRIM(grocery_list.item_name))
|
|
||||||
)
|
|
||||||
GROUP BY LOWER(TRIM(item_name)), item_image, image_mime_type
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- STEP 5: MIGRATE GROCERY_LIST TO HOUSEHOLD_LISTS
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Migrate current list to household_lists
|
|
||||||
INSERT INTO household_lists (
|
|
||||||
household_id,
|
|
||||||
store_id,
|
|
||||||
item_id,
|
|
||||||
quantity,
|
|
||||||
bought,
|
|
||||||
custom_image,
|
|
||||||
custom_image_mime_type,
|
|
||||||
added_by,
|
|
||||||
modified_on
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
(SELECT id FROM households WHERE name = 'Main Household'),
|
|
||||||
(SELECT id FROM stores WHERE name = 'Costco'),
|
|
||||||
i.id,
|
|
||||||
gl.quantity,
|
|
||||||
gl.bought,
|
|
||||||
CASE WHEN gl.item_image != i.default_image THEN gl.item_image ELSE NULL END, -- Only store if different
|
|
||||||
CASE WHEN gl.item_image != i.default_image THEN gl.image_mime_type ELSE NULL END,
|
|
||||||
gl.added_by,
|
|
||||||
gl.modified_on
|
|
||||||
FROM grocery_list gl
|
|
||||||
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM household_lists hl
|
|
||||||
WHERE hl.household_id = (SELECT id FROM households WHERE name = 'Main Household')
|
|
||||||
AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco')
|
|
||||||
AND hl.item_id = i.id
|
|
||||||
)
|
|
||||||
ON CONFLICT (household_id, store_id, item_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- STEP 6: MIGRATE ITEM_CLASSIFICATION TO HOUSEHOLD_ITEM_CLASSIFICATIONS
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Migrate classifications
|
|
||||||
INSERT INTO household_item_classifications (
|
|
||||||
household_id,
|
|
||||||
store_id,
|
|
||||||
item_id,
|
|
||||||
item_type,
|
|
||||||
item_group,
|
|
||||||
zone,
|
|
||||||
confidence,
|
|
||||||
source,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
(SELECT id FROM households WHERE name = 'Main Household'),
|
|
||||||
(SELECT id FROM stores WHERE name = 'Costco'),
|
|
||||||
i.id,
|
|
||||||
ic.item_type,
|
|
||||||
ic.item_group,
|
|
||||||
ic.zone,
|
|
||||||
ic.confidence,
|
|
||||||
ic.source,
|
|
||||||
ic.created_at,
|
|
||||||
ic.updated_at
|
|
||||||
FROM item_classification ic
|
|
||||||
JOIN grocery_list gl ON ic.id = gl.id
|
|
||||||
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM household_item_classifications hic
|
|
||||||
WHERE hic.household_id = (SELECT id FROM households WHERE name = 'Main Household')
|
|
||||||
AND hic.store_id = (SELECT id FROM stores WHERE name = 'Costco')
|
|
||||||
AND hic.item_id = i.id
|
|
||||||
)
|
|
||||||
ON CONFLICT (household_id, store_id, item_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- STEP 7: MIGRATE GROCERY_HISTORY TO HOUSEHOLD_LIST_HISTORY
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Migrate history records
|
|
||||||
INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on)
|
|
||||||
SELECT
|
|
||||||
hl.id,
|
|
||||||
gh.quantity,
|
|
||||||
gh.added_by,
|
|
||||||
gh.added_on
|
|
||||||
FROM grocery_history gh
|
|
||||||
JOIN grocery_list gl ON gh.list_item_id = gl.id
|
|
||||||
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
|
|
||||||
JOIN household_lists hl ON hl.item_id = i.id
|
|
||||||
AND hl.household_id = (SELECT id FROM households WHERE name = 'Main Household')
|
|
||||||
AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco')
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM household_list_history hlh
|
|
||||||
WHERE hlh.household_list_id = hl.id
|
|
||||||
AND hlh.added_by = gh.added_by
|
|
||||||
AND hlh.added_on = gh.added_on
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- STEP 8: UPDATE USER ROLES (SYSTEM-WIDE)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Update system roles: admin → system_admin, others → user
|
|
||||||
UPDATE users
|
|
||||||
SET role = 'system_admin'
|
|
||||||
WHERE role = 'admin';
|
|
||||||
|
|
||||||
UPDATE users
|
|
||||||
SET role = 'user'
|
|
||||||
WHERE role IN ('editor', 'viewer');
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- VERIFICATION QUERIES
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Run these to verify migration success:
|
|
||||||
|
|
||||||
-- Check household created
|
|
||||||
-- SELECT * FROM households;
|
|
||||||
|
|
||||||
-- Check all users added to household
|
|
||||||
-- SELECT u.username, u.role as system_role, hm.role as household_role
|
|
||||||
-- FROM users u
|
|
||||||
-- JOIN household_members hm ON u.id = hm.user_id
|
|
||||||
-- ORDER BY u.id;
|
|
||||||
|
|
||||||
-- Check items migrated
|
|
||||||
-- SELECT COUNT(*) as total_items FROM items;
|
|
||||||
-- SELECT COUNT(*) as original_items FROM (SELECT DISTINCT item_name FROM grocery_list) sub;
|
|
||||||
|
|
||||||
-- Check lists migrated
|
|
||||||
-- SELECT COUNT(*) as new_lists FROM household_lists;
|
|
||||||
-- SELECT COUNT(*) as old_lists FROM grocery_list;
|
|
||||||
|
|
||||||
-- Check classifications migrated
|
|
||||||
-- SELECT COUNT(*) as new_classifications FROM household_item_classifications;
|
|
||||||
-- SELECT COUNT(*) as old_classifications FROM item_classification;
|
|
||||||
|
|
||||||
-- Check history migrated
|
|
||||||
-- SELECT COUNT(*) as new_history FROM household_list_history;
|
|
||||||
-- SELECT COUNT(*) as old_history FROM grocery_history;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- MANUAL STEPS AFTER VERIFICATION
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- After verifying data integrity, uncomment and run these to clean up:
|
|
||||||
|
|
||||||
-- DROP TABLE IF EXISTS grocery_history CASCADE;
|
|
||||||
-- DROP TABLE IF EXISTS item_classification CASCADE;
|
|
||||||
-- DROP TABLE IF EXISTS grocery_list CASCADE;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- ROLLBACK (if something goes wrong)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- ROLLBACK;
|
|
||||||
|
|
||||||
-- Then restore from backup:
|
|
||||||
-- psql -U your_user -d grocery_list < backup_YYYYMMDD.sql
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
const pool = require("../db/pool");
|
|
||||||
|
|
||||||
// Get all households a user belongs to
|
|
||||||
exports.getUserHouseholds = async (userId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
h.id,
|
|
||||||
h.name,
|
|
||||||
h.invite_code,
|
|
||||||
h.created_at,
|
|
||||||
hm.role,
|
|
||||||
hm.joined_at,
|
|
||||||
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
|
|
||||||
FROM households h
|
|
||||||
JOIN household_members hm ON h.id = hm.household_id
|
|
||||||
WHERE hm.user_id = $1
|
|
||||||
ORDER BY hm.joined_at DESC`,
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get household by ID (with member check)
|
|
||||||
exports.getHouseholdById = async (householdId, userId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
h.id,
|
|
||||||
h.name,
|
|
||||||
h.invite_code,
|
|
||||||
h.created_at,
|
|
||||||
h.created_by,
|
|
||||||
hm.role as user_role,
|
|
||||||
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
|
|
||||||
FROM households h
|
|
||||||
LEFT JOIN household_members hm ON h.id = hm.household_id AND hm.user_id = $2
|
|
||||||
WHERE h.id = $1`,
|
|
||||||
[householdId, userId]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create new household
|
|
||||||
exports.createHousehold = async (name, createdBy) => {
|
|
||||||
// Generate random 6-digit invite code
|
|
||||||
const inviteCode = 'H' + Math.random().toString(36).substring(2, 8).toUpperCase();
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`INSERT INTO households (name, created_by, invite_code)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING id, name, invite_code, created_at`,
|
|
||||||
[name, createdBy, inviteCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add creator as admin
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO household_members (household_id, user_id, role)
|
|
||||||
VALUES ($1, $2, 'admin')`,
|
|
||||||
[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, 'user')`,
|
|
||||||
[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 'admin' THEN 1
|
|
||||||
WHEN 'user' THEN 2
|
|
||||||
END,
|
|
||||||
hm.joined_at ASC`,
|
|
||||||
[householdId]
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update member role
|
|
||||||
exports.updateMemberRole = async (householdId, userId, newRole) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`UPDATE household_members
|
|
||||||
SET role = $1
|
|
||||||
WHERE household_id = $2 AND user_id = $3
|
|
||||||
RETURNING user_id, role`,
|
|
||||||
[newRole, householdId, userId]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove member from household
|
|
||||||
exports.removeMember = async (householdId, userId) => {
|
|
||||||
await pool.query(
|
|
||||||
`DELETE FROM household_members
|
|
||||||
WHERE household_id = $1 AND user_id = $2`,
|
|
||||||
[householdId, userId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get user's role in household
|
|
||||||
exports.getUserRole = async (householdId, userId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT role FROM household_members
|
|
||||||
WHERE household_id = $1 AND user_id = $2`,
|
|
||||||
[householdId, userId]
|
|
||||||
);
|
|
||||||
return result.rows[0]?.role || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if user is household member
|
|
||||||
exports.isHouseholdMember = async (householdId, userId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT 1 FROM household_members
|
|
||||||
WHERE household_id = $1 AND user_id = $2`,
|
|
||||||
[householdId, userId]
|
|
||||||
);
|
|
||||||
return result.rows.length > 0;
|
|
||||||
};
|
|
||||||
@ -1,410 +0,0 @@
|
|||||||
const pool = require("../db/pool");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list items for a specific household and store
|
|
||||||
* @param {number} householdId - Household ID
|
|
||||||
* @param {number} storeId - Store ID
|
|
||||||
* @param {boolean} includeHistory - Include purchase history
|
|
||||||
* @returns {Promise<Array>} List of items
|
|
||||||
*/
|
|
||||||
exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
hl.id,
|
|
||||||
i.name AS item_name,
|
|
||||||
hl.quantity,
|
|
||||||
hl.bought,
|
|
||||||
ENCODE(hl.custom_image, 'base64') as item_image,
|
|
||||||
hl.custom_image_mime_type as image_mime_type,
|
|
||||||
${includeHistory ? `
|
|
||||||
(
|
|
||||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT hlh.added_by
|
|
||||||
FROM household_list_history hlh
|
|
||||||
WHERE hlh.household_list_id = hl.id
|
|
||||||
ORDER BY hlh.added_by
|
|
||||||
) hlh
|
|
||||||
JOIN users u ON hlh.added_by = u.id
|
|
||||||
) as added_by_users,
|
|
||||||
` : 'NULL as added_by_users,'}
|
|
||||||
hl.modified_on as last_added_on,
|
|
||||||
hic.item_type,
|
|
||||||
hic.item_group,
|
|
||||||
hic.zone
|
|
||||||
FROM household_lists hl
|
|
||||||
JOIN items i ON hl.item_id = i.id
|
|
||||||
LEFT JOIN household_item_classifications hic
|
|
||||||
ON hl.household_id = hic.household_id
|
|
||||||
AND hl.item_id = hic.item_id
|
|
||||||
WHERE hl.household_id = $1
|
|
||||||
AND hl.store_id = $2
|
|
||||||
AND hl.bought = FALSE
|
|
||||||
ORDER BY hl.id ASC`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific item from household list by name
|
|
||||||
* @param {number} householdId - Household ID
|
|
||||||
* @param {number} storeId - Store ID
|
|
||||||
* @param {string} itemName - Item name to search for
|
|
||||||
* @returns {Promise<Object|null>} Item or null
|
|
||||||
*/
|
|
||||||
exports.getItemByName = async (householdId, storeId, itemName) => {
|
|
||||||
// First check if item exists in master catalog
|
|
||||||
const itemResult = await pool.query(
|
|
||||||
"SELECT id FROM items WHERE name ILIKE $1",
|
|
||||||
[itemName]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (itemResult.rowCount === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemId = itemResult.rows[0].id;
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
hl.id,
|
|
||||||
i.name AS item_name,
|
|
||||||
hl.quantity,
|
|
||||||
hl.bought,
|
|
||||||
ENCODE(hl.custom_image, 'base64') as item_image,
|
|
||||||
hl.custom_image_mime_type as image_mime_type,
|
|
||||||
(
|
|
||||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT hlh.added_by
|
|
||||||
FROM household_list_history hlh
|
|
||||||
WHERE hlh.household_list_id = hl.id
|
|
||||||
ORDER BY hlh.added_by
|
|
||||||
) hlh
|
|
||||||
JOIN users u ON hlh.added_by = u.id
|
|
||||||
) as added_by_users,
|
|
||||||
hl.modified_on as last_added_on,
|
|
||||||
hic.item_type,
|
|
||||||
hic.item_group,
|
|
||||||
hic.zone
|
|
||||||
FROM household_lists hl
|
|
||||||
JOIN items i ON hl.item_id = i.id
|
|
||||||
LEFT JOIN household_item_classifications hic
|
|
||||||
ON hl.household_id = hic.household_id
|
|
||||||
AND hl.item_id = hic.item_id
|
|
||||||
WHERE hl.household_id = $1
|
|
||||||
AND hl.store_id = $2
|
|
||||||
AND hl.item_id = $3`,
|
|
||||||
[householdId, storeId, itemId]
|
|
||||||
);
|
|
||||||
console.log(result.rows);
|
|
||||||
return result.rows[0] || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add or update an item in household list
|
|
||||||
* @param {number} householdId - Household ID
|
|
||||||
* @param {number} storeId - Store ID
|
|
||||||
* @param {string} itemName - Item name
|
|
||||||
* @param {number} quantity - Quantity
|
|
||||||
* @param {number} userId - User adding the item
|
|
||||||
* @param {Buffer|null} imageBuffer - Image buffer
|
|
||||||
* @param {string|null} mimeType - MIME type
|
|
||||||
* @returns {Promise<number>} List item ID
|
|
||||||
*/
|
|
||||||
exports.addOrUpdateItem = async (
|
|
||||||
householdId,
|
|
||||||
storeId,
|
|
||||||
itemName,
|
|
||||||
quantity,
|
|
||||||
userId,
|
|
||||||
imageBuffer = null,
|
|
||||||
mimeType = null
|
|
||||||
) => {
|
|
||||||
const lowerItemName = itemName.toLowerCase();
|
|
||||||
|
|
||||||
let itemResult = await pool.query(
|
|
||||||
"SELECT id FROM items WHERE name ILIKE $1",
|
|
||||||
[lowerItemName]
|
|
||||||
);
|
|
||||||
|
|
||||||
let itemId;
|
|
||||||
if (itemResult.rowCount === 0) {
|
|
||||||
const insertItem = await pool.query(
|
|
||||||
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
|
||||||
[lowerItemName]
|
|
||||||
);
|
|
||||||
itemId = insertItem.rows[0].id;
|
|
||||||
} else {
|
|
||||||
itemId = itemResult.rows[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const listResult = await pool.query(
|
|
||||||
`SELECT id, bought FROM household_lists
|
|
||||||
WHERE household_id = $1
|
|
||||||
AND store_id = $2
|
|
||||||
AND item_id = $3`,
|
|
||||||
[householdId, storeId, itemId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (listResult.rowCount > 0) {
|
|
||||||
const listId = listResult.rows[0].id;
|
|
||||||
if (imageBuffer && mimeType) {
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_lists
|
|
||||||
SET quantity = $1,
|
|
||||||
bought = FALSE,
|
|
||||||
custom_image = $2,
|
|
||||||
custom_image_mime_type = $3,
|
|
||||||
modified_on = NOW()
|
|
||||||
WHERE id = $4`,
|
|
||||||
[quantity, imageBuffer, mimeType, listId]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_lists
|
|
||||||
SET quantity = $1,
|
|
||||||
bought = FALSE,
|
|
||||||
modified_on = NOW()
|
|
||||||
WHERE id = $2`,
|
|
||||||
[quantity, listId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return listId;
|
|
||||||
} else {
|
|
||||||
const insert = await pool.query(
|
|
||||||
`INSERT INTO household_lists
|
|
||||||
(household_id, store_id, item_id, quantity, custom_image, custom_image_mime_type)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING id`,
|
|
||||||
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
|
|
||||||
);
|
|
||||||
return insert.rows[0].id;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark item as bought (full or partial)
|
|
||||||
* @param {number} listId - List item ID
|
|
||||||
* @param {boolean} bought - True to mark as bought, false to unmark
|
|
||||||
* @param {number} quantityBought - Optional quantity bought (for partial purchases)
|
|
||||||
*/
|
|
||||||
exports.setBought = async (listId, bought, quantityBought = null) => {
|
|
||||||
if (bought === false) {
|
|
||||||
// Unmarking - just set bought to false
|
|
||||||
await pool.query(
|
|
||||||
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
|
|
||||||
[listId]
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marking as bought
|
|
||||||
if (quantityBought && quantityBought > 0) {
|
|
||||||
// Partial purchase - reduce quantity
|
|
||||||
const item = await pool.query(
|
|
||||||
"SELECT quantity FROM household_lists WHERE id = $1",
|
|
||||||
[listId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!item.rows[0]) return;
|
|
||||||
|
|
||||||
const currentQuantity = item.rows[0].quantity;
|
|
||||||
const remainingQuantity = currentQuantity - quantityBought;
|
|
||||||
|
|
||||||
if (remainingQuantity <= 0) {
|
|
||||||
// All bought - mark as bought
|
|
||||||
await pool.query(
|
|
||||||
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
|
||||||
[listId]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Partial - reduce quantity
|
|
||||||
await pool.query(
|
|
||||||
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
|
|
||||||
[remainingQuantity, listId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Full purchase - mark as bought
|
|
||||||
await pool.query(
|
|
||||||
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
|
||||||
[listId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add history record for item addition
|
|
||||||
* @param {number} listId - List item ID
|
|
||||||
* @param {number} quantity - Quantity added
|
|
||||||
* @param {number} userId - User who added
|
|
||||||
*/
|
|
||||||
exports.addHistoryRecord = async (listId, quantity, userId) => {
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on)
|
|
||||||
VALUES ($1, $2, $3, NOW())`,
|
|
||||||
[listId, quantity, userId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get suggestions for autocomplete
|
|
||||||
* @param {string} query - Search query
|
|
||||||
* @param {number} householdId - Household ID (for personalized suggestions)
|
|
||||||
* @param {number} storeId - Store ID
|
|
||||||
* @returns {Promise<Array>} Suggestions
|
|
||||||
*/
|
|
||||||
exports.getSuggestions = async (query, householdId, storeId) => {
|
|
||||||
// Get items from both master catalog and household history
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT DISTINCT
|
|
||||||
i.name as item_name,
|
|
||||||
CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END as sort_order
|
|
||||||
FROM items i
|
|
||||||
LEFT JOIN household_lists hl
|
|
||||||
ON i.id = hl.item_id
|
|
||||||
AND hl.household_id = $2
|
|
||||||
AND hl.store_id = $3
|
|
||||||
WHERE i.name ILIKE $1
|
|
||||||
ORDER BY sort_order, i.name
|
|
||||||
LIMIT 10`,
|
|
||||||
[`%${query}%`, householdId, storeId]
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recently bought items for household/store
|
|
||||||
* @param {number} householdId - Household ID
|
|
||||||
* @param {number} storeId - Store ID
|
|
||||||
* @returns {Promise<Array>} Recently bought items
|
|
||||||
*/
|
|
||||||
exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
hl.id,
|
|
||||||
i.name AS item_name,
|
|
||||||
hl.quantity,
|
|
||||||
hl.bought,
|
|
||||||
ENCODE(hl.custom_image, 'base64') as item_image,
|
|
||||||
hl.custom_image_mime_type as image_mime_type,
|
|
||||||
(
|
|
||||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT hlh.added_by
|
|
||||||
FROM household_list_history hlh
|
|
||||||
WHERE hlh.household_list_id = hl.id
|
|
||||||
ORDER BY hlh.added_by
|
|
||||||
) hlh
|
|
||||||
JOIN users u ON hlh.added_by = u.id
|
|
||||||
) as added_by_users,
|
|
||||||
hl.modified_on as last_added_on
|
|
||||||
FROM household_lists hl
|
|
||||||
JOIN items i ON hl.item_id = i.id
|
|
||||||
WHERE hl.household_id = $1
|
|
||||||
AND hl.store_id = $2
|
|
||||||
AND hl.bought = TRUE
|
|
||||||
AND hl.modified_on >= NOW() - INTERVAL '24 hours'
|
|
||||||
ORDER BY hl.modified_on DESC`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get classification for household item
|
|
||||||
* @param {number} householdId - Household ID
|
|
||||||
* @param {number} itemId - Item ID
|
|
||||||
* @returns {Promise<Object|null>} Classification or null
|
|
||||||
*/
|
|
||||||
exports.getClassification = async (householdId, itemId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT item_type, item_group, zone, confidence, source
|
|
||||||
FROM household_item_classifications
|
|
||||||
WHERE household_id = $1 AND item_id = $2`,
|
|
||||||
[householdId, itemId]
|
|
||||||
);
|
|
||||||
return result.rows[0] || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upsert classification for household item
|
|
||||||
* @param {number} householdId - Household ID
|
|
||||||
* @param {number} itemId - Item ID
|
|
||||||
* @param {Object} classification - Classification data
|
|
||||||
* @returns {Promise<Object>} Updated classification
|
|
||||||
*/
|
|
||||||
exports.upsertClassification = async (householdId, itemId, classification) => {
|
|
||||||
const { item_type, item_group, zone, confidence, source } = classification;
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`INSERT INTO household_item_classifications
|
|
||||||
(household_id, item_id, item_type, item_group, zone, confidence, source)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
ON CONFLICT (household_id, item_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
item_type = EXCLUDED.item_type,
|
|
||||||
item_group = EXCLUDED.item_group,
|
|
||||||
zone = EXCLUDED.zone,
|
|
||||||
confidence = EXCLUDED.confidence,
|
|
||||||
source = EXCLUDED.source
|
|
||||||
RETURNING *`,
|
|
||||||
[householdId, itemId, item_type, item_group, zone, confidence, source]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update list item details
|
|
||||||
* @param {number} listId - List item ID
|
|
||||||
* @param {string} itemName - New item name (optional)
|
|
||||||
* @param {number} quantity - New quantity (optional)
|
|
||||||
* @param {string} notes - Notes (optional)
|
|
||||||
* @returns {Promise<Object>} Updated item
|
|
||||||
*/
|
|
||||||
exports.updateItem = async (listId, itemName, quantity, notes) => {
|
|
||||||
// Build dynamic update query
|
|
||||||
const updates = [];
|
|
||||||
const values = [listId];
|
|
||||||
let paramCount = 1;
|
|
||||||
|
|
||||||
if (quantity !== undefined) {
|
|
||||||
paramCount++;
|
|
||||||
updates.push(`quantity = $${paramCount}`);
|
|
||||||
values.push(quantity);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notes !== undefined) {
|
|
||||||
paramCount++;
|
|
||||||
updates.push(`notes = $${paramCount}`);
|
|
||||||
values.push(notes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always update modified_on
|
|
||||||
updates.push(`modified_on = NOW()`);
|
|
||||||
|
|
||||||
if (updates.length === 1) {
|
|
||||||
// Only modified_on update
|
|
||||||
const result = await pool.query(
|
|
||||||
`UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *`,
|
|
||||||
[listId]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`UPDATE household_lists SET ${updates.join(', ')} WHERE id = $1 RETURNING *`,
|
|
||||||
values
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a list item
|
|
||||||
* @param {number} listId - List item ID
|
|
||||||
*/
|
|
||||||
exports.deleteItem = async (listId) => {
|
|
||||||
await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]);
|
|
||||||
};
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
const pool = require("../db/pool");
|
|
||||||
|
|
||||||
// Get all available stores
|
|
||||||
exports.getAllStores = async () => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT id, name, default_zones, created_at
|
|
||||||
FROM stores
|
|
||||||
ORDER BY name ASC`
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get store by ID
|
|
||||||
exports.getStoreById = async (storeId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT id, name, default_zones, created_at
|
|
||||||
FROM stores
|
|
||||||
WHERE id = $1`,
|
|
||||||
[storeId]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get stores for a specific household
|
|
||||||
exports.getHouseholdStores = async (householdId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
s.id,
|
|
||||||
s.name,
|
|
||||||
s.default_zones,
|
|
||||||
hs.is_default,
|
|
||||||
hs.added_at
|
|
||||||
FROM stores s
|
|
||||||
JOIN household_stores hs ON s.id = hs.store_id
|
|
||||||
WHERE hs.household_id = $1
|
|
||||||
ORDER BY hs.is_default DESC, s.name ASC`,
|
|
||||||
[householdId]
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add store to household
|
|
||||||
exports.addStoreToHousehold = async (householdId, storeId, isDefault = false) => {
|
|
||||||
// If setting as default, unset other defaults
|
|
||||||
if (isDefault) {
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_stores
|
|
||||||
SET is_default = FALSE
|
|
||||||
WHERE household_id = $1`,
|
|
||||||
[householdId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`INSERT INTO household_stores (household_id, store_id, is_default)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (household_id, store_id)
|
|
||||||
DO UPDATE SET is_default = $3
|
|
||||||
RETURNING household_id, store_id, is_default`,
|
|
||||||
[householdId, storeId, isDefault]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove store from household
|
|
||||||
exports.removeStoreFromHousehold = async (householdId, storeId) => {
|
|
||||||
await pool.query(
|
|
||||||
`DELETE FROM household_stores
|
|
||||||
WHERE household_id = $1 AND store_id = $2`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set default store for household
|
|
||||||
exports.setDefaultStore = async (householdId, storeId) => {
|
|
||||||
// Unset all defaults
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_stores
|
|
||||||
SET is_default = FALSE
|
|
||||||
WHERE household_id = $1`,
|
|
||||||
[householdId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set new default
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_stores
|
|
||||||
SET is_default = TRUE
|
|
||||||
WHERE household_id = $1 AND store_id = $2`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create new store (system admin only)
|
|
||||||
exports.createStore = async (name, defaultZones) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`INSERT INTO stores (name, default_zones)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
RETURNING id, name, default_zones, created_at`,
|
|
||||||
[name, JSON.stringify(defaultZones)]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update store (system admin only)
|
|
||||||
exports.updateStore = async (storeId, updates) => {
|
|
||||||
const { name, default_zones } = updates;
|
|
||||||
const result = await pool.query(
|
|
||||||
`UPDATE stores
|
|
||||||
SET
|
|
||||||
name = COALESCE($1, name),
|
|
||||||
default_zones = COALESCE($2, default_zones)
|
|
||||||
WHERE id = $3
|
|
||||||
RETURNING id, name, default_zones, created_at`,
|
|
||||||
[name, default_zones ? JSON.stringify(default_zones) : null, storeId]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete store (system admin only, only if not in use)
|
|
||||||
exports.deleteStore = async (storeId) => {
|
|
||||||
// Check if store is in use
|
|
||||||
const usage = await pool.query(
|
|
||||||
`SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`,
|
|
||||||
[storeId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (parseInt(usage.rows[0].count) > 0) {
|
|
||||||
throw new Error('Cannot delete store that is in use by households');
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.query('DELETE FROM stores WHERE id = $1', [storeId]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if household has store
|
|
||||||
exports.householdHasStore = async (householdId, storeId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT 1 FROM household_stores
|
|
||||||
WHERE household_id = $1 AND store_id = $2`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
return result.rows.length > 0;
|
|
||||||
};
|
|
||||||
@ -1,8 +1,9 @@
|
|||||||
const pool = require("../db/pool");
|
const pool = require("../db/pool");
|
||||||
|
|
||||||
exports.ROLES = {
|
exports.ROLES = {
|
||||||
SYSTEM_ADMIN: "system_admin",
|
VIEWER: "viewer",
|
||||||
USER: "user",
|
EDITOR: "editor",
|
||||||
|
ADMIN: "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.findByUsername = async (username) => {
|
exports.findByUsername = async (username) => {
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
# API Test Suite
|
|
||||||
|
|
||||||
The test suite has been reorganized into separate files for better maintainability:
|
|
||||||
|
|
||||||
## New Modular Structure (✅ Complete)
|
|
||||||
- **api-tests.html** - Main HTML file
|
|
||||||
- **test-config.js** - Global state management
|
|
||||||
- **test-definitions.js** - All 62 test cases across 8 categories
|
|
||||||
- **test-runner.js** - Test execution logic
|
|
||||||
- **test-ui.js** - UI manipulation functions
|
|
||||||
- **test-styles.css** - All CSS styles
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
1. Start the dev server: `docker-compose -f docker-compose.dev.yml up`
|
|
||||||
2. Navigate to: `http://localhost:5000/test/api-tests.html`
|
|
||||||
3. Configure credentials (default: admin/admin123)
|
|
||||||
4. Click "▶ Run All Tests"
|
|
||||||
|
|
||||||
## Features
|
|
||||||
- ✅ 62 comprehensive tests
|
|
||||||
- ✅ Collapsible test cards (collapsed by default)
|
|
||||||
- ✅ Expected field validation with visual indicators
|
|
||||||
- ✅ Color-coded HTTP status badges
|
|
||||||
- ✅ Auto-expansion on test run
|
|
||||||
- ✅ Expand/Collapse all buttons
|
|
||||||
- ✅ Real-time pass/fail/error states
|
|
||||||
- ✅ Summary dashboard
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
```
|
|
||||||
backend/public/
|
|
||||||
├── api-tests.html # Main entry point (use this)
|
|
||||||
├── test-config.js # State management (19 lines)
|
|
||||||
├── test-definitions.js # Test cases (450+ lines)
|
|
||||||
├── test-runner.js # Test execution (160+ lines)
|
|
||||||
├── test-ui.js # UI functions (90+ lines)
|
|
||||||
└── test-styles.css # All styles (310+ lines)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Old File
|
|
||||||
- **api-test.html** - Original monolithic version (kept for reference)
|
|
||||||
|
|
||||||
Total: ~1030 lines split into 6 clean, modular files
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,63 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>API Test Suite - Grocery List</title>
|
|
||||||
<link rel="stylesheet" href="test-styles.css">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🧪 API Test Suite</h1>
|
|
||||||
<p style="color: #666; margin-bottom: 20px;">Multi-Household Grocery List API Testing</p>
|
|
||||||
|
|
||||||
<div class="config">
|
|
||||||
<h3 style="margin-bottom: 15px;">Configuration</h3>
|
|
||||||
<div class="config-row">
|
|
||||||
<label>API URL:</label>
|
|
||||||
<input type="text" id="apiUrl" value="http://localhost:5000" />
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<label>Username:</label>
|
|
||||||
<input type="text" id="username" value="admin" />
|
|
||||||
</div>
|
|
||||||
<div class="config-row">
|
|
||||||
<label>Password:</label>
|
|
||||||
<input type="password" id="password" value="admin123" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button onclick="runAllTests(event)">▶ Run All Tests</button>
|
|
||||||
<button onclick="clearResults()">🗑 Clear Results</button>
|
|
||||||
<button onclick="expandAllTests()">📂 Expand All</button>
|
|
||||||
<button onclick="collapseAllTests()">📁 Collapse All</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="summary" id="summary" style="display: none;">
|
|
||||||
<div class="summary-item total">
|
|
||||||
<div class="summary-value" id="totalTests">0</div>
|
|
||||||
<div class="summary-label">Total Tests</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-item pass">
|
|
||||||
<div class="summary-value" id="passedTests">0</div>
|
|
||||||
<div class="summary-label">Passed</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-item fail">
|
|
||||||
<div class="summary-value" id="failedTests">0</div>
|
|
||||||
<div class="summary-label">Failed</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="testResults"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="test-config.js"></script>
|
|
||||||
<script src="test-definitions.js"></script>
|
|
||||||
<script src="test-runner.js"></script>
|
|
||||||
<script src="test-ui.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
// Global state
|
|
||||||
let authToken = null;
|
|
||||||
let householdId = null;
|
|
||||||
let storeId = null;
|
|
||||||
let testUserId = null;
|
|
||||||
let createdHouseholdId = null;
|
|
||||||
let secondHouseholdId = null;
|
|
||||||
let inviteCode = null;
|
|
||||||
|
|
||||||
// Reset state
|
|
||||||
function resetState() {
|
|
||||||
authToken = null;
|
|
||||||
householdId = null;
|
|
||||||
storeId = null;
|
|
||||||
testUserId = null;
|
|
||||||
createdHouseholdId = null;
|
|
||||||
secondHouseholdId = null;
|
|
||||||
inviteCode = null;
|
|
||||||
}
|
|
||||||
@ -1,826 +0,0 @@
|
|||||||
// Test definitions - 108 tests across 14 categories
|
|
||||||
const tests = [
|
|
||||||
{
|
|
||||||
category: "Authentication",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Login with valid credentials",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/auth/login",
|
|
||||||
auth: false,
|
|
||||||
body: () => ({ username: document.getElementById('username').value, password: document.getElementById('password').value }),
|
|
||||||
expect: (res) => res.token && res.role,
|
|
||||||
expectedFields: ['token', 'username', 'role'],
|
|
||||||
onSuccess: (res) => { authToken = res.token; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Login with invalid credentials",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/auth/login",
|
|
||||||
auth: false,
|
|
||||||
body: { username: "wronguser", password: "wrongpass" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 401,
|
|
||||||
expectedFields: ['message']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Access protected route without token",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: false,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 401
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Households",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get user's households",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
expect: (res) => Array.isArray(res),
|
|
||||||
onSuccess: (res) => { if (res.length > 0) householdId = res[0].id; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Create new household",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
body: { name: `Test Household ${Date.now()}` },
|
|
||||||
expect: (res) => res.household && res.household.invite_code,
|
|
||||||
expectedFields: ['message', 'household', 'household.id', 'household.name', 'household.invite_code']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Get household details",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
expect: (res) => res.id === householdId,
|
|
||||||
expectedFields: ['id', 'name', 'invite_code', 'created_at']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update household name",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${householdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
body: { name: `Updated Household ${Date.now()}` },
|
|
||||||
expect: (res) => res.household,
|
|
||||||
expectedFields: ['message', 'household', 'household.id', 'household.name']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Refresh invite code",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${householdId}/invite/refresh`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
expect: (res) => res.household && res.household.invite_code,
|
|
||||||
expectedFields: ['message', 'household', 'household.invite_code']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Join household with invalid code",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/households/join/INVALID123",
|
|
||||||
auth: true,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Create household with empty name (validation)",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
body: { name: "" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400,
|
|
||||||
expectedFields: ['error']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Members",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get household members",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}/members`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length > 0,
|
|
||||||
onSuccess: (res) => { testUserId = res[0].user_id; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update member role (non-admin attempting)",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${householdId}/members/${testUserId}/role`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !testUserId,
|
|
||||||
body: { role: "user" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400 || status === 403
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Stores",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get all stores catalog",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/stores",
|
|
||||||
auth: true,
|
|
||||||
expect: (res) => Array.isArray(res),
|
|
||||||
onSuccess: (res) => { if (res.length > 0) storeId = res[0].id; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Get household stores",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/stores/household/${householdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
expect: (res) => Array.isArray(res)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Add store to household",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/stores/household/${householdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: () => ({ storeId: storeId, isDefault: true }),
|
|
||||||
expect: (res) => res.store,
|
|
||||||
expectedFields: ['message', 'store', 'store.id', 'store.name']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Set default store",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/stores/household/${householdId}/${storeId}/default`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Add invalid store to household",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/stores/household/${householdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
body: { storeId: 99999 },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Advanced Household Tests",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Create household for complex workflows",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
body: { name: `Workflow Test ${Date.now()}` },
|
|
||||||
expect: (res) => res.household && res.household.id,
|
|
||||||
onSuccess: (res) => {
|
|
||||||
createdHouseholdId = res.household.id;
|
|
||||||
inviteCode = res.household.invite_code;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify invite code format (7 chars)",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expect: (res) => res.invite_code && res.invite_code.length === 7 && res.invite_code.startsWith('H')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Get household with no stores added yet",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length === 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update household with very long name (validation)",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
body: { name: "A".repeat(101) },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Refresh invite code changes value",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}/invite/refresh`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !inviteCode,
|
|
||||||
expect: (res) => res.household && res.household.invite_code !== inviteCode,
|
|
||||||
onSuccess: (res) => { inviteCode = res.household.invite_code; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Join same household twice (idempotent)",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/join/${inviteCode}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !inviteCode,
|
|
||||||
expect: (res, status) => status === 200 && res.message.includes("already a member")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Get non-existent household",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/households/99999",
|
|
||||||
auth: true,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update non-existent household",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: "/households/99999",
|
|
||||||
auth: true,
|
|
||||||
body: { name: "Test" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 403 || status === 404
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Member Management Edge Cases",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get members for created household",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}/members`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length >= 1 && res[0].role === 'admin'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update own role (should fail)",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}/members/${testUserId}/role`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !testUserId,
|
|
||||||
body: { role: "user" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400 && res.error && res.error.includes("own role")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update role with invalid value",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}/members/1/role`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
body: { role: "superadmin" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Remove non-existent member",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}/members/99999`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404 || status === 500
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Store Management Advanced",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Add multiple stores to household",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !storeId,
|
|
||||||
body: () => ({ storeId: storeId, isDefault: false }),
|
|
||||||
expect: (res) => res.store
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Add same store twice (duplicate check)",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !storeId,
|
|
||||||
body: () => ({ storeId: storeId, isDefault: false }),
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400 || status === 409 || status === 500
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Set default store for household",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}/default`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !storeId,
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify default store is first in list",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !storeId,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length > 0 && res[0].is_default === true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Set non-existent store as default",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}/99999/default`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404 || status === 500
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Remove store from household",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !storeId,
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify store removed from household",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length === 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Data Integrity & Cleanup",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Create second household for testing",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
body: { name: `Second Test ${Date.now()}` },
|
|
||||||
expect: (res) => res.household && res.household.id,
|
|
||||||
onSuccess: (res) => { secondHouseholdId = res.household.id; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify user belongs to multiple households",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length >= 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Delete created test household",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify deleted household is gone",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404 || status === 403
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Delete second test household",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/households/${secondHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !secondHouseholdId,
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify households list updated",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
expect: (res) => Array.isArray(res)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "List Operations",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get grocery list for household+store",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
expect: (res) => Array.isArray(res),
|
|
||||||
expectedFields: ['items']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Add item to list",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Test API Item",
|
|
||||||
quantity: "2 units"
|
|
||||||
},
|
|
||||||
expect: (res) => res.item,
|
|
||||||
expectedFields: ['item', 'item.id', 'item.item_name', 'item.quantity']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Add duplicate item (should update quantity)",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Test API Item",
|
|
||||||
quantity: "3 units"
|
|
||||||
},
|
|
||||||
expect: (res) => res.item && res.item.quantity === "3 units"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Mark item as bought",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Test API Item",
|
|
||||||
bought: true
|
|
||||||
},
|
|
||||||
expect: (res) => res.message,
|
|
||||||
expectedFields: ['message']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Unmark item (set bought to false)",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Test API Item",
|
|
||||||
bought: false
|
|
||||||
},
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update item details",
|
|
||||||
method: "PUT",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Test API Item",
|
|
||||||
quantity: "5 units",
|
|
||||||
notes: "Updated via API test"
|
|
||||||
},
|
|
||||||
expect: (res) => res.item,
|
|
||||||
expectedFields: ['item', 'item.quantity', 'item.notes']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Get suggestions based on history",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/suggestions?query=test`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
expect: (res) => Array.isArray(res)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Get recently bought items",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/recent`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
expect: (res) => Array.isArray(res)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Delete item from list",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Test API Item"
|
|
||||||
},
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Try to add item with empty name",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "",
|
|
||||||
quantity: "1"
|
|
||||||
},
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Item Classifications",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get item classification",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification?item_name=Milk`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
expect: (res) => res.classification !== undefined,
|
|
||||||
expectedFields: ['classification']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Set item classification",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Test Classified Item",
|
|
||||||
classification: "dairy"
|
|
||||||
},
|
|
||||||
expect: (res) => res.message || res.classification
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update item classification",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Test Classified Item",
|
|
||||||
classification: "produce"
|
|
||||||
},
|
|
||||||
expect: (res) => res.message || res.classification
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify classification persists",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification?item_name=Test Classified Item`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
expect: (res) => res.classification === "produce"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Account Management",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get current user profile",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/users/me",
|
|
||||||
auth: true,
|
|
||||||
expect: (res) => res.username,
|
|
||||||
expectedFields: ['id', 'username', 'name', 'display_name', 'role'],
|
|
||||||
onSuccess: (res) => { testUserId = res.id; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update display name",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: "/users/me/display-name",
|
|
||||||
auth: true,
|
|
||||||
body: {
|
|
||||||
display_name: "Test Display Name"
|
|
||||||
},
|
|
||||||
expect: (res) => res.message,
|
|
||||||
expectedFields: ['message']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify display name updated",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/users/me",
|
|
||||||
auth: true,
|
|
||||||
expect: (res) => res.display_name === "Test Display Name"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Clear display name (set to null)",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: "/users/me/display-name",
|
|
||||||
auth: true,
|
|
||||||
body: {
|
|
||||||
display_name: null
|
|
||||||
},
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update password",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: "/users/me/password",
|
|
||||||
auth: true,
|
|
||||||
body: () => ({
|
|
||||||
currentPassword: document.getElementById('password').value,
|
|
||||||
newPassword: document.getElementById('password').value
|
|
||||||
}),
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Try to update password with wrong current password",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: "/users/me/password",
|
|
||||||
auth: true,
|
|
||||||
body: {
|
|
||||||
currentPassword: "wrongpassword",
|
|
||||||
newPassword: "newpass123"
|
|
||||||
},
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 401
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Config Endpoints",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get classifications list",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/config/classifications",
|
|
||||||
auth: false,
|
|
||||||
expect: (res) => Array.isArray(res),
|
|
||||||
expectedFields: ['[0].value', '[0].label', '[0].color']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Get system config",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/config",
|
|
||||||
auth: false,
|
|
||||||
expect: (res) => res.classifications,
|
|
||||||
expectedFields: ['classifications']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Advanced List Scenarios",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Add multiple items rapidly",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Rapid Test Item 1",
|
|
||||||
quantity: "1"
|
|
||||||
},
|
|
||||||
expect: (res) => res.item
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Add second rapid item",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Rapid Test Item 2",
|
|
||||||
quantity: "1"
|
|
||||||
},
|
|
||||||
expect: (res) => res.item
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify list contains both items",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
expect: (res) => res.items && res.items.length >= 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Mark both items as bought",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Rapid Test Item 1",
|
|
||||||
bought: true
|
|
||||||
},
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Mark second item as bought",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Rapid Test Item 2",
|
|
||||||
bought: true
|
|
||||||
},
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify recent items includes bought items",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/recent`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length > 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Delete first rapid test item",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Rapid Test Item 1"
|
|
||||||
},
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Delete second rapid test item",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Rapid Test Item 2"
|
|
||||||
},
|
|
||||||
expect: (res) => res.message
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Edge Cases & Error Handling",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Access non-existent household",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/households/99999",
|
|
||||||
auth: true,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 403 || status === 404
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Access non-existent store in household",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/99999/list`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 403 || status === 404
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Try to update non-existent item",
|
|
||||||
method: "PUT",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Non Existent Item 999",
|
|
||||||
quantity: "1"
|
|
||||||
},
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Try to delete non-existent item",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Non Existent Item 999"
|
|
||||||
},
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid classification value",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: {
|
|
||||||
item_name: "Test Item",
|
|
||||||
classification: "invalid_category_xyz"
|
|
||||||
},
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty household name on creation",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
body: {
|
|
||||||
name: ""
|
|
||||||
},
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
async function makeRequest(test) {
|
|
||||||
const apiUrl = document.getElementById('apiUrl').value;
|
|
||||||
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
|
|
||||||
const url = `${apiUrl}${endpoint}`;
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
method: test.method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (test.auth && authToken) {
|
|
||||||
options.headers['Authorization'] = `Bearer ${authToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (test.body) {
|
|
||||||
options.body = JSON.stringify(typeof test.body === 'function' ? test.body() : test.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, options);
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
|
|
||||||
return { data, status: response.status };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTest(categoryIdx, testIdx) {
|
|
||||||
const test = tests[categoryIdx].tests[testIdx];
|
|
||||||
const testId = `test-${categoryIdx}-${testIdx}`;
|
|
||||||
const testEl = document.getElementById(testId);
|
|
||||||
const contentEl = document.getElementById(`${testId}-content`);
|
|
||||||
const toggleEl = document.getElementById(`${testId}-toggle`);
|
|
||||||
const resultEl = testEl.querySelector('.test-result');
|
|
||||||
|
|
||||||
if (test.skip && test.skip()) {
|
|
||||||
testEl.querySelector('.test-status').textContent = 'SKIPPED';
|
|
||||||
testEl.querySelector('.test-status').className = 'test-status pending';
|
|
||||||
resultEl.style.display = 'block';
|
|
||||||
resultEl.className = 'test-result';
|
|
||||||
resultEl.innerHTML = '⚠️ Prerequisites not met';
|
|
||||||
return 'skip';
|
|
||||||
}
|
|
||||||
|
|
||||||
testEl.className = 'test-case running';
|
|
||||||
testEl.querySelector('.test-status').textContent = 'RUNNING';
|
|
||||||
testEl.querySelector('.test-status').className = 'test-status running';
|
|
||||||
resultEl.style.display = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data, status } = await makeRequest(test);
|
|
||||||
|
|
||||||
const expectFail = test.expectFail || false;
|
|
||||||
const passed = test.expect(data, status);
|
|
||||||
|
|
||||||
const success = expectFail ? !passed || status >= 400 : passed;
|
|
||||||
|
|
||||||
testEl.className = success ? 'test-case pass' : 'test-case fail';
|
|
||||||
testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL';
|
|
||||||
testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`;
|
|
||||||
|
|
||||||
// Determine status code class
|
|
||||||
let statusClass = 'status-5xx';
|
|
||||||
if (status >= 200 && status < 300) statusClass = 'status-2xx';
|
|
||||||
else if (status >= 300 && status < 400) statusClass = 'status-3xx';
|
|
||||||
else if (status >= 400 && status < 500) statusClass = 'status-4xx';
|
|
||||||
|
|
||||||
// Check expected fields if defined
|
|
||||||
let expectedFieldsHTML = '';
|
|
||||||
if (test.expectedFields) {
|
|
||||||
const fieldChecks = test.expectedFields.map(field => {
|
|
||||||
const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined;
|
|
||||||
const icon = exists ? '✓' : '✗';
|
|
||||||
const className = exists ? 'pass' : 'fail';
|
|
||||||
return `<div class="field-check ${className}">${icon} ${field}</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
expectedFieldsHTML = `
|
|
||||||
<div class="expected-section">
|
|
||||||
<div class="expected-label">Expected Fields:</div>
|
|
||||||
${fieldChecks}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
resultEl.style.display = 'block';
|
|
||||||
resultEl.className = 'test-result';
|
|
||||||
resultEl.innerHTML = `
|
|
||||||
<div style="margin-bottom: 8px;">
|
|
||||||
<span class="response-status ${statusClass}">HTTP ${status}</span>
|
|
||||||
<span style="color: #666;">${success ? '✓ Test passed' : '✗ Test failed'}</span>
|
|
||||||
</div>
|
|
||||||
${expectedFieldsHTML}
|
|
||||||
<div style="color: #666; font-size: 12px; margin-bottom: 4px;">Response:</div>
|
|
||||||
<div>${JSON.stringify(data, null, 2)}</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (success && test.onSuccess) {
|
|
||||||
test.onSuccess(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
return success ? 'pass' : 'fail';
|
|
||||||
} catch (error) {
|
|
||||||
testEl.className = 'test-case fail';
|
|
||||||
testEl.querySelector('.test-status').textContent = 'ERROR';
|
|
||||||
testEl.querySelector('.test-status').className = 'test-status fail';
|
|
||||||
|
|
||||||
resultEl.style.display = 'block';
|
|
||||||
resultEl.className = 'test-error';
|
|
||||||
resultEl.innerHTML = `
|
|
||||||
<div style="font-weight: bold; margin-bottom: 8px;">❌ Network/Request Error</div>
|
|
||||||
<div>${error.message}</div>
|
|
||||||
${error.stack ? `<div style="margin-top: 8px; font-size: 11px; opacity: 0.7;">${error.stack}</div>` : ''}
|
|
||||||
`;
|
|
||||||
return 'fail';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAllTests(event) {
|
|
||||||
resetState();
|
|
||||||
|
|
||||||
const button = event.target;
|
|
||||||
button.disabled = true;
|
|
||||||
button.textContent = '⏳ Running Tests...';
|
|
||||||
|
|
||||||
let totalTests = 0;
|
|
||||||
let passedTests = 0;
|
|
||||||
let failedTests = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < tests.length; i++) {
|
|
||||||
for (let j = 0; j < tests[i].tests.length; j++) {
|
|
||||||
const result = await runTest(i, j);
|
|
||||||
if (result !== 'skip') {
|
|
||||||
totalTests++;
|
|
||||||
if (result === 'pass') passedTests++;
|
|
||||||
if (result === 'fail') failedTests++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('summary').style.display = 'flex';
|
|
||||||
document.getElementById('totalTests').textContent = totalTests;
|
|
||||||
document.getElementById('passedTests').textContent = passedTests;
|
|
||||||
document.getElementById('failedTests').textContent = failedTests;
|
|
||||||
|
|
||||||
button.disabled = false;
|
|
||||||
button.textContent = '▶ Run All Tests';
|
|
||||||
}
|
|
||||||
@ -1,666 +0,0 @@
|
|||||||
let authToken = null;
|
|
||||||
let householdId = null;
|
|
||||||
let storeId = null;
|
|
||||||
let testUserId = null;
|
|
||||||
let createdHouseholdId = null;
|
|
||||||
let secondHouseholdId = null;
|
|
||||||
let inviteCode = null;
|
|
||||||
|
|
||||||
const tests = [
|
|
||||||
{
|
|
||||||
category: "Authentication",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Login with valid credentials",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/auth/login",
|
|
||||||
auth: false,
|
|
||||||
body: () => ({ username: document.getElementById('username').value, password: document.getElementById('password').value }),
|
|
||||||
expect: (res) => res.token && res.role,
|
|
||||||
expectedFields: ['token', 'username', 'role'],
|
|
||||||
onSuccess: (res) => { authToken = res.token; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Login with invalid credentials",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/auth/login",
|
|
||||||
auth: false,
|
|
||||||
body: { username: "wronguser", password: "wrongpass" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 401,
|
|
||||||
expectedFields: ['message']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Access protected route without token",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: false,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 401
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Households",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get user's households",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
expect: (res) => Array.isArray(res),
|
|
||||||
onSuccess: (res) => { if (res.length > 0) householdId = res[0].id; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Create new household",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
body: { name: `Test Household ${Date.now()}` },
|
|
||||||
expect: (res) => res.household && res.household.invite_code,
|
|
||||||
expectedFields: ['message', 'household', 'household.id', 'household.name', 'household.invite_code']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Get household details",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
expect: (res) => res.id === householdId,
|
|
||||||
expectedFields: ['id', 'name', 'invite_code', 'created_at']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update household name",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${householdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
body: { name: `Updated Household ${Date.now()}` },
|
|
||||||
expect: (res) => res.household,
|
|
||||||
expectedFields: ['message', 'household', 'household.id', 'household.name']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Refresh invite code",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${householdId}/invite/refresh`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
expect: (res) => res.household && res.household.invite_code,
|
|
||||||
expectedFields: ['message', 'household', 'household.invite_code']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Join household with invalid code",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/households/join/INVALID123",
|
|
||||||
auth: true,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Create household with empty name (validation)",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
body: { name: "" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400,
|
|
||||||
expectedFields: ['error']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Members",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get household members",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${householdId}/members`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length > 0,
|
|
||||||
onSuccess: (res) => { testUserId = res[0].user_id; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update member role (non-admin attempting)",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${householdId}/members/${testUserId}/role`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !testUserId,
|
|
||||||
body: { role: "user" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400 || status === 403
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Stores",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get all stores catalog",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/stores",
|
|
||||||
auth: true,
|
|
||||||
expect: (res) => Array.isArray(res),
|
|
||||||
onSuccess: (res) => { if (res.length > 0) storeId = res[0].id; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Get household stores",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/stores/household/${householdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
expect: (res) => Array.isArray(res)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Add store to household",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/stores/household/${householdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
body: () => ({ storeId: storeId, isDefault: true }),
|
|
||||||
expect: (res) => res.store,
|
|
||||||
expectedFields: ['message', 'store', 'store.id', 'store.name']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Set default store",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/stores/household/${householdId}/${storeId}/default`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId || !storeId,
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Add invalid store to household",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/stores/household/${householdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !householdId,
|
|
||||||
body: { storeId: 99999 },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Advanced Household Tests",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Create household for complex workflows",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
body: { name: `Workflow Test ${Date.now()}` },
|
|
||||||
expect: (res) => res.household && res.household.id,
|
|
||||||
onSuccess: (res) => {
|
|
||||||
createdHouseholdId = res.household.id;
|
|
||||||
inviteCode = res.household.invite_code;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify invite code format (7 chars)",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expect: (res) => res.invite_code && res.invite_code.length === 7 && res.invite_code.startsWith('H')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Get household with no stores added yet",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length === 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update household with very long name (validation)",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
body: { name: "A".repeat(101) },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Refresh invite code changes value",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}/invite/refresh`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !inviteCode,
|
|
||||||
expect: (res) => res.household && res.household.invite_code !== inviteCode,
|
|
||||||
onSuccess: (res) => { inviteCode = res.household.invite_code; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Join same household twice (idempotent)",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/households/join/${inviteCode}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !inviteCode,
|
|
||||||
expect: (res, status) => status === 200 && res.message.includes("already a member")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Get non-existent household",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/households/99999",
|
|
||||||
auth: true,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update non-existent household",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: "/households/99999",
|
|
||||||
auth: true,
|
|
||||||
body: { name: "Test" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 403 || status === 404
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Member Management Edge Cases",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Get members for created household",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}/members`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length >= 1 && res[0].role === 'admin'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update own role (should fail)",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}/members/${testUserId}/role`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !testUserId,
|
|
||||||
body: { role: "user" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400 && res.error && res.error.includes("own role")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update role with invalid value",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}/members/1/role`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
body: { role: "superadmin" },
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Remove non-existent member",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}/members/99999`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404 || status === 500
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Store Management Advanced",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Add multiple stores to household",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !storeId,
|
|
||||||
body: () => ({ storeId: storeId, isDefault: false }),
|
|
||||||
expect: (res) => res.store
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Add same store twice (duplicate check)",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !storeId,
|
|
||||||
body: () => ({ storeId: storeId, isDefault: false }),
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 400 || status === 409 || status === 500
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Set default store for household",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}/default`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !storeId,
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify default store is first in list",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !storeId,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length > 0 && res[0].is_default === true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Set non-existent store as default",
|
|
||||||
method: "PATCH",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}/99999/default`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404 || status === 500
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Remove store from household",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId || !storeId,
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify store removed from household",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/stores/household/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length === 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Data Integrity & Cleanup",
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: "Create second household for testing",
|
|
||||||
method: "POST",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
body: { name: `Second Test ${Date.now()}` },
|
|
||||||
expect: (res) => res.household && res.household.id,
|
|
||||||
onSuccess: (res) => { secondHouseholdId = res.household.id; }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify user belongs to multiple households",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
expect: (res) => Array.isArray(res) && res.length >= 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Delete created test household",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify deleted household is gone",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: () => `/households/${createdHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !createdHouseholdId,
|
|
||||||
expectFail: true,
|
|
||||||
expect: (res, status) => status === 404 || status === 403
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Delete second test household",
|
|
||||||
method: "DELETE",
|
|
||||||
endpoint: () => `/households/${secondHouseholdId}`,
|
|
||||||
auth: true,
|
|
||||||
skip: () => !secondHouseholdId,
|
|
||||||
expect: (res) => res.message
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verify households list updated",
|
|
||||||
method: "GET",
|
|
||||||
endpoint: "/households",
|
|
||||||
auth: true,
|
|
||||||
expect: (res) => Array.isArray(res)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
async function makeRequest(test) {
|
|
||||||
const apiUrl = document.getElementById('apiUrl').value;
|
|
||||||
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
|
|
||||||
const url = `${apiUrl}${endpoint}`;
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
method: test.method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (test.auth && authToken) {
|
|
||||||
options.headers['Authorization'] = `Bearer ${authToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (test.body) {
|
|
||||||
options.body = JSON.stringify(typeof test.body === 'function' ? test.body() : test.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, options);
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
|
|
||||||
return { data, status: response.status };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTest(categoryIdx, testIdx) {
|
|
||||||
const test = tests[categoryIdx].tests[testIdx];
|
|
||||||
const testId = `test-${categoryIdx}-${testIdx}`;
|
|
||||||
const testEl = document.getElementById(testId);
|
|
||||||
const contentEl = document.getElementById(`${testId}-content`);
|
|
||||||
const toggleEl = document.getElementById(`${testId}-toggle`);
|
|
||||||
const resultEl = testEl.querySelector('.test-result');
|
|
||||||
|
|
||||||
// Auto-expand when running
|
|
||||||
contentEl.classList.add('expanded');
|
|
||||||
toggleEl.classList.add('expanded');
|
|
||||||
resultEl.style.display = 'block';
|
|
||||||
resultEl.className = 'test-result';
|
|
||||||
resultEl.innerHTML = '⚠️ Prerequisites not met';
|
|
||||||
return 'skip';
|
|
||||||
}
|
|
||||||
|
|
||||||
testEl.className = 'test-case running';
|
|
||||||
testEl.querySelector('.test-status').textContent = 'RUNNING';
|
|
||||||
testEl.querySelector('.test-status').className = 'test-status running';
|
|
||||||
resultEl.style.display = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data, status } = await makeRequest(test);
|
|
||||||
|
|
||||||
const expectFail = test.expectFail || false;
|
|
||||||
const passed = test.expect(data, status);
|
|
||||||
|
|
||||||
const success = expectFail ? !passed || status >= 400 : passed;
|
|
||||||
|
|
||||||
testEl.className = success ? 'test-case pass' : 'test-case fail';
|
|
||||||
testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL';
|
|
||||||
testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`;
|
|
||||||
|
|
||||||
// Determine status code class
|
|
||||||
let statusClass = 'status-5xx';
|
|
||||||
if (status >= 200 && status < 300) statusClass = 'status-2xx';
|
|
||||||
else if (status >= 300 && status < 400) statusClass = 'status-3xx';
|
|
||||||
else if (status >= 400 && status < 500) statusClass = 'status-4xx';
|
|
||||||
|
|
||||||
resultEl.style.display = 'block';
|
|
||||||
resultEl.className = 'test-result';
|
|
||||||
|
|
||||||
// Check expected fields if defined
|
|
||||||
let expectedFieldsHTML = '';
|
|
||||||
if (test.expectedFields) {
|
|
||||||
const fieldChecks = test.expectedFields.map(field => {
|
|
||||||
const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined;
|
|
||||||
const icon = exists ? '✓' : '✗';
|
|
||||||
const className = exists ? 'pass' : 'fail';
|
|
||||||
return `<div class="field-check ${className}">${icon} ${field}</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
expectedFieldsHTML = `
|
|
||||||
<div class="expected-section">
|
|
||||||
<div class="expected-label">Expected Fields:</div>
|
|
||||||
${fieldChecks}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
resultEl.innerHTML = `
|
|
||||||
<div style="margin-bottom: 8px;">
|
|
||||||
<span class="response-status ${statusClass}">HTTP ${status}</span>
|
|
||||||
<span style="color: #666;">${success ? '✓ Test passed' : '✗ Test failed'}</span>
|
|
||||||
</div>
|
|
||||||
${expectedFieldsHTML}
|
|
||||||
<div style="color: #666; font-size: 12px; margin-bottom: 4px;">Response:</div>
|
|
||||||
<div>${JSON.stringify(data, null, 2)}</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (success && test.onSuccess) {
|
|
||||||
test.onSuccess(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
return success ? 'pass' : 'fail';
|
|
||||||
} catch (error) {
|
|
||||||
testEl.className = 'test-case fail';
|
|
||||||
testEl.querySelector('.test-status').textContent = 'ERROR';
|
|
||||||
testEl.querySelector('.test-status').className = 'test-status fail';
|
|
||||||
|
|
||||||
resultEl.style.display = 'block';
|
|
||||||
resultEl.className = 'test-error';
|
|
||||||
resultEl.innerHTML = `
|
|
||||||
<div style="font-weight: bold; margin-bottom: 8px;">❌ Network/Request Error</div>
|
|
||||||
<div>${error.message}</div>
|
|
||||||
${error.stack ? `<div style="margin-top: 8px; font-size: 11px; opacity: 0.7;">${error.stack}</div>` : ''}
|
|
||||||
`;
|
|
||||||
return 'fail';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAllTests(event) {
|
|
||||||
authToken = null;
|
|
||||||
householdId = null;
|
|
||||||
storeId = null;
|
|
||||||
testUserId = null;
|
|
||||||
createdHouseholdId = null;
|
|
||||||
secondHouseholdId = null;
|
|
||||||
inviteCode = null;
|
|
||||||
|
|
||||||
const button = event.target;
|
|
||||||
button.disabled = true;
|
|
||||||
button.textContent = '⏳ Running Tests...';
|
|
||||||
|
|
||||||
let totalTests = 0;
|
|
||||||
let passedTests = 0;
|
|
||||||
let failedTests = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < tests.length; i++) {
|
|
||||||
for (let j = 0; j < tests[i].tests.length; j++) {
|
|
||||||
const result = await runTest(i, j);
|
|
||||||
if (result !== 'skip') {
|
|
||||||
totalTests++;
|
|
||||||
if (result === 'pass') passedTests++;
|
|
||||||
if (result === 'fail') failedTests++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('summary').style.display = 'flex';
|
|
||||||
document.getElementById('totalTests').textContent = totalTests;
|
|
||||||
document.getElementById('passedTests').textContent = passedTests;
|
|
||||||
document.getElementById('failedTests').textContent = failedTests;
|
|
||||||
|
|
||||||
button.disabled = false;
|
|
||||||
button.textContent = '▶ Run All Tests';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTest(testId) {
|
|
||||||
const content = document.getElementById(`${testId}-content`);
|
|
||||||
const toggle = document.getElementById(`${testId}-toggle`);
|
|
||||||
|
|
||||||
if (content.classList.contains('expanded')) {
|
|
||||||
content.classList.remove('expanded');
|
|
||||||
toggle.classList.remove('expanded');
|
|
||||||
} else {
|
|
||||||
content.classList.add('expanded');
|
|
||||||
toggle.classList.add('expanded');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandAllTests() {
|
|
||||||
document.querySelectorAll('.test-content').forEach(content => {
|
|
||||||
content.classList.add('expanded');
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.toggle-icon').forEach(icon => {
|
|
||||||
icon.classList.add('expanded');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapseAllTests() {
|
|
||||||
document.querySelectorAll('.test-content').forEach(content => {
|
|
||||||
content.classList.remove('expanded');
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.toggle-icon').forEach(icon => {
|
|
||||||
icon.classList.remove('expanded');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearResults() {
|
|
||||||
renderTests();
|
|
||||||
document.getElementById('summary').style.display = 'none';
|
|
||||||
authToken = null;
|
|
||||||
householdId = null;
|
|
||||||
storeId = null;
|
|
||||||
testUserId = null;
|
|
||||||
createdHouseholdId = null;
|
|
||||||
secondHouseholdId = null;
|
|
||||||
inviteCode = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTests() {
|
|
||||||
const container = document.getElementById('testResults');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
tests.forEach((category, catIdx) => {
|
|
||||||
const categoryDiv = document.createElement('div');
|
|
||||||
categoryDiv.className = 'test-category';
|
|
||||||
|
|
||||||
const categoryHeader = document.createElement('h2');
|
|
||||||
categoryHeader.textContent = category.category;
|
|
||||||
categoryDiv.appendChild(categoryHeader);
|
|
||||||
|
|
||||||
category.tests.forEach((test, testIdx) => {
|
|
||||||
const testDiv = document.createElement('div');
|
|
||||||
testDiv.className = 'test-case';
|
|
||||||
testDiv.id = `test-${catIdx}-${testIdx}`;
|
|
||||||
|
|
||||||
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
|
|
||||||
|
|
||||||
testDiv.innerHTML = `
|
|
||||||
<div class="test-header" onclick="toggleTest('${testDiv.id}')">
|
|
||||||
<div class="test-name">
|
|
||||||
<span class="toggle-icon" id="${testDiv.id}-toggle">▶</span>
|
|
||||||
${test.name}
|
|
||||||
</div>
|
|
||||||
<div class="test-status pending">PENDING</div>
|
|
||||||
</div>
|
|
||||||
<div class="test-content" id="${testDiv.id}-content">
|
|
||||||
<div class="test-details">
|
|
||||||
<strong>${test.method}</strong> ${endpoint}
|
|
||||||
${test.expectFail ? ' <span style="color: #dc3545; font-weight: 600;">(Expected to fail)</span>' : ''}
|
|
||||||
${test.auth ? ' <span style="color: #0066cc; font-weight: 600;">🔒 Requires Auth</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<div class="test-result" style="display: none;"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
categoryDiv.appendChild(testDiv);
|
|
||||||
});
|
|
||||||
|
|
||||||
container.appendChild(categoryDiv);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
renderTests();
|
|
||||||
@ -1,309 +0,0 @@
|
|||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-row label {
|
|
||||||
min-width: 100px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-row input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: #0066cc;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background: #0052a3;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
background: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-category {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-category h2 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
border-bottom: 2px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-case {
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border-left: 4px solid #ddd;
|
|
||||||
background: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-case.running {
|
|
||||||
border-left-color: #ffa500;
|
|
||||||
background: #fff8e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-case.pass {
|
|
||||||
border-left-color: #28a745;
|
|
||||||
background: #e8f5e9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-case.fail {
|
|
||||||
border-left-color: #dc3545;
|
|
||||||
background: #ffebee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-header:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.02);
|
|
||||||
margin: -5px;
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-icon {
|
|
||||||
font-size: 12px;
|
|
||||||
margin-right: 8px;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-icon.expanded {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-content {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-content.expanded {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-status {
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-status.pending {
|
|
||||||
background: #e0e0e0;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-status.running {
|
|
||||||
background: #ffa500;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-status.pass {
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-status.fail {
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-details {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-result {
|
|
||||||
font-size: 13px;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: monospace;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expected-section {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
background: #f0f7ff;
|
|
||||||
border-left: 3px solid #2196f3;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expected-label {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #1976d2;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-check {
|
|
||||||
margin: 2px 0;
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-check.pass {
|
|
||||||
color: #2e7d32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-check.fail {
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-error {
|
|
||||||
font-size: 13px;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
background: #fff5f5;
|
|
||||||
border: 1px solid #ffcdd2;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #c62828;
|
|
||||||
font-family: monospace;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.response-status {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-2xx {
|
|
||||||
background: #c8e6c9;
|
|
||||||
color: #2e7d32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-3xx {
|
|
||||||
background: #fff9c4;
|
|
||||||
color: #f57f17;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-4xx {
|
|
||||||
background: #ffccbc;
|
|
||||||
color: #d84315;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-5xx {
|
|
||||||
background: #ffcdd2;
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-item {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-item.total {
|
|
||||||
background: #e3f2fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-item.pass {
|
|
||||||
background: #e8f5e9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-item.fail {
|
|
||||||
background: #ffebee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-value {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
function toggleTest(testId) {
|
|
||||||
const content = document.getElementById(`${testId}-content`);
|
|
||||||
const toggle = document.getElementById(`${testId}-toggle`);
|
|
||||||
|
|
||||||
if (content.classList.contains('expanded')) {
|
|
||||||
content.classList.remove('expanded');
|
|
||||||
toggle.classList.remove('expanded');
|
|
||||||
} else {
|
|
||||||
content.classList.add('expanded');
|
|
||||||
toggle.classList.add('expanded');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandAllTests() {
|
|
||||||
document.querySelectorAll('.test-content').forEach(content => {
|
|
||||||
content.classList.add('expanded');
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.toggle-icon').forEach(icon => {
|
|
||||||
icon.classList.add('expanded');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapseAllTests() {
|
|
||||||
document.querySelectorAll('.test-content').forEach(content => {
|
|
||||||
content.classList.remove('expanded');
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.toggle-icon').forEach(icon => {
|
|
||||||
icon.classList.remove('expanded');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearResults() {
|
|
||||||
renderTests();
|
|
||||||
document.getElementById('summary').style.display = 'none';
|
|
||||||
resetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTests() {
|
|
||||||
const container = document.getElementById('testResults');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
tests.forEach((category, catIdx) => {
|
|
||||||
const categoryDiv = document.createElement('div');
|
|
||||||
categoryDiv.className = 'test-category';
|
|
||||||
|
|
||||||
const categoryHeader = document.createElement('h2');
|
|
||||||
categoryHeader.textContent = category.category;
|
|
||||||
categoryDiv.appendChild(categoryHeader);
|
|
||||||
|
|
||||||
category.tests.forEach((test, testIdx) => {
|
|
||||||
const testDiv = document.createElement('div');
|
|
||||||
testDiv.className = 'test-case';
|
|
||||||
testDiv.id = `test-${catIdx}-${testIdx}`;
|
|
||||||
|
|
||||||
const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint;
|
|
||||||
|
|
||||||
testDiv.innerHTML = `
|
|
||||||
<div class="test-header" onclick="toggleTest('${testDiv.id}')">
|
|
||||||
<div class="test-name">
|
|
||||||
<span class="toggle-icon" id="${testDiv.id}-toggle">▶</span>
|
|
||||||
${test.name}
|
|
||||||
</div>
|
|
||||||
<div class="test-status pending">PENDING</div>
|
|
||||||
</div>
|
|
||||||
<div class="test-content" id="${testDiv.id}-content">
|
|
||||||
<div class="test-details">
|
|
||||||
<strong>${test.method}</strong> ${endpoint}
|
|
||||||
${test.expectFail ? ' <span style="color: #dc3545; font-weight: 600;">(Expected to fail)</span>' : ''}
|
|
||||||
${test.auth ? ' <span style="color: #0066cc; font-weight: 600;">🔒 Requires Auth</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<div class="test-result" style="display: none;"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
categoryDiv.appendChild(testDiv);
|
|
||||||
});
|
|
||||||
|
|
||||||
container.appendChild(categoryDiv);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
renderTests();
|
|
||||||
});
|
|
||||||
@ -4,9 +4,8 @@ const requireRole = require("../middleware/rbac");
|
|||||||
const usersController = require("../controllers/users.controller");
|
const usersController = require("../controllers/users.controller");
|
||||||
const { ROLES } = require("../models/user.model");
|
const { ROLES } = require("../models/user.model");
|
||||||
|
|
||||||
// router.get("/users", auth, (req, res, next) => next(), usersController.getAllUsers);
|
router.get("/users", auth, requireRole(ROLES.ADMIN), usersController.getAllUsers);
|
||||||
router.get("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.getAllUsers);
|
router.put("/users", auth, requireRole(ROLES.ADMIN), usersController.updateUserRole);
|
||||||
router.put("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.updateUserRole);
|
router.delete("/users", auth, requireRole(ROLES.ADMIN), usersController.deleteUser);
|
||||||
router.delete("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.deleteUser);
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -1,169 +0,0 @@
|
|||||||
const express = require("express");
|
|
||||||
const router = express.Router();
|
|
||||||
const controller = require("../controllers/households.controller");
|
|
||||||
const listsController = require("../controllers/lists.controller.v2");
|
|
||||||
const auth = require("../middleware/auth");
|
|
||||||
const {
|
|
||||||
householdAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
storeAccess,
|
|
||||||
} = require("../middleware/household");
|
|
||||||
const { upload, processImage } = require("../middleware/image");
|
|
||||||
|
|
||||||
// Public routes (authenticated only)
|
|
||||||
router.get("/", auth, controller.getUserHouseholds);
|
|
||||||
router.post("/", auth, controller.createHousehold);
|
|
||||||
router.post("/join/:inviteCode", auth, controller.joinHousehold);
|
|
||||||
|
|
||||||
// Household-scoped routes (member access required)
|
|
||||||
router.get("/:householdId", auth, householdAccess, controller.getHousehold);
|
|
||||||
router.patch(
|
|
||||||
"/:householdId",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
controller.updateHousehold
|
|
||||||
);
|
|
||||||
router.delete(
|
|
||||||
"/:householdId",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
controller.deleteHousehold
|
|
||||||
);
|
|
||||||
router.post(
|
|
||||||
"/:householdId/invite/refresh",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
controller.refreshInviteCode
|
|
||||||
);
|
|
||||||
|
|
||||||
// Member management routes
|
|
||||||
router.get(
|
|
||||||
"/:householdId/members",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
controller.getMembers
|
|
||||||
);
|
|
||||||
router.patch(
|
|
||||||
"/:householdId/members/:userId/role",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
controller.updateMemberRole
|
|
||||||
);
|
|
||||||
router.delete(
|
|
||||||
"/:householdId/members/:userId",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
controller.removeMember
|
|
||||||
);
|
|
||||||
|
|
||||||
// ==================== List Operations Routes ====================
|
|
||||||
// All list routes require household access AND store access
|
|
||||||
|
|
||||||
// Get grocery list
|
|
||||||
router.get(
|
|
||||||
"/:householdId/stores/:storeId/list",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
listsController.getList
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get specific item by name
|
|
||||||
router.get(
|
|
||||||
"/:householdId/stores/:storeId/list/item",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
listsController.getItemByName
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add item to list
|
|
||||||
router.post(
|
|
||||||
"/:householdId/stores/:storeId/list/add",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
upload.single("image"),
|
|
||||||
processImage,
|
|
||||||
listsController.addItem
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mark item as bought/unbought
|
|
||||||
router.patch(
|
|
||||||
"/:householdId/stores/:storeId/list/item",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
listsController.markBought
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update item details (quantity, notes)
|
|
||||||
router.put(
|
|
||||||
"/:householdId/stores/:storeId/list/item",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
listsController.updateItem
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete item
|
|
||||||
router.delete(
|
|
||||||
"/:householdId/stores/:storeId/list/item",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
listsController.deleteItem
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get suggestions
|
|
||||||
router.get(
|
|
||||||
"/:householdId/stores/:storeId/list/suggestions",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
listsController.getSuggestions
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get recently bought items
|
|
||||||
router.get(
|
|
||||||
"/:householdId/stores/:storeId/list/recent",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
listsController.getRecentlyBought
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get item classification
|
|
||||||
router.get(
|
|
||||||
"/:householdId/stores/:storeId/list/classification",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
listsController.getClassification
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set item classification
|
|
||||||
router.post(
|
|
||||||
"/:householdId/stores/:storeId/list/classification",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
listsController.setClassification
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update item image
|
|
||||||
router.post(
|
|
||||||
"/:householdId/stores/:storeId/list/update-image",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
upload.single("image"),
|
|
||||||
processImage,
|
|
||||||
listsController.updateItemImage
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
const express = require("express");
|
|
||||||
const router = express.Router();
|
|
||||||
const controller = require("../controllers/stores.controller");
|
|
||||||
const auth = require("../middleware/auth");
|
|
||||||
const {
|
|
||||||
householdAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
requireSystemAdmin,
|
|
||||||
} = require("../middleware/household");
|
|
||||||
|
|
||||||
// Public routes
|
|
||||||
router.get("/", auth, controller.getAllStores);
|
|
||||||
|
|
||||||
// Household store management
|
|
||||||
router.get(
|
|
||||||
"/household/:householdId",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
controller.getHouseholdStores
|
|
||||||
);
|
|
||||||
router.post(
|
|
||||||
"/household/:householdId",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
controller.addStoreToHousehold
|
|
||||||
);
|
|
||||||
router.delete(
|
|
||||||
"/household/:householdId/:storeId",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
controller.removeStoreFromHousehold
|
|
||||||
);
|
|
||||||
router.patch(
|
|
||||||
"/household/:householdId/:storeId/default",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
controller.setDefaultStore
|
|
||||||
);
|
|
||||||
|
|
||||||
// System admin routes
|
|
||||||
router.post("/", auth, requireSystemAdmin, controller.createStore);
|
|
||||||
router.patch("/:storeId", auth, requireSystemAdmin, controller.updateStore);
|
|
||||||
router.delete("/:storeId", auth, requireSystemAdmin, controller.deleteStore);
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
services:
|
|
||||||
backend:
|
|
||||||
image: git.nicosaya.com/nalalangan/costco-grocery-list/backend:main-new
|
|
||||||
restart: always
|
|
||||||
env_file:
|
|
||||||
- ./backend.env
|
|
||||||
ports:
|
|
||||||
- "5001:5000"
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
image: git.nicosaya.com/nalalangan/costco-grocery-list/frontend:main-new
|
|
||||||
restart: always
|
|
||||||
env_file:
|
|
||||||
- ./frontend.env
|
|
||||||
ports:
|
|
||||||
- "3001:5173"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
# Documentation Index
|
|
||||||
|
|
||||||
This directory contains all project documentation organized by category.
|
|
||||||
|
|
||||||
## 📁 Directory Structure
|
|
||||||
|
|
||||||
### `/architecture` - System Design & Structure
|
|
||||||
- **[component-structure.md](architecture/component-structure.md)** - Frontend component organization and patterns
|
|
||||||
- **[multi-household-architecture-plan.md](architecture/multi-household-architecture-plan.md)** - Multi-household system architecture design
|
|
||||||
|
|
||||||
### `/features` - Feature Implementation Details
|
|
||||||
- **[classification-implementation.md](features/classification-implementation.md)** - Item classification system (zones, types, groups)
|
|
||||||
- **[image-storage-implementation.md](features/image-storage-implementation.md)** - Image storage and handling (bytea, MIME types)
|
|
||||||
|
|
||||||
### `/guides` - How-To & Reference Guides
|
|
||||||
- **[api-documentation.md](guides/api-documentation.md)** - REST API endpoints and usage
|
|
||||||
- **[frontend-readme.md](guides/frontend-readme.md)** - Frontend development guide
|
|
||||||
- **[MOBILE_RESPONSIVE_AUDIT.md](guides/MOBILE_RESPONSIVE_AUDIT.md)** - Mobile-first design guidelines and audit checklist
|
|
||||||
- **[setup-checklist.md](guides/setup-checklist.md)** - Development environment setup steps
|
|
||||||
|
|
||||||
### `/migration` - Database Migrations & Updates
|
|
||||||
- **[MIGRATION_GUIDE.md](migration/MIGRATION_GUIDE.md)** - Multi-household migration instructions (also in `backend/migrations/`)
|
|
||||||
- **[POST_MIGRATION_UPDATES.md](migration/POST_MIGRATION_UPDATES.md)** - Required updates after migration
|
|
||||||
|
|
||||||
### `/archive` - Completed Implementation Records
|
|
||||||
Historical documentation of completed features. Useful for reference but not actively maintained.
|
|
||||||
|
|
||||||
- **[ACCOUNT_MANAGEMENT_IMPLEMENTATION.md](archive/ACCOUNT_MANAGEMENT_IMPLEMENTATION.md)** - Phase 4: Display name and password change
|
|
||||||
- **[code-cleanup-guide.md](archive/code-cleanup-guide.md)** - Code cleanup checklist (completed)
|
|
||||||
- **[HOUSEHOLD_MANAGEMENT_IMPLEMENTATION.md](archive/HOUSEHOLD_MANAGEMENT_IMPLEMENTATION.md)** - Household management UI implementation
|
|
||||||
- **[IMPLEMENTATION_STATUS.md](archive/IMPLEMENTATION_STATUS.md)** - Multi-household migration sprint status
|
|
||||||
- **[settings-dark-mode.md](archive/settings-dark-mode.md)** - Dark mode implementation notes
|
|
||||||
- **[TEST_SUITE_README.md](archive/TEST_SUITE_README.md)** - Testing infrastructure documentation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📄 Root-Level Documentation
|
|
||||||
|
|
||||||
These files remain at the project root for easy access:
|
|
||||||
|
|
||||||
- **[../README.md](../README.md)** - Project overview and quick start
|
|
||||||
- **[../.github/copilot-instructions.md](../.github/copilot-instructions.md)** - AI assistant instructions (architecture, RBAC, conventions)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Quick Reference
|
|
||||||
|
|
||||||
**Setting up the project?** → Start with [setup-checklist.md](guides/setup-checklist.md)
|
|
||||||
|
|
||||||
**Understanding the API?** → See [api-documentation.md](guides/api-documentation.md)
|
|
||||||
|
|
||||||
**Working on mobile UI?** → Check [MOBILE_RESPONSIVE_AUDIT.md](guides/MOBILE_RESPONSIVE_AUDIT.md)
|
|
||||||
|
|
||||||
**Need architecture context?** → Read [../.github/copilot-instructions.md](../.github/copilot-instructions.md)
|
|
||||||
|
|
||||||
**Running migrations?** → Follow [MIGRATION_GUIDE.md](migration/MIGRATION_GUIDE.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Contributing to Documentation
|
|
||||||
|
|
||||||
When adding new documentation:
|
|
||||||
|
|
||||||
1. **Guides** (`/guides`) - General how-to, setup, reference
|
|
||||||
2. **Features** (`/features`) - Specific feature implementation details
|
|
||||||
3. **Architecture** (`/architecture`) - System design, patterns, structure
|
|
||||||
4. **Migration** (`/migration`) - Database migrations and upgrade guides
|
|
||||||
5. **Archive** (`/archive`) - Completed implementation records (for reference only)
|
|
||||||
|
|
||||||
Keep documentation:
|
|
||||||
- ✅ Up-to-date with code changes
|
|
||||||
- ✅ Concise and scannable
|
|
||||||
- ✅ Linked to relevant files (use relative paths)
|
|
||||||
- ✅ Organized by category
|
|
||||||
@ -1,865 +0,0 @@
|
|||||||
# Multi-Household & Multi-Store Architecture Plan
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
This document outlines the architecture and implementation strategy for extending the application to support:
|
|
||||||
1. **Multiple Households** - Users can belong to multiple households (families, roommates, etc.)
|
|
||||||
2. **Multiple Stores** - Households can manage lists for different store types (Costco, Target, Walmart, etc.)
|
|
||||||
|
|
||||||
## Current Architecture Analysis
|
|
||||||
|
|
||||||
### Existing Schema
|
|
||||||
```sql
|
|
||||||
users (id, username, password, name, role, display_name)
|
|
||||||
grocery_list (id, item_name, quantity, bought, item_image, image_mime_type, added_by, modified_on)
|
|
||||||
grocery_history (id, list_item_id, quantity, added_by, added_on)
|
|
||||||
item_classification (id, item_type, item_group, zone, confidence, source)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Current Limitations
|
|
||||||
- **Single global list** - All users share one grocery list
|
|
||||||
- **No household concept** - Cannot separate different families' items
|
|
||||||
- **Store-specific zones** - Classification system assumes Costco layout
|
|
||||||
- **Single-level roles** - User has same role everywhere (cannot be admin in one household, viewer in another)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Considerations & Trade-offs
|
|
||||||
|
|
||||||
### Key Questions to Resolve
|
|
||||||
|
|
||||||
#### 1. Item Management Strategy
|
|
||||||
|
|
||||||
**Option A: Shared Item Master (Recommended)**
|
|
||||||
- ✅ **Pro**: Single source of truth for item definitions (name, default image, common classification)
|
|
||||||
- ✅ **Pro**: Consistent item naming across households
|
|
||||||
- ✅ **Pro**: Can build item recommendation system across all households
|
|
||||||
- ✅ **Pro**: Easier to implement smart features (price tracking, common items)
|
|
||||||
- ❌ **Con**: Requires careful privacy controls (who can see which items)
|
|
||||||
- ❌ **Con**: Different households may classify items differently
|
|
||||||
|
|
||||||
**Option B: Per-Household Items**
|
|
||||||
- ✅ **Pro**: Complete household isolation
|
|
||||||
- ✅ **Pro**: Each household fully controls item definitions
|
|
||||||
- ✅ **Pro**: No privacy concerns about item names
|
|
||||||
- ❌ **Con**: Duplicate data across households
|
|
||||||
- ❌ **Con**: Cannot leverage cross-household intelligence
|
|
||||||
- ❌ **Con**: More complex to implement suggestions
|
|
||||||
|
|
||||||
**Option C: Hybrid Approach (RECOMMENDED)**
|
|
||||||
- ✅ **Pro**: Best of both worlds
|
|
||||||
- ✅ **Pro**: Shared item catalog with household-specific classifications
|
|
||||||
- ✅ **Pro**: Privacy-preserving (only households share item usage, not personal data)
|
|
||||||
- **How it works**:
|
|
||||||
- Global `items` table (id, name, default_image, created_at)
|
|
||||||
- Household-specific `household_list` table references item + household
|
|
||||||
- Each household can override classifications per store
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposed Schema Design
|
|
||||||
|
|
||||||
### New Tables
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Households (e.g., "Smith Family", "Apartment 5B")
|
|
||||||
CREATE TABLE households (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
created_by INTEGER REFERENCES users(id),
|
|
||||||
invite_code VARCHAR(20) UNIQUE NOT NULL, -- Random code for inviting users
|
|
||||||
code_expires_at TIMESTAMP -- Optional expiration
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Store Types (e.g., "Costco", "Target", "Walmart")
|
|
||||||
CREATE TABLE stores (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(50) NOT NULL UNIQUE,
|
|
||||||
default_zones JSONB, -- Store-specific zone layout
|
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- User-Household Membership with per-household roles
|
|
||||||
CREATE TABLE household_members (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
|
|
||||||
joined_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
UNIQUE(household_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Household-Store Relationship (which stores does this household shop at?)
|
|
||||||
CREATE TABLE household_stores (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
|
||||||
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
|
|
||||||
is_default BOOLEAN DEFAULT FALSE, -- Default store for this household
|
|
||||||
UNIQUE(household_id, store_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Master Item Catalog (shared across all households)
|
|
||||||
CREATE TABLE items (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
default_image BYTEA,
|
|
||||||
default_image_mime_type VARCHAR(50),
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
usage_count INTEGER DEFAULT 0 -- For popularity tracking
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Household-specific grocery lists (per store)
|
|
||||||
CREATE TABLE household_lists (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
|
||||||
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
|
|
||||||
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
quantity INTEGER NOT NULL DEFAULT 1,
|
|
||||||
bought BOOLEAN DEFAULT FALSE,
|
|
||||||
custom_image BYTEA, -- Household can override item image
|
|
||||||
custom_image_mime_type VARCHAR(50),
|
|
||||||
added_by INTEGER REFERENCES users(id),
|
|
||||||
modified_on TIMESTAMP DEFAULT NOW(),
|
|
||||||
UNIQUE(household_id, store_id, item_id) -- One item per household+store combo
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Household-specific item classifications (per store)
|
|
||||||
CREATE TABLE household_item_classifications (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
|
|
||||||
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
|
|
||||||
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
item_type VARCHAR(50),
|
|
||||||
item_group VARCHAR(100),
|
|
||||||
zone VARCHAR(100),
|
|
||||||
confidence DECIMAL(3,2) DEFAULT 1.0,
|
|
||||||
source VARCHAR(20) DEFAULT 'user',
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
UNIQUE(household_id, store_id, item_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- History tracking (who added what, when, to which household+store list)
|
|
||||||
CREATE TABLE household_list_history (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE,
|
|
||||||
quantity INTEGER NOT NULL,
|
|
||||||
added_by INTEGER REFERENCES users(id),
|
|
||||||
added_on TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Indexes for Performance
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Household member lookups
|
|
||||||
CREATE INDEX idx_household_members_user ON household_members(user_id);
|
|
||||||
CREATE INDEX idx_household_members_household ON household_members(household_id);
|
|
||||||
|
|
||||||
-- List queries (most common operations)
|
|
||||||
CREATE INDEX idx_household_lists_household_store ON household_lists(household_id, store_id);
|
|
||||||
CREATE INDEX idx_household_lists_bought ON household_lists(household_id, store_id, bought);
|
|
||||||
|
|
||||||
-- Item search
|
|
||||||
CREATE INDEX idx_items_name ON items(name);
|
|
||||||
CREATE INDEX idx_items_usage_count ON items(usage_count DESC);
|
|
||||||
|
|
||||||
-- Classification lookups
|
|
||||||
CREATE INDEX idx_household_classifications ON household_item_classifications(household_id, store_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Role System Redesign
|
|
||||||
|
|
||||||
### Dual-Role Hierarchy: System-Wide + Household-Scoped
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// System-wide roles (app administration)
|
|
||||||
users {
|
|
||||||
id, username, password, name, display_name,
|
|
||||||
role: 'system_admin' | 'user' // Kept for app-wide controls
|
|
||||||
}
|
|
||||||
|
|
||||||
// Household-scoped roles (per-household permissions)
|
|
||||||
household_members {
|
|
||||||
household_id, user_id,
|
|
||||||
role: 'admin' | 'user'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### System-Wide Role Definitions
|
|
||||||
|
|
||||||
| Role | Permissions |
|
|
||||||
|------|-------------|
|
|
||||||
| **system_admin** | Create/delete stores globally, view all households (moderation), manage global item catalog, access system metrics, promote users to system_admin |
|
|
||||||
| **user** | Standard user - can create households, join households via invite, manage own profile |
|
|
||||||
|
|
||||||
### Household-Scoped Role Definitions
|
|
||||||
|
|
||||||
| Role | Permissions |
|
|
||||||
|------|-------------|
|
|
||||||
| **admin** | Full household control: delete household, invite/remove members, change member roles, manage stores, add/edit/delete items, mark bought, upload images, update classifications |
|
|
||||||
| **user** | Standard member: add/edit/delete items, mark bought, upload images, update classifications, view all lists |
|
|
||||||
|
|
||||||
### Role Transition Plan
|
|
||||||
|
|
||||||
**Migration Strategy:**
|
|
||||||
1. Create default household "Main Household"
|
|
||||||
2. Migrate all existing users → household_members (old admins become household admins, others become users)
|
|
||||||
3. Keep existing `users.role` column, update values:
|
|
||||||
- `admin` → `system_admin` (app-wide admin)
|
|
||||||
- `editor` → `user` (standard user)
|
|
||||||
- `viewer` → `user` (standard user)
|
|
||||||
4. Migrate grocery_list → household_lists (all to default household + default store)
|
|
||||||
5. Migrate item_classification → household_item_classifications
|
|
||||||
|
|
||||||
---
|
|
||||||
, systemRole } // System-wide role
|
|
||||||
req.household = { id, name, role } // Household-scoped role
|
|
||||||
req.store = { id, name } // Active store context
|
|
||||||
### Authentication Context
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
req.user = { id, username, role }
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
req.user = { id, username }
|
|
||||||
req.household = { id, name, role } // Set by household middleware
|
|
||||||
req.store = { id, name } // Set by store middleware
|
|
||||||
```
|
|
||||||
|
|
||||||
### Middleware Chain with systemRole)
|
|
||||||
router.use(auth);
|
|
||||||
|
|
||||||
// 2. Household middleware (validates household access, sets req.household with householdRole)
|
|
||||||
router.use('/households/:householdId', householdAccess);
|
|
||||||
|
|
||||||
// 3. Household role middleware (checks household-scoped permissions)
|
|
||||||
router.post('/add', requireHouseholdRole(['user', 'admin']), controller.addItem);
|
|
||||||
|
|
||||||
// 4. Admin-only household operations
|
|
||||||
router.delete('/:id', requireHouseholdRole(['admin']), controller.deleteHousehold);
|
|
||||||
|
|
||||||
// 5. System admin middleware (for app-wide operations)
|
|
||||||
router.post('/stores', requireSystemRole('system_admin'), controller.createStore
|
|
||||||
|
|
||||||
// 3. Role middleware (checks household-specific role)
|
|
||||||
rouSystem Administration (system_admin only)
|
|
||||||
GET /api/admin/stores // Manage all stores
|
|
||||||
POST /api/admin/stores // Create new store type
|
|
||||||
PATCH /api/admin/stores/:id // Update store
|
|
||||||
DELETE /api/admin/stores/:id // Delete store (if unused)
|
|
||||||
GET /api/admin/households // View all households (moderation)
|
|
||||||
GET /api/admin/items // Manage global item catalog
|
|
||||||
GET /api/admin/metrics // System-wide analytics
|
|
||||||
|
|
||||||
// Household Management (any user can create)
|
|
||||||
GET /api/households // Get all households user belongs to
|
|
||||||
POST /api/households // Create new household (any user)
|
|
||||||
GET /api/households/:id // Get household details
|
|
||||||
PATCH /api/households/:id // Update household (admin only)
|
|
||||||
DELETE /api/households/:id // Delete household (admin only)
|
|
||||||
|
|
||||||
// Household Members
|
|
||||||
GET /api/households/:id/members // List members (all roles)
|
|
||||||
POST /api/households/:id/invite // Generate/refresh invite code (admin only)
|
|
||||||
POST /api/households/join/:inviteCode // Join household via invite code (joins as 'user')
|
|
||||||
PATCH /api/households/:id/members/:userId // Update member role (admin only)
|
|
||||||
DELETE /api/households/:id/members/:userId // Remove member (admin only, or self)
|
|
||||||
|
|
||||||
// Store Management
|
|
||||||
GET /api/stores // Get all available store types
|
|
||||||
GET /api/households/:id/stores // Get stores for household
|
|
||||||
POST /api/households/:id/stores // Add store to household (admin only)
|
|
||||||
DELETE /api/households/:id/stores/:storeId // Remove store from household (admin only)
|
|
||||||
// Store Management
|
|
||||||
GET /api/stores // Get all available stores
|
|
||||||
POST /api/stores // Create custom store (system admin)
|
|
||||||
GET /api/households/:id/stores // Get stores for household
|
|
||||||
POST /api/households/:id/stores // Add store to household (admin+)
|
|
||||||
DELETE /api/households/:id/stores/:storeId // Remove store (admin+)
|
|
||||||
|
|
||||||
// List Operations (now scoped to household + store)
|
|
||||||
GET /api/households/:hId/stores/:sId/list // Get list
|
|
||||||
POST /api/households/:hId/stores/:sId/list/add // Add item
|
|
||||||
PATCH /api/households/:hId/stores/:sId/list/:itemId // Update item
|
|
||||||
DELETE /api/households/:hId/stores/:sId/list/:itemId // Delete item
|
|
||||||
POST /api/households/:hId/stores/:sId/list/:itemId/buy // Mark bought
|
|
||||||
|
|
||||||
// Item Suggestions (across user's households)
|
|
||||||
GET /api/items/suggestions?q=milk // Search master catalog
|
|
||||||
|
|
||||||
// Classifications (per household + store)
|
|
||||||
GET /api/households/:hId/stores/:sId/classifications/:itemId
|
|
||||||
POST /api/households/:hId/stores/:sId/classifications/:itemId
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## React Context Refactoring Pattern
|
|
||||||
|
|
||||||
### Current Pattern (To Be Replaced)
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// Bad: Context is exported, consumers use it directly
|
|
||||||
export const AuthContext = createContext(null);
|
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
|
||||||
const [user, setUser] = useState(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ user, setUser }}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consumer must import context and useContext
|
|
||||||
import { useContext } from 'react';
|
|
||||||
import { AuthContext } from '../context/AuthContext';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { user, setUser } = useContext(AuthContext);
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### New Pattern (Best Practice)
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// Good: Context is internal, custom hook is exported
|
|
||||||
const AuthContext = createContext(null); // Not exported!
|
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
|
||||||
const [user, setUser] = useState(null);
|
|
||||||
const [token, setToken] = useState(null);
|
|
||||||
|
|
||||||
const login = (userData, authToken) => {
|
|
||||||
setUser(userData);
|
|
||||||
setToken(authToken);
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
setUser(null);
|
|
||||||
setToken(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ user, token, login, logout }}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export custom hook instead
|
|
||||||
export function useAuth() {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useAuth must be used within AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consumer usage - clean and simple
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { user, login, logout } = useAuth();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Benefits
|
|
||||||
|
|
||||||
1. **Encapsulation** - Context implementation is hidden, only the hook is public API
|
|
||||||
2. **Type Safety** - Can add TypeScript types to the hook return value
|
|
||||||
3. **Validation** - Hook can check if used within provider (prevents null errors)
|
|
||||||
4. **Cleaner Imports** - One import instead of two (`useContext` + `Context`)
|
|
||||||
5. **Easier Refactoring** - Can change context internals without affecting consumers
|
|
||||||
6. **Standard Pattern** - Aligns with React best practices and popular libraries
|
|
||||||
|
|
||||||
### Implementation Plan
|
|
||||||
|
|
||||||
**Existing Contexts to Refactor:**
|
|
||||||
- `AuthContext` → `useAuth()`
|
|
||||||
- `SettingsContext` → `useSettings()`
|
|
||||||
- `ConfigContext` → `useConfig()` (if still used)
|
|
||||||
|
|
||||||
**New Contexts to Create:**
|
|
||||||
- `HouseholdContext` → `useHousehold()`
|
|
||||||
- `StoreContext` → `useStore()`
|
|
||||||
|
|
||||||
**Migration Steps:**
|
|
||||||
1. Keep old context export temporarily
|
|
||||||
2. Add custom hook export
|
|
||||||
3. Update all components to use hook
|
|
||||||
4. Remove old context export
|
|
||||||
5. Make context `const` internal to file
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Architecture Changes
|
|
||||||
|
|
||||||
### Context Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// AuthContext - User identity
|
|
||||||
{
|
|
||||||
user: { id, username, display_name, systemRole },
|
|
||||||
token: string,
|
|
||||||
login, logout,
|
|
||||||
isSystemAdmin: boolean // Computed from systemRole
|
|
||||||
}
|
|
||||||
|
|
||||||
// HouseholdContext - Active household + household role
|
|
||||||
{
|
|
||||||
activeHousehold: { id, name, role }, // role is 'admin' or 'user'
|
|
||||||
households: Household[],
|
|
||||||
switchHousehold: (id) => void,
|
|
||||||
createHousehold: (name) => void,
|
|
||||||
joinHousehold: (code) => void,
|
|
||||||
isAdmin: boolean // Computed helper: role === 'admin'
|
|
||||||
}
|
|
||||||
|
|
||||||
// StoreContext - Active store
|
|
||||||
{
|
|
||||||
activeStore: { id, name },
|
|
||||||
householdStores: Store[],
|
|
||||||
allStores: Store[], // Available store types (for adding)
|
|
||||||
switchStore: (id) => void,
|
|
||||||
addStore: (storeId) => void // Admin+ onlyme, role },
|
|
||||||
households: Household[],
|
|
||||||
switchHousehold: (id) => void,
|
|
||||||
createHousehold: (name) => void,
|
|
||||||
joinHousehold: (code) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// StoreContext - Active store
|
|
||||||
{
|
|
||||||
/admin → System admin panel (system_admin only)
|
|
||||||
/admin/stores → Manage store types
|
|
||||||
/admin/households → View all households
|
|
||||||
/admin/items → Global item catalog
|
|
||||||
activeStore: { id, name },
|
|
||||||
householdStores: Store[],
|
|
||||||
switchStore: (id) => void
|
|
||||||
} (Owner)</option>
|
|
||||||
<option value={2}>Work Team (Editor)</option>
|
|
||||||
<option value={3}>Apartment 5B (Viewer)</option>
|
|
||||||
<option>+ Create New Household</option>
|
|
||||||
{user.systemRole === 'system_admin' && (
|
|
||||||
<option>⚙️ System Admin</option>
|
|
||||||
)}
|
|
||||||
</HouseholdDropdown>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Store Tabs** (Within Household)
|
|
||||||
```tsx
|
|
||||||
<StoreTabs householdId={activeHousehold.id}>
|
|
||||||
<Tab active>Costco</Tab>
|
|
||||||
<Tab>Target</Tab>
|
|
||||||
<Tab>Walmart</Tab>
|
|
||||||
{(isAdmin || isOwner) && <Tab>+ Add Store</Tab>} → User settings (personal)
|
|
||||||
```
|
|
||||||
|
|
||||||
### UI Components
|
|
||||||
|
|
||||||
**Household Switcher** (Navbar)
|
|
||||||
```tsx
|
|
||||||
<HouseholdDropdown>
|
|
||||||
<option value={1}>Smith Family</option>
|
|
||||||
<option value={2}>Work Team</option>
|
|
||||||
<option value={3}>Apartment 5B</option>
|
|
||||||
<option>+ Create New Household</option>
|
|
||||||
</HouseholdDropdown>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Store Tabs** (Within Household)
|
|
||||||
```tsx
|
|
||||||
<StoreTabs householdId={activeHousehold.id}>
|
|
||||||
<Tab active>Costco</Tab>
|
|
||||||
<Tab>Target</Tab>
|
|
||||||
<Tab>Walmart</Tab>
|
|
||||||
<Tab>+ Add Store</Tab>
|
|
||||||
</StoreTabs>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Strategy
|
|
||||||
|
|
||||||
### Phase 1: Database Schema (Breaking Change)
|
|
||||||
|
|
||||||
**Step 1: Backup**
|
|
||||||
```bash
|
|
||||||
pg_dump grocery_list > backup_$(date +%Y%m%d).sql
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run Migrations**
|
|
||||||
```sql
|
|
||||||
-- 1. Create new tables
|
|
||||||
CREATE TABLE households (...);
|
|
||||||
CREATE TABLE household_members (...);
|
|
||||||
-- ... (all new tables)
|
|
||||||
|
|
||||||
-- 2. Create default household
|
|
||||||
INSERT INTO households (name, created_by, invite_code)
|
|
||||||
VALUES ('Main Household', 1, 'DEFAULT123');
|
|
||||||
|
|
||||||
-- 3. Migrate users → household_members
|
|
||||||
INSERT INTO household_members (household_id, user_id, role)
|
|
||||||
SELECT 1, id,
|
|
||||||
CASE
|
|
||||||
WHEN role = 'admin' THEN 'admin' -- Old admins become household admins
|
|
||||||
ELSE 'user' -- Everyone else becomes standard user
|
|
||||||
END
|
|
||||||
FROM users;
|
|
||||||
|
|
||||||
-- 4. Create default store
|
|
||||||
INSERT INTO stores (name, default_zones)
|
|
||||||
VALUES ('Costco', '{"zones": [...]}');
|
|
||||||
|
|
||||||
-- 5. Link household to store
|
|
||||||
INSERT INTO household_stores (household_id, store_id, is_default)
|
|
||||||
VALUES (1, 1, TRUE);
|
|
||||||
|
|
||||||
-- 6. Migrate items
|
|
||||||
INSERT INTO items (name, default_image, default_image_mime_type)
|
|
||||||
SELECT DISTINCT item_name, item_image, image_mime_type
|
|
||||||
FROM grocery_list;
|
|
||||||
|
|
||||||
-- 7. Migrate grocery_list → household_lists
|
|
||||||
INSERT INTO household_lists (household_id, store_id, item_id, quantity, bought, added_by, modified_on)
|
|
||||||
SELECT
|
|
||||||
1, -- default household
|
|
||||||
1, -- default store
|
|
||||||
i.id,
|
|
||||||
gl.quantity,
|
|
||||||
gl.bought,
|
|
||||||
gl.added_by,
|
|
||||||
gl.modified_on
|
|
||||||
FROM grocery_list gl
|
|
||||||
JOIN items i ON LOWER(i.name) = LOWER(gl.item_name);
|
|
||||||
|
|
||||||
-- 8. Migrate classifications
|
|
||||||
INSERT INTO household_item_classifications
|
|
||||||
(household_id, store_id, item_id, item_type, item_group, zone, confidence, source)
|
|
||||||
SELECT
|
|
||||||
1, 1, i.id,
|
|
||||||
ic.item_type, ic.item_group, ic.zone, ic.confidence, ic.source
|
|
||||||
FROM item_classification ic
|
|
||||||
JOIN grUpdate system roles (keep role column)
|
|
||||||
UPDATE users SET role = 'system_admin' WHERE role = 'admin';
|
|
||||||
UPDATE users SET role = 'user' WHERE role IN ('editor', 'viewer');
|
|
||||||
|
|
||||||
-- 11. Drop old tables (after verification!)
|
|
||||||
-- DROP TABLE grocery_history;
|
|
||||||
-- DROP TABLE item_classification;
|
|
||||||
-- DROP TABLE grocery_listousehold_list_id, quantity, added_by, added_on)
|
|
||||||
SELECT hl.id, gh.quantity, gh.added_by, gh.added_on
|
|
||||||
FROM grocery_history gh
|
|
||||||
JOIN grocery_list gl ON gh.list_item_id = gl.id
|
|
||||||
JOIN items i ON LOWER(i.name) = LOWER(gl.item_name)
|
|
||||||
JOIN household_lists hl ON hl.item_id = i.id AND hl.household_id = 1 AND hl.store_id = 1;
|
|
||||||
|
|
||||||
-- 10. Drop old tables (after verification!)
|
|
||||||
-- DROP TABLE grocery_history;
|
|
||||||
-- DROP TABLE item_classification;
|
|
||||||
-- DROP TABLE grocery_list;
|
|
||||||
-- ALTER TABLE users DROP COLUMN role;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2: Backend API (Incremental)
|
|
||||||
|
|
||||||
1. ✅ Create new models (households, stores, household_lists)
|
|
||||||
2. ✅ Create new middleware (householdAccess, storeAccess)
|
|
||||||
3. ✅ Create new controllers (households, stores)
|
|
||||||
4. ✅ Add new routes alongside old ones
|
|
||||||
5. ✅ Update list controllers to be household+store aware
|
|
||||||
6. ✅ Deprecate old routes (return 410 Gone)
|
|
||||||
|
|
||||||
### Phase 3: Frontend UI (Incremental)
|
|
||||||
|
|
||||||
1. ✅ **Refactor Context Pattern** (applies to all contexts)
|
|
||||||
- Move `createContext` inside component files (not exported)
|
|
||||||
- Export custom hooks instead: `useAuth()`, `useHousehold()`, `useStore()`, `useSettings()`
|
|
||||||
- Consumers use hooks directly instead of `useContext(ExportedContext)`
|
|
||||||
2. ✅ Create HouseholdContext with `useHousehold()` hook
|
|
||||||
3. ✅ Create StoreContext with `useStore()` hook
|
|
||||||
4. ✅ Refactor existing AuthContext to use custom `useAuth()` hook
|
|
||||||
5. ✅ Refactor existing SettingsContext to use custom `useSettings()` hook
|
|
||||||
6. ✅ Add household switcher to navbar
|
|
||||||
7. ✅ Create household management pages
|
|
||||||
8. ✅ Add store tabs to list view
|
|
||||||
9. ✅ Update all API calls to use household + store IDs
|
|
||||||
7. ✅ Add invite system UI
|
|
||||||
8. ✅ Update settings page to show household-specific settings
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Advanced Features (Future)
|
|
||||||
|
|
||||||
### 1. Item Sharing & Privacy
|
|
||||||
|
|
||||||
**Levels:**
|
|
||||||
- **Private**: Only visible to your household
|
|
||||||
- **Public**: Available in global item catalog
|
|
||||||
- **Suggested**: Anonymously contribute to shared catalog
|
|
||||||
|
|
||||||
### 2. Smart Features
|
|
||||||
|
|
||||||
**Cross-Household Intelligence:**
|
|
||||||
- "10,000 households buy milk at Costco" → suggest classification
|
|
||||||
- "Items commonly bought together"
|
|
||||||
- Price tracking across stores
|
|
||||||
- Store-specific suggestions
|
|
||||||
|
|
||||||
**Household Patterns:**
|
|
||||||
- "You usually buy milk every 5 days"
|
|
||||||
- "Bananas are typically added by [User]"
|
|
||||||
- Auto-add recurring items
|
|
||||||
|
|
||||||
### 3. Multi-Store Optimization
|
|
||||||
|
|
||||||
**Store Comparison:**
|
|
||||||
- Track which items each household buys at which store
|
|
||||||
- "This item is 20% cheaper at Target"
|
|
||||||
- Generate shopping lists across stores
|
|
||||||
|
|
||||||
**Route Optimization:**
|
|
||||||
- Sort list by store zone
|
|
||||||
- "You can save 15 minutes by shopping in this order"
|
|
||||||
|
|
||||||
### 4. Enhanced Collaboration
|
|
||||||
|
|
||||||
**Shopping Mode:**
|
|
||||||
- Real-time collaboration (one person shops, another adds from home)
|
|
||||||
- Live updates via WebSockets
|
|
||||||
- "John is currently at Costco (aisle 12)"
|
|
||||||
|
|
||||||
**Shopping Lists:**
|
|
||||||
- Pre-planned lists (weekly meal prep)
|
|
||||||
- Recurring lists (monthly bulk buy)
|
|
||||||
- Shared templates between households
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Timeline
|
|
||||||
|
|
||||||
### Sprint 1: Foundation (2-3 weeks)
|
|
||||||
- [ ] Design finalization & review
|
|
||||||
- [ ] Create migration scripts
|
|
||||||
- [ ] Implement new database tables
|
|
||||||
- [ ] Test migration on staging data
|
|
||||||
- [ ] Create new models (household, store, household_list)
|
|
||||||
|
|
||||||
### Sprint 2: Backend API (2-3 weeks)
|
|
||||||
- [ ] Implement household management endpoints
|
|
||||||
- [ ] Implement store management endpoints
|
|
||||||
- [ ] Update list endpoints for household+store scope
|
|
||||||
- [ ] Create new middleware (householdAccess, storeAccess)
|
|
||||||
- [ ] Update authentication to remove global role
|
|
||||||
|
|
||||||
### Sprint 3: Frontend Core (2-3 weeks)
|
|
||||||
- [ ] **Refactor Context Pattern** (foundational change):
|
|
||||||
- [ ] Refactor AuthContext to internal context + `useAuth()` hook
|
|
||||||
- [ ] Refactor SettingsContext to internal context + `useSettings()` hook
|
|
||||||
- [ ] Update all components using old context pattern
|
|
||||||
- [ ] Create HouseholdContext with `useHousehold()` hook
|
|
||||||
- [ ] Create StoreContext with `useStore()` hook
|
|
||||||
- [ ] Build household switcher UI
|
|
||||||
- [ ] Build store tabs UI
|
|
||||||
- [ ] Update GroceryList page for new API
|
|
||||||
- [ ] Create household management pages
|
|
||||||
|
|
||||||
### Sprint 4: Member Management (1-2 weeks)
|
|
||||||
- [ ] Implement invite code system
|
|
||||||
- [ ] Build member management UI
|
|
||||||
- [ ] Implement role updates
|
|
||||||
- [ ] Add join household flow
|
|
||||||
|
|
||||||
### Sprint 5: Polish & Testing (1-2 weeks)
|
|
||||||
- [ ] End-to-end testing
|
|
||||||
- [ ] Performance optimization
|
|
||||||
- [ ] Mobile responsiveness
|
|
||||||
- [ ] Documentation updates
|
|
||||||
- [ ] Migration dry-run on production backup
|
|
||||||
|
|
||||||
### Sprint 6: Production Migration (1 week)
|
|
||||||
- [ ] Announce maintenance window
|
|
||||||
- [ ] Run migration on production
|
|
||||||
- [ ] Verify data integrity
|
|
||||||
- [ ] Deploy new frontend
|
|
||||||
- [ ] Monitor for issues
|
|
||||||
|
|
||||||
**Total: 9-14 weeks**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risk Assessment & Mitigation
|
|
||||||
|
|
||||||
### High Risk Areas
|
|
||||||
|
|
||||||
1. **Data Loss During Migration**
|
|
||||||
- **Mitigation**: Full backup, dry-run on production copy, rollback plan
|
|
||||||
|
|
||||||
2. **Breaking Existing Users**
|
|
||||||
- **Mitigation**: Default household preserves current behavior, phased rollout
|
|
||||||
|
|
||||||
3. **Performance Degradation**
|
|
||||||
- **Mitigation**: Proper indexing, query optimization, caching strategy
|
|
||||||
|
|
||||||
4. **Complexity Creep**
|
|
||||||
- **Mitigation**: MVP first (basic households), iterate based on feedback
|
|
||||||
|
|
||||||
### Testing Strategy
|
|
||||||
|
|
||||||
1. **Unit Tests**: All new models and controllers
|
|
||||||
2. **Integration Tests**: API endpoint flows
|
|
||||||
3. **Migration Tests**: Verify data integrity post-migration
|
|
||||||
4. **Load Tests**: Multi-household concurrent access
|
|
||||||
5. **User Acceptance**: Beta test with small group before full rollout
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Questions & Decisions Needed
|
|
||||||
|
|
||||||
### 1. Item Naming Strategy
|
|
||||||
- **Question**: Should "milk" from Household A and "Milk" from Household B be the same item?
|
|
||||||
- **Options**:
|
|
||||||
- Case-insensitive merge (current behavior, recommended)
|
|
||||||
- Exact match only
|
|
||||||
- User prompt for merge confirmation
|
|
||||||
- **Recommendation**: Case-insensitive with optional household override
|
|
||||||
|
|
||||||
### 2. Store Management
|
|
||||||
- **Question**: Should all stores be predefined, or can users create custom stores?
|
|
||||||
- **Options**:
|
|
||||||
- Admin-only store creation (controlled list)
|
|
||||||
- Users can create custom stores (flexible but messy)
|
|
||||||
- Hybrid: predefined + custom
|
|
||||||
- **Recommendation**: Start with predefined stores, add custom later
|
|
||||||
|
|
||||||
### 3. Historical Data
|
|
||||||
- **Question**: When a user leaves a household, what happens to their history?
|
|
||||||
- **Options**:
|
|
||||||
- Keep history, anonymize user
|
|
||||||
- Keep history with user name (allows recovery if re-added)
|
|
||||||
- Delete history
|
|
||||||
- **Recommendation**: Keep history with actual user name preserved
|
|
||||||
- **Rationale**: If user is accidentally removed, their contributions remain attributed correctly when re-added
|
|
||||||
- History queries should JOIN with users table but handle missing users gracefully
|
|
||||||
- Display format: Show user name if still exists, otherwise show "User [id]" or handle as deleted account
|
|
||||||
|
|
||||||
### 4. Invite System
|
|
||||||
- **Question**: Should invite codes expire?
|
|
||||||
- **Options**:
|
|
||||||
- Never expire (simpler)
|
|
||||||
- 7-day expiration (more secure)
|
|
||||||
- Configurable per household
|
|
||||||
- **Recommendation**: Optional expiration, default to never
|
|
||||||
|
|
||||||
### 5. Default Household
|
|
||||||
- **Question**: When user logs in, which household/store do they see?
|
|
||||||
- **Options**:
|
|
||||||
- Last used (remember preference)
|
|
||||||
- Most recently modified list
|
|
||||||
- User-configured default
|
|
||||||
- **Recommendation**: Remember last used in localStorage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary & Next Steps
|
|
||||||
|
|
||||||
### Recommended Approach: **Hybrid Multi-Tenant Architecture**
|
|
||||||
|
|
||||||
**Core Principles:**
|
|
||||||
1. ✅ Shared item catalog with household-specific lists
|
|
||||||
2. ✅ Per-household roles (not global)
|
|
||||||
3. ✅ Store-specific classifications
|
|
||||||
4. ✅ Invite-based household joining
|
|
||||||
5. ✅ Backward-compatible migration
|
|
||||||
|
|
||||||
### Immediate Actions
|
|
||||||
|
|
||||||
1. **Review & Approve**: Get stakeholder buy-in on this architecture
|
|
||||||
2. **Validate Assumptions**: Confirm design decisions (item sharing, store management)
|
|
||||||
3. **Create Detailed Tickets**: Break down sprints into individual tasks
|
|
||||||
4. **Set Up Staging**: Create test environment with production data copy
|
|
||||||
5. **Begin Sprint 1**: Start with database design and migration scripts
|
|
||||||
|
|
||||||
### Success Metrics
|
|
||||||
|
|
||||||
- ✅ Zero data loss during migration
|
|
||||||
- ✅ 100% existing users migrated to default household
|
|
||||||
- ✅ Performance within 20% of current (queries < 200ms)
|
|
||||||
- ✅ Users can create households and invite others
|
|
||||||
- ✅ Lists properly isolated between households
|
|
||||||
- ✅ Mobile UI remains responsive
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix A: Example User Flows
|
|
||||||
|
|
||||||
### Creating a Household
|
|
||||||
1. User clicks "Create Household"
|
|
||||||
2. Enters name "Smith Family"
|
|
||||||
3. System generates invite code "SMITH2026"
|
|
||||||
4. User is set as "admin" role (creator is always admin)
|
|
||||||
5. User can share code with family members
|
|
||||||
|
|
||||||
### Joining a Household
|
|
||||||
1. User receives invite code "SMITH2026"
|
|
||||||
2. Navigates to /join/SMITH2026
|
|
||||||
3. Sees "Join Smith Family?"
|
|
||||||
4. Confirms, added as "user" role by default
|
|
||||||
5. Admin can promote to "admin" role if needed
|
|
||||||
|
|
||||||
### Managing Multiple Households
|
|
||||||
1. User belongs to "Smith Family" and "Work Team"
|
|
||||||
2. Navbar shows dropdown: [Smith Family ▼]
|
|
||||||
3. Clicks dropdown, sees both households
|
|
||||||
4. Switches to "Work Team"
|
|
||||||
5. List updates to show Work Team's items
|
|
||||||
6. Store tabs show Work Team's configured stores
|
|
||||||
|
|
||||||
### Adding Item to Store
|
|
||||||
1. User in "Smith Family" household
|
|
||||||
2. Sees store tabs: [Costco] [Target]
|
|
||||||
3. Clicks "Costco" tab
|
|
||||||
4. Adds "Milk" - goes to Costco list
|
|
||||||
5. Switches to "Target" tab
|
|
||||||
6. Adds "Bread" - goes to Target list
|
|
||||||
7. Milk and Bread are separate list entries (same item, different stores)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix B: Database Size Estimates
|
|
||||||
|
|
||||||
**Current Single List:**
|
|
||||||
- Users: 10
|
|
||||||
- Items: 200
|
|
||||||
- History records: 5,000
|
|
||||||
|
|
||||||
**After Multi-Household (10 households, 5 stores each):**
|
|
||||||
- Users: 10
|
|
||||||
- Households: 10
|
|
||||||
- Household_members: 30 (avg 3 users per household)
|
|
||||||
- Stores: 5
|
|
||||||
- Household_stores: 50
|
|
||||||
- Items: 500 (some shared, some unique)
|
|
||||||
- Household_lists: 2,500 (500 items × 5 stores)
|
|
||||||
- History: 25,000
|
|
||||||
|
|
||||||
**Storage Impact:** ~5x increase in list records, but items are deduplicated.
|
|
||||||
|
|
||||||
**Query Performance:**
|
|
||||||
- Without indexes: O(n) → O(10n) = 10x slower
|
|
||||||
- With indexes: O(log n) → O(log 10n) = minimal impact
|
|
||||||
|
|
||||||
**Conclusion:** With proper indexing, performance should remain acceptable even at 100+ households.
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
# Household & Store Management - Implementation Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Built comprehensive household and store management UI for the multi-household grocery list application. Users can now fully manage their households, members, and stores through a polished interface.
|
|
||||||
|
|
||||||
## Features Implemented
|
|
||||||
|
|
||||||
### 1. Manage Page (`/manage`)
|
|
||||||
**Location**: [frontend/src/pages/Manage.jsx](frontend/src/pages/Manage.jsx)
|
|
||||||
|
|
||||||
- Tab-based interface for Household and Store management
|
|
||||||
- Context-aware - always operates on the active household
|
|
||||||
- Accessible via "Manage" link in the navbar
|
|
||||||
|
|
||||||
### 2. Household Management
|
|
||||||
**Component**: [frontend/src/components/manage/ManageHousehold.jsx](frontend/src/components/manage/ManageHousehold.jsx)
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- **Edit Household Name**: Admin-only, inline editing
|
|
||||||
- **Invite Code Management**:
|
|
||||||
- Show/hide invite code with copy-to-clipboard
|
|
||||||
- Generate new invite code (invalidates old one)
|
|
||||||
- Admin-only access
|
|
||||||
- **Member Management**:
|
|
||||||
- View all household members with roles
|
|
||||||
- Promote/demote members between admin and member roles
|
|
||||||
- Remove members from household
|
|
||||||
- Cannot remove yourself
|
|
||||||
- Admin-only actions
|
|
||||||
- **Delete Household**:
|
|
||||||
- Admin-only
|
|
||||||
- Double confirmation required
|
|
||||||
- Permanently deletes all data
|
|
||||||
|
|
||||||
**Permissions**:
|
|
||||||
- Viewers: Can only see household name and members
|
|
||||||
- Members: Same as viewers
|
|
||||||
- Admins: Full access to all features
|
|
||||||
|
|
||||||
### 3. Store Management
|
|
||||||
**Component**: [frontend/src/components/manage/ManageStores.jsx](frontend/src/components/manage/ManageStores.jsx)
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- **View Household Stores**:
|
|
||||||
- Grid layout showing all stores
|
|
||||||
- Shows store name, location, and default status
|
|
||||||
- **Add Stores**:
|
|
||||||
- Select from system-wide store catalog
|
|
||||||
- Admin-only
|
|
||||||
- Cannot add already-linked stores
|
|
||||||
- **Remove Stores**:
|
|
||||||
- Admin-only
|
|
||||||
- Cannot remove last store (validation)
|
|
||||||
- **Set Default Store**:
|
|
||||||
- Admin-only
|
|
||||||
- Default store loads automatically
|
|
||||||
|
|
||||||
**Permissions**:
|
|
||||||
- Viewers & Members: Read-only view of stores
|
|
||||||
- Admins: Full CRUD operations
|
|
||||||
|
|
||||||
### 4. Create/Join Household Modal
|
|
||||||
**Component**: [frontend/src/components/manage/CreateJoinHousehold.jsx](frontend/src/components/manage/CreateJoinHousehold.jsx)
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Tabbed interface: "Create New" or "Join Existing"
|
|
||||||
- **Create Mode**:
|
|
||||||
- Enter household name
|
|
||||||
- Auto-generates invite code
|
|
||||||
- Creates household with user as admin
|
|
||||||
- **Join Mode**:
|
|
||||||
- Enter invite code
|
|
||||||
- Validates code and adds user as member
|
|
||||||
- Error handling for invalid codes
|
|
||||||
|
|
||||||
**Access**:
|
|
||||||
- Available from household switcher dropdown
|
|
||||||
- "+ Create or Join Household" button at bottom
|
|
||||||
- All authenticated users can access
|
|
||||||
|
|
||||||
### 5. Updated Household Switcher
|
|
||||||
**Component**: [frontend/src/components/household/HouseholdSwitcher.jsx](frontend/src/components/household/HouseholdSwitcher.jsx)
|
|
||||||
|
|
||||||
**Enhancements**:
|
|
||||||
- Added divider between household list and actions
|
|
||||||
- "+ Create or Join Household" button
|
|
||||||
- Opens CreateJoinHousehold modal
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
### CSS Files Created
|
|
||||||
1. **[frontend/src/styles/pages/Manage.css](frontend/src/styles/pages/Manage.css)**
|
|
||||||
- Page layout and tab navigation
|
|
||||||
- Responsive design
|
|
||||||
|
|
||||||
2. **[frontend/src/styles/components/manage/ManageHousehold.css](frontend/src/styles/components/manage/ManageHousehold.css)**
|
|
||||||
- Section cards with proper spacing
|
|
||||||
- Member cards with role badges
|
|
||||||
- Invite code display
|
|
||||||
- Danger zone styling
|
|
||||||
- Button styles (primary, secondary, danger)
|
|
||||||
|
|
||||||
3. **[frontend/src/styles/components/manage/ManageStores.css](frontend/src/styles/components/manage/ManageStores.css)**
|
|
||||||
- Grid layout for store cards
|
|
||||||
- Default badge styling
|
|
||||||
- Add store panel
|
|
||||||
- Available stores grid
|
|
||||||
|
|
||||||
4. **[frontend/src/styles/components/manage/CreateJoinHousehold.css](frontend/src/styles/components/manage/CreateJoinHousehold.css)**
|
|
||||||
- Modal overlay and container
|
|
||||||
- Mode tabs styling
|
|
||||||
- Form inputs and buttons
|
|
||||||
- Error message styling
|
|
||||||
|
|
||||||
### Theme Updates
|
|
||||||
**[frontend/src/styles/theme.css](frontend/src/styles/theme.css)**
|
|
||||||
|
|
||||||
Added simplified CSS variable aliases:
|
|
||||||
```css
|
|
||||||
--primary: var(--color-primary);
|
|
||||||
--primary-dark: var(--color-primary-dark);
|
|
||||||
--primary-light: var(--color-primary-light);
|
|
||||||
--danger: var(--color-danger);
|
|
||||||
--danger-dark: var(--color-danger-hover);
|
|
||||||
--text-primary: var(--color-text-primary);
|
|
||||||
--text-secondary: var(--color-text-secondary);
|
|
||||||
--background: var(--color-bg-body);
|
|
||||||
--border: var(--color-border-light);
|
|
||||||
--card-hover: var(--color-bg-hover);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backend Endpoints Used
|
|
||||||
|
|
||||||
All endpoints already existed - no backend changes required!
|
|
||||||
|
|
||||||
### Household Endpoints
|
|
||||||
- `GET /households` - Get user's households
|
|
||||||
- `POST /households` - Create household
|
|
||||||
- `PATCH /households/:id` - Update household name
|
|
||||||
- `DELETE /households/:id` - Delete household
|
|
||||||
- `POST /households/:id/invite/refresh` - Refresh invite code
|
|
||||||
- `POST /households/join/:inviteCode` - Join via invite code
|
|
||||||
- `GET /households/:id/members` - Get members
|
|
||||||
- `PATCH /households/:id/members/:userId/role` - Update member role
|
|
||||||
- `DELETE /households/:id/members/:userId` - Remove member
|
|
||||||
|
|
||||||
### Store Endpoints
|
|
||||||
- `GET /stores` - Get all stores
|
|
||||||
- `GET /stores/household/:householdId` - Get household stores
|
|
||||||
- `POST /stores/household/:householdId` - Add store to household
|
|
||||||
- `DELETE /stores/household/:householdId/:storeId` - Remove store
|
|
||||||
- `PATCH /stores/household/:householdId/:storeId/default` - Set default
|
|
||||||
|
|
||||||
## User Flow
|
|
||||||
|
|
||||||
### Managing Household
|
|
||||||
1. Click "Manage" in navbar
|
|
||||||
2. View household overview (name, members, invite code)
|
|
||||||
3. As admin:
|
|
||||||
- Edit household name
|
|
||||||
- Generate new invite codes
|
|
||||||
- Promote/demote members
|
|
||||||
- Remove members
|
|
||||||
- Delete household (danger zone)
|
|
||||||
|
|
||||||
### Managing Stores
|
|
||||||
1. Click "Manage" in navbar
|
|
||||||
2. Click "Stores" tab
|
|
||||||
3. View all linked stores with default badge
|
|
||||||
4. As admin:
|
|
||||||
- Click "+ Add Store" to see available stores
|
|
||||||
- Click "Add" on any unlinked store
|
|
||||||
- Click "Set as Default" on non-default stores
|
|
||||||
- Click "Remove" to unlink store (except last one)
|
|
||||||
|
|
||||||
### Creating/Joining Household
|
|
||||||
1. Click household name in navbar
|
|
||||||
2. Click "+ Create or Join Household" at bottom of dropdown
|
|
||||||
3. Select "Create New" or "Join Existing" tab
|
|
||||||
4. Fill form and submit
|
|
||||||
5. New household appears in list and becomes active
|
|
||||||
|
|
||||||
## Responsive Design
|
|
||||||
|
|
||||||
All components are fully responsive:
|
|
||||||
- **Desktop**: Grid layouts, side-by-side buttons
|
|
||||||
- **Tablet**: Adjusted spacing, smaller grids
|
|
||||||
- **Mobile**:
|
|
||||||
- Single column layouts
|
|
||||||
- Full-width buttons
|
|
||||||
- Stacked form elements
|
|
||||||
- Optimized spacing
|
|
||||||
|
|
||||||
## Permissions Summary
|
|
||||||
|
|
||||||
| Feature | Viewer | Member | Admin |
|
|
||||||
|---------|--------|--------|-------|
|
|
||||||
| View household info | ✅ | ✅ | ✅ |
|
|
||||||
| Edit household name | ❌ | ❌ | ✅ |
|
|
||||||
| View invite code | ❌ | ❌ | ✅ |
|
|
||||||
| Refresh invite code | ❌ | ❌ | ✅ |
|
|
||||||
| View members | ✅ | ✅ | ✅ |
|
|
||||||
| Change member roles | ❌ | ❌ | ✅ |
|
|
||||||
| Remove members | ❌ | ❌ | ✅ |
|
|
||||||
| Delete household | ❌ | ❌ | ✅ |
|
|
||||||
| View stores | ✅ | ✅ | ✅ |
|
|
||||||
| Add stores | ❌ | ❌ | ✅ |
|
|
||||||
| Remove stores | ❌ | ❌ | ✅ |
|
|
||||||
| Set default store | ❌ | ❌ | ✅ |
|
|
||||||
| Create household | ✅ | ✅ | ✅ |
|
|
||||||
| Join household | ✅ | ✅ | ✅ |
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
Consider adding:
|
|
||||||
1. **Household Settings**: Description, profile image, preferences
|
|
||||||
2. **Member Invitations**: Direct user search instead of just invite codes
|
|
||||||
3. **Store Details**: View item counts, last activity per store
|
|
||||||
4. **Audit Log**: Track household/store changes
|
|
||||||
5. **Notifications**: Member added/removed, role changes
|
|
||||||
6. **Bulk Operations**: Remove multiple members at once
|
|
||||||
7. **Store Categories**: Group stores by region/type
|
|
||||||
8. **Export Data**: Download household grocery history
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Create new household and verify admin role
|
|
||||||
- [ ] Generate and copy invite code
|
|
||||||
- [ ] Join household using invite code
|
|
||||||
- [ ] Edit household name as admin
|
|
||||||
- [ ] Promote member to admin
|
|
||||||
- [ ] Demote admin to member
|
|
||||||
- [ ] Remove member from household
|
|
||||||
- [ ] Add store to household
|
|
||||||
- [ ] Set default store
|
|
||||||
- [ ] Remove store (verify last store protection)
|
|
||||||
- [ ] Try admin actions as non-admin (should be hidden/disabled)
|
|
||||||
- [ ] Delete household and verify redirect
|
|
||||||
- [ ] Test responsive layouts on mobile/tablet/desktop
|
|
||||||
- [ ] Verify all error messages display properly
|
|
||||||
- [ ] Test with multiple households
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
# Multi-Household Implementation - Quick Reference
|
|
||||||
|
|
||||||
## Implementation Status
|
|
||||||
|
|
||||||
### ✅ Sprint 1: Database Foundation (COMPLETE)
|
|
||||||
- [x] Created migration script: `multi_household_architecture.sql`
|
|
||||||
- [x] Created migration guide: `MIGRATION_GUIDE.md`
|
|
||||||
- [x] Created migration runner scripts: `run-migration.sh` / `run-migration.bat`
|
|
||||||
- [x] **Tested migration on 'grocery' database (copy of Costco)**
|
|
||||||
- [x] Migration successful - all data migrated correctly
|
|
||||||
- [x] Verification passed - 0 data integrity issues
|
|
||||||
|
|
||||||
**Migration Results:**
|
|
||||||
- ✅ 1 Household created: "Main Household" (invite code: MAIN755114)
|
|
||||||
- ✅ 7 Users migrated (2 system_admins, 5 standard users)
|
|
||||||
- ✅ 122 Items extracted to master catalog
|
|
||||||
- ✅ 122 Household lists created
|
|
||||||
- ✅ 27 Classifications migrated
|
|
||||||
- ✅ 273 History records preserved
|
|
||||||
- ✅ All users assigned to household (admin/user roles)
|
|
||||||
- ✅ 0 orphaned records or data loss
|
|
||||||
|
|
||||||
**Database:** `grocery` (using Costco as template for safety)
|
|
||||||
|
|
||||||
### ⏳ Sprint 2: Backend API (NEXT - READY TO START)
|
|
||||||
- [ ] Create household.model.js
|
|
||||||
- [ ] Create store.model.js
|
|
||||||
- [ ] Update list.model.js for household+store scope
|
|
||||||
- [ ] Create householdAccess middleware
|
|
||||||
- [ ] Create storeAccess middleware
|
|
||||||
- [ ] Create households.controller.js
|
|
||||||
- [ ] Create stores.controller.js
|
|
||||||
- [ ] Update lists.controller.js
|
|
||||||
- [ ] Update users.controller.js
|
|
||||||
- [ ] Create/update routes for new structure
|
|
||||||
|
|
||||||
### ⏳ Sprint 3: Frontend Core (PENDING)
|
|
||||||
- [ ] Refactor contexts
|
|
||||||
- [ ] Create household UI
|
|
||||||
- [ ] Create store UI
|
|
||||||
|
|
||||||
## New Database Schema
|
|
||||||
|
|
||||||
### Core Tables
|
|
||||||
1. **households** - Household entities with invite codes
|
|
||||||
2. **stores** - Store types (Costco, Target, etc.)
|
|
||||||
3. **household_members** - User membership with per-household roles
|
|
||||||
4. **household_stores** - Which stores each household uses
|
|
||||||
5. **items** - Master item catalog (shared)
|
|
||||||
6. **household_lists** - Lists scoped to household + store
|
|
||||||
7. **household_item_classifications** - Classifications per household + store
|
|
||||||
8. **household_list_history** - History tracking
|
|
||||||
|
|
||||||
### Key Relationships
|
|
||||||
- User → household_members → Household (many-to-many)
|
|
||||||
- Household → household_stores → Store (many-to-many)
|
|
||||||
- Household + Store → household_lists → Item (unique per combo)
|
|
||||||
- household_lists → household_list_history (one-to-many)
|
|
||||||
|
|
||||||
## Role System
|
|
||||||
|
|
||||||
### System-Wide (users.role)
|
|
||||||
- **system_admin**: App infrastructure control
|
|
||||||
- **user**: Standard user
|
|
||||||
|
|
||||||
### Household-Scoped (household_members.role)
|
|
||||||
- **admin**: Full household control
|
|
||||||
- **user**: Standard member
|
|
||||||
|
|
||||||
## Migration Steps
|
|
||||||
|
|
||||||
1. **Backup**: `pg_dump grocery_list > backup.sql`
|
|
||||||
2. **Run**: `psql -d grocery_list -f backend/migrations/multi_household_architecture.sql`
|
|
||||||
3. **Verify**: Check counts, run integrity queries
|
|
||||||
4. **Test**: Ensure app functionality
|
|
||||||
5. **Cleanup**: Drop old tables after verification
|
|
||||||
|
|
||||||
## API Changes (Planned)
|
|
||||||
|
|
||||||
### Old Format
|
|
||||||
```
|
|
||||||
GET /api/list
|
|
||||||
POST /api/list/add
|
|
||||||
```
|
|
||||||
|
|
||||||
### New Format
|
|
||||||
```
|
|
||||||
GET /api/households/:hId/stores/:sId/list
|
|
||||||
POST /api/households/:hId/stores/:sId/list/add
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Changes (Planned)
|
|
||||||
|
|
||||||
### New Contexts
|
|
||||||
```jsx
|
|
||||||
const { user, isSystemAdmin } = useAuth();
|
|
||||||
const { activeHousehold, isAdmin } = useHousehold();
|
|
||||||
const { activeStore, householdStores } = useStore();
|
|
||||||
```
|
|
||||||
|
|
||||||
### New Routes
|
|
||||||
```
|
|
||||||
/households - List households
|
|
||||||
/households/:id/stores/:sId - Grocery list
|
|
||||||
/households/:id/members - Manage members
|
|
||||||
/join/:inviteCode - Join household
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### Phase 1: Database (Current)
|
|
||||||
1. Review migration script
|
|
||||||
2. Test on local dev database
|
|
||||||
3. Run verification queries
|
|
||||||
4. Document any issues
|
|
||||||
|
|
||||||
### Phase 2: Backend API (Next)
|
|
||||||
1. Create household.model.js
|
|
||||||
2. Create store.model.js
|
|
||||||
3. Update list.model.js for household scope
|
|
||||||
4. Create middleware for household access
|
|
||||||
5. Update routes
|
|
||||||
|
|
||||||
### Phase 3: Frontend
|
|
||||||
1. Refactor AuthContext → useAuth()
|
|
||||||
2. Create HouseholdContext → useHousehold()
|
|
||||||
3. Create StoreContext → useStore()
|
|
||||||
4. Build household switcher
|
|
||||||
5. Build store tabs
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
### Database Migration
|
|
||||||
- [ ] All tables created
|
|
||||||
- [ ] All indexes created
|
|
||||||
- [ ] Users migrated to household
|
|
||||||
- [ ] Items deduplicated correctly
|
|
||||||
- [ ] Lists migrated with correct references
|
|
||||||
- [ ] Classifications preserved
|
|
||||||
- [ ] History preserved
|
|
||||||
- [ ] No NULL foreign keys
|
|
||||||
|
|
||||||
### Backend API
|
|
||||||
- [ ] Household CRUD works
|
|
||||||
- [ ] Member management works
|
|
||||||
- [ ] Invite codes work
|
|
||||||
- [ ] Store management works
|
|
||||||
- [ ] List operations scoped correctly
|
|
||||||
- [ ] Permissions enforced
|
|
||||||
- [ ] History tracked correctly
|
|
||||||
|
|
||||||
### Frontend UI
|
|
||||||
- [ ] Login/logout works
|
|
||||||
- [ ] Household switcher works
|
|
||||||
- [ ] Store tabs work
|
|
||||||
- [ ] Can create household
|
|
||||||
- [ ] Can join household
|
|
||||||
- [ ] Can add items
|
|
||||||
- [ ] Can mark bought
|
|
||||||
- [ ] Roles respected in UI
|
|
||||||
|
|
||||||
## Rollback Strategy
|
|
||||||
|
|
||||||
If migration fails:
|
|
||||||
```sql
|
|
||||||
ROLLBACK;
|
|
||||||
```
|
|
||||||
|
|
||||||
If issues found after:
|
|
||||||
```bash
|
|
||||||
psql -d grocery_list < backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- **Migration Script**: `backend/migrations/multi_household_architecture.sql`
|
|
||||||
- **Guide**: `backend/migrations/MIGRATION_GUIDE.md`
|
|
||||||
- **Architecture**: `docs/multi-household-architecture-plan.md`
|
|
||||||
- **Status**: This file
|
|
||||||
|
|
||||||
## Key Decisions
|
|
||||||
|
|
||||||
1. ✅ Keep users.role for system admin
|
|
||||||
2. ✅ Simplify household roles to admin/user
|
|
||||||
3. ✅ Preserve user names in history (no anonymization)
|
|
||||||
4. ✅ Shared item catalog with household-specific lists
|
|
||||||
5. ✅ Context pattern refactoring (internal context + custom hooks)
|
|
||||||
|
|
||||||
## Timeline
|
|
||||||
|
|
||||||
- **Week 1-2**: Database migration + testing
|
|
||||||
- **Week 3-4**: Backend API implementation
|
|
||||||
- **Week 5-6**: Frontend core implementation
|
|
||||||
- **Week 7**: Member management
|
|
||||||
- **Week 8-9**: Testing & polish
|
|
||||||
- **Week 10**: Production migration
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
|
|
||||||
For questions or issues during implementation, refer to:
|
|
||||||
- Architecture plan for design decisions
|
|
||||||
- Migration guide for database steps
|
|
||||||
- This file for quick status updates
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
# API Test Suite
|
|
||||||
|
|
||||||
The test suite has been reorganized into separate files for better maintainability:
|
|
||||||
|
|
||||||
## New Modular Structure (✅ Complete)
|
|
||||||
- **api-tests.html** - Main HTML file
|
|
||||||
- **test-config.js** - Global state management
|
|
||||||
- **test-definitions.js** - All 62 test cases across 8 categories
|
|
||||||
- **test-runner.js** - Test execution logic
|
|
||||||
- **test-ui.js** - UI manipulation functions
|
|
||||||
- **test-styles.css** - All CSS styles
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
1. Start the dev server: `docker-compose -f docker-compose.dev.yml up`
|
|
||||||
2. Navigate to: `http://localhost:5000/test/api-tests.html`
|
|
||||||
3. Configure credentials (default: admin/admin123)
|
|
||||||
4. Click "▶ Run All Tests"
|
|
||||||
|
|
||||||
## Features
|
|
||||||
- ✅ 62 comprehensive tests
|
|
||||||
- ✅ Collapsible test cards (collapsed by default)
|
|
||||||
- ✅ Expected field validation with visual indicators
|
|
||||||
- ✅ Color-coded HTTP status badges
|
|
||||||
- ✅ Auto-expansion on test run
|
|
||||||
- ✅ Expand/Collapse all buttons
|
|
||||||
- ✅ Real-time pass/fail/error states
|
|
||||||
- ✅ Summary dashboard
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
```
|
|
||||||
backend/public/
|
|
||||||
├── api-tests.html # Main entry point (use this)
|
|
||||||
├── test-config.js # State management (19 lines)
|
|
||||||
├── test-definitions.js # Test cases (450+ lines)
|
|
||||||
├── test-runner.js # Test execution (160+ lines)
|
|
||||||
├── test-ui.js # UI functions (90+ lines)
|
|
||||||
└── test-styles.css # All styles (310+ lines)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Old File
|
|
||||||
- **api-test.html** - Original monolithic version (kept for reference)
|
|
||||||
|
|
||||||
Total: ~1030 lines split into 6 clean, modular files
|
|
||||||
@ -1,283 +0,0 @@
|
|||||||
# Mobile Responsive Design Audit & Recommendations
|
|
||||||
|
|
||||||
## ✅ Already Mobile-Friendly
|
|
||||||
|
|
||||||
### Components
|
|
||||||
1. **Navbar** - Just updated with hamburger menu, dropdowns, sticky positioning
|
|
||||||
2. **AdminPanel** - Has responsive breakpoints (768px, 480px)
|
|
||||||
3. **Manage page** - Has responsive breakpoints (768px, 480px)
|
|
||||||
4. **ManageHousehold** - Has 768px breakpoint
|
|
||||||
5. **Settings** - Has 768px breakpoint
|
|
||||||
6. **StoreManagement** - Has 768px breakpoint
|
|
||||||
7. **GroceryList** - Has 480px breakpoint
|
|
||||||
|
|
||||||
## ✅ Recently Completed (2026-01-26)
|
|
||||||
|
|
||||||
### **All Modals** - Mobile optimization COMPLETE ✓
|
|
||||||
**Files updated with responsive styles:**
|
|
||||||
- ✅ `frontend/src/styles/AddImageModal.css` - Added 768px & 480px breakpoints
|
|
||||||
- ✅ `frontend/src/styles/ImageUploadModal.css` - Added 768px & 480px breakpoints
|
|
||||||
- ✅ `frontend/src/styles/ItemClassificationModal.css` - Added 768px & 480px breakpoints
|
|
||||||
- ✅ `frontend/src/styles/SimilarItemModal.css` - Added 768px & 480px breakpoints
|
|
||||||
- ✅ `frontend/src/styles/components/EditItemModal.css` - Added 768px & 480px breakpoints
|
|
||||||
- ✅ `frontend/src/styles/components/ConfirmAddExistingModal.css` - Added 768px & 480px breakpoints
|
|
||||||
- ✅ `frontend/src/styles/ImageModal.css` - Enhanced with 480px breakpoint
|
|
||||||
- ✅ `frontend/src/styles/components/AddItemWithDetailsModal.css` - Enhanced with 768px breakpoint
|
|
||||||
- ✅ `frontend/src/styles/ConfirmBuyModal.css` - Already excellent (480px & 360px breakpoints)
|
|
||||||
|
|
||||||
**Mobile improvements implemented:**
|
|
||||||
- Modal width: 95% at 768px, 100% at 480px
|
|
||||||
- All buttons: Full-width stacking on mobile with 44px minimum height
|
|
||||||
- Input fields: 16px font-size to prevent iOS zoom
|
|
||||||
- Image previews: Responsive sizing (180-200px on mobile)
|
|
||||||
- Touch targets: 44x44px minimum for all interactive elements
|
|
||||||
- Overflow: Auto scrolling for tall modals (max-height: 90vh)
|
|
||||||
- Spacing: Reduced padding on small screens
|
|
||||||
|
|
||||||
## ⚠️ Needs Improvement
|
|
||||||
|
|
||||||
### High Priority
|
|
||||||
|
|
||||||
#### 1. **HouseholdSwitcher** - Dropdown might overflow on mobile
|
|
||||||
**File:** `frontend/src/styles/components/HouseholdSwitcher.css`
|
|
||||||
|
|
||||||
**Current:** No mobile breakpoints
|
|
||||||
**Needs:**
|
|
||||||
```css
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.household-switcher-dropdown {
|
|
||||||
max-width: 90vw;
|
|
||||||
right: auto;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. **StoreTabs** - Horizontal scrolling tabs on mobile
|
|
||||||
**File:** `frontend/src/styles/components/StoreTabs.css`
|
|
||||||
|
|
||||||
**Needs:**
|
|
||||||
```css
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.store-tabs {
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-tab {
|
|
||||||
min-width: 100px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. **Login/Register Pages** - Need better mobile padding
|
|
||||||
**Files:**
|
|
||||||
- `frontend/src/styles/pages/Login.css`
|
|
||||||
- `frontend/src/styles/pages/Register.css`
|
|
||||||
|
|
||||||
**Needs:**
|
|
||||||
```css
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.card {
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
margin: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
font-size: 16px; /* Prevents iOS zoom on focus */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
|
|
||||||
#### 4. **GroceryList Item Cards** - Could be more touch-friendly
|
|
||||||
**File:** `frontend/src/styles/pages/GroceryList.css`
|
|
||||||
|
|
||||||
**Current:** Has 480px breakpoint
|
|
||||||
**Enhancement needed:**
|
|
||||||
- Increase touch target sizes for mobile
|
|
||||||
- Better spacing between items on small screens
|
|
||||||
- Optimize image display on mobile
|
|
||||||
|
|
||||||
#### 5. **AddItemForm** - Input width and spacing
|
|
||||||
**File:** `frontend/src/styles/components/AddItemForm.css`
|
|
||||||
|
|
||||||
**Has 480px breakpoint** but verify:
|
|
||||||
- Input font-size is 16px+ (prevents iOS zoom)
|
|
||||||
- Buttons are full-width on mobile
|
|
||||||
- Adequate spacing between form elements
|
|
||||||
|
|
||||||
#### 6. **CreateJoinHousehold Modal**
|
|
||||||
**File:** `frontend/src/styles/components/manage/CreateJoinHousehold.css`
|
|
||||||
|
|
||||||
**Has 600px breakpoint** - Review for:
|
|
||||||
- Full-screen on very small devices
|
|
||||||
- Button sizing and spacing
|
|
||||||
- Tab navigation usability
|
|
||||||
|
|
||||||
### Low Priority
|
|
||||||
|
|
||||||
#### 7. **SuggestionList** - Touch interactions
|
|
||||||
**File:** `frontend/src/styles/components/SuggestionList.css`
|
|
||||||
|
|
||||||
**Needs:** Mobile-specific styles for:
|
|
||||||
- Larger tap targets
|
|
||||||
- Better scrolling behavior
|
|
||||||
- Touch feedback
|
|
||||||
|
|
||||||
#### 8. **ClassificationSection** - Zone selection on mobile
|
|
||||||
**File:** `frontend/src/styles/components/ClassificationSection.css`
|
|
||||||
|
|
||||||
**Needs:**
|
|
||||||
- Ensure zone buttons are touch-friendly
|
|
||||||
- Stack vertically if needed on small screens
|
|
||||||
|
|
||||||
#### 9. **ImageUploadSection**
|
|
||||||
**File:** `frontend/src/styles/components/ImageUploadSection.css`
|
|
||||||
|
|
||||||
**Needs:**
|
|
||||||
- Camera access optimization for mobile
|
|
||||||
- Preview image sizing
|
|
||||||
- Upload button sizing
|
|
||||||
|
|
||||||
## 🎯 General Recommendations
|
|
||||||
|
|
||||||
### 1. **Global Styles**
|
|
||||||
Update `frontend/src/index.css`:
|
|
||||||
```css
|
|
||||||
/* Prevent zoom on input focus (iOS) */
|
|
||||||
input, select, textarea {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Better touch scrolling */
|
|
||||||
* {
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure body doesn't overflow horizontally */
|
|
||||||
body {
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Container Max-Widths**
|
|
||||||
Standardize across the app:
|
|
||||||
- Small components: `max-width: 600px`
|
|
||||||
- Medium pages: `max-width: 800px`
|
|
||||||
- Wide layouts: `max-width: 1200px`
|
|
||||||
- Always pair with `margin: 0 auto` and `padding: 1rem`
|
|
||||||
|
|
||||||
### 3. **Button Sizing**
|
|
||||||
Mobile-friendly buttons:
|
|
||||||
```css
|
|
||||||
.btn-primary, .btn-secondary {
|
|
||||||
min-height: 44px; /* Apple's recommended minimum */
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.btn-primary, .btn-secondary {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Form Layouts**
|
|
||||||
Stack form fields on mobile:
|
|
||||||
```css
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.form-row {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. **Image Handling**
|
|
||||||
Responsive images:
|
|
||||||
```css
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. **Typography**
|
|
||||||
Adjust for mobile readability:
|
|
||||||
```css
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
h1 { font-size: 1.75rem; }
|
|
||||||
h2 { font-size: 1.5rem; }
|
|
||||||
h3 { font-size: 1.25rem; }
|
|
||||||
body { font-size: 16px; } /* Prevents iOS zoom */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📱 Testing Checklist
|
|
||||||
|
|
||||||
Test on these viewports:
|
|
||||||
- [ ] 320px (iPhone SE)
|
|
||||||
- [ ] 375px (iPhone 12/13 Pro)
|
|
||||||
- [ ] 390px (iPhone 14 Pro)
|
|
||||||
- [ ] 414px (iPhone Pro Max)
|
|
||||||
- [ ] 768px (iPad Portrait)
|
|
||||||
- [ ] 1024px (iPad Landscape)
|
|
||||||
- [ ] 1280px+ (Desktop)
|
|
||||||
|
|
||||||
Test these interactions:
|
|
||||||
- [ ] Navigation menu (hamburger)
|
|
||||||
- [ ] Dropdowns (household, user menu)
|
|
||||||
- [ ] All modals
|
|
||||||
- [ ] Form inputs (no zoom on focus)
|
|
||||||
- [ ] Touch gestures (swipe, long-press)
|
|
||||||
- [ ] Scrolling (no horizontal overflow)
|
|
||||||
- [ ] Image upload/viewing
|
|
||||||
- [ ] Tab navigation
|
|
||||||
|
|
||||||
## 🔄 Future Considerations
|
|
||||||
|
|
||||||
1. **Progressive Web App (PWA)**
|
|
||||||
- Add manifest.json
|
|
||||||
- Service worker for offline support
|
|
||||||
- Install prompt
|
|
||||||
|
|
||||||
2. **Touch Gestures**
|
|
||||||
- Swipe to delete items
|
|
||||||
- Pull to refresh lists
|
|
||||||
- Long-press for context menu
|
|
||||||
|
|
||||||
3. **Keyboard Handling**
|
|
||||||
- iOS keyboard overlap handling
|
|
||||||
- Android keyboard behavior
|
|
||||||
- Input focus management
|
|
||||||
|
|
||||||
4. **Performance**
|
|
||||||
- Lazy load images
|
|
||||||
- Virtual scrolling for long lists
|
|
||||||
- Code splitting by route
|
|
||||||
|
|
||||||
## 📝 How to Maintain Mobile-First Design
|
|
||||||
|
|
||||||
I've updated `.github/copilot-instructions.md` with mobile-first design principles. This will be included in all future conversations automatically.
|
|
||||||
|
|
||||||
**To ensure I remember in new conversations:**
|
|
||||||
1. ✅ Mobile-first guidelines are now in copilot-instructions.md (automatically loaded)
|
|
||||||
2. Start conversations with: "Remember to keep mobile/desktop responsiveness in mind"
|
|
||||||
3. Review this audit document before making UI changes
|
|
||||||
4. Run mobile testing after any CSS/layout changes
|
|
||||||
|
|
||||||
**Quick reminder phrases:**
|
|
||||||
- "Make this mobile-friendly"
|
|
||||||
- "Add responsive breakpoints"
|
|
||||||
- "Test on mobile viewports"
|
|
||||||
- "Ensure touch-friendly targets"
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
# Multi-Household Architecture Migration Guide
|
|
||||||
|
|
||||||
## Pre-Migration Checklist
|
|
||||||
|
|
||||||
- [ ] **Backup Database**
|
|
||||||
```bash
|
|
||||||
pg_dump -U your_user -d grocery_list > backup_$(date +%Y%m%d_%H%M%S).sql
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Test on Staging First**
|
|
||||||
- Copy production database to staging environment
|
|
||||||
- Run migration on staging
|
|
||||||
- Verify all data migrated correctly
|
|
||||||
- Test application functionality
|
|
||||||
|
|
||||||
- [ ] **Review Migration Script**
|
|
||||||
- Read through `multi_household_architecture.sql`
|
|
||||||
- Understand each step
|
|
||||||
- Note verification queries
|
|
||||||
|
|
||||||
- [ ] **Announce Maintenance Window**
|
|
||||||
- Notify users of downtime
|
|
||||||
- Schedule during low-usage period
|
|
||||||
- Estimate 15-30 minutes for migration
|
|
||||||
|
|
||||||
## Running the Migration
|
|
||||||
|
|
||||||
### 1. Connect to Database
|
|
||||||
|
|
||||||
```bash
|
|
||||||
psql -U your_user -d grocery_list
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Run Migration
|
|
||||||
|
|
||||||
```sql
|
|
||||||
\i backend/migrations/multi_household_architecture.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
The script will:
|
|
||||||
1. ✅ Create 8 new tables
|
|
||||||
2. ✅ Create default "Main Household"
|
|
||||||
3. ✅ Create default "Costco" store
|
|
||||||
4. ✅ Migrate all users to household members
|
|
||||||
5. ✅ Extract items to master catalog
|
|
||||||
6. ✅ Migrate grocery_list → household_lists
|
|
||||||
7. ✅ Migrate classifications
|
|
||||||
8. ✅ Migrate history records
|
|
||||||
9. ✅ Update user system roles
|
|
||||||
|
|
||||||
### 3. Verify Migration
|
|
||||||
|
|
||||||
Run these queries inside psql:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Check household created
|
|
||||||
SELECT * FROM households;
|
|
||||||
|
|
||||||
-- Check all users migrated
|
|
||||||
SELECT u.username, u.role as system_role, hm.role as household_role
|
|
||||||
FROM users u
|
|
||||||
JOIN household_members hm ON u.id = hm.user_id
|
|
||||||
ORDER BY u.id;
|
|
||||||
|
|
||||||
-- Check item counts match
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(DISTINCT item_name) FROM grocery_list) as old_unique_items,
|
|
||||||
(SELECT COUNT(*) FROM items) as new_items;
|
|
||||||
|
|
||||||
-- Check list counts
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM grocery_list) as old_lists,
|
|
||||||
(SELECT COUNT(*) FROM household_lists) as new_lists;
|
|
||||||
|
|
||||||
-- Check classification counts
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM item_classification) as old_classifications,
|
|
||||||
(SELECT COUNT(*) FROM household_item_classifications) as new_classifications;
|
|
||||||
|
|
||||||
-- Check history counts
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM grocery_history) as old_history,
|
|
||||||
(SELECT COUNT(*) FROM household_list_history) as new_history;
|
|
||||||
|
|
||||||
-- Verify no data loss - check if all old items have corresponding new records
|
|
||||||
SELECT gl.item_name
|
|
||||||
FROM grocery_list gl
|
|
||||||
LEFT JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
|
|
||||||
LEFT JOIN household_lists hl ON hl.item_id = i.id
|
|
||||||
WHERE hl.id IS NULL;
|
|
||||||
-- Should return 0 rows
|
|
||||||
|
|
||||||
-- Check invite code
|
|
||||||
SELECT name, invite_code FROM households;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Test Application
|
|
||||||
|
|
||||||
- [ ] Users can log in
|
|
||||||
- [ ] Can view "Main Household" list
|
|
||||||
- [ ] Can add items
|
|
||||||
- [ ] Can mark items as bought
|
|
||||||
- [ ] History shows correctly
|
|
||||||
- [ ] Classifications preserved
|
|
||||||
- [ ] Images display correctly
|
|
||||||
|
|
||||||
## Post-Migration Cleanup
|
|
||||||
|
|
||||||
**Only after verifying everything works correctly:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Drop old tables (CAREFUL - THIS IS IRREVERSIBLE)
|
|
||||||
DROP TABLE IF EXISTS grocery_history CASCADE;
|
|
||||||
DROP TABLE IF EXISTS item_classification CASCADE;
|
|
||||||
DROP TABLE IF EXISTS grocery_list CASCADE;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
### If Migration Fails
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Inside psql during migration
|
|
||||||
ROLLBACK;
|
|
||||||
|
|
||||||
-- Then restore from backup
|
|
||||||
\q
|
|
||||||
psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### If Issues Found After Migration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Drop the database and restore
|
|
||||||
dropdb grocery_list
|
|
||||||
createdb grocery_list
|
|
||||||
psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Issues & Solutions
|
|
||||||
|
|
||||||
### Issue: Duplicate items in items table
|
|
||||||
**Cause**: Case-insensitive matching not working
|
|
||||||
**Solution**: Check item names for leading/trailing spaces
|
|
||||||
|
|
||||||
### Issue: Foreign key constraint errors
|
|
||||||
**Cause**: User or item references not found
|
|
||||||
**Solution**: Verify all users and items exist before migrating lists
|
|
||||||
|
|
||||||
### Issue: History not showing
|
|
||||||
**Cause**: household_list_id references incorrect
|
|
||||||
**Solution**: Check JOIN conditions in history migration
|
|
||||||
|
|
||||||
### Issue: Images not displaying
|
|
||||||
**Cause**: BYTEA encoding issues
|
|
||||||
**Solution**: Verify image_mime_type correctly migrated
|
|
||||||
|
|
||||||
## Migration Timeline
|
|
||||||
|
|
||||||
- **T-0**: Begin maintenance window
|
|
||||||
- **T+2min**: Backup complete
|
|
||||||
- **T+3min**: Start migration script
|
|
||||||
- **T+8min**: Migration complete (for ~1000 items)
|
|
||||||
- **T+10min**: Run verification queries
|
|
||||||
- **T+15min**: Test application functionality
|
|
||||||
- **T+20min**: If successful, announce completion
|
|
||||||
- **T+30min**: End maintenance window
|
|
||||||
|
|
||||||
## Data Integrity Checks
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Ensure all users belong to at least one household
|
|
||||||
SELECT u.id, u.username
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN household_members hm ON u.id = hm.user_id
|
|
||||||
WHERE hm.id IS NULL;
|
|
||||||
-- Should return 0 rows
|
|
||||||
|
|
||||||
-- Ensure all household lists have valid items
|
|
||||||
SELECT hl.id
|
|
||||||
FROM household_lists hl
|
|
||||||
LEFT JOIN items i ON hl.item_id = i.id
|
|
||||||
WHERE i.id IS NULL;
|
|
||||||
-- Should return 0 rows
|
|
||||||
|
|
||||||
-- Ensure all history has valid list references
|
|
||||||
SELECT hlh.id
|
|
||||||
FROM household_list_history hlh
|
|
||||||
LEFT JOIN household_lists hl ON hlh.household_list_id = hl.id
|
|
||||||
WHERE hl.id IS NULL;
|
|
||||||
-- Should return 0 rows
|
|
||||||
|
|
||||||
-- Check for orphaned classifications
|
|
||||||
SELECT hic.id
|
|
||||||
FROM household_item_classifications hic
|
|
||||||
LEFT JOIN household_lists hl ON hic.item_id = hl.item_id
|
|
||||||
AND hic.household_id = hl.household_id
|
|
||||||
AND hic.store_id = hl.store_id
|
|
||||||
WHERE hl.id IS NULL;
|
|
||||||
-- Should return 0 rows (or classifications for removed items, which is ok)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
✅ All tables created successfully
|
|
||||||
✅ All users migrated to "Main Household"
|
|
||||||
✅ Item count matches (unique items from old → new)
|
|
||||||
✅ List count matches (all grocery_list items → household_lists)
|
|
||||||
✅ Classification count matches
|
|
||||||
✅ History count matches
|
|
||||||
✅ No NULL foreign keys
|
|
||||||
✅ Application loads without errors
|
|
||||||
✅ Users can perform all CRUD operations
|
|
||||||
✅ Images display correctly
|
|
||||||
✅ Bought items still marked as bought
|
|
||||||
✅ Recently bought still shows correctly
|
|
||||||
|
|
||||||
## Next Steps After Migration
|
|
||||||
|
|
||||||
1. ✅ Update backend models (Sprint 2)
|
|
||||||
2. ✅ Update API routes
|
|
||||||
3. ✅ Update controllers
|
|
||||||
4. ✅ Test all endpoints
|
|
||||||
5. ✅ Update frontend contexts
|
|
||||||
6. ✅ Update UI components
|
|
||||||
7. ✅ Enable multi-household features
|
|
||||||
|
|
||||||
## Support & Troubleshooting
|
|
||||||
|
|
||||||
If issues arise:
|
|
||||||
1. Check PostgreSQL logs: `/var/log/postgresql/`
|
|
||||||
2. Check application logs
|
|
||||||
3. Restore from backup if needed
|
|
||||||
4. Review migration script for errors
|
|
||||||
|
|
||||||
## Monitoring Post-Migration
|
|
||||||
|
|
||||||
For the first 24 hours after migration:
|
|
||||||
- Monitor error logs
|
|
||||||
- Watch for performance issues
|
|
||||||
- Verify user activity normal
|
|
||||||
- Check for any data inconsistencies
|
|
||||||
- Be ready to rollback if critical issues found
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
# Post-Migration Updates Required
|
|
||||||
|
|
||||||
This document outlines the remaining updates needed after migrating to the multi-household architecture.
|
|
||||||
|
|
||||||
## ✅ Completed Fixes
|
|
||||||
|
|
||||||
1. **Column name corrections** in `list.model.v2.js`:
|
|
||||||
- Fixed `item_image` → `custom_image`
|
|
||||||
- Fixed `image_mime_type` → `custom_image_mime_type`
|
|
||||||
- Fixed `hlh.list_id` → `hlh.household_list_id`
|
|
||||||
|
|
||||||
2. **SQL query fixes**:
|
|
||||||
- Fixed ORDER BY with DISTINCT in `getSuggestions`
|
|
||||||
- Fixed `setBought` to use boolean instead of quantity logic
|
|
||||||
|
|
||||||
3. **Created migration**: `add_notes_column.sql` for missing notes column
|
|
||||||
|
|
||||||
## 🔧 Required Database Migration
|
|
||||||
|
|
||||||
**Run this SQL on your PostgreSQL database:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- From backend/migrations/add_notes_column.sql
|
|
||||||
ALTER TABLE household_lists
|
|
||||||
ADD COLUMN IF NOT EXISTS notes TEXT;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item';
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🧹 Optional Cleanup (Not Critical)
|
|
||||||
|
|
||||||
### Legacy Files Still Present
|
|
||||||
|
|
||||||
These files reference the old `grocery_list` table but are not actively used by the frontend:
|
|
||||||
|
|
||||||
- `backend/models/list.model.js` - Old model
|
|
||||||
- `backend/controllers/lists.controller.js` - Old controller
|
|
||||||
- `backend/routes/list.routes.js` - Old routes (still mounted at `/list`)
|
|
||||||
|
|
||||||
**Recommendation**: Can be safely removed once you confirm the new architecture is working, or kept as fallback.
|
|
||||||
|
|
||||||
### Route Cleanup in app.js
|
|
||||||
|
|
||||||
The old `/list` route is still mounted in `backend/app.js`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const listRoutes = require("./routes/list.routes");
|
|
||||||
app.use("/list", listRoutes); // ← Not used by frontend anymore
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommendation**: Comment out or remove once migration is confirmed successful.
|
|
||||||
|
|
||||||
## ✅ No Frontend Changes Needed
|
|
||||||
|
|
||||||
The frontend is already correctly calling the new household-scoped endpoints:
|
|
||||||
- All calls use `/households/:householdId/stores/:storeId/list/*` pattern
|
|
||||||
- No references to old `/list/*` endpoints
|
|
||||||
|
|
||||||
## 🎯 Next Steps
|
|
||||||
|
|
||||||
1. **Run the notes column migration** (required for notes feature to work)
|
|
||||||
2. **Test the application** thoroughly:
|
|
||||||
- Add items with images
|
|
||||||
- Mark items as bought/unbought
|
|
||||||
- Update item quantities and notes
|
|
||||||
- Test suggestions/autocomplete
|
|
||||||
- Test recently bought items
|
|
||||||
3. **Remove legacy files** (optional, once confirmed working)
|
|
||||||
|
|
||||||
## 📝 Architecture Notes
|
|
||||||
|
|
||||||
**Current Structure:**
|
|
||||||
- All list operations are scoped to `household_id + store_id`
|
|
||||||
- History tracking uses `household_list_history` table
|
|
||||||
- Image storage uses `custom_image` and `custom_image_mime_type` columns
|
|
||||||
- Classifications use `household_item_classifications` table (per household+store)
|
|
||||||
|
|
||||||
**Middleware Chain:**
|
|
||||||
```javascript
|
|
||||||
auth → householdAccess → storeAccess → controller
|
|
||||||
```
|
|
||||||
|
|
||||||
This ensures users can only access data for households they belong to and stores linked to those households.
|
|
||||||
@ -2,14 +2,11 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
|
|||||||
import { ROLES } from "./constants/roles";
|
import { ROLES } from "./constants/roles";
|
||||||
import { AuthProvider } from "./context/AuthContext.jsx";
|
import { AuthProvider } from "./context/AuthContext.jsx";
|
||||||
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
||||||
import { HouseholdProvider } from "./context/HouseholdContext.jsx";
|
|
||||||
import { SettingsProvider } from "./context/SettingsContext.jsx";
|
import { SettingsProvider } from "./context/SettingsContext.jsx";
|
||||||
import { StoreProvider } from "./context/StoreContext.jsx";
|
|
||||||
|
|
||||||
import AdminPanel from "./pages/AdminPanel.jsx";
|
import AdminPanel from "./pages/AdminPanel.jsx";
|
||||||
import GroceryList from "./pages/GroceryList.jsx";
|
import GroceryList from "./pages/GroceryList.jsx";
|
||||||
import Login from "./pages/Login.jsx";
|
import Login from "./pages/Login.jsx";
|
||||||
import Manage from "./pages/Manage.jsx";
|
|
||||||
import Register from "./pages/Register.jsx";
|
import Register from "./pages/Register.jsx";
|
||||||
import Settings from "./pages/Settings.jsx";
|
import Settings from "./pages/Settings.jsx";
|
||||||
|
|
||||||
@ -23,8 +20,6 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<HouseholdProvider>
|
|
||||||
<StoreProvider>
|
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
@ -42,13 +37,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<GroceryList />} />
|
<Route path="/" element={<GroceryList />} />
|
||||||
<Route path="/manage" element={<Manage />} />
|
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
<RoleGuard allowed={[ROLES.SYSTEM_ADMIN]}>
|
<RoleGuard allowed={[ROLES.ADMIN]}>
|
||||||
<AdminPanel />
|
<AdminPanel />
|
||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
}
|
}
|
||||||
@ -58,8 +52,6 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</StoreProvider>
|
|
||||||
</HouseholdProvider>
|
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
import api from "./axios";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all households for the current user
|
|
||||||
*/
|
|
||||||
export const getUserHouseholds = () => api.get("/households");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get details of a specific household
|
|
||||||
*/
|
|
||||||
export const getHousehold = (householdId) => api.get(`/households/${householdId}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new household
|
|
||||||
*/
|
|
||||||
export const createHousehold = (name) => api.post("/households", { name });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update household name
|
|
||||||
*/
|
|
||||||
export const updateHousehold = (householdId, name) =>
|
|
||||||
api.patch(`/households/${householdId}`, { name });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a household
|
|
||||||
*/
|
|
||||||
export const deleteHousehold = (householdId) =>
|
|
||||||
api.delete(`/households/${householdId}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh household invite code
|
|
||||||
*/
|
|
||||||
export const refreshInviteCode = (householdId) =>
|
|
||||||
api.post(`/households/${householdId}/invite/refresh`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Join a household using invite code
|
|
||||||
*/
|
|
||||||
export const joinHousehold = (inviteCode) =>
|
|
||||||
api.post(`/households/join/${inviteCode}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get household members
|
|
||||||
*/
|
|
||||||
export const getHouseholdMembers = (householdId) =>
|
|
||||||
api.get(`/households/${householdId}/members`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update member role
|
|
||||||
*/
|
|
||||||
export const updateMemberRole = (householdId, userId, role) =>
|
|
||||||
api.patch(`/households/${householdId}/members/${userId}/role`, { role });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove member from household
|
|
||||||
*/
|
|
||||||
export const removeMember = (householdId, userId) =>
|
|
||||||
api.delete(`/households/${householdId}/members/${userId}`);
|
|
||||||
@ -1,120 +1,44 @@
|
|||||||
import api from "./axios";
|
import api from "./axios";
|
||||||
|
|
||||||
/**
|
export const getList = () => api.get("/list");
|
||||||
* Get grocery list for household and store
|
export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } });
|
||||||
*/
|
|
||||||
export const getList = (householdId, storeId) =>
|
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/list`);
|
|
||||||
|
|
||||||
/**
|
export const addItem = (itemName, quantity, imageFile = null) => {
|
||||||
* Get specific item by name
|
|
||||||
*/
|
|
||||||
export const getItemByName = (householdId, storeId, itemName) =>
|
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/list/item`, {
|
|
||||||
params: { item_name: itemName }
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add item to list
|
|
||||||
*/
|
|
||||||
export const addItem = (householdId, storeId, itemName, quantity, imageFile = null, notes = null) => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("item_name", itemName);
|
formData.append("itemName", itemName);
|
||||||
formData.append("quantity", quantity);
|
formData.append("quantity", quantity);
|
||||||
if (notes) {
|
|
||||||
formData.append("notes", notes);
|
|
||||||
}
|
|
||||||
if (imageFile) {
|
if (imageFile) {
|
||||||
formData.append("image", imageFile);
|
formData.append("image", imageFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, {
|
return api.post("/list/add", formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
export const getClassification = (id) => api.get(`/list/item/${id}/classification`);
|
||||||
|
|
||||||
/**
|
export const updateItemWithClassification = (id, itemName, quantity, classification) => {
|
||||||
* Get item classification
|
return api.put(`/list/item/${id}`, {
|
||||||
*/
|
itemName,
|
||||||
export const getClassification = (householdId, storeId, itemName) =>
|
quantity,
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/list/classification`, {
|
|
||||||
params: { item_name: itemName }
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set item classification
|
|
||||||
*/
|
|
||||||
export const setClassification = (householdId, storeId, itemName, classification) =>
|
|
||||||
api.post(`/households/${householdId}/stores/${storeId}/list/classification`, {
|
|
||||||
item_name: itemName,
|
|
||||||
classification
|
classification
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Update item with classification (legacy method - split into separate calls)
|
|
||||||
*/
|
|
||||||
export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => {
|
|
||||||
// This is now two operations: update item + set classification
|
|
||||||
return Promise.all([
|
|
||||||
updateItem(householdId, storeId, itemName, quantity),
|
|
||||||
classification ? setClassification(householdId, storeId, itemName, classification) : Promise.resolve()
|
|
||||||
]);
|
|
||||||
};
|
};
|
||||||
|
export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity });
|
||||||
|
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
||||||
|
export const getRecentlyBought = () => api.get("/list/recently-bought");
|
||||||
|
|
||||||
/**
|
export const updateItemImage = (id, itemName, quantity, imageFile) => {
|
||||||
* Update item details (quantity, notes)
|
|
||||||
*/
|
|
||||||
export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
|
|
||||||
api.put(`/households/${householdId}/stores/${storeId}/list/item`, {
|
|
||||||
item_name: itemName,
|
|
||||||
quantity,
|
|
||||||
notes
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark item as bought or unbought
|
|
||||||
*/
|
|
||||||
export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) =>
|
|
||||||
api.patch(`/households/${householdId}/stores/${storeId}/list/item`, {
|
|
||||||
item_name: itemName,
|
|
||||||
bought,
|
|
||||||
quantity_bought: quantityBought
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete item from list
|
|
||||||
*/
|
|
||||||
export const deleteItem = (householdId, storeId, itemName) =>
|
|
||||||
api.delete(`/households/${householdId}/stores/${storeId}/list/item`, {
|
|
||||||
data: { item_name: itemName }
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get suggestions based on query
|
|
||||||
*/
|
|
||||||
export const getSuggestions = (householdId, storeId, query) =>
|
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, {
|
|
||||||
params: { query }
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recently bought items
|
|
||||||
*/
|
|
||||||
export const getRecentlyBought = (householdId, storeId) =>
|
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/list/recent`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update item image
|
|
||||||
*/
|
|
||||||
export const updateItemImage = (householdId, storeId, itemName, quantity, imageFile) => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("item_name", itemName);
|
formData.append("id", id);
|
||||||
|
formData.append("itemName", itemName);
|
||||||
formData.append("quantity", quantity);
|
formData.append("quantity", quantity);
|
||||||
formData.append("image", imageFile);
|
formData.append("image", imageFile);
|
||||||
|
|
||||||
return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, {
|
return api.post("/list/update-image", formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
import api from "./axios";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all stores in the system
|
|
||||||
*/
|
|
||||||
export const getAllStores = () => api.get("/stores");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get stores linked to a household
|
|
||||||
*/
|
|
||||||
export const getHouseholdStores = (householdId) =>
|
|
||||||
api.get(`/stores/household/${householdId}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a store to a household
|
|
||||||
*/
|
|
||||||
export const addStoreToHousehold = (householdId, storeId, isDefault = false) =>
|
|
||||||
api.post(`/stores/household/${householdId}`, { storeId: storeId, isDefault: isDefault });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a store from a household
|
|
||||||
*/
|
|
||||||
export const removeStoreFromHousehold = (householdId, storeId) =>
|
|
||||||
api.delete(`/stores/household/${householdId}/${storeId}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a store as default for a household
|
|
||||||
*/
|
|
||||||
export const setDefaultStore = (householdId, storeId) =>
|
|
||||||
api.patch(`/stores/household/${householdId}/${storeId}/default`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new store (system admin only)
|
|
||||||
*/
|
|
||||||
export const createStore = (name, location) =>
|
|
||||||
api.post("/stores", { name, location });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update store details (system admin only)
|
|
||||||
*/
|
|
||||||
export const updateStore = (storeId, name, location) =>
|
|
||||||
api.patch(`/stores/${storeId}`, { name, location });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a store (system admin only)
|
|
||||||
*/
|
|
||||||
export const deleteStore = (storeId) =>
|
|
||||||
api.delete(`/stores/${storeId}`);
|
|
||||||
@ -1,285 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { createStore, deleteStore, getAllStores, updateStore } from "../../api/stores";
|
|
||||||
import "../../styles/components/admin/StoreManagement.css";
|
|
||||||
|
|
||||||
export default function StoreManagement() {
|
|
||||||
const [stores, setStores] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [editingStore, setEditingStore] = useState(null);
|
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: "",
|
|
||||||
zones: []
|
|
||||||
});
|
|
||||||
const [newZone, setNewZone] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadStores();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadStores = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getAllStores();
|
|
||||||
setStores(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load stores:", error);
|
|
||||||
alert("Failed to load stores");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!formData.name.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null;
|
|
||||||
await createStore(formData.name, zonesJson);
|
|
||||||
await loadStores();
|
|
||||||
setShowCreateForm(false);
|
|
||||||
setFormData({ name: "", zones: [] });
|
|
||||||
setNewZone("");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to create store:", error);
|
|
||||||
alert(error.response?.data?.error || "Failed to create store");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!editingStore || !formData.name.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null;
|
|
||||||
await updateStore(editingStore.id, formData.name, zonesJson);
|
|
||||||
await loadStores();
|
|
||||||
setEditingStore(null);
|
|
||||||
setFormData({ name: "", zones: [] });
|
|
||||||
setNewZone("");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update store:", error);
|
|
||||||
alert(error.response?.data?.error || "Failed to update store");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (storeId, storeName) => {
|
|
||||||
if (!confirm(`Delete store "${storeName}"? This cannot be undone.`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteStore(storeId);
|
|
||||||
await loadStores();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete store:", error);
|
|
||||||
alert(error.response?.data?.error || "Failed to delete store");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startEdit = (store) => {
|
|
||||||
console.log('Starting edit for store:', store);
|
|
||||||
setEditingStore(store);
|
|
||||||
let zones = [];
|
|
||||||
if (store.default_zones) {
|
|
||||||
try {
|
|
||||||
let parsed = typeof store.default_zones === 'string'
|
|
||||||
? JSON.parse(store.default_zones)
|
|
||||||
: store.default_zones;
|
|
||||||
|
|
||||||
// Handle both formats: direct array or object with zones property
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
zones = parsed;
|
|
||||||
} else if (parsed && Array.isArray(parsed.zones)) {
|
|
||||||
zones = parsed.zones;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse zones:', e);
|
|
||||||
zones = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('Parsed zones:', zones);
|
|
||||||
setFormData({
|
|
||||||
name: store.name,
|
|
||||||
zones: zones
|
|
||||||
});
|
|
||||||
setShowCreateForm(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelEdit = () => {
|
|
||||||
setEditingStore(null);
|
|
||||||
setFormData({ name: "", zones: [] });
|
|
||||||
setNewZone("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const startCreate = () => {
|
|
||||||
setShowCreateForm(true);
|
|
||||||
setEditingStore(null);
|
|
||||||
setFormData({ name: "", zones: [] });
|
|
||||||
setNewZone("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const addZone = () => {
|
|
||||||
const zone = newZone.trim();
|
|
||||||
if (!zone) return;
|
|
||||||
if (formData.zones.includes(zone)) {
|
|
||||||
alert("Zone already exists");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFormData({ ...formData, zones: [...formData.zones, zone] });
|
|
||||||
setNewZone("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeZone = (index) => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
zones: formData.zones.filter((_, i) => i !== index)
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseZones = (defaultZones) => {
|
|
||||||
if (!defaultZones) return [];
|
|
||||||
try {
|
|
||||||
let parsed = typeof defaultZones === 'string' ? JSON.parse(defaultZones) : defaultZones;
|
|
||||||
|
|
||||||
// Handle both formats: direct array or object with zones property
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
} else if (parsed && Array.isArray(parsed.zones)) {
|
|
||||||
return parsed.zones;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
} catch (e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="store-management">
|
|
||||||
<div className="store-management-header">
|
|
||||||
<h2>Store Management</h2>
|
|
||||||
{!showCreateForm && !editingStore && (
|
|
||||||
<button onClick={startCreate} className="btn-primary">
|
|
||||||
+ Create Store
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create/Edit Form */}
|
|
||||||
{(showCreateForm || editingStore) && (
|
|
||||||
<div className="store-form-card">
|
|
||||||
<h3>{editingStore ? "Edit Store" : "Create New Store"}</h3>
|
|
||||||
<form onSubmit={editingStore ? handleUpdate : handleCreate}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="storeName">Store Name *</label>
|
|
||||||
<input
|
|
||||||
id="storeName"
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="e.g., Costco Richmond"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="defaultZones">Default Zones</label>
|
|
||||||
<div className="zone-input-container">
|
|
||||||
<input
|
|
||||||
id="zoneInput"
|
|
||||||
type="text"
|
|
||||||
value={newZone}
|
|
||||||
onChange={(e) => setNewZone(e.target.value)}
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addZone())}
|
|
||||||
placeholder="Enter zone name and press Enter or click Add"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addZone}
|
|
||||||
className="btn-secondary btn-small"
|
|
||||||
>
|
|
||||||
Add Zone
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{formData.zones.length > 0 && (
|
|
||||||
<div className="zones-list">
|
|
||||||
{formData.zones.map((zone, index) => (
|
|
||||||
<div key={index} className="zone-chip">
|
|
||||||
<span>{zone}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeZone(index)}
|
|
||||||
className="zone-remove"
|
|
||||||
aria-label="Remove zone"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="form-hint">
|
|
||||||
Add zones that will be used for organizing items in this store
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={editingStore ? cancelEdit : () => setShowCreateForm(false)}
|
|
||||||
className="btn-secondary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-primary">
|
|
||||||
{editingStore ? "Update Store" : "Create Store"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stores List */}
|
|
||||||
<div className="stores-grid">
|
|
||||||
{loading ? (
|
|
||||||
<p>Loading stores...</p>
|
|
||||||
) : stores.length === 0 ? (
|
|
||||||
<p className="empty-message">No stores found. Create one to get started.</p>
|
|
||||||
) : (
|
|
||||||
stores.map((store) => (
|
|
||||||
<div key={store.id} className="store-admin-card">
|
|
||||||
<div className="store-admin-info">
|
|
||||||
<h3>{store.name}</h3>
|
|
||||||
{store.default_zones && parseZones(store.default_zones).length > 0 && (
|
|
||||||
<div className="store-zones-display">
|
|
||||||
<p className="zones-label">Default Zones:</p>
|
|
||||||
<div className="zones-list-display">
|
|
||||||
{parseZones(store.default_zones).map((zone, idx) => (
|
|
||||||
<span key={idx} className="zone-badge">{zone}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="store-meta">ID: {store.id}</p>
|
|
||||||
</div>
|
|
||||||
<div className="store-admin-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => startEdit(store)}
|
|
||||||
className="btn-secondary btn-small"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(store.id, store.name)}
|
|
||||||
className="btn-danger btn-small"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,8 +1,6 @@
|
|||||||
import { ROLES } from "../../constants/roles";
|
import { ROLES } from "../../constants/roles";
|
||||||
|
|
||||||
export default function UserRoleCard({ user, onRoleChange }) {
|
export default function UserRoleCard({ user, onRoleChange }) {
|
||||||
console.log(user)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card flex-between p-3 my-2 shadow-sm" style={{ transition: 'var(--transition-base)' }}>
|
<div className="card flex-between p-3 my-2 shadow-sm" style={{ transition: 'var(--transition-base)' }}>
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
import { useContext, useState } from 'react';
|
|
||||||
import { HouseholdContext } from '../../context/HouseholdContext';
|
|
||||||
import '../../styles/components/HouseholdSwitcher.css';
|
|
||||||
import CreateJoinHousehold from '../manage/CreateJoinHousehold';
|
|
||||||
|
|
||||||
export default function HouseholdSwitcher() {
|
|
||||||
const { households, activeHousehold, setActiveHousehold, loading } = useContext(HouseholdContext);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [showCreateJoin, setShowCreateJoin] = useState(false);
|
|
||||||
|
|
||||||
if (!activeHousehold || households.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelect = (household) => {
|
|
||||||
setActiveHousehold(household);
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="household-switcher">
|
|
||||||
<button
|
|
||||||
className="household-switcher-toggle"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<span className="household-name">{activeHousehold.name}</span>
|
|
||||||
<span className={`dropdown-icon ${isOpen ? 'open' : ''}`}>▼</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<>
|
|
||||||
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
|
|
||||||
<div className="household-switcher-dropdown">
|
|
||||||
{households.map(household => (
|
|
||||||
<button
|
|
||||||
key={household.id}
|
|
||||||
className={`household-option ${household.id === activeHousehold.id ? 'active' : ''}`}
|
|
||||||
onClick={() => handleSelect(household)}
|
|
||||||
>
|
|
||||||
{household.name}
|
|
||||||
{household.id === activeHousehold.id && (
|
|
||||||
<span className="check-mark">✓</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<div className="household-divider"></div>
|
|
||||||
<button
|
|
||||||
className="household-option create-household-btn"
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
setShowCreateJoin(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+ Create or Join Household
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showCreateJoin && (
|
|
||||||
<CreateJoinHousehold onClose={() => setShowCreateJoin(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,84 +1,31 @@
|
|||||||
import "../../styles/components/Navbar.css";
|
import "../../styles/components/Navbar.css";
|
||||||
|
|
||||||
import { useContext, useState } from "react";
|
import { useContext } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { AuthContext } from "../../context/AuthContext";
|
import { AuthContext } from "../../context/AuthContext";
|
||||||
import HouseholdSwitcher from "../household/HouseholdSwitcher";
|
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { role, logout, username } = useContext(AuthContext);
|
const { role, logout, username } = useContext(AuthContext);
|
||||||
const [showNavMenu, setShowNavMenu] = useState(false);
|
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
|
||||||
|
|
||||||
const closeMenus = () => {
|
|
||||||
setShowNavMenu(false);
|
|
||||||
setShowUserMenu(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
{/* Left: Navigation Menu */}
|
<div className="navbar-links">
|
||||||
<div className="navbar-section navbar-left">
|
<Link to="/">Home</Link>
|
||||||
<button
|
<Link to="/settings">Settings</Link>
|
||||||
className="navbar-menu-btn"
|
|
||||||
onClick={() => {
|
|
||||||
setShowNavMenu(!showNavMenu);
|
|
||||||
setShowUserMenu(false);
|
|
||||||
}}
|
|
||||||
aria-label="Navigation menu"
|
|
||||||
>
|
|
||||||
<span className="hamburger-icon">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showNavMenu && (
|
{role === "admin" && <Link to="/admin">Admin</Link>}
|
||||||
<>
|
|
||||||
<div className="menu-overlay" onClick={closeMenus}></div>
|
|
||||||
<div className="navbar-dropdown nav-dropdown">
|
|
||||||
<Link to="/" onClick={closeMenus}>Home</Link>
|
|
||||||
<Link to="/manage" onClick={closeMenus}>Manage</Link>
|
|
||||||
<Link to="/settings" onClick={closeMenus}>Settings</Link>
|
|
||||||
{role === "system_admin" && <Link to="/admin" onClick={closeMenus}>Admin</Link>}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center: Household Switcher */}
|
<div className="navbar-idcard">
|
||||||
<div className="navbar-section navbar-center">
|
<div className="navbar-idinfo">
|
||||||
<HouseholdSwitcher />
|
<span className="navbar-username">{username}</span>
|
||||||
|
<span className="navbar-role">{role}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: User Menu */}
|
<button className="navbar-logout" onClick={logout}>
|
||||||
<div className="navbar-section navbar-right">
|
|
||||||
<button
|
|
||||||
className="navbar-user-btn"
|
|
||||||
onClick={() => {
|
|
||||||
setShowUserMenu(!showUserMenu);
|
|
||||||
setShowNavMenu(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{username}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showUserMenu && (
|
|
||||||
<>
|
|
||||||
<div className="menu-overlay" onClick={closeMenus}></div>
|
|
||||||
<div className="navbar-dropdown user-dropdown">
|
|
||||||
<div className="user-dropdown-info">
|
|
||||||
<span className="user-dropdown-username">{username}</span>
|
|
||||||
<span className="user-dropdown-role">{role}</span>
|
|
||||||
</div>
|
|
||||||
<button className="user-dropdown-logout" onClick={() => { logout(); closeMenus(); }}>
|
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,131 +0,0 @@
|
|||||||
import { useContext, useState } from "react";
|
|
||||||
import { createHousehold, joinHousehold } from "../../api/households";
|
|
||||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
|
||||||
import "../../styles/components/manage/CreateJoinHousehold.css";
|
|
||||||
|
|
||||||
export default function CreateJoinHousehold({ onClose }) {
|
|
||||||
const { refreshHouseholds } = useContext(HouseholdContext);
|
|
||||||
const [mode, setMode] = useState("create"); // "create" or "join"
|
|
||||||
const [householdName, setHouseholdName] = useState("");
|
|
||||||
const [inviteCode, setInviteCode] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!householdName.trim()) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createHousehold(householdName);
|
|
||||||
await refreshHouseholds();
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to create household:", err);
|
|
||||||
setError(err.response?.data?.message || "Failed to create household");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJoin = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!inviteCode.trim()) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("Joining household with invite code:", inviteCode);
|
|
||||||
await joinHousehold(inviteCode);
|
|
||||||
await refreshHouseholds();
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to join household:", err);
|
|
||||||
setError(err.response?.data?.message || "Failed to join household");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="create-join-modal-overlay" onClick={onClose}>
|
|
||||||
<div className="create-join-modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="modal-header">
|
|
||||||
<h2>Household</h2>
|
|
||||||
<button className="close-btn" onClick={onClose}>×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mode-tabs">
|
|
||||||
<button
|
|
||||||
className={`mode-tab ${mode === "create" ? "active" : ""}`}
|
|
||||||
onClick={() => setMode("create")}
|
|
||||||
>
|
|
||||||
Create New
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`mode-tab ${mode === "join" ? "active" : ""}`}
|
|
||||||
onClick={() => setMode("join")}
|
|
||||||
>
|
|
||||||
Join Existing
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
|
||||||
|
|
||||||
{mode === "create" ? (
|
|
||||||
<form onSubmit={handleCreate} className="household-form">
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="householdName">Household Name</label>
|
|
||||||
<input
|
|
||||||
id="householdName"
|
|
||||||
type="text"
|
|
||||||
value={householdName}
|
|
||||||
onChange={(e) => setHouseholdName(e.target.value)}
|
|
||||||
placeholder="e.g., Smith Family"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-actions">
|
|
||||||
<button type="button" onClick={onClose} className="btn-secondary">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-primary" disabled={loading}>
|
|
||||||
{loading ? "Creating..." : "Create Household"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleJoin} className="household-form">
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="inviteCode">Invite Code</label>
|
|
||||||
<input
|
|
||||||
id="inviteCode"
|
|
||||||
type="text"
|
|
||||||
value={inviteCode}
|
|
||||||
onChange={(e) => setInviteCode(e.target.value)}
|
|
||||||
placeholder="Enter invite code"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<p className="form-hint">
|
|
||||||
Ask the household admin for the invite code
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="form-actions">
|
|
||||||
<button type="button" onClick={onClose} className="btn-secondary">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-primary" disabled={loading}>
|
|
||||||
{loading ? "Joining..." : "Join Household"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,233 +0,0 @@
|
|||||||
import React, { useContext, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
deleteHousehold,
|
|
||||||
getHouseholdMembers,
|
|
||||||
refreshInviteCode,
|
|
||||||
removeMember,
|
|
||||||
updateHousehold,
|
|
||||||
updateMemberRole
|
|
||||||
} from "../../api/households";
|
|
||||||
import { AuthContext } from "../../context/AuthContext";
|
|
||||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
|
||||||
import "../../styles/components/manage/ManageHousehold.css";
|
|
||||||
|
|
||||||
export default function ManageHousehold() {
|
|
||||||
const { userId } = useContext(AuthContext);
|
|
||||||
const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext);
|
|
||||||
const [members, setMembers] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [showInviteCode, setShowInviteCode] = useState(false);
|
|
||||||
|
|
||||||
const isAdmin = activeHousehold?.role === "admin";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadMembers();
|
|
||||||
}, [activeHousehold?.id]);
|
|
||||||
|
|
||||||
const loadMembers = async () => {
|
|
||||||
if (!activeHousehold?.id) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getHouseholdMembers(activeHousehold.id);
|
|
||||||
setMembers(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load members:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateName = async () => {
|
|
||||||
if (!newName.trim() || newName === activeHousehold.name) {
|
|
||||||
setEditingName(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Updating household:', activeHousehold.id, 'with name:', newName);
|
|
||||||
const response = await updateHousehold(activeHousehold.id, newName);
|
|
||||||
console.log('Update response:', response);
|
|
||||||
await refreshHouseholds();
|
|
||||||
setEditingName(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update household name:", error);
|
|
||||||
console.error("Error response:", error.response?.data);
|
|
||||||
alert(`Failed to update household name: ${error.response?.data?.error || error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefreshInvite = async () => {
|
|
||||||
if (!confirm("Generate a new invite code? The old code will no longer work.")) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await refreshInviteCode(activeHousehold.id);
|
|
||||||
await refreshHouseholds();
|
|
||||||
alert(`New invite code: ${response.data.inviteCode}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to refresh invite code:", error);
|
|
||||||
alert("Failed to refresh invite code");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateRole = async (userId, currentRole) => {
|
|
||||||
const newRole = currentRole === "admin" ? "member" : "admin";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateMemberRole(activeHousehold.id, userId, newRole);
|
|
||||||
await loadMembers();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update role:", error);
|
|
||||||
alert("Failed to update member role");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveMember = async (userId, username) => {
|
|
||||||
if (!confirm(`Remove ${username} from this household?`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeMember(activeHousehold.id, userId);
|
|
||||||
await loadMembers();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to remove member:", error);
|
|
||||||
alert("Failed to remove member");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteHousehold = async () => {
|
|
||||||
if (!confirm(`Delete "${activeHousehold.name}"? This will delete all lists and data. This cannot be undone.`)) return;
|
|
||||||
if (!confirm("Are you absolutely sure? Type DELETE to confirm.")) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteHousehold(activeHousehold.id);
|
|
||||||
await refreshHouseholds();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete household:", error);
|
|
||||||
alert("Failed to delete household");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyInviteCode = () => {
|
|
||||||
navigator.clipboard.writeText(activeHousehold.invite_code);
|
|
||||||
alert("Invite code copied to clipboard!");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="manage-household">
|
|
||||||
{/* Household Name Section */}
|
|
||||||
<section key="household-name" className="manage-section">
|
|
||||||
<h2>Household Name</h2>
|
|
||||||
{editingName ? (
|
|
||||||
<div className="edit-name-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
placeholder="Household name"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button onClick={handleUpdateName} className="btn-primary">Save</button>
|
|
||||||
<button onClick={() => setEditingName(false)} className="btn-secondary">Cancel</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="name-display">
|
|
||||||
<h3>{activeHousehold.name}</h3>
|
|
||||||
{isAdmin && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setNewName(activeHousehold.name);
|
|
||||||
setEditingName(true);
|
|
||||||
}}
|
|
||||||
className="btn-secondary"
|
|
||||||
>
|
|
||||||
Edit Name
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Invite Code Section */}
|
|
||||||
{isAdmin && (
|
|
||||||
<section key="invite-code" className="manage-section">
|
|
||||||
<h2>Invite Code</h2>
|
|
||||||
<p className="section-description">
|
|
||||||
Share this code with others to invite them to your household.
|
|
||||||
</p>
|
|
||||||
<div className="invite-actions">
|
|
||||||
<button onClick={() => setShowInviteCode(!showInviteCode)} className="btn-secondary">
|
|
||||||
{showInviteCode ? "Hide Code" : "Show Code"}
|
|
||||||
</button>
|
|
||||||
{showInviteCode && (
|
|
||||||
<React.Fragment key="invite-code-display">
|
|
||||||
<code className="invite-code">{activeHousehold.invite_code}</code>
|
|
||||||
<button onClick={copyInviteCode} className="btn-secondary">Copy</button>
|
|
||||||
</React.Fragment>
|
|
||||||
)}
|
|
||||||
<button onClick={handleRefreshInvite} className="btn-secondary">
|
|
||||||
Generate New Code
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Members Section */}
|
|
||||||
<section key="members" className="manage-section">
|
|
||||||
<h2>Members ({members.length})</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p>Loading members...</p>
|
|
||||||
) : (
|
|
||||||
<div className="members-list">
|
|
||||||
{members.map((member) => (
|
|
||||||
<div key={member.id} className="member-card">
|
|
||||||
<div className="member-info">
|
|
||||||
<span className="member-role">
|
|
||||||
{member.role}
|
|
||||||
</span>
|
|
||||||
<span className="member-name">
|
|
||||||
{`
|
|
||||||
${member.username}
|
|
||||||
[${member.id}]
|
|
||||||
${(member.id === parseInt(userId) ? " (You)" : "")}
|
|
||||||
`}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{isAdmin && member.id !== parseInt(userId) && (
|
|
||||||
<div className="member-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpdateRole(member.id, member.role)}
|
|
||||||
className="btn-secondary btn-small"
|
|
||||||
>
|
|
||||||
{member.role === "admin" ? "Make Member" : "Make Admin"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveMember(member.id, member.username)}
|
|
||||||
className="btn-danger btn-small"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Danger Zone */}
|
|
||||||
{isAdmin && (
|
|
||||||
<section key="danger-zone" className="manage-section danger-zone">
|
|
||||||
<h2>Danger Zone</h2>
|
|
||||||
<p className="section-description">
|
|
||||||
Deleting a household is permanent and will delete all lists, items, and history.
|
|
||||||
</p>
|
|
||||||
<button onClick={handleDeleteHousehold} className="btn-danger">
|
|
||||||
Delete Household
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
addStoreToHousehold,
|
|
||||||
getAllStores,
|
|
||||||
removeStoreFromHousehold,
|
|
||||||
setDefaultStore
|
|
||||||
} from "../../api/stores";
|
|
||||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
|
||||||
import { StoreContext } from "../../context/StoreContext";
|
|
||||||
import "../../styles/components/manage/ManageStores.css";
|
|
||||||
|
|
||||||
export default function ManageStores() {
|
|
||||||
const { activeHousehold } = useContext(HouseholdContext);
|
|
||||||
const { stores: householdStores, refreshStores } = useContext(StoreContext);
|
|
||||||
const [allStores, setAllStores] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showAddStore, setShowAddStore] = useState(false);
|
|
||||||
|
|
||||||
const isAdmin = activeHousehold?.role === "admin";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadAllStores();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadAllStores = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getAllStores();
|
|
||||||
setAllStores(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load stores:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddStore = async (storeId) => {
|
|
||||||
try {
|
|
||||||
console.log("Adding store with ID:", storeId);
|
|
||||||
await addStoreToHousehold(activeHousehold.id, storeId, false);
|
|
||||||
await refreshStores();
|
|
||||||
setShowAddStore(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to add store:", error);
|
|
||||||
alert("Failed to add store");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveStore = async (storeId, storeName) => {
|
|
||||||
if (!confirm(`Remove ${storeName} from this household?`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeStoreFromHousehold(activeHousehold.id, storeId);
|
|
||||||
await refreshStores();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to remove store:", error);
|
|
||||||
alert("Failed to remove store");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSetDefault = async (storeId) => {
|
|
||||||
try {
|
|
||||||
await setDefaultStore(activeHousehold.id, storeId);
|
|
||||||
await refreshStores();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to set default store:", error);
|
|
||||||
alert("Failed to set default store");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableStores = allStores.filter(
|
|
||||||
store => !householdStores.some(hs => hs.id === store.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="manage-stores">
|
|
||||||
{/* Current Stores Section */}
|
|
||||||
<section className="manage-section">
|
|
||||||
<h2>Your Stores ({householdStores.length})</h2>
|
|
||||||
{householdStores.length === 0 ? (
|
|
||||||
<p className="empty-message">No stores added yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="stores-list">
|
|
||||||
{householdStores.map((store) => (
|
|
||||||
<div key={store.id} className="store-card">
|
|
||||||
<div className="store-info">
|
|
||||||
<h3>{store.name}</h3>
|
|
||||||
{store.location && <p className="store-location">{store.location}</p>}
|
|
||||||
{store.is_default && <span className="default-badge">Default</span>}
|
|
||||||
</div>
|
|
||||||
{isAdmin && (
|
|
||||||
<div className="store-actions">
|
|
||||||
{!store.is_default && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleSetDefault(store.id)}
|
|
||||||
className="btn-secondary btn-small"
|
|
||||||
>
|
|
||||||
Set as Default
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveStore(store.id, store.name)}
|
|
||||||
className="btn-danger btn-small"
|
|
||||||
disabled={householdStores.length === 1}
|
|
||||||
title={householdStores.length === 1 ? "Cannot remove last store" : ""}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Add Store Section */}
|
|
||||||
{isAdmin && (
|
|
||||||
<section className="manage-section">
|
|
||||||
<h2>Add Store</h2>
|
|
||||||
{!showAddStore ? (
|
|
||||||
<button onClick={() => setShowAddStore(true)} className="btn-primary">
|
|
||||||
+ Add Store
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="add-store-panel">
|
|
||||||
<button onClick={() => setShowAddStore(false)} className="btn-secondary">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
{loading ? (
|
|
||||||
<p>Loading stores...</p>
|
|
||||||
) : availableStores.length === 0 ? (
|
|
||||||
<p className="empty-message">All available stores have been added.</p>
|
|
||||||
) : (
|
|
||||||
<div className="available-stores">
|
|
||||||
{availableStores.map((store) => (
|
|
||||||
<div key={store.id} className="available-store-card">
|
|
||||||
<div className="store-info">
|
|
||||||
<h3>{store.name}</h3>
|
|
||||||
{store.location && <p className="store-location">{store.location}</p>}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleAddStore(store.id)}
|
|
||||||
className="btn-primary btn-small"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export { default as CreateJoinHousehold } from './CreateJoinHousehold';
|
|
||||||
export { default as ManageHousehold } from './ManageHousehold';
|
|
||||||
export { default as ManageStores } from './ManageStores';
|
|
||||||
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import { useContext } from 'react';
|
|
||||||
import { StoreContext } from '../../context/StoreContext';
|
|
||||||
import '../../styles/components/StoreTabs.css';
|
|
||||||
|
|
||||||
export default function StoreTabs() {
|
|
||||||
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
|
|
||||||
|
|
||||||
if (!stores || stores.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="store-tabs">
|
|
||||||
<div className="store-tabs-empty">
|
|
||||||
No stores available for this household
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="store-tabs">
|
|
||||||
<div className="store-tabs-container">
|
|
||||||
{stores.map(store => (
|
|
||||||
<button
|
|
||||||
key={store.id}
|
|
||||||
className={`store-tab ${store.id === activeStore?.id ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveStore(store)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<span className="store-name">{store.name}</span>
|
|
||||||
{store.is_default && <span className="default-badge">Default</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -2,7 +2,5 @@ export const ROLES = {
|
|||||||
VIEWER: "viewer",
|
VIEWER: "viewer",
|
||||||
EDITOR: "editor",
|
EDITOR: "editor",
|
||||||
ADMIN: "admin",
|
ADMIN: "admin",
|
||||||
SYSTEM_ADMIN: "system_admin",
|
|
||||||
USER: "user",
|
|
||||||
UP_TO_ADMIN: ["viewer", "editor", "admin"],
|
UP_TO_ADMIN: ["viewer", "editor", "admin"],
|
||||||
};
|
};
|
||||||
@ -2,7 +2,6 @@ import { createContext, useState } from 'react';
|
|||||||
|
|
||||||
export const AuthContext = createContext({
|
export const AuthContext = createContext({
|
||||||
token: null,
|
token: null,
|
||||||
userId: null,
|
|
||||||
role: null,
|
role: null,
|
||||||
username: null,
|
username: null,
|
||||||
login: () => { },
|
login: () => { },
|
||||||
@ -11,17 +10,14 @@ export const AuthContext = createContext({
|
|||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [token, setToken] = useState(localStorage.getItem('token') || null);
|
const [token, setToken] = useState(localStorage.getItem('token') || null);
|
||||||
const [userId, setUserId] = useState(localStorage.getItem('userId') || null);
|
|
||||||
const [role, setRole] = useState(localStorage.getItem('role') || null);
|
const [role, setRole] = useState(localStorage.getItem('role') || null);
|
||||||
const [username, setUsername] = useState(localStorage.getItem('username') || null);
|
const [username, setUsername] = useState(localStorage.getItem('username') || null);
|
||||||
|
|
||||||
const login = (data) => {
|
const login = (data) => {
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
localStorage.setItem('userId', data.userId);
|
|
||||||
localStorage.setItem('role', data.role);
|
localStorage.setItem('role', data.role);
|
||||||
localStorage.setItem('username', data.username);
|
localStorage.setItem('username', data.username);
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
setUserId(data.userId);
|
|
||||||
setRole(data.role);
|
setRole(data.role);
|
||||||
setUsername(data.username);
|
setUsername(data.username);
|
||||||
};
|
};
|
||||||
@ -30,14 +26,12 @@ export const AuthProvider = ({ children }) => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUserId(null);
|
|
||||||
setRole(null);
|
setRole(null);
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
token,
|
token,
|
||||||
userId,
|
|
||||||
role,
|
role,
|
||||||
username,
|
username,
|
||||||
login,
|
login,
|
||||||
|
|||||||
@ -1,115 +0,0 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households';
|
|
||||||
import { AuthContext } from './AuthContext';
|
|
||||||
|
|
||||||
export const HouseholdContext = createContext({
|
|
||||||
households: [],
|
|
||||||
activeHousehold: null,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
setActiveHousehold: () => { },
|
|
||||||
refreshHouseholds: () => { },
|
|
||||||
createHousehold: () => { },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const HouseholdProvider = ({ children }) => {
|
|
||||||
const { token } = useContext(AuthContext);
|
|
||||||
const [households, setHouseholds] = useState([]);
|
|
||||||
const [activeHousehold, setActiveHouseholdState] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Load households on mount and when token changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (token) {
|
|
||||||
loadHouseholds();
|
|
||||||
} else {
|
|
||||||
// Clear state when logged out
|
|
||||||
setHouseholds([]);
|
|
||||||
setActiveHouseholdState(null);
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
// Load active household from localStorage on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (households.length === 0) return;
|
|
||||||
|
|
||||||
console.log('[HouseholdContext] Setting active household from:', households);
|
|
||||||
const savedHouseholdId = localStorage.getItem('activeHouseholdId');
|
|
||||||
if (savedHouseholdId) {
|
|
||||||
const household = households.find(h => h.id === parseInt(savedHouseholdId));
|
|
||||||
if (household) {
|
|
||||||
console.log('[HouseholdContext] Found saved household:', household);
|
|
||||||
setActiveHouseholdState(household);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No saved household or not found, use first one
|
|
||||||
console.log('[HouseholdContext] Using first household:', households[0]);
|
|
||||||
setActiveHouseholdState(households[0]);
|
|
||||||
localStorage.setItem('activeHouseholdId', households[0].id);
|
|
||||||
}, [households]);
|
|
||||||
|
|
||||||
const loadHouseholds = async () => {
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
console.log('[HouseholdContext] Loading households...');
|
|
||||||
const response = await getUserHouseholds();
|
|
||||||
console.log('[HouseholdContext] Loaded households:', response.data);
|
|
||||||
setHouseholds(response.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[HouseholdContext] Failed to load households:', err);
|
|
||||||
setError(err.response?.data?.message || 'Failed to load households');
|
|
||||||
setHouseholds([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setActiveHousehold = (household) => {
|
|
||||||
setActiveHouseholdState(household);
|
|
||||||
if (household) {
|
|
||||||
localStorage.setItem('activeHouseholdId', household.id);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('activeHouseholdId');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createHousehold = async (name) => {
|
|
||||||
try {
|
|
||||||
const response = await createHouseholdApi(name);
|
|
||||||
const newHousehold = response.data.household;
|
|
||||||
|
|
||||||
// Refresh households list
|
|
||||||
await loadHouseholds();
|
|
||||||
|
|
||||||
// Set new household as active
|
|
||||||
setActiveHousehold(newHousehold);
|
|
||||||
|
|
||||||
return newHousehold;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to create household:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
households,
|
|
||||||
activeHousehold,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
setActiveHousehold,
|
|
||||||
refreshHouseholds: loadHouseholds,
|
|
||||||
createHousehold,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HouseholdContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</HouseholdContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
import { getHouseholdStores } from '../api/stores';
|
|
||||||
import { AuthContext } from './AuthContext';
|
|
||||||
import { HouseholdContext } from './HouseholdContext';
|
|
||||||
|
|
||||||
export const StoreContext = createContext({
|
|
||||||
stores: [],
|
|
||||||
activeStore: null,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
setActiveStore: () => { },
|
|
||||||
refreshStores: () => { },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const StoreProvider = ({ children }) => {
|
|
||||||
const { token } = useContext(AuthContext);
|
|
||||||
const { activeHousehold } = useContext(HouseholdContext);
|
|
||||||
const [stores, setStores] = useState([]);
|
|
||||||
const [activeStore, setActiveStoreState] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Load stores when household changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (token && activeHousehold) {
|
|
||||||
loadStores();
|
|
||||||
} else {
|
|
||||||
// Clear state when logged out or no household
|
|
||||||
setStores([]);
|
|
||||||
setActiveStoreState(null);
|
|
||||||
}
|
|
||||||
}, [token, activeHousehold?.id]);
|
|
||||||
|
|
||||||
// Load active store from localStorage on mount (per household)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeHousehold || stores.length === 0) return;
|
|
||||||
|
|
||||||
console.log('[StoreContext] Setting active store from:', stores);
|
|
||||||
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
|
||||||
const savedStoreId = localStorage.getItem(storageKey);
|
|
||||||
|
|
||||||
if (savedStoreId) {
|
|
||||||
const store = stores.find(s => s.id === parseInt(savedStoreId));
|
|
||||||
if (store) {
|
|
||||||
console.log('[StoreContext] Found saved store:', store);
|
|
||||||
setActiveStoreState(store);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No saved store or not found, use default or first one
|
|
||||||
const defaultStore = stores.find(s => s.is_default) || stores[0];
|
|
||||||
console.log('[StoreContext] Using store:', defaultStore);
|
|
||||||
setActiveStoreState(defaultStore);
|
|
||||||
localStorage.setItem(storageKey, defaultStore.id);
|
|
||||||
}, [stores, activeHousehold]);
|
|
||||||
|
|
||||||
const loadStores = async () => {
|
|
||||||
if (!token || !activeHousehold) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
console.log('[StoreContext] Loading stores for household:', activeHousehold.id);
|
|
||||||
const response = await getHouseholdStores(activeHousehold.id);
|
|
||||||
console.log('[StoreContext] Loaded stores:', response.data);
|
|
||||||
setStores(response.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[StoreContext] Failed to load stores:', err);
|
|
||||||
setError(err.response?.data?.message || 'Failed to load stores');
|
|
||||||
setStores([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setActiveStore = (store) => {
|
|
||||||
setActiveStoreState(store);
|
|
||||||
if (store && activeHousehold) {
|
|
||||||
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
|
||||||
localStorage.setItem(storageKey, store.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
stores,
|
|
||||||
activeStore,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
setActiveStore,
|
|
||||||
refreshStores: loadStores,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StoreContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</StoreContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,17 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getAllUsers, updateRole } from "../api/users";
|
import { getAllUsers, updateRole } from "../api/users";
|
||||||
import StoreManagement from "../components/admin/StoreManagement";
|
|
||||||
import UserRoleCard from "../components/common/UserRoleCard";
|
import UserRoleCard from "../components/common/UserRoleCard";
|
||||||
import "../styles/UserRoleCard.css";
|
import "../styles/UserRoleCard.css";
|
||||||
import "../styles/pages/AdminPanel.css";
|
import "../styles/pages/AdminPanel.css";
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [activeTab, setActiveTab] = useState("users");
|
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
const allUsers = await getAllUsers();
|
const allUsers = await getAllUsers();
|
||||||
console.log("Users found:", users);
|
|
||||||
setUsers(allUsers.data);
|
setUsers(allUsers.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,28 +23,10 @@ export default function AdminPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-panel-body">
|
<div className="p-4" style={{ minHeight: '100vh' }}>
|
||||||
<div className="admin-panel-container">
|
<div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||||
<h1 className="admin-panel-title">Admin Panel</h1>
|
<h1 className="text-center text-3xl font-bold mb-4">Admin Panel</h1>
|
||||||
|
<div className="mt-4">
|
||||||
<div className="admin-tabs">
|
|
||||||
<button
|
|
||||||
className={`admin-tab ${activeTab === "users" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("users")}
|
|
||||||
>
|
|
||||||
User Management
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`admin-tab ${activeTab === "stores" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("stores")}
|
|
||||||
>
|
|
||||||
Store Management
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-content">
|
|
||||||
{activeTab === "users" && (
|
|
||||||
<div className="users-section">
|
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<UserRoleCard
|
<UserRoleCard
|
||||||
key={user.id}
|
key={user.id}
|
||||||
@ -56,10 +35,6 @@ export default function AdminPanel() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "stores" && <StoreManagement />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -18,26 +18,18 @@ import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModa
|
|||||||
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
||||||
import EditItemModal from "../components/modals/EditItemModal";
|
import EditItemModal from "../components/modals/EditItemModal";
|
||||||
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
||||||
import StoreTabs from "../components/store/StoreTabs";
|
|
||||||
import { ZONE_FLOW } from "../constants/classifications";
|
import { ZONE_FLOW } from "../constants/classifications";
|
||||||
import { ROLES } from "../constants/roles";
|
import { ROLES } from "../constants/roles";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
import { HouseholdContext } from "../context/HouseholdContext";
|
|
||||||
import { SettingsContext } from "../context/SettingsContext";
|
import { SettingsContext } from "../context/SettingsContext";
|
||||||
import { StoreContext } from "../context/StoreContext";
|
|
||||||
import "../styles/pages/GroceryList.css";
|
import "../styles/pages/GroceryList.css";
|
||||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||||
|
|
||||||
|
|
||||||
export default function GroceryList() {
|
export default function GroceryList() {
|
||||||
const { role: systemRole } = useContext(AuthContext);
|
const { role } = useContext(AuthContext);
|
||||||
const { activeHousehold } = useContext(HouseholdContext);
|
|
||||||
const { activeStore } = useContext(StoreContext);
|
|
||||||
const { settings } = useContext(SettingsContext);
|
const { settings } = useContext(SettingsContext);
|
||||||
|
|
||||||
// Get household role for permissions
|
|
||||||
const householdRole = activeHousehold?.role;
|
|
||||||
|
|
||||||
// === State === //
|
// === State === //
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||||
@ -61,29 +53,17 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
// === Data Loading ===
|
// === Data Loading ===
|
||||||
const loadItems = async () => {
|
const loadItems = async () => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
const res = await getList();
|
||||||
const res = await getList(activeHousehold.id, activeStore.id);
|
console.log(res.data);
|
||||||
console.log('[GroceryList] Items loaded:', res.data);
|
setItems(res.data);
|
||||||
setItems(res.data.items || res.data || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[GroceryList] Failed to load items:', error);
|
|
||||||
setItems([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const loadRecentlyBought = async () => {
|
const loadRecentlyBought = async () => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
|
||||||
try {
|
try {
|
||||||
const res = await getRecentlyBought(activeHousehold.id, activeStore.id);
|
const res = await getRecentlyBought();
|
||||||
setRecentlyBoughtItems(res.data);
|
setRecentlyBoughtItems(res.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load recently bought items:", error);
|
console.error("Failed to load recently bought items:", error);
|
||||||
@ -95,7 +75,7 @@ export default function GroceryList() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadItems();
|
loadItems();
|
||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
}, [activeHousehold?.id, activeStore?.id]);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// === Zone Collapse Handler ===
|
// === Zone Collapse Handler ===
|
||||||
@ -157,16 +137,10 @@ export default function GroceryList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeHousehold?.id || !activeStore?.id) {
|
|
||||||
setSuggestions([]);
|
|
||||||
setButtonText("Create + Add");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerText = text.toLowerCase().trim();
|
const lowerText = text.toLowerCase().trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getSuggestions(activeHousehold.id, activeStore.id, text);
|
const response = await getSuggestions(text);
|
||||||
const suggestionList = response.data.map(s => s.item_name);
|
const suggestionList = response.data.map(s => s.item_name);
|
||||||
setSuggestions(suggestionList);
|
setSuggestions(suggestionList);
|
||||||
|
|
||||||
@ -183,15 +157,13 @@ export default function GroceryList() {
|
|||||||
// === Item Addition Handlers ===
|
// === Item Addition Handlers ===
|
||||||
const handleAdd = useCallback(async (itemName, quantity) => {
|
const handleAdd = useCallback(async (itemName, quantity) => {
|
||||||
if (!itemName.trim()) return;
|
if (!itemName.trim()) return;
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
|
||||||
|
|
||||||
// Check if item already exists
|
|
||||||
let existingItem = null;
|
let existingItem = null;
|
||||||
try {
|
try {
|
||||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
const response = await getItemByName(itemName);
|
||||||
existingItem = response.data;
|
existingItem = response.data;
|
||||||
} catch {
|
} catch {
|
||||||
// Item doesn't exist, continue
|
existingItem = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
@ -211,19 +183,16 @@ export default function GroceryList() {
|
|||||||
processItemAddition(itemName, quantity);
|
processItemAddition(itemName, quantity);
|
||||||
return prevItems;
|
return prevItems;
|
||||||
});
|
});
|
||||||
}, [activeHousehold?.id, activeStore?.id, recentlyBoughtItems]);
|
}, [recentlyBoughtItems]);
|
||||||
|
|
||||||
|
|
||||||
const processItemAddition = useCallback(async (itemName, quantity) => {
|
const processItemAddition = useCallback(async (itemName, quantity) => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
|
||||||
|
|
||||||
// Fetch current item state from backend
|
|
||||||
let existingItem = null;
|
let existingItem = null;
|
||||||
try {
|
try {
|
||||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
const response = await getItemByName(itemName);
|
||||||
existingItem = response.data;
|
existingItem = response.data;
|
||||||
} catch {
|
} catch {
|
||||||
// Item doesn't exist, continue with add
|
existingItem = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingItem?.bought === false) {
|
if (existingItem?.bought === false) {
|
||||||
@ -240,7 +209,7 @@ export default function GroceryList() {
|
|||||||
});
|
});
|
||||||
setShowConfirmAddExisting(true);
|
setShowConfirmAddExisting(true);
|
||||||
} else if (existingItem) {
|
} else if (existingItem) {
|
||||||
await addItem(activeHousehold.id, activeStore.id, itemName, quantity, null);
|
await addItem(itemName, quantity, null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
|
|
||||||
@ -251,7 +220,7 @@ export default function GroceryList() {
|
|||||||
setPendingItem({ itemName, quantity });
|
setPendingItem({ itemName, quantity });
|
||||||
setShowAddDetailsModal(true);
|
setShowAddDetailsModal(true);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, items, loadItems]);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// === Similar Item Modal Handlers ===
|
// === Similar Item Modal Handlers ===
|
||||||
@ -280,7 +249,6 @@ export default function GroceryList() {
|
|||||||
// === Confirm Add Existing Modal Handlers ===
|
// === Confirm Add Existing Modal Handlers ===
|
||||||
const handleConfirmAddExisting = useCallback(async () => {
|
const handleConfirmAddExisting = useCallback(async () => {
|
||||||
if (!confirmAddExistingData) return;
|
if (!confirmAddExistingData) return;
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
|
||||||
|
|
||||||
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
|
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
|
||||||
|
|
||||||
@ -288,11 +256,14 @@ export default function GroceryList() {
|
|||||||
setConfirmAddExistingData(null);
|
setConfirmAddExistingData(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addItem(activeHousehold.id, activeStore.id, itemName, newQuantity, null);
|
// Update the item
|
||||||
|
await addItem(itemName, newQuantity, null);
|
||||||
|
|
||||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
// Fetch the updated item with properly formatted data
|
||||||
|
const response = await getItemByName(itemName);
|
||||||
const updatedItem = response.data;
|
const updatedItem = response.data;
|
||||||
|
|
||||||
|
// Update state with the full item data
|
||||||
setItems(prevItems =>
|
setItems(prevItems =>
|
||||||
prevItems.map(item =>
|
prevItems.map(item =>
|
||||||
item.id === existingItem.id ? updatedItem : item
|
item.id === existingItem.id ? updatedItem : item
|
||||||
@ -303,34 +274,37 @@ export default function GroceryList() {
|
|||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update item:", error);
|
console.error("Failed to update item:", error);
|
||||||
|
// Fallback to full reload on error
|
||||||
await loadItems();
|
await loadItems();
|
||||||
}
|
}
|
||||||
|
}, [confirmAddExistingData, loadItems]);
|
||||||
|
|
||||||
|
const handleCancelAddExisting = useCallback(() => {
|
||||||
|
setShowConfirmAddExisting(false);
|
||||||
|
setConfirmAddExistingData(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// === Add Details Modal Handlers ===
|
// === Add Details Modal Handlers ===
|
||||||
const handleAddWithDetails = useCallback(async (imageFile, classification) => {
|
const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => {
|
||||||
if (!pendingItem) return;
|
if (!pendingItem) return;
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, imageFile);
|
const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||||
|
let newItem = addResponse.data;
|
||||||
|
|
||||||
if (classification) {
|
if (classification) {
|
||||||
// Apply classification if provided
|
const itemResponse = await getItemByName(pendingItem.itemName);
|
||||||
await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification);
|
const itemId = itemResponse.data.id;
|
||||||
|
const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification);
|
||||||
|
newItem = { ...newItem, ...updateResponse.data };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the newly added item
|
|
||||||
const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName);
|
|
||||||
const newItem = itemResponse.data;
|
|
||||||
|
|
||||||
setShowAddDetailsModal(false);
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
|
|
||||||
// Add to state
|
|
||||||
if (newItem) {
|
if (newItem) {
|
||||||
setItems(prevItems => [...prevItems, newItem]);
|
setItems(prevItems => [...prevItems, newItem]);
|
||||||
}
|
}
|
||||||
@ -338,32 +312,28 @@ export default function GroceryList() {
|
|||||||
console.error("Failed to add item:", error);
|
console.error("Failed to add item:", error);
|
||||||
alert("Failed to add item. Please try again.");
|
alert("Failed to add item. Please try again.");
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, pendingItem]);
|
}, [pendingItem]);
|
||||||
|
|
||||||
|
|
||||||
const handleAddDetailsSkip = useCallback(async () => {
|
const handleAddDetailsSkip = useCallback(async () => {
|
||||||
if (!pendingItem) return;
|
if (!pendingItem) return;
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, null);
|
const response = await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
||||||
|
|
||||||
// Fetch the newly added item
|
|
||||||
const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName);
|
|
||||||
const newItem = itemResponse.data;
|
|
||||||
|
|
||||||
setShowAddDetailsModal(false);
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
|
|
||||||
if (newItem) {
|
if (response.data) {
|
||||||
setItems(prevItems => [...prevItems, newItem]);
|
setItems(prevItems => [...prevItems, response.data]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add item:", error);
|
console.error("Failed to add item:", error);
|
||||||
alert("Failed to add item. Please try again.");
|
alert("Failed to add item. Please try again.");
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, pendingItem]);
|
}, [pendingItem]);
|
||||||
|
|
||||||
|
|
||||||
const handleAddDetailsCancel = useCallback(() => {
|
const handleAddDetailsCancel = useCallback(() => {
|
||||||
@ -376,34 +346,31 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
// === Item Action Handlers ===
|
// === Item Action Handlers ===
|
||||||
const handleBought = useCallback(async (id, quantity) => {
|
const handleBought = useCallback(async (id, quantity) => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
|
||||||
|
|
||||||
const item = items.find(i => i.id === id);
|
const item = items.find(i => i.id === id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
|
await markBought(id, quantity);
|
||||||
|
|
||||||
// If buying full quantity, remove from list
|
// If buying full quantity, remove from list
|
||||||
if (quantity >= item.quantity) {
|
if (quantity >= item.quantity) {
|
||||||
setItems(prevItems => prevItems.filter(item => item.id !== id));
|
setItems(prevItems => prevItems.filter(item => item.id !== id));
|
||||||
} else {
|
} else {
|
||||||
// If partial, fetch updated item
|
// If partial, update quantity
|
||||||
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
|
const response = await getItemByName(item.item_name);
|
||||||
const updatedItem = response.data;
|
if (response.data) {
|
||||||
|
|
||||||
setItems(prevItems =>
|
setItems(prevItems =>
|
||||||
prevItems.map(i => i.id === id ? updatedItem : i)
|
prevItems.map(item => item.id === id ? response.data : item)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
}, [activeHousehold?.id, activeStore?.id, items]);
|
}, [items]);
|
||||||
|
|
||||||
|
|
||||||
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
|
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await updateItemImage(activeHousehold.id, activeStore.id, id, itemName, quantity, imageFile);
|
const response = await updateItemImage(id, itemName, quantity, imageFile);
|
||||||
|
|
||||||
setItems(prevItems =>
|
setItems(prevItems =>
|
||||||
prevItems.map(item =>
|
prevItems.map(item =>
|
||||||
@ -420,15 +387,14 @@ export default function GroceryList() {
|
|||||||
console.error("Failed to add image:", error);
|
console.error("Failed to add image:", error);
|
||||||
alert("Failed to add image. Please try again.");
|
alert("Failed to add image. Please try again.");
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id]);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const handleLongPress = useCallback(async (item) => {
|
const handleLongPress = useCallback(async (item) => {
|
||||||
if (!householdRole || householdRole === 'viewer') return;
|
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.id);
|
const classificationResponse = await getClassification(item.id);
|
||||||
setEditingItem({
|
setEditingItem({
|
||||||
...item,
|
...item,
|
||||||
classification: classificationResponse.data
|
classification: classificationResponse.data
|
||||||
@ -439,26 +405,20 @@ export default function GroceryList() {
|
|||||||
setEditingItem({ ...item, classification: null });
|
setEditingItem({ ...item, classification: null });
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, householdRole]);
|
}, [role]);
|
||||||
|
|
||||||
|
|
||||||
// === Edit Modal Handlers ===
|
// === Edit Modal Handlers ===
|
||||||
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
|
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateItemWithClassification(activeHousehold.id, activeStore.id, itemName, quantity, classification);
|
const response = await updateItemWithClassification(id, itemName, quantity, classification);
|
||||||
|
|
||||||
// Fetch the updated item
|
|
||||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
|
||||||
const updatedItem = response.data;
|
|
||||||
|
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
|
|
||||||
|
const updatedItem = response.data;
|
||||||
setItems(prevItems =>
|
setItems(prevItems =>
|
||||||
prevItems.map(item =>
|
prevItems.map(item =>
|
||||||
item.id === id ? updatedItem : item
|
item.id === id ? { ...item, ...updatedItem } : item
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -471,7 +431,7 @@ export default function GroceryList() {
|
|||||||
console.error("Failed to update item:", error);
|
console.error("Failed to update item:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id]);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const handleEditCancel = useCallback(() => {
|
const handleEditCancel = useCallback(() => {
|
||||||
@ -494,30 +454,7 @@ export default function GroceryList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (!activeHousehold || !activeStore) {
|
if (loading) return <p>Loading...</p>;
|
||||||
return (
|
|
||||||
<div className="glist-body">
|
|
||||||
<div className="glist-container">
|
|
||||||
<h1 className="glist-title">Costco Grocery List</h1>
|
|
||||||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
|
||||||
{!activeHousehold ? 'Loading households...' : 'Loading stores...'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="glist-body">
|
|
||||||
<div className="glist-container">
|
|
||||||
<h1 className="glist-title">Costco Grocery List</h1>
|
|
||||||
<StoreTabs />
|
|
||||||
<p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -525,9 +462,8 @@ export default function GroceryList() {
|
|||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<h1 className="glist-title">Costco Grocery List</h1>
|
<h1 className="glist-title">Costco Grocery List</h1>
|
||||||
|
|
||||||
<StoreTabs />
|
|
||||||
|
|
||||||
{householdRole && householdRole !== 'viewer' && showAddForm && (
|
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
|
||||||
<AddItemForm
|
<AddItemForm
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onSuggest={handleSuggest}
|
onSuggest={handleSuggest}
|
||||||
@ -567,13 +503,13 @@ export default function GroceryList() {
|
|||||||
allItems={sortedItems}
|
allItems={sortedItems}
|
||||||
compact={settings.compactView}
|
compact={settings.compactView}
|
||||||
onClick={(id, quantity) =>
|
onClick={(id, quantity) =>
|
||||||
householdRole && householdRole !== 'viewer' && handleBought(id, quantity)
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
|
||||||
}
|
}
|
||||||
onImageAdded={
|
onImageAdded={
|
||||||
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
}
|
}
|
||||||
onLongPress={
|
onLongPress={
|
||||||
householdRole && householdRole !== 'viewer' ? handleLongPress : null
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -628,10 +564,10 @@ export default function GroceryList() {
|
|||||||
compact={settings.compactView}
|
compact={settings.compactView}
|
||||||
onClick={null}
|
onClick={null}
|
||||||
onImageAdded={
|
onImageAdded={
|
||||||
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
}
|
}
|
||||||
onLongPress={
|
onLongPress={
|
||||||
householdRole && householdRole !== 'viewer' ? handleLongPress : null
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -652,7 +588,7 @@ export default function GroceryList() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{householdRole && householdRole !== 'viewer' && (
|
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
|
||||||
<FloatingActionButton
|
<FloatingActionButton
|
||||||
isOpen={showAddForm}
|
isOpen={showAddForm}
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
@ -662,7 +598,7 @@ export default function GroceryList() {
|
|||||||
{showAddDetailsModal && pendingItem && (
|
{showAddDetailsModal && pendingItem && (
|
||||||
<AddItemWithDetailsModal
|
<AddItemWithDetailsModal
|
||||||
itemName={pendingItem.itemName}
|
itemName={pendingItem.itemName}
|
||||||
onConfirm={handleAddWithDetails}
|
onConfirm={handleAddDetailsConfirm}
|
||||||
onSkip={handleAddDetailsSkip}
|
onSkip={handleAddDetailsSkip}
|
||||||
onCancel={handleAddDetailsCancel}
|
onCancel={handleAddDetailsCancel}
|
||||||
/>
|
/>
|
||||||
@ -693,10 +629,7 @@ export default function GroceryList() {
|
|||||||
currentQuantity={confirmAddExistingData.currentQuantity}
|
currentQuantity={confirmAddExistingData.currentQuantity}
|
||||||
addingQuantity={confirmAddExistingData.addingQuantity}
|
addingQuantity={confirmAddExistingData.addingQuantity}
|
||||||
onConfirm={handleConfirmAddExisting}
|
onConfirm={handleConfirmAddExisting}
|
||||||
onCancel={() => {
|
onCancel={handleCancelAddExisting}
|
||||||
setShowConfirmAddExisting(false);
|
|
||||||
setConfirmAddExistingData(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
import { useContext, useState } from "react";
|
|
||||||
import ManageHousehold from "../components/manage/ManageHousehold";
|
|
||||||
import ManageStores from "../components/manage/ManageStores";
|
|
||||||
import { HouseholdContext } from "../context/HouseholdContext";
|
|
||||||
import "../styles/pages/Manage.css";
|
|
||||||
|
|
||||||
export default function Manage() {
|
|
||||||
const { activeHousehold } = useContext(HouseholdContext);
|
|
||||||
const [activeTab, setActiveTab] = useState("household");
|
|
||||||
|
|
||||||
if (!activeHousehold) {
|
|
||||||
return (
|
|
||||||
<div className="manage-body">
|
|
||||||
<div className="manage-container">
|
|
||||||
<h1 className="manage-title">Manage</h1>
|
|
||||||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
|
||||||
Loading household...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="manage-body">
|
|
||||||
<div className="manage-container">
|
|
||||||
<h1 className="manage-title">Manage {activeHousehold.name}</h1>
|
|
||||||
|
|
||||||
<div className="manage-tabs">
|
|
||||||
<button
|
|
||||||
className={`manage-tab ${activeTab === "household" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("household")}
|
|
||||||
>
|
|
||||||
Household
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`manage-tab ${activeTab === "stores" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("stores")}
|
|
||||||
>
|
|
||||||
Stores
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="manage-content">
|
|
||||||
{activeTab === "household" && <ManageHousehold />}
|
|
||||||
{activeTab === "stores" && <ManageStores />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -81,38 +81,3 @@
|
|||||||
.add-image-remove:hover {
|
.add-image-remove:hover {
|
||||||
background: rgba(255, 0, 0, 1);
|
background: rgba(255, 0, 0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive Styles */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.add-image-preview-container {
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-preview {
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-option-btn {
|
|
||||||
padding: 1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.add-image-preview {
|
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-option-btn {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-image-remove {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -64,20 +64,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.image-modal-overlay {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-modal-content {
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 95vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-modal-img {
|
|
||||||
max-height: 50vh;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -201,67 +201,3 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive Styles */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.image-upload-modal {
|
|
||||||
width: 95%;
|
|
||||||
padding: 1.5rem;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-upload-modal h2 {
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-upload-option-btn {
|
|
||||||
padding: 1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-image-preview {
|
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-upload-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-upload-cancel,
|
|
||||||
.image-upload-skip,
|
|
||||||
.image-upload-confirm {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.image-upload-modal {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-upload-modal h2 {
|
|
||||||
font-size: 1.15em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-upload-subtitle {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-image-preview {
|
|
||||||
width: 160px;
|
|
||||||
height: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-remove-image {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -100,52 +100,3 @@
|
|||||||
.classification-modal-btn-confirm:hover {
|
.classification-modal-btn-confirm:hover {
|
||||||
background: #0056b3;
|
background: #0056b3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive Styles */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.classification-modal-content {
|
|
||||||
width: 95%;
|
|
||||||
padding: 1.25rem;
|
|
||||||
max-height: 85vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.classification-modal-title {
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.classification-modal-subtitle {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.classification-modal-select {
|
|
||||||
padding: 0.7em;
|
|
||||||
font-size: 16px; /* Prevents iOS zoom */
|
|
||||||
}
|
|
||||||
|
|
||||||
.classification-modal-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.classification-modal-btn {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.classification-modal-content {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.classification-modal-title {
|
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.classification-modal-field label {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -22,23 +22,3 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive Styles */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.similar-item-suggested,
|
|
||||||
.similar-item-original {
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-modal-actions .btn {
|
|
||||||
min-height: 44px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.similar-item-suggested,
|
|
||||||
.similar-item-original {
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -195,61 +195,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsiveness */
|
/* Mobile responsiveness */
|
||||||
@media (max-width: 768px) {
|
|
||||||
.add-item-details-overlay {
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-details-modal {
|
|
||||||
width: 95%;
|
|
||||||
max-width: 95%;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-details-title {
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-details-select {
|
|
||||||
padding: 0.7em;
|
|
||||||
font-size: 16px; /* Prevents iOS zoom */
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-details-image-options {
|
|
||||||
gap: 0.6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-details-image-btn {
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-details-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-details-btn {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.add-item-details-modal {
|
.add-item-details-modal {
|
||||||
padding: 1rem;
|
padding: 1.2em;
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-title {
|
.add-item-details-title {
|
||||||
font-size: 1.15em;
|
font-size: 1.2em;
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-details-subtitle {
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-details-section-title {
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-image-options {
|
.add-item-details-image-options {
|
||||||
@ -258,10 +210,9 @@
|
|||||||
|
|
||||||
.add-item-details-image-btn {
|
.add-item-details-image-btn {
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-field label {
|
.add-item-details-actions {
|
||||||
font-size: 0.85em;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,30 +39,3 @@
|
|||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive Styles */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.confirm-add-existing-qty-info {
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-row {
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-value {
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-total .qty-label,
|
|
||||||
.qty-total .qty-value {
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.qty-row {
|
|
||||||
font-size: 0.9em;
|
|
||||||
padding: var(--spacing-xxs) 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -187,83 +187,3 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive Styles */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.edit-modal-overlay {
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-content {
|
|
||||||
width: 95%;
|
|
||||||
max-width: 95%;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-title {
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-input,
|
|
||||||
.edit-modal-select {
|
|
||||||
font-size: 16px; /* Prevents iOS zoom */
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-quantity-input {
|
|
||||||
width: 70px;
|
|
||||||
font-size: 16px; /* Prevents iOS zoom */
|
|
||||||
}
|
|
||||||
|
|
||||||
.quantity-btn {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
font-size: 1.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-inline-field {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-inline-field label {
|
|
||||||
min-width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-inline-field .edit-modal-select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-btn {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.edit-modal-content {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-title {
|
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-modal-quantity-control {
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quantity-btn {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
.household-switcher {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-switcher-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-switcher-toggle:hover {
|
|
||||||
background: var(--card-hover);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-switcher-toggle:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-name {
|
|
||||||
font-weight: 500;
|
|
||||||
flex: 1;
|
|
||||||
text-align: left;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-icon {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-icon.open {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-switcher-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-switcher-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.5rem);
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
||||||
z-index: 1000;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1rem;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-option:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-option:hover {
|
|
||||||
background: var(--card-hover);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-option.active {
|
|
||||||
background: rgba(30, 144, 255, 0.15);
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-mark {
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-divider {);
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-household-btn {
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-household-btn:hover {
|
|
||||||
background: rgba(30, 144, 255, 0.15
|
|
||||||
.create-household-btn:hover {
|
|
||||||
background: var(--primary-color-light);
|
|
||||||
}
|
|
||||||
@ -1,232 +1,58 @@
|
|||||||
/* Navbar - Sticky at top */
|
|
||||||
.navbar {
|
.navbar {
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
background: #343a40;
|
background: #343a40;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.6em 1em;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navbar Sections */
|
|
||||||
.navbar-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-left {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-center {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
max-width: 80%;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-right {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hamburger Menu Button */
|
|
||||||
.navbar-menu-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hamburger-icon {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
width: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hamburger-icon span {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 3px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-menu-btn:hover .hamburger-icon span {
|
|
||||||
background: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User Button */
|
|
||||||
.navbar-user-btn {
|
|
||||||
background: #495057;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
margin-bottom: 1em;
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-user-btn:hover {
|
.navbar-links a {
|
||||||
background: #5a6268;
|
color: white;
|
||||||
}
|
margin-right: 1em;
|
||||||
|
|
||||||
/* Dropdown Overlay */
|
|
||||||
.menu-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 999;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown Base Styles */
|
|
||||||
.navbar-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.5rem);
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
z-index: 1001;
|
|
||||||
min-width: 180px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation Dropdown */
|
|
||||||
.nav-dropdown {
|
|
||||||
left: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-dropdown a {
|
|
||||||
color: #343a40;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0.75rem 1.25rem;
|
font-size: 1.1em;
|
||||||
font-size: 1rem;
|
|
||||||
transition: background 0.2s;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-dropdown a:last-child {
|
.navbar-links a:hover {
|
||||||
border-bottom: none;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-dropdown a:hover {
|
.navbar-logout {
|
||||||
background: #f8f9fa;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User Dropdown */
|
|
||||||
.user-dropdown {
|
|
||||||
right: 0;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-dropdown-info {
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
background: #f8f9fa;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-dropdown-username {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #343a40;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-dropdown-role {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #6c757d;
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-dropdown-logout {
|
|
||||||
width: 100%;
|
|
||||||
background: #dc3545;
|
background: #dc3545;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.75rem 1.25rem;
|
padding: 0.4em 0.8em;
|
||||||
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
width: 100px;
|
||||||
font-weight: 500;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-dropdown-logout:hover {
|
.navbar-idcard {
|
||||||
background: #c82333;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-content: center;
|
||||||
|
margin-right: 1em;
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
background: #495057;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Household Switcher - Centered with max width */
|
.navbar-idinfo {
|
||||||
.navbar-center > * {
|
display: flex;
|
||||||
width: 100%;
|
flex-direction: column;
|
||||||
max-width: 24ch; /* 24 characters max width */
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive */
|
.navbar-username {
|
||||||
@media (max-width: 768px) {
|
font-size: 0.95em;
|
||||||
.navbar {
|
font-weight: bold;
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-center {
|
|
||||||
max-width: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-user-btn {
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-dropdown {
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-dropdown {
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
.navbar-role {
|
||||||
.navbar {
|
font-size: 0.75em;
|
||||||
padding: 0.5rem;
|
opacity: 0.8;
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-center {
|
|
||||||
max-width: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-user-btn {
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hamburger-icon {
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hamburger-icon span {
|
|
||||||
height: 2.5px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
.store-tabs {
|
|
||||||
background: var(--surface-color);
|
|
||||||
border-bottom: 2px solid var(--border-color);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-tabs-container {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 0.5rem 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-tabs-container::-webkit-scrollbar {
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-tabs-container::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-tab:hover {
|
|
||||||
color: var(--text-color);
|
|
||||||
background: var(--hover-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-tab.active {
|
|
||||||
color: var(--primary-color);
|
|
||||||
border-bottom-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-tab:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.default-badge {
|
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
background: var(--primary-color-light);
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-tabs-empty {
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
@ -1,278 +0,0 @@
|
|||||||
/* Store Management Component */
|
|
||||||
.store-management {
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-management-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-management-header h2 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Card */
|
|
||||||
.store-form-card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 2px solid var(--primary);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-form-card h3 {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1rem;
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: inherit;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group textarea {
|
|
||||||
resize: vertical;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-hint {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Zone Input Section */
|
|
||||||
.zone-input-container {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-input-container input {
|
|
||||||
flex: 1 1 300px;
|
|
||||||
width: auto !important;
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-input-container .btn-small {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Zones List (Chips in Form) */
|
|
||||||
.zones-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
min-height: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
padding: 0.4rem 0.7rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-remove:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Store Zones Display (in cards) */
|
|
||||||
.store-zones-display {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zones-label {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zones-list-display {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
padding: 0.3rem 0.7rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stores Grid */
|
|
||||||
.stores-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-admin-card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-admin-card:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-admin-info h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 0.75rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-zones {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-meta {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin: 0.5rem 0 0 0;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-admin-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-admin-actions button {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-message {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 3rem;
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.store-management {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-management-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-management-header button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stores-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-form-card {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
/* Create/Join Household Modal */
|
|
||||||
.create-join-modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-join-modal {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|
||||||
max-width: 500px;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 2rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
background: var(--card-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mode Tabs */
|
|
||||||
.mode-tabs {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 2px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-tab {
|
|
||||||
flex: 1;
|
|
||||||
padding: 1rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-tab:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--card-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-tab.active {
|
|
||||||
color: var(--primary);
|
|
||||||
border-bottom-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form */
|
|
||||||
.household-form {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1rem;
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--text-primary);
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-hint {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
margin: 1rem 1.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--danger-light, rgba(220, 53, 69, 0.1));
|
|
||||||
border: 1px solid var(--danger);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--danger);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions button {
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.create-join-modal {
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
max-height: 100vh;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,247 +0,0 @@
|
|||||||
/* Manage Household Component */
|
|
||||||
.manage-household {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section Styling */
|
|
||||||
.manage-section {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-section h2 {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-description {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Household Name Section */
|
|
||||||
.name-display {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name-display h3 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-name-form {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-name-form input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1rem;
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Invite Code Section */
|
|
||||||
.invite-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invite-code {
|
|
||||||
background: var(--background);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--primary);
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Members Section */
|
|
||||||
.members-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1.25rem;
|
|
||||||
background: var(--background);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-card:hover {
|
|
||||||
background: var(--card-hover);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-name {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-role {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
width: fit-content;
|
|
||||||
text-transform: capitalize;
|
|
||||||
background: var(--primary-light, rgba(0, 122, 255, 0.1));
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Danger Zone */
|
|
||||||
.danger-zone {
|
|
||||||
border-color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger-zone h2 {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn-primary,
|
|
||||||
.btn-secondary,
|
|
||||||
.btn-danger {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary,
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover,
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--primary-dark, #0056b3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--danger);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: var(--danger-dark, #c82333);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-small {
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:disabled {
|
|
||||||
background: var(--text-secondary);
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.manage-section {
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name-display {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-name-form {
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-name-form input {
|
|
||||||
width: 100%;
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-name-form button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-card {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-actions {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-actions button {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invite-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invite-actions button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invite-code {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
/* Manage Stores Component */
|
|
||||||
.manage-stores {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section Styling */
|
|
||||||
.manage-section {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-section h2 {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stores List */
|
|
||||||
.stores-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-card {
|
|
||||||
background: var(--background);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-card:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-info h3 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-location {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.default-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add Store Panel */
|
|
||||||
.add-store-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-stores {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-store-card {
|
|
||||||
background: var(--background);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-store-card:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-store-card .store-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-store-card h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-store-card .store-location {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty State */
|
|
||||||
.empty-message {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.stores-list,
|
|
||||||
.available-stores {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-actions {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-actions button {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-store-card {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-store-card button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,109 +1,9 @@
|
|||||||
/* Admin Panel Layout */
|
/* Admin Panel - uses utility classes */
|
||||||
.admin-panel-body {
|
/* Responsive adjustments only */
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--background);
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-panel-container {
|
|
||||||
max-width: 1200px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-panel-title {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tabs */
|
|
||||||
.admin-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border-bottom: 2px solid var(--border);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-tab {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-tab:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--card-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-tab.active {
|
|
||||||
color: var(--primary);
|
|
||||||
border-bottom-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content Area */
|
|
||||||
.admin-content {
|
|
||||||
animation: fadeIn 0.3s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Users Section */
|
|
||||||
.users-section {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.admin-panel-body {
|
.admin-panel-page {
|
||||||
padding: 1rem 0.75rem;
|
padding: var(--spacing-md) !important;
|
||||||
}
|
|
||||||
|
|
||||||
.admin-panel-title {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-tab {
|
|
||||||
padding: 0.65rem 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.admin-panel-body {
|
|
||||||
padding: 1rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-panel-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-tab {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,119 +0,0 @@
|
|||||||
/* Manage Page Layout */
|
|
||||||
.manage-body {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--background);
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-container {
|
|
||||||
max-width: 850px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-title {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tabs */
|
|
||||||
.manage-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border-bottom: 2px solid var(--border);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-tab {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-tab:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--card-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-tab.active {
|
|
||||||
color: var(--primary);
|
|
||||||
border-bottom-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content Area */
|
|
||||||
.manage-content {
|
|
||||||
animation: fadeIn 0.3s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button Styles */
|
|
||||||
.btn-primary,
|
|
||||||
.btn-secondary {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover,
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--primary-dark, #0056b3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.manage-body {
|
|
||||||
padding: 1rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-title {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-tab {
|
|
||||||
padding: 0.65rem 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.manage-body {
|
|
||||||
padding: 1rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-tab {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,13 +12,6 @@
|
|||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
margin-bottom: var(--spacing-xl);
|
margin-bottom: var(--spacing-xl);
|
||||||
border-bottom: 2px solid var(--color-border-light);
|
border-bottom: 2px solid var(--color-border-light);
|
||||||
touch-action: pan-x; /* Lock Y-axis, allow only horizontal scrolling */
|
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
-ms-overflow-style: none; /* IE/Edge */
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tabs::-webkit-scrollbar {
|
|
||||||
display: none; /* Chrome/Safari/Opera */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab {
|
.settings-tab {
|
||||||
|
|||||||
@ -14,10 +14,10 @@
|
|||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
/* Primary Colors */
|
/* Primary Colors */
|
||||||
--color-primary: dodgerblue;
|
--color-primary: #007bff;
|
||||||
--color-primary-hover: #0066cc;
|
--color-primary-hover: #0056b3;
|
||||||
--color-primary-light: #e7f3ff;
|
--color-primary-light: #e7f3ff;
|
||||||
--color-primary-dark: #0056b3;
|
--color-primary-dark: #0067d8;
|
||||||
|
|
||||||
/* Secondary Colors */
|
/* Secondary Colors */
|
||||||
--color-secondary: #6c757d;
|
--color-secondary: #6c757d;
|
||||||
@ -187,20 +187,6 @@
|
|||||||
--modal-border-radius: var(--border-radius-lg);
|
--modal-border-radius: var(--border-radius-lg);
|
||||||
--modal-padding: var(--spacing-lg);
|
--modal-padding: var(--spacing-lg);
|
||||||
--modal-max-width: 500px;
|
--modal-max-width: 500px;
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SIMPLIFIED ALIASES (for component convenience)
|
|
||||||
============================================ */
|
|
||||||
--primary: var(--color-primary);
|
|
||||||
--primary-dark: var(--color-primary-dark);
|
|
||||||
--primary-light: var(--color-primary-light);
|
|
||||||
--danger: var(--color-danger);
|
|
||||||
--danger-dark: var(--color-danger-hover);
|
|
||||||
--text-primary: var(--color-text-primary);
|
|
||||||
--text-secondary: var(--color-text-secondary);
|
|
||||||
--background: var(--color-bg-body);
|
|
||||||
--border: var(--color-border-light);
|
|
||||||
--card-hover: var(--color-bg-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -109,12 +109,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--color-primary);
|
background: var(--color-secondary);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: var(--color-primary-hover);
|
background: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
@echo off
|
|
||||||
REM Multi-Household Migration Runner (Windows)
|
|
||||||
REM This script handles the complete migration process with safety checks
|
|
||||||
|
|
||||||
setlocal enabledelayedexpansion
|
|
||||||
|
|
||||||
REM Database configuration
|
|
||||||
set DB_USER=postgres
|
|
||||||
set DB_HOST=192.168.7.112
|
|
||||||
set DB_NAME=grocery
|
|
||||||
set PGPASSWORD=Asdwed123A.
|
|
||||||
|
|
||||||
set BACKUP_DIR=backend\migrations\backups
|
|
||||||
set TIMESTAMP=%date:~-4%%date:~-10,2%%date:~-7,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
|
||||||
set TIMESTAMP=%TIMESTAMP: =0%
|
|
||||||
set BACKUP_FILE=%BACKUP_DIR%\backup_%TIMESTAMP%.sql
|
|
||||||
|
|
||||||
echo ================================================
|
|
||||||
echo Multi-Household Architecture Migration
|
|
||||||
echo ================================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM Create backup directory
|
|
||||||
if not exist "%BACKUP_DIR%" mkdir "%BACKUP_DIR%"
|
|
||||||
|
|
||||||
REM Step 1: Backup (SKIPPED - using database template copy)
|
|
||||||
echo [1/5] Backup: SKIPPED (using 'grocery' database copy)
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM Step 2: Show current stats
|
|
||||||
echo [2/5] Current database statistics:
|
|
||||||
psql -h %DB_HOST% -U %DB_USER% -d %DB_NAME% -c "SELECT 'Users' as table_name, COUNT(*) as count FROM users UNION ALL SELECT 'Grocery Items', COUNT(*) FROM grocery_list UNION ALL SELECT 'Classifications', COUNT(*) FROM item_classification UNION ALL SELECT 'History Records', COUNT(*) FROM grocery_history;"
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM Step 3: Confirm
|
|
||||||
echo [3/5] Ready to run migration
|
|
||||||
echo Database: %DB_NAME% on %DB_HOST%
|
|
||||||
echo Backup: %BACKUP_FILE%
|
|
||||||
echo.
|
|
||||||
set /p CONFIRM="Continue with migration? (yes/no): "
|
|
||||||
if /i not "%CONFIRM%"=="yes" (
|
|
||||||
echo Migration cancelled.
|
|
||||||
exit /b 0
|
|
||||||
)
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM Step 4: Run migration
|
|
||||||
echo [4/5] Running migration script...
|
|
||||||
psql -h %DB_HOST% -U %DB_USER% -d %DB_NAME% -f backend\migrations\multi_household_architecture.sql
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [ERROR] Migration failed! Rolling back...
|
|
||||||
echo Restoring from backup: %BACKUP_FILE%
|
|
||||||
psql -h %DB_HOST% -U %DB_USER% -d %DB_NAME% < "%BACKUP_FILE%"
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
echo [OK] Migration completed successfully
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM Step 5: Verification
|
|
||||||
echo [5/5] Verifying migration...
|
|
||||||
psql -h %DB_HOST% -U %DB_USER% -d %DB_NAME% -c "SELECT id, name, invite_code FROM households;"
|
|
||||||
psql -h %DB_HOST% -U %DB_USER% -d %DB_NAME% -c "SELECT u.id, u.username, u.role as system_role, hm.role as household_role FROM users u LEFT JOIN household_members hm ON u.id = hm.user_id ORDER BY u.id LIMIT 10;"
|
|
||||||
psql -h %DB_HOST% -U %DB_USER% -d %DB_NAME% -c "SELECT 'Items' as metric, COUNT(*)::text as count FROM items UNION ALL SELECT 'Household Lists', COUNT(*)::text FROM household_lists UNION ALL SELECT 'Classifications', COUNT(*)::text FROM household_item_classifications UNION ALL SELECT 'History Records', COUNT(*)::text FROM household_list_history;"
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo ================================================
|
|
||||||
echo Migration Complete!
|
|
||||||
echo ================================================
|
|
||||||
echo.
|
|
||||||
echo Next Steps:
|
|
||||||
echo 1. Review verification results above
|
|
||||||
echo 2. Test the application
|
|
||||||
echo 3. If issues found, rollback with:
|
|
||||||
echo psql -h %DB_HOST% -U %DB_USER% -d %DB_NAME% ^< %BACKUP_FILE%
|
|
||||||
echo 4. If successful, proceed to Sprint 2 (Backend API)
|
|
||||||
echo.
|
|
||||||
echo Backup location: %BACKUP_FILE%
|
|
||||||
echo.
|
|
||||||
|
|
||||||
pause
|
|
||||||
146
run-migration.sh
146
run-migration.sh
@ -1,146 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Multi-Household Migration Runner
|
|
||||||
# This script handles the complete migration process with safety checks
|
|
||||||
|
|
||||||
set -e # Exit on error
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Database configuration (from .env)
|
|
||||||
DB_USER="postgres"
|
|
||||||
DB_HOST="192.168.7.112"
|
|
||||||
DB_NAME="grocery"
|
|
||||||
export PGPASSWORD="Asdwed123A."
|
|
||||||
|
|
||||||
BACKUP_DIR="./backend/migrations/backups"
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
BACKUP_FILE="${BACKUP_DIR}/backup_${TIMESTAMP}.sql"
|
|
||||||
|
|
||||||
echo -e "${BLUE}╔════════════════════════════════════════════════╗${NC}"
|
|
||||||
echo -e "${BLUE}║ Multi-Household Architecture Migration ║${NC}"
|
|
||||||
echo -e "${BLUE}╚════════════════════════════════════════════════╝${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Create backup directory if it doesn't exist
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
|
|
||||||
# Step 1: Backup
|
|
||||||
echo -e "${YELLOW}[1/5] Creating database backup...${NC}"
|
|
||||||
pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" > "$BACKUP_FILE"
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo -e "${GREEN}✓ Backup created: $BACKUP_FILE${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗ Backup failed!${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Step 2: Show current stats
|
|
||||||
echo -e "${YELLOW}[2/5] Current database statistics:${NC}"
|
|
||||||
psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "
|
|
||||||
SELECT
|
|
||||||
'Users' as table_name, COUNT(*) as count FROM users
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'Grocery Items', COUNT(*) FROM grocery_list
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'Classifications', COUNT(*) FROM item_classification
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'History Records', COUNT(*) FROM grocery_history;
|
|
||||||
"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Step 3: Confirm
|
|
||||||
echo -e "${YELLOW}[3/5] Ready to run migration${NC}"
|
|
||||||
echo -e "Database: ${BLUE}$DB_NAME${NC} on ${BLUE}$DB_HOST${NC}"
|
|
||||||
echo -e "Backup: ${GREEN}$BACKUP_FILE${NC}"
|
|
||||||
echo ""
|
|
||||||
read -p "Continue with migration? (yes/no): " -r
|
|
||||||
echo ""
|
|
||||||
if [[ ! $REPLY =~ ^[Yy]es$ ]]; then
|
|
||||||
echo -e "${RED}Migration cancelled.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 4: Run migration
|
|
||||||
echo -e "${YELLOW}[4/5] Running migration script...${NC}"
|
|
||||||
psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -f backend/migrations/multi_household_architecture.sql
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo -e "${GREEN}✓ Migration completed successfully${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗ Migration failed! Rolling back...${NC}"
|
|
||||||
echo -e "${YELLOW}Restoring from backup: $BACKUP_FILE${NC}"
|
|
||||||
psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" < "$BACKUP_FILE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Step 5: Verification
|
|
||||||
echo -e "${YELLOW}[5/5] Verifying migration...${NC}"
|
|
||||||
psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" << 'EOF'
|
|
||||||
\echo ''
|
|
||||||
\echo '=== Household Created ==='
|
|
||||||
SELECT id, name, invite_code FROM households;
|
|
||||||
|
|
||||||
\echo ''
|
|
||||||
\echo '=== User Roles ==='
|
|
||||||
SELECT u.id, u.username, u.role as system_role, hm.role as household_role
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN household_members hm ON u.id = hm.user_id
|
|
||||||
ORDER BY u.id
|
|
||||||
LIMIT 10;
|
|
||||||
|
|
||||||
\echo ''
|
|
||||||
\echo '=== Migration Counts ==='
|
|
||||||
SELECT
|
|
||||||
'Items (Master Catalog)' as metric, COUNT(*)::text as count FROM items
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'Household Lists', COUNT(*)::text FROM household_lists
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'Classifications', COUNT(*)::text FROM household_item_classifications
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'History Records', COUNT(*)::text FROM household_list_history
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'Household Members', COUNT(*)::text FROM household_members
|
|
||||||
UNION ALL
|
|
||||||
SELECT 'Stores', COUNT(*)::text FROM stores;
|
|
||||||
|
|
||||||
\echo ''
|
|
||||||
\echo '=== Data Integrity Checks ==='
|
|
||||||
\echo 'Users without household membership (should be 0):'
|
|
||||||
SELECT COUNT(*) FROM users u
|
|
||||||
LEFT JOIN household_members hm ON u.id = hm.user_id
|
|
||||||
WHERE hm.id IS NULL;
|
|
||||||
|
|
||||||
\echo ''
|
|
||||||
\echo 'Lists without valid items (should be 0):'
|
|
||||||
SELECT COUNT(*) FROM household_lists hl
|
|
||||||
LEFT JOIN items i ON hl.item_id = i.id
|
|
||||||
WHERE i.id IS NULL;
|
|
||||||
|
|
||||||
\echo ''
|
|
||||||
\echo 'History without valid lists (should be 0):'
|
|
||||||
SELECT COUNT(*) FROM household_list_history hlh
|
|
||||||
LEFT JOIN household_lists hl ON hlh.household_list_id = hl.id
|
|
||||||
WHERE hl.id IS NULL;
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}╔════════════════════════════════════════════════╗${NC}"
|
|
||||||
echo -e "${GREEN}║ Migration Complete! ║${NC}"
|
|
||||||
echo -e "${GREEN}╚════════════════════════════════════════════════╝${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}Next Steps:${NC}"
|
|
||||||
echo -e "1. Review verification results above"
|
|
||||||
echo -e "2. Test the application"
|
|
||||||
echo -e "3. If issues found, rollback with:"
|
|
||||||
echo -e " ${YELLOW}psql -h $DB_HOST -U $DB_USER -d $DB_NAME < $BACKUP_FILE${NC}"
|
|
||||||
echo -e "4. If successful, proceed to Sprint 2 (Backend API)"
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Backup location: $BACKUP_FILE${NC}"
|
|
||||||
echo ""
|
|
||||||
Loading…
Reference in New Issue
Block a user