Compare commits

...

8 Commits

Author SHA1 Message Date
Nico
67d681114f update gitea workflow to allow for 2 different actions
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 27s
Build & Deploy Costco Grocery List / deploy (push) Successful in 4s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
2026-01-27 23:48:02 -08:00
Nico
11f23eb643 styling fix and readme files reorg 2026-01-27 00:03:58 -08:00
Nico
31eda793ab polished implementation of new artchitecture 2026-01-26 22:52:16 -08:00
Nico
213134c4a5 Included household/stores management features 2026-01-26 00:37:43 -08:00
Nico
9fc25f2274 phase 3 - create minimal hooks to tie the new architecture between backend and frontend 2026-01-25 23:23:00 -08:00
Nico
4d5d2f0f6d phase2 - get backend api modified for new implmentations and create api test 2026-01-25 01:40:18 -08:00
Nico
ccf0c39294 phase1 - implement database foundation 2026-01-25 00:18:04 -08:00
Nico
fc887bdc65 create plan for multi household 2026-01-24 23:59:11 -08:00
90 changed files with 11696 additions and 235 deletions

View File

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

View File

@ -7,40 +7,142 @@ This is a full-stack grocery list management app with **role-based access contro
- **Frontend**: React 19 + TypeScript + Vite (port 3000/5173)
- **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
**Three-tier RBAC system** (`viewer`, `editor`, `admin`):
- `viewer`: Read-only access to grocery lists
- `editor`: Can add items and mark as bought
- `admin`: Full user management via admin panel
- Roles defined in [backend/models/user.model.js](backend/models/user.model.js) and mirrored in [frontend/src/constants/roles.js](frontend/src/constants/roles.js)
**Dual RBAC System** - Two separate role hierarchies:
**1. System Roles** (users.role column):
- `system_admin`: Access to Admin Panel for system-wide management (stores, users)
- `user`: Regular system user (default for new registrations)
- 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:
```javascript
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem);
// System-level protection
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
- `requireRole` checks if user's role matches allowed roles
- See [backend/routes/list.routes.js](backend/routes/list.routes.js) for examples
- `requireRole` checks system role only
- Household role checks happen in controllers using `household.model.js` methods
**Frontend route protection**:
- `<PrivateRoute>`: Requires authentication, redirects to `/login` if no token
- `<RoleGuard allowed={[ROLES.ADMIN]}>`: Requires specific role(s), redirects to `/` if unauthorized
- `<RoleGuard allowed={[ROLES.SYSTEM_ADMIN]}>`: Requires system_admin role for Admin Panel
- Household permissions: Check `activeHousehold.role` in components (not route-level)
- 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
**PostgreSQL server runs externally** - not in Docker Compose. Connection configured in [backend/.env](backend/.env) via standard environment variables.
**Tables** (inferred from models, no formal migrations):
- **users**: `id`, `username`, `password` (bcrypt hashed), `name`, `role`
- **grocery_list**: `id`, `item_name`, `quantity`, `bought`, `added_by`
- **grocery_history**: Junction table tracking which users added which items
**Core Tables**:
**users** - System users
- `id` (PK), `username`, `password` (bcrypt), `name`, `display_name`
- `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**:
- No migration system - schema changes are manual SQL
- No formal migration system - schema changes are manual SQL
- Items use case-insensitive matching (`ILIKE`) to prevent duplicates
- JOINs with `ARRAY_AGG` for multi-contributor queries (see [backend/models/list.model.js](backend/models/list.model.js))
- JOINs with `ARRAY_AGG` for multi-contributor queries (see [backend/models/list.model.v2.js](backend/models/list.model.v2.js))
- All list operations require `household_id` parameter for scoping
- Image storage: `bytea` columns for images with separate MIME type columns
## Development Workflow
@ -137,11 +239,16 @@ See [.gitea/workflows/deploy.yml](.gitea/workflows/deploy.yml) for full workflow
## Authentication Flow
1. User logs in → backend returns `{token, role, username}` ([backend/controllers/auth.controller.js](backend/controllers/auth.controller.js))
1. User logs in → backend returns `{token, userId, 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))
3. Axios interceptor auto-attaches `Authorization: Bearer <token>` header ([frontend/src/api/axios.js](frontend/src/api/axios.js))
4. Backend validates JWT on protected routes ([backend/middleware/auth.js](backend/middleware/auth.js))
5. On 401 "Invalid or expired token" response, frontend clears storage and redirects to login
3. `HouseholdContext` loads user's households and sets active household
- Active household includes `household.role` (the **household role**)
4. Axios interceptor auto-attaches `Authorization: Bearer <token>` header ([frontend/src/api/axios.js](frontend/src/api/axios.js))
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
@ -167,16 +274,36 @@ See [.gitea/workflows/deploy.yml](.gitea/workflows/deploy.yml) for full workflow
## Common Tasks
**Add a new protected route**:
1. Backend: Add route with `auth` + `requireRole(...)` middleware
2. Frontend: Add route in [frontend/src/App.jsx](frontend/src/App.jsx) wrapped in `<PrivateRoute>` and/or `<RoleGuard>`
1. Backend: Add route with `auth` middleware (+ `requireRole(...)` if system role check needed)
2. Frontend: Add route in [frontend/src/App.jsx](frontend/src/App.jsx) wrapped in `<PrivateRoute>` (and `<RoleGuard>` for Admin Panel)
**Access user info in backend controller**:
```javascript
const { id, role } = req.user; // Set by auth middleware
const { id, role } = req.user; // Set by auth middleware (system role)
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**:
Use the JOIN pattern in [backend/models/list.model.js](backend/models/list.model.js) - aggregates user names via `grocery_history` table.
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.
## Testing

View File

@ -1,10 +1,14 @@
const express = require("express");
const cors = require("cors");
const path = require("path");
const User = require("./models/user.model");
const app = express();
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());
console.log("Allowed Origins:", allowedOrigins);
app.use(
@ -14,9 +18,10 @@ app.use(
if (allowedOrigins.includes(origin)) return callback(null, true);
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
callback(new Error("Not allowed by CORS"));
console.error(`🚫 CORS blocked origin: ${origin}`);
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
},
methods: ["GET", "POST", "PUT", "DELETE"],
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
})
);
@ -43,4 +48,10 @@ app.use("/users", usersRoutes);
const configRoutes = require("./routes/config.routes");
app.use("/config", configRoutes);
const householdsRoutes = require("./routes/households.routes");
app.use("/households", householdsRoutes);
const storesRoutes = require("./routes/stores.routes");
app.use("/stores", storesRoutes);
module.exports = app;

View File

@ -40,5 +40,5 @@ exports.login = async (req, res) => {
{ expiresIn: "1 year" }
);
res.json({ token, username, role: user.role });
res.json({ token, userId: user.id, username, role: user.role });
};

View File

@ -0,0 +1,207 @@
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" });
}
};

View File

@ -0,0 +1,324 @@
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" });
}
};

View File

@ -0,0 +1,145 @@
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" });
}
};

View File

@ -7,7 +7,6 @@ exports.test = async (req, res) => {
};
exports.getAllUsers = async (req, res) => {
console.log(req);
const users = await User.getAllUsers();
res.json(users);
};

View File

@ -0,0 +1,110 @@
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();
};

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,410 @@
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]);
};

View File

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

View File

@ -1,9 +1,8 @@
const pool = require("../db/pool");
exports.ROLES = {
VIEWER: "viewer",
EDITOR: "editor",
ADMIN: "admin",
SYSTEM_ADMIN: "system_admin",
USER: "user",
}
exports.findByUsername = async (username) => {

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

74
docs/README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,11 +2,14 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ROLES } from "./constants/roles";
import { AuthProvider } from "./context/AuthContext.jsx";
import { ConfigProvider } from "./context/ConfigContext.jsx";
import { HouseholdProvider } from "./context/HouseholdContext.jsx";
import { SettingsProvider } from "./context/SettingsContext.jsx";
import { StoreProvider } from "./context/StoreContext.jsx";
import AdminPanel from "./pages/AdminPanel.jsx";
import GroceryList from "./pages/GroceryList.jsx";
import Login from "./pages/Login.jsx";
import Manage from "./pages/Manage.jsx";
import Register from "./pages/Register.jsx";
import Settings from "./pages/Settings.jsx";
@ -20,38 +23,43 @@ function App() {
return (
<ConfigProvider>
<AuthProvider>
<SettingsProvider>
<BrowserRouter>
<Routes>
<HouseholdProvider>
<StoreProvider>
<SettingsProvider>
<BrowserRouter>
<Routes>
{/* Public route */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Public route */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Private routes with layout */}
<Route
element={
<PrivateRoute>
<AppLayout />
</PrivateRoute>
}
>
<Route path="/" element={<GroceryList />} />
<Route path="/settings" element={<Settings />} />
{/* Private routes with layout */}
<Route
element={
<PrivateRoute>
<AppLayout />
</PrivateRoute>
}
>
<Route path="/" element={<GroceryList />} />
<Route path="/manage" element={<Manage />} />
<Route path="/settings" element={<Settings />} />
<Route
path="/admin"
element={
<RoleGuard allowed={[ROLES.ADMIN]}>
<AdminPanel />
</RoleGuard>
}
/>
</Route>
<Route
path="/admin"
element={
<RoleGuard allowed={[ROLES.SYSTEM_ADMIN]}>
<AdminPanel />
</RoleGuard>
}
/>
</Route>
</Routes>
</BrowserRouter>
</SettingsProvider>
</Routes>
</BrowserRouter>
</SettingsProvider>
</StoreProvider>
</HouseholdProvider>
</AuthProvider>
</ConfigProvider>
);

View File

@ -0,0 +1,58 @@
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}`);

View File

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

View File

@ -0,0 +1,48 @@
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}`);

View File

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

View File

@ -1,6 +1,8 @@
import { ROLES } from "../../constants/roles";
export default function UserRoleCard({ user, onRoleChange }) {
console.log(user)
return (
<div className="card flex-between p-3 my-2 shadow-sm" style={{ transition: 'var(--transition-base)' }}>
<div className="user-info">

View File

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

View File

@ -1,31 +1,84 @@
import "../../styles/components/Navbar.css";
import { useContext } from "react";
import { useContext, useState } from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "../../context/AuthContext";
import HouseholdSwitcher from "../household/HouseholdSwitcher";
export default function Navbar() {
const { role, logout, username } = useContext(AuthContext);
const [showNavMenu, setShowNavMenu] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const closeMenus = () => {
setShowNavMenu(false);
setShowUserMenu(false);
};
return (
<nav className="navbar">
<div className="navbar-links">
<Link to="/">Home</Link>
<Link to="/settings">Settings</Link>
{/* Left: Navigation Menu */}
<div className="navbar-section navbar-left">
<button
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>
{role === "admin" && <Link to="/admin">Admin</Link>}
{showNavMenu && (
<>
<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 className="navbar-idcard">
<div className="navbar-idinfo">
<span className="navbar-username">{username}</span>
<span className="navbar-role">{role}</span>
</div>
{/* Center: Household Switcher */}
<div className="navbar-section navbar-center">
<HouseholdSwitcher />
</div>
<button className="navbar-logout" onClick={logout}>
Logout
</button>
{/* Right: User Menu */}
<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
</button>
</div>
</>
)}
</div>
</nav>
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export { default as CreateJoinHousehold } from './CreateJoinHousehold';
export { default as ManageHousehold } from './ManageHousehold';
export { default as ManageStores } from './ManageStores';

View File

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

View File

@ -2,5 +2,7 @@ export const ROLES = {
VIEWER: "viewer",
EDITOR: "editor",
ADMIN: "admin",
SYSTEM_ADMIN: "system_admin",
USER: "user",
UP_TO_ADMIN: ["viewer", "editor", "admin"],
};

View File

@ -2,6 +2,7 @@ import { createContext, useState } from 'react';
export const AuthContext = createContext({
token: null,
userId: null,
role: null,
username: null,
login: () => { },
@ -10,14 +11,17 @@ export const AuthContext = createContext({
export const AuthProvider = ({ children }) => {
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 [username, setUsername] = useState(localStorage.getItem('username') || null);
const login = (data) => {
localStorage.setItem('token', data.token);
localStorage.setItem('userId', data.userId);
localStorage.setItem('role', data.role);
localStorage.setItem('username', data.username);
setToken(data.token);
setUserId(data.userId);
setRole(data.role);
setUsername(data.username);
};
@ -26,12 +30,14 @@ export const AuthProvider = ({ children }) => {
localStorage.clear();
setToken(null);
setUserId(null);
setRole(null);
setUsername(null);
};
const value = {
token,
userId,
role,
username,
login,

View File

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

View File

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

View File

@ -1,14 +1,17 @@
import { useEffect, useState } from "react";
import { getAllUsers, updateRole } from "../api/users";
import StoreManagement from "../components/admin/StoreManagement";
import UserRoleCard from "../components/common/UserRoleCard";
import "../styles/UserRoleCard.css";
import "../styles/pages/AdminPanel.css";
export default function AdminPanel() {
const [users, setUsers] = useState([]);
const [activeTab, setActiveTab] = useState("users");
async function loadUsers() {
const allUsers = await getAllUsers();
console.log("Users found:", users);
setUsers(allUsers.data);
}
@ -23,17 +26,39 @@ export default function AdminPanel() {
}
return (
<div className="p-4" style={{ minHeight: '100vh' }}>
<div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}>
<h1 className="text-center text-3xl font-bold mb-4">Admin Panel</h1>
<div className="mt-4">
{users.map((user) => (
<UserRoleCard
key={user.id}
user={user}
onRoleChange={changeRole}
/>
))}
<div className="admin-panel-body">
<div className="admin-panel-container">
<h1 className="admin-panel-title">Admin Panel</h1>
<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) => (
<UserRoleCard
key={user.id}
user={user}
onRoleChange={changeRole}
/>
))}
</div>
)}
{activeTab === "stores" && <StoreManagement />}
</div>
</div>
</div>

View File

@ -18,18 +18,26 @@ import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModa
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
import EditItemModal from "../components/modals/EditItemModal";
import SimilarItemModal from "../components/modals/SimilarItemModal";
import StoreTabs from "../components/store/StoreTabs";
import { ZONE_FLOW } from "../constants/classifications";
import { ROLES } from "../constants/roles";
import { AuthContext } from "../context/AuthContext";
import { HouseholdContext } from "../context/HouseholdContext";
import { SettingsContext } from "../context/SettingsContext";
import { StoreContext } from "../context/StoreContext";
import "../styles/pages/GroceryList.css";
import { findSimilarItems } from "../utils/stringSimilarity";
export default function GroceryList() {
const { role } = useContext(AuthContext);
const { role: systemRole } = useContext(AuthContext);
const { activeHousehold } = useContext(HouseholdContext);
const { activeStore } = useContext(StoreContext);
const { settings } = useContext(SettingsContext);
// Get household role for permissions
const householdRole = activeHousehold?.role;
// === State === //
const [items, setItems] = useState([]);
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
@ -53,17 +61,29 @@ export default function GroceryList() {
// === Data Loading ===
const loadItems = async () => {
if (!activeHousehold?.id || !activeStore?.id) {
setLoading(false);
return;
}
setLoading(true);
const res = await getList();
console.log(res.data);
setItems(res.data);
setLoading(false);
try {
const res = await getList(activeHousehold.id, activeStore.id);
console.log('[GroceryList] Items loaded:', res.data);
setItems(res.data.items || res.data || []);
} catch (error) {
console.error('[GroceryList] Failed to load items:', error);
setItems([]);
} finally {
setLoading(false);
}
};
const loadRecentlyBought = async () => {
if (!activeHousehold?.id || !activeStore?.id) return;
try {
const res = await getRecentlyBought();
const res = await getRecentlyBought(activeHousehold.id, activeStore.id);
setRecentlyBoughtItems(res.data);
} catch (error) {
console.error("Failed to load recently bought items:", error);
@ -75,7 +95,7 @@ export default function GroceryList() {
useEffect(() => {
loadItems();
loadRecentlyBought();
}, []);
}, [activeHousehold?.id, activeStore?.id]);
// === Zone Collapse Handler ===
@ -137,10 +157,16 @@ export default function GroceryList() {
return;
}
if (!activeHousehold?.id || !activeStore?.id) {
setSuggestions([]);
setButtonText("Create + Add");
return;
}
const lowerText = text.toLowerCase().trim();
try {
const response = await getSuggestions(text);
const response = await getSuggestions(activeHousehold.id, activeStore.id, text);
const suggestionList = response.data.map(s => s.item_name);
setSuggestions(suggestionList);
@ -157,13 +183,15 @@ export default function GroceryList() {
// === Item Addition Handlers ===
const handleAdd = useCallback(async (itemName, quantity) => {
if (!itemName.trim()) return;
if (!activeHousehold?.id || !activeStore?.id) return;
// Check if item already exists
let existingItem = null;
try {
const response = await getItemByName(itemName);
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
existingItem = response.data;
} catch {
existingItem = null;
// Item doesn't exist, continue
}
if (existingItem) {
@ -183,16 +211,19 @@ export default function GroceryList() {
processItemAddition(itemName, quantity);
return prevItems;
});
}, [recentlyBoughtItems]);
}, [activeHousehold?.id, activeStore?.id, recentlyBoughtItems]);
const processItemAddition = useCallback(async (itemName, quantity) => {
if (!activeHousehold?.id || !activeStore?.id) return;
// Fetch current item state from backend
let existingItem = null;
try {
const response = await getItemByName(itemName);
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
existingItem = response.data;
} catch {
existingItem = null;
// Item doesn't exist, continue with add
}
if (existingItem?.bought === false) {
@ -209,7 +240,7 @@ export default function GroceryList() {
});
setShowConfirmAddExisting(true);
} else if (existingItem) {
await addItem(itemName, quantity, null);
await addItem(activeHousehold.id, activeStore.id, itemName, quantity, null);
setSuggestions([]);
setButtonText("Add Item");
@ -220,7 +251,7 @@ export default function GroceryList() {
setPendingItem({ itemName, quantity });
setShowAddDetailsModal(true);
}
}, []);
}, [activeHousehold?.id, activeStore?.id, items, loadItems]);
// === Similar Item Modal Handlers ===
@ -249,6 +280,7 @@ export default function GroceryList() {
// === Confirm Add Existing Modal Handlers ===
const handleConfirmAddExisting = useCallback(async () => {
if (!confirmAddExistingData) return;
if (!activeHousehold?.id || !activeStore?.id) return;
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
@ -256,14 +288,11 @@ export default function GroceryList() {
setConfirmAddExistingData(null);
try {
// Update the item
await addItem(itemName, newQuantity, null);
await addItem(activeHousehold.id, activeStore.id, itemName, newQuantity, null);
// Fetch the updated item with properly formatted data
const response = await getItemByName(itemName);
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
const updatedItem = response.data;
// Update state with the full item data
setItems(prevItems =>
prevItems.map(item =>
item.id === existingItem.id ? updatedItem : item
@ -274,32 +303,54 @@ export default function GroceryList() {
setButtonText("Add Item");
} catch (error) {
console.error("Failed to update item:", error);
// Fallback to full reload on error
await loadItems();
}
}, [confirmAddExistingData, loadItems]);
const handleCancelAddExisting = useCallback(() => {
setShowConfirmAddExisting(false);
setConfirmAddExistingData(null);
}, []);
// === Add Details Modal Handlers ===
const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => {
const handleAddWithDetails = useCallback(async (imageFile, classification) => {
if (!pendingItem) return;
if (!activeHousehold?.id || !activeStore?.id) return;
try {
const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
let newItem = addResponse.data;
await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, imageFile);
if (classification) {
const itemResponse = await getItemByName(pendingItem.itemName);
const itemId = itemResponse.data.id;
const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification);
newItem = { ...newItem, ...updateResponse.data };
// Apply classification if provided
await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification);
}
// Fetch the newly added item
const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName);
const newItem = itemResponse.data;
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
// Add to state
if (newItem) {
setItems(prevItems => [...prevItems, newItem]);
}
} catch (error) {
console.error("Failed to add item:", error);
alert("Failed to add item. Please try again.");
}
}, [activeHousehold?.id, activeStore?.id, pendingItem]);
const handleAddDetailsSkip = useCallback(async () => {
if (!pendingItem) return;
if (!activeHousehold?.id || !activeStore?.id) return;
try {
await addItem(activeHousehold.id, activeStore.id, 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);
setPendingItem(null);
setSuggestions([]);
@ -312,28 +363,7 @@ export default function GroceryList() {
console.error("Failed to add item:", error);
alert("Failed to add item. Please try again.");
}
}, [pendingItem]);
const handleAddDetailsSkip = useCallback(async () => {
if (!pendingItem) return;
try {
const response = await addItem(pendingItem.itemName, pendingItem.quantity, null);
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
if (response.data) {
setItems(prevItems => [...prevItems, response.data]);
}
} catch (error) {
console.error("Failed to add item:", error);
alert("Failed to add item. Please try again.");
}
}, [pendingItem]);
}, [activeHousehold?.id, activeStore?.id, pendingItem]);
const handleAddDetailsCancel = useCallback(() => {
@ -346,31 +376,34 @@ export default function GroceryList() {
// === Item Action Handlers ===
const handleBought = useCallback(async (id, quantity) => {
if (!activeHousehold?.id || !activeStore?.id) return;
const item = items.find(i => i.id === id);
if (!item) return;
await markBought(id, quantity);
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
// If buying full quantity, remove from list
if (quantity >= item.quantity) {
setItems(prevItems => prevItems.filter(item => item.id !== id));
} else {
// If partial, update quantity
const response = await getItemByName(item.item_name);
if (response.data) {
setItems(prevItems =>
prevItems.map(item => item.id === id ? response.data : item)
);
}
// If partial, fetch updated item
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
const updatedItem = response.data;
setItems(prevItems =>
prevItems.map(i => i.id === id ? updatedItem : i)
);
}
loadRecentlyBought();
}, [items]);
}, [activeHousehold?.id, activeStore?.id, items]);
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
if (!activeHousehold?.id || !activeStore?.id) return;
try {
const response = await updateItemImage(id, itemName, quantity, imageFile);
const response = await updateItemImage(activeHousehold.id, activeStore.id, id, itemName, quantity, imageFile);
setItems(prevItems =>
prevItems.map(item =>
@ -387,14 +420,15 @@ export default function GroceryList() {
console.error("Failed to add image:", error);
alert("Failed to add image. Please try again.");
}
}, []);
}, [activeHousehold?.id, activeStore?.id]);
const handleLongPress = useCallback(async (item) => {
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
if (!householdRole || householdRole === 'viewer') return;
if (!activeHousehold?.id || !activeStore?.id) return;
try {
const classificationResponse = await getClassification(item.id);
const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.id);
setEditingItem({
...item,
classification: classificationResponse.data
@ -405,20 +439,26 @@ export default function GroceryList() {
setEditingItem({ ...item, classification: null });
setShowEditModal(true);
}
}, [role]);
}, [activeHousehold?.id, activeStore?.id, householdRole]);
// === Edit Modal Handlers ===
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
if (!activeHousehold?.id || !activeStore?.id) return;
try {
const response = await updateItemWithClassification(id, itemName, quantity, classification);
await updateItemWithClassification(activeHousehold.id, activeStore.id, itemName, quantity, classification);
// Fetch the updated item
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
const updatedItem = response.data;
setShowEditModal(false);
setEditingItem(null);
const updatedItem = response.data;
setItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...updatedItem } : item
item.id === id ? updatedItem : item
)
);
@ -431,7 +471,7 @@ export default function GroceryList() {
console.error("Failed to update item:", error);
throw error;
}
}, []);
}, [activeHousehold?.id, activeStore?.id]);
const handleEditCancel = useCallback(() => {
@ -454,7 +494,30 @@ export default function GroceryList() {
};
if (loading) return <p>Loading...</p>;
if (!activeHousehold || !activeStore) {
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 (
@ -462,8 +525,9 @@ export default function GroceryList() {
<div className="glist-container">
<h1 className="glist-title">Costco Grocery List</h1>
<StoreTabs />
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
{householdRole && householdRole !== 'viewer' && showAddForm && (
<AddItemForm
onAdd={handleAdd}
onSuggest={handleSuggest}
@ -503,13 +567,13 @@ export default function GroceryList() {
allItems={sortedItems}
compact={settings.compactView}
onClick={(id, quantity) =>
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
householdRole && householdRole !== 'viewer' && handleBought(id, quantity)
}
onImageAdded={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
}
onLongPress={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
householdRole && householdRole !== 'viewer' ? handleLongPress : null
}
/>
))}
@ -564,10 +628,10 @@ export default function GroceryList() {
compact={settings.compactView}
onClick={null}
onImageAdded={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
}
onLongPress={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
householdRole && householdRole !== 'viewer' ? handleLongPress : null
}
/>
))}
@ -588,7 +652,7 @@ export default function GroceryList() {
)}
</div>
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
{householdRole && householdRole !== 'viewer' && (
<FloatingActionButton
isOpen={showAddForm}
onClick={() => setShowAddForm(!showAddForm)}
@ -598,7 +662,7 @@ export default function GroceryList() {
{showAddDetailsModal && pendingItem && (
<AddItemWithDetailsModal
itemName={pendingItem.itemName}
onConfirm={handleAddDetailsConfirm}
onConfirm={handleAddWithDetails}
onSkip={handleAddDetailsSkip}
onCancel={handleAddDetailsCancel}
/>
@ -629,9 +693,12 @@ export default function GroceryList() {
currentQuantity={confirmAddExistingData.currentQuantity}
addingQuantity={confirmAddExistingData.addingQuantity}
onConfirm={handleConfirmAddExisting}
onCancel={handleCancelAddExisting}
onCancel={() => {
setShowConfirmAddExisting(false);
setConfirmAddExistingData(null);
}}
/>
)}
</div>
);
}
}

View File

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

View File

@ -81,3 +81,38 @@
.add-image-remove:hover {
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;
}
}

View File

@ -64,3 +64,20 @@
}
}
@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;
}
}

View File

@ -201,3 +201,67 @@
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;
}
}

View File

@ -100,3 +100,52 @@
.classification-modal-btn-confirm:hover {
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;
}
}

View File

@ -22,3 +22,23 @@
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;
}
}

View File

@ -195,13 +195,61 @@
}
/* Mobile responsiveness */
@media (max-width: 480px) {
@media (max-width: 768px) {
.add-item-details-overlay {
padding: var(--spacing-sm);
}
.add-item-details-modal {
padding: 1.2em;
width: 95%;
max-width: 95%;
padding: var(--spacing-md);
}
.add-item-details-title {
font-size: 1.2em;
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) {
.add-item-details-modal {
padding: 1rem;
border-radius: 8px;
}
.add-item-details-title {
font-size: 1.15em;
}
.add-item-details-subtitle {
font-size: 0.85em;
}
.add-item-details-section-title {
font-size: 1em;
}
.add-item-details-image-options {
@ -210,9 +258,10 @@
.add-item-details-image-btn {
min-width: 100%;
font-size: 0.9em;
}
.add-item-details-actions {
flex-direction: column;
.add-item-details-field label {
font-size: 0.85em;
}
}

View File

@ -39,3 +39,30 @@
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;
}
}

View File

@ -187,3 +187,83 @@
opacity: 0.6;
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;
}
}

View File

@ -0,0 +1,125 @@
.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);
}

View File

@ -1,58 +1,232 @@
/* Navbar - Sticky at top */
.navbar {
position: sticky;
top: 0;
z-index: 1000;
background: #343a40;
color: white;
padding: 0.6em 1em;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 4px;
margin-bottom: 1em;
gap: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.navbar-links a {
/* 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;
margin-right: 1em;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
white-space: nowrap;
transition: background 0.2s;
}
.navbar-user-btn:hover {
background: #5a6268;
}
/* 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;
font-size: 1.1em;
padding: 0.75rem 1.25rem;
font-size: 1rem;
transition: background 0.2s;
border-bottom: 1px solid #f0f0f0;
}
.navbar-links a:hover {
text-decoration: underline;
.nav-dropdown a:last-child {
border-bottom: none;
}
.navbar-logout {
.nav-dropdown a:hover {
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;
color: white;
border: none;
padding: 0.4em 0.8em;
border-radius: 4px;
padding: 0.75rem 1.25rem;
cursor: pointer;
width: 100px;
font-size: 1rem;
font-weight: 500;
transition: background 0.2s;
}
.navbar-idcard {
display: flex;
align-items: center;
align-content: center;
margin-right: 1em;
padding: 0.3em 0.6em;
background: #495057;
border-radius: 4px;
color: white;
.user-dropdown-logout:hover {
background: #c82333;
}
.navbar-idinfo {
display: flex;
flex-direction: column;
line-height: 1.1;
/* Household Switcher - Centered with max width */
.navbar-center > * {
width: 100%;
max-width: 24ch; /* 24 characters max width */
}
.navbar-username {
font-size: 0.95em;
font-weight: bold;
/* Mobile Responsive */
@media (max-width: 768px) {
.navbar {
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;
}
}
.navbar-role {
font-size: 0.75em;
opacity: 0.8;
@media (max-width: 480px) {
.navbar {
padding: 0.5rem;
}
.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;
}
}

View File

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

View File

@ -0,0 +1,278 @@
/* 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%;
}
}

View File

@ -0,0 +1,165 @@
/* 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%;
}
}

View File

@ -0,0 +1,247 @@
/* 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%;
}
}

View File

@ -0,0 +1,161 @@
/* 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%;
}
}

View File

@ -1,9 +1,109 @@
/* Admin Panel - uses utility classes */
/* Responsive adjustments only */
/* Admin Panel Layout */
.admin-panel-body {
min-height: 100vh;
background: var(--background);
padding: 2rem 1rem;
display: flex;
justify-content: center;
}
@media (max-width: 768px) {
.admin-panel-page {
padding: var(--spacing-md) !important;
.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) {
.admin-panel-body {
padding: 1rem 0.75rem;
}
.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;
}
}

View File

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

View File

@ -12,6 +12,13 @@
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
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 {

View File

@ -14,10 +14,10 @@
============================================ */
/* Primary Colors */
--color-primary: #007bff;
--color-primary-hover: #0056b3;
--color-primary: dodgerblue;
--color-primary-hover: #0066cc;
--color-primary-light: #e7f3ff;
--color-primary-dark: #0067d8;
--color-primary-dark: #0056b3;
/* Secondary Colors */
--color-secondary: #6c757d;
@ -187,6 +187,20 @@
--modal-border-radius: var(--border-radius-lg);
--modal-padding: var(--spacing-lg);
--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);
}

View File

@ -109,12 +109,12 @@
}
.btn-secondary {
background: var(--color-secondary);
background: var(--color-primary);
color: var(--color-text-inverse);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-secondary-hover);
background: var(--color-primary-hover);
}
.btn-danger {

80
run-migration.bat Normal file
View File

@ -0,0 +1,80 @@
@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 Normal file
View File

@ -0,0 +1,146 @@
#!/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 ""