feature-custom-store-locations #4

Merged
nalalangan merged 16 commits from feature-custom-store-locations into main 2026-05-31 00:35:29 -09:00
61 changed files with 5299 additions and 1092 deletions

11
.gitignore vendored
View File

@ -1,8 +1,9 @@
# Environment variables (DO NOT COMMIT) # Environment variables (DO NOT COMMIT)
.env .env
.codex-local.env
# Node dependencies
node_modules/ # Node dependencies
node_modules/
# Build output (if using a bundler or React later) # Build output (if using a bundler or React later)
dist/ dist/

View File

@ -19,6 +19,15 @@ If anything conflicts, follow **this** doc.
- Dev/Prod share schema via migrations in: `packages/db/migrations`. - Dev/Prod share schema via migrations in: `packages/db/migrations`.
- Active migration runbook: `docs/DB_MIGRATION_WORKFLOW.md` (active set + status commands). - Active migration runbook: `docs/DB_MIGRATION_WORKFLOW.md` (active set + status commands).
### Docker dev runtime
- After backend/API code changes while using `docker-compose.dev.yml`, rebuild and restart only the backend service:
- `docker compose -f docker-compose.dev.yml up -d --build backend`
- After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded:
- `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend`
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include the exact browser origin, for example `http://localhost:3010` and `http://127.0.0.1:3010`.
- Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`.
- Do not print or commit real `.env` values while checking or updating local Docker env.
### No background jobs ### No background jobs
- **No cron/worker jobs**. Any fix must work without background tasks. - **No cron/worker jobs**. Any fix must work without background tasks.
@ -192,14 +201,90 @@ Usage rules:
--- ---
## 12) Commit Discipline (required) ## 12) Git Intake, Branching, Commit, and PR Discipline (required)
### Read-only intake before editing
Before editing, run this read-only intake:
- `git status --short --branch`
- `git branch -vv`
- `git log --oneline --decorate -8`
- `git ls-files --others --exclude-standard`
- Check current PR status when GitHub CLI is available.
- Check open PRs for overlapping work before editing shared or collision-prone areas.
### Branch suitability gate before editing
- Continue on the current branch only when the requested work belongs to that branch or PR.
- Start independent work from `main` after pulling latest.
- Start stacked work from the current PR branch when the work intentionally builds on that PR.
- If the current branch purpose does not match the request, stop before editing and switch or create the correct branch.
- If either `main` or the current branch could be valid, ask whether the work is independent side work or required follow-on work.
- Do not layer unrelated work on top of a dirty worktree.
- If unrelated local changes exist, pause and ask how to separate them.
### Branch creation and naming
- Create a descriptive branch before writing code.
- Preferred branch prefixes:
- `feature/<short-description>`
- `bugfix/<short-description>`
- `refactor/<short-description>`
- `chore/<short-description>`
- `spike/<short-description>`
- Do not include tracker numbers in branch names.
- Use standalone branches from `main` for independent work.
- Use stacked branches from the parent PR branch for follow-on work.
- Target standalone PRs at `main`.
- Target stacked PRs at the parent PR branch.
- Never push directly to `main`.
### Commit discipline
- Treat committing as a first-class part of the workflow: create frequent, verified checkpoint commits for completed work instead of accumulating large uncommitted changes. - Treat committing as a first-class part of the workflow: create frequent, verified checkpoint commits for completed work instead of accumulating large uncommitted changes.
- Commit after each coherent logical unit of work.
- Commit in small, logical slices (no broad mixed-purpose commits). - Commit in small, logical slices (no broad mixed-purpose commits).
- Before committing:
1. Run `git diff --stat`.
2. Run relevant tests or checks when practical.
3. Stage only files that belong to the logical unit.
4. Run `git diff --cached --stat`.
5. Commit with an imperative, present-tense subject at or below 72 characters.
- Each commit must: - Each commit must:
- follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`) - follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
- include only related files for that slice - include only related files for that slice
- exclude secrets, credentials, and generated noise - exclude secrets, credentials, and generated noise
- Do not stage unrelated user or collaborator changes.
- Do not start a second unrelated task while the first has uncommitted work.
- If existing local changes are external/user changes, leave them untouched unless explicitly told otherwise.
- If asked to commit and external changes already exist, commit those separately on the proper branch before starting new work.
- Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas). - Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas).
- Prefer frequent checkpoint commits during agentic work rather than one large end-state commit. - Prefer frequent checkpoint commits during agentic work rather than one large end-state commit.
- Before switching tasks or stopping after a completed change, check git status and either commit the finished slice or clearly document why it remains uncommitted. - Before switching tasks or stopping after a completed change, check git status and either commit the finished slice or clearly document why it remains uncommitted.
- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code). - If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code).
### Push and PR coordination
- Push the branch before opening a PR.
- For this Gitea repo, use `docs/GITEA_PR_WORKFLOW.md` and `scripts/gitea-pr.js` for PR creation, lookup, and merge operations.
- PR tooling must read auth from `GITEA_TOKEN`/`GITEA_BASE_URL` shell environment or ignored `.codex-local.env` only; never commit tokens or print token values.
- Open a draft PR early for non-trivial, collision-prone, or multi-agent work once the first coherent commit exists.
- Use the PR body as the coordination record:
- `Owner:`
- `Status: proposed / in-progress / blocked / review / done`
- `Branch:`
- `Branch relationship: standalone from main / stacked on parent branch / continuing existing branch`
- `Likely modified areas:`
- `Actual modified files:`
- `Collision risk: low / medium / high`
- `Last meaningful update:`
- Collision risk levels:
- Low: isolated docs, tests, or one leaf component.
- Medium: shared stores/types, panels/components, handlers, helpers.
- High: interface contracts, broad app flows, core registries, cross-cutting behavior.
- If a branch already contains assigned feature work and has no current PR, stop before adding more feature commits. Push the branch and open a draft PR, or record the GitHub/auth blocker.
- Before writing or updating the final PR body, inspect:
- `git log <base>..HEAD`
- `git diff --stat <base>..HEAD`
- The PR should describe the cumulative branch diff against the target branch, not only the latest commit.
- Include:
- Summary of functional changes.
- Tests run, or a clear reason tests were not run.
- For broad branches, organize the summary by subsystem, workflow, or behavior area.
- Do not use auto-closing keywords such as `Closes`, `Fixes`, or `Resolves`.
- Merge PRs only after explicit operator approval, required checks, and a final `npm run pr:view -- --number <pr-number>` status check.

View File

@ -1,6 +1,6 @@
const AvailableItems = require("../models/available-item.model"); const AvailableItems = require("../models/available-item.model");
const List = require("../models/list.model.v2"); const List = require("../models/list.model.v2");
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); const { isValidItemType, isValidItemGroup } = require("../constants/classifications");
const { sendError } = require("../utils/http"); const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger"); const { logError } = require("../utils/logger");
@ -13,6 +13,10 @@ function parseBoolean(value) {
return value === true || value === "true" || value === "1"; return value === true || value === "true" || value === "1";
} }
function getStoreLocationId(req) {
return req.params.locationId || req.params.storeId;
}
function isCatalogTableMissing(error) { function isCatalogTableMissing(error) {
return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || ""); return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || "");
} }
@ -84,7 +88,7 @@ function normalizeClassificationPayload(classification) {
return { item_type, item_group, zone }; return { item_type, item_group, zone };
} }
function validateClassification(res, classification) { async function validateClassification(res, householdId, storeLocationId, classification) {
if (!classification) { if (!classification) {
return false; return false;
} }
@ -106,9 +110,12 @@ function validateClassification(res, classification) {
return true; return true;
} }
if (zone && !isValidZone(zone)) { if (zone) {
sendError(res, 400, "Invalid zone"); const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone);
return true; if (!zoneRecord) {
sendError(res, 400, "Invalid zone");
return true;
}
} }
return false; return false;
@ -121,8 +128,13 @@ function parseItemId(value) {
exports.getAvailableItems = async (req, res) => { exports.getAvailableItems = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || ""); const storeLocationId = getStoreLocationId(req);
const items = await AvailableItems.listAvailableItems(
householdId,
storeLocationId,
req.query.query || ""
);
res.json({ items, catalog_ready: true }); res.json({ items, catalog_ready: true });
} catch (error) { } catch (error) {
if (isCatalogTableMissing(error)) { if (isCatalogTableMissing(error)) {
@ -139,7 +151,8 @@ exports.getAvailableItems = async (req, res) => {
exports.createAvailableItem = async (req, res) => { exports.createAvailableItem = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const storeLocationId = getStoreLocationId(req);
const { item_name } = req.body; const { item_name } = req.body;
if (!item_name || item_name.trim() === "") { if (!item_name || item_name.trim() === "") {
@ -152,7 +165,7 @@ exports.createAvailableItem = async (req, res) => {
} }
const normalizedClassification = normalizeClassificationPayload(parsedClassification); const normalizedClassification = normalizeClassificationPayload(parsedClassification);
if (validateClassification(res, normalizedClassification)) { if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) {
return; return;
} }
@ -161,21 +174,37 @@ exports.createAvailableItem = async (req, res) => {
const item = await AvailableItems.createAvailableItem( const item = await AvailableItems.createAvailableItem(
householdId, householdId,
storeId, storeLocationId,
item_name, item_name,
imageBuffer, imageBuffer,
mimeType mimeType,
req.user.id
); );
if (normalizedClassification) { if (normalizedClassification) {
await List.upsertClassification(householdId, storeId, item.item_id, { await List.upsertClassification(householdId, storeLocationId, item.item_id, {
...normalizedClassification, ...normalizedClassification,
confidence: 1.0, confidence: 1.0,
source: "user", source: "user",
}); });
await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: item.item_id,
actorUserId: req.user.id,
eventType: "ITEM_CLASSIFICATION_CHANGED",
metadata: {
item_name,
...normalizedClassification,
},
});
} }
const refreshedItem = await AvailableItems.getAvailableItemById(householdId, storeId, item.item_id); const refreshedItem = await AvailableItems.getAvailableItemById(
householdId,
storeLocationId,
item.item_id
);
res.status(201).json({ res.status(201).json({
message: "Available item added", message: "Available item added",
@ -199,7 +228,8 @@ exports.createAvailableItem = async (req, res) => {
exports.updateAvailableItem = async (req, res) => { exports.updateAvailableItem = async (req, res) => {
try { try {
const { householdId, storeId, itemId: rawItemId } = req.params; const { householdId, itemId: rawItemId } = req.params;
const storeLocationId = getStoreLocationId(req);
const itemId = parseItemId(rawItemId); const itemId = parseItemId(rawItemId);
if (!itemId) { if (!itemId) {
@ -214,15 +244,19 @@ exports.updateAvailableItem = async (req, res) => {
} }
const normalizedClassification = normalizeClassificationPayload(parsedClassification); const normalizedClassification = normalizeClassificationPayload(parsedClassification);
if (normalizedClassification && validateClassification(res, normalizedClassification)) { if (
normalizedClassification &&
(await validateClassification(res, householdId, storeLocationId, normalizedClassification))
) {
return; return;
} }
const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeId, itemId, { const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeLocationId, itemId, {
itemName: req.body.item_name, itemName: req.body.item_name,
imageBuffer: req.processedImage?.buffer || null, imageBuffer: req.processedImage?.buffer || null,
mimeType: req.processedImage?.mimeType || null, mimeType: req.processedImage?.mimeType || null,
removeImage: parseBoolean(req.body.remove_image), removeImage: parseBoolean(req.body.remove_image),
userId: req.user.id,
}); });
if (!updatedItem) { if (!updatedItem) {
@ -231,19 +265,30 @@ exports.updateAvailableItem = async (req, res) => {
if (hasClassificationField) { if (hasClassificationField) {
if (normalizedClassification) { if (normalizedClassification) {
await List.upsertClassification(householdId, storeId, updatedItem.item_id, { await List.upsertClassification(householdId, storeLocationId, updatedItem.item_id, {
...normalizedClassification, ...normalizedClassification,
confidence: 1.0, confidence: 1.0,
source: "user", source: "user",
}); });
await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: updatedItem.item_id,
actorUserId: req.user.id,
eventType: "ITEM_CLASSIFICATION_CHANGED",
metadata: {
item_name: updatedItem.item_name,
...normalizedClassification,
},
});
} else { } else {
await List.deleteClassification(householdId, storeId, updatedItem.item_id); await List.deleteClassification(householdId, storeLocationId, updatedItem.item_id);
} }
} }
const refreshedItem = await AvailableItems.getAvailableItemById( const refreshedItem = await AvailableItems.getAvailableItemById(
householdId, householdId,
storeId, storeLocationId,
updatedItem.item_id updatedItem.item_id
); );
@ -269,14 +314,20 @@ exports.updateAvailableItem = async (req, res) => {
exports.deleteAvailableItem = async (req, res) => { exports.deleteAvailableItem = async (req, res) => {
try { try {
const { householdId, storeId, itemId: rawItemId } = req.params; const { householdId, itemId: rawItemId } = req.params;
const storeLocationId = getStoreLocationId(req);
const itemId = parseItemId(rawItemId); const itemId = parseItemId(rawItemId);
if (!itemId) { if (!itemId) {
return sendError(res, 400, "Item ID must be a positive integer"); return sendError(res, 400, "Item ID must be a positive integer");
} }
const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId); const deleted = await AvailableItems.deleteAvailableItem(
householdId,
storeLocationId,
itemId,
req.user.id
);
if (!deleted) { if (!deleted) {
return sendError(res, 404, "Store item not found"); return sendError(res, 404, "Store item not found");
@ -298,8 +349,9 @@ exports.deleteAvailableItem = async (req, res) => {
exports.importCurrentItems = async (req, res) => { exports.importCurrentItems = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeId); const storeLocationId = getStoreLocationId(req);
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeLocationId);
res.json({ res.json({
message: importedCount > 0 ? "Imported current list items" : "No current list items to import", message: importedCount > 0 ? "Imported current list items" : "No current list items to import",

View File

@ -14,6 +14,42 @@ exports.getUserHouseholds = async (req, res) => {
} }
}; };
exports.reorderHouseholds = async (req, res) => {
try {
const rawHouseholdIds = req.body.household_ids || req.body.householdIds;
if (!Array.isArray(rawHouseholdIds)) {
return sendError(res, 400, "household_ids must be an array");
}
const householdIds = rawHouseholdIds.map((householdId) =>
Number.parseInt(householdId, 10)
);
const hasInvalidId = householdIds.some(
(householdId) => !Number.isInteger(householdId) || householdId <= 0
);
const hasDuplicates = new Set(householdIds).size !== householdIds.length;
if (hasInvalidId || hasDuplicates) {
return sendError(res, 400, "household_ids must contain unique positive household IDs");
}
const households = await householdModel.reorderUserHouseholds(req.user.id, householdIds);
if (!households) {
return sendError(res, 400, "Household order must include every household you belong to");
}
res.json({
message: "Household order updated successfully",
households,
});
} catch (error) {
logError(req, "households.reorderHouseholds", error);
sendError(res, 500, "Failed to update household order");
}
};
// Get household details // Get household details
exports.getHousehold = async (req, res) => { exports.getHousehold = async (req, res) => {
try { try {

View File

@ -1,6 +1,6 @@
const List = require("../models/list.model.v2"); const List = require("../models/list.model.v2");
const householdModel = require("../models/household.model"); const householdModel = require("../models/household.model");
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); const { isValidItemType, isValidItemGroup } = require("../constants/classifications");
const { sendError } = require("../utils/http"); const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger"); const { logError } = require("../utils/logger");
@ -9,6 +9,10 @@ const LEGACY_ITEM_TYPE_MAP = {
snacks: "snack", snacks: "snack",
}; };
function getStoreLocationId(req) {
return req.params.locationId || req.params.storeId;
}
function normalizeClassificationPayload(classification) { function normalizeClassificationPayload(classification) {
if (typeof classification === "string") { if (typeof classification === "string") {
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification; const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
@ -43,14 +47,40 @@ function normalizeClassificationPayload(classification) {
return { item_type, item_group, zone }; return { item_type, item_group, zone };
} }
/** async function validateClassification(res, householdId, storeLocationId, classification) {
* Get list items for household and store const { item_type, item_group, zone } = classification;
* GET /households/:householdId/stores/:storeId/list
*/ if (item_type && !isValidItemType(item_type)) {
sendError(res, 400, "Invalid item_type");
return true;
}
if (item_group && !item_type) {
sendError(res, 400, "Item type is required when item group is provided");
return true;
}
if (item_group && !isValidItemGroup(item_type, item_group)) {
sendError(res, 400, "Invalid item_group for selected item_type");
return true;
}
if (zone) {
const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone);
if (!zoneRecord) {
sendError(res, 400, "Invalid zone");
return true;
}
}
return false;
}
exports.getList = async (req, res) => { exports.getList = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const items = await List.getHouseholdStoreList(householdId, storeId); const storeLocationId = getStoreLocationId(req);
const items = await List.getHouseholdStoreList(householdId, storeLocationId);
res.json({ items }); res.json({ items });
} catch (error) { } catch (error) {
logError(req, "listsV2.getList", error); logError(req, "listsV2.getList", error);
@ -58,20 +88,17 @@ exports.getList = async (req, res) => {
} }
}; };
/**
* Get specific item by name
* GET /households/:householdId/stores/:storeId/list/item
*/
exports.getItemByName = async (req, res) => { exports.getItemByName = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const storeLocationId = getStoreLocationId(req);
const { item_name } = req.query; const { item_name } = req.query;
if (!item_name) { if (!item_name) {
return sendError(res, 400, "Item name is required"); return sendError(res, 400, "Item name is required");
} }
const item = await List.getItemByName(householdId, storeId, item_name); const item = await List.getItemByName(householdId, storeLocationId, item_name);
if (!item) { if (!item) {
return sendError(res, 404, "Item not found"); return sendError(res, 404, "Item not found");
} }
@ -83,13 +110,10 @@ exports.getItemByName = async (req, res) => {
} }
}; };
/**
* Add or update item in household store list
* POST /households/:householdId/stores/:storeId/list/add
*/
exports.addItem = async (req, res) => { exports.addItem = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const storeLocationId = getStoreLocationId(req);
const { item_name, quantity, notes, added_for_user_id } = req.body; const { item_name, quantity, notes, added_for_user_id } = req.body;
const userId = req.user.id; const userId = req.user.id;
let historyUserId = userId; let historyUserId = userId;
@ -118,13 +142,12 @@ exports.addItem = async (req, res) => {
historyUserId = parsedUserId; historyUserId = parsedUserId;
} }
// Get processed image if uploaded
const imageBuffer = req.processedImage?.buffer || null; const imageBuffer = req.processedImage?.buffer || null;
const mimeType = req.processedImage?.mimeType || null; const mimeType = req.processedImage?.mimeType || null;
const result = await List.addOrUpdateItem( const result = await List.addOrUpdateItem(
householdId, householdId,
storeId, storeLocationId,
item_name, item_name,
quantity || "1", quantity || "1",
userId, userId,
@ -133,17 +156,38 @@ exports.addItem = async (req, res) => {
notes notes
); );
// Add history record await List.addHistoryRecord(
await List.addHistoryRecord(result.listId, result.householdStoreItemId, quantity || "1", historyUserId); result.listId,
result.householdStoreItemId,
result.historyQuantity ?? quantity ?? "1",
historyUserId,
storeLocationId
);
await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: result.householdStoreItemId,
householdListId: result.listId,
actorUserId: historyUserId,
eventType: "ITEM_ADDED",
quantityDelta: result.historyQuantity ?? Number.parseInt(quantity || "1", 10),
quantityAfter: result.quantity,
metadata: {
item_name: result.itemName,
is_new_list_item: result.isNew,
added_by_request_user_id: userId,
},
});
res.json({ res.json({
message: result.isNew ? "Item added" : "Item updated", message: result.isNew ? "Item added" : "Item updated",
item: { item: {
id: result.listId, id: result.listId,
item_name: result.itemName, item_name: result.itemName,
quantity: quantity || "1", quantity: result.quantity ?? quantity ?? "1",
bought: false bought: false,
} },
}); });
} catch (error) { } catch (error) {
logError(req, "listsV2.addItem", error); logError(req, "listsV2.addItem", error);
@ -151,23 +195,35 @@ exports.addItem = async (req, res) => {
} }
}; };
/**
* Mark item as bought or unbought
* PATCH /households/:householdId/stores/:storeId/list/item
*/
exports.markBought = async (req, res) => { exports.markBought = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const storeLocationId = getStoreLocationId(req);
const { item_name, bought, quantity_bought } = req.body; const { item_name, bought, quantity_bought } = req.body;
if (!item_name) return sendError(res, 400, "Item name is required"); if (!item_name) return sendError(res, 400, "Item name is required");
const item = await List.getItemByName(householdId, storeId, item_name); const item = await List.getItemByName(householdId, storeLocationId, item_name);
if (!item) return sendError(res, 404, "Item not found"); if (!item) return sendError(res, 404, "Item not found");
const eventDetails = await List.setBought(item.id, bought, quantity_bought);
// Update bought status (with optional partial purchase) if (eventDetails) {
await List.setBought(item.id, bought, quantity_bought); await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: item.household_store_item_id,
householdListId: item.id,
actorUserId: req.user.id,
eventType: eventDetails.eventType,
quantityDelta: eventDetails.quantityDelta,
quantityAfter: eventDetails.quantityAfter,
metadata: {
item_name,
requested_quantity: quantity_bought || null,
},
});
}
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" }); res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
} catch (error) { } catch (error) {
@ -176,27 +232,42 @@ exports.markBought = async (req, res) => {
} }
}; };
/**
* Update item details (quantity, notes)
* PUT /households/:householdId/stores/:storeId/list/item
*/
exports.updateItem = async (req, res) => { exports.updateItem = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const storeLocationId = getStoreLocationId(req);
const { item_name, quantity, notes } = req.body; const { item_name, quantity, notes } = req.body;
if (!item_name) { if (!item_name) {
return sendError(res, 400, "Item name is required"); return sendError(res, 400, "Item name is required");
} }
// Get the list item const item = await List.getItemByName(householdId, storeLocationId, item_name);
const item = await List.getItemByName(householdId, storeId, item_name);
if (!item) { if (!item) {
return sendError(res, 404, "Item not found"); return sendError(res, 404, "Item not found");
} }
// Update item const updateResult = await List.updateItem(item.id, item_name, quantity, notes);
await List.updateItem(item.id, item_name, quantity, notes); if (!updateResult) {
return sendError(res, 404, "Item not found");
}
if (quantity !== undefined && Number(quantity) !== Number(updateResult.previous.quantity)) {
await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: item.household_store_item_id,
householdListId: item.id,
actorUserId: req.user.id,
eventType: "ITEM_QUANTITY_CHANGED",
quantityDelta: Number(quantity) - Number(updateResult.previous.quantity),
quantityAfter: Number(quantity),
metadata: {
item_name,
previous_quantity: updateResult.previous.quantity,
},
});
}
res.json({ res.json({
message: "Item updated", message: "Item updated",
@ -204,8 +275,8 @@ exports.updateItem = async (req, res) => {
id: item.id, id: item.id,
item_name, item_name,
quantity, quantity,
notes notes,
} },
}); });
} catch (error) { } catch (error) {
logError(req, "listsV2.updateItem", error); logError(req, "listsV2.updateItem", error);
@ -213,26 +284,36 @@ exports.updateItem = async (req, res) => {
} }
}; };
/**
* Delete item from list
* DELETE /households/:householdId/stores/:storeId/list/item
*/
exports.deleteItem = async (req, res) => { exports.deleteItem = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const storeLocationId = getStoreLocationId(req);
const { item_name } = req.body; const { item_name } = req.body;
if (!item_name) { if (!item_name) {
return sendError(res, 400, "Item name is required"); return sendError(res, 400, "Item name is required");
} }
// Get the list item const item = await List.getItemByName(householdId, storeLocationId, item_name);
const item = await List.getItemByName(householdId, storeId, item_name);
if (!item) { if (!item) {
return sendError(res, 404, "Item not found"); return sendError(res, 404, "Item not found");
} }
await List.deleteItem(item.id); const deleted = await List.deleteItem(item.id);
if (deleted) {
await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: item.household_store_item_id,
householdListId: item.id,
actorUserId: req.user.id,
eventType: "ITEM_DELETED",
quantityDelta: -Number(item.quantity || 0),
quantityAfter: 0,
metadata: { item_name },
});
}
res.json({ message: "Item deleted" }); res.json({ message: "Item deleted" });
} catch (error) { } catch (error) {
@ -241,16 +322,13 @@ exports.deleteItem = async (req, res) => {
} }
}; };
/**
* Get item suggestions based on query
* GET /households/:householdId/stores/:storeId/list/suggestions
*/
exports.getSuggestions = async (req, res) => { exports.getSuggestions = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const storeLocationId = getStoreLocationId(req);
const { query } = req.query; const { query } = req.query;
const suggestions = await List.getSuggestions(query || "", householdId, storeId); const suggestions = await List.getSuggestions(query || "", householdId, storeLocationId);
res.json(suggestions); res.json(suggestions);
} catch (error) { } catch (error) {
logError(req, "listsV2.getSuggestions", error); logError(req, "listsV2.getSuggestions", error);
@ -258,14 +336,11 @@ exports.getSuggestions = async (req, res) => {
} }
}; };
/**
* Get recently bought items
* GET /households/:householdId/stores/:storeId/list/recent
*/
exports.getRecentlyBought = async (req, res) => { exports.getRecentlyBought = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const items = await List.getRecentlyBoughtItems(householdId, storeId); const storeLocationId = getStoreLocationId(req);
const items = await List.getRecentlyBoughtItems(householdId, storeLocationId);
res.json(items); res.json(items);
} catch (error) { } catch (error) {
logError(req, "listsV2.getRecentlyBought", error); logError(req, "listsV2.getRecentlyBought", error);
@ -273,26 +348,26 @@ exports.getRecentlyBought = async (req, res) => {
} }
}; };
/**
* Get item classification
* GET /households/:householdId/stores/:storeId/list/classification
*/
exports.getClassification = async (req, res) => { exports.getClassification = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const storeLocationId = getStoreLocationId(req);
const { item_name } = req.query; const { item_name } = req.query;
if (!item_name) { if (!item_name) {
return sendError(res, 400, "Item name is required"); return sendError(res, 400, "Item name is required");
} }
// Get item ID from name const item = await List.getItemByName(householdId, storeLocationId, item_name);
const item = await List.getItemByName(householdId, storeId, item_name);
if (!item) { if (!item) {
return res.json({ classification: null }); return res.json({ classification: null });
} }
const classification = await List.getClassification(householdId, storeId, item.item_id); const classification = await List.getClassification(
householdId,
storeLocationId,
item.item_id
);
res.json({ classification }); res.json({ classification });
} catch (error) { } catch (error) {
logError(req, "listsV2.getClassification", error); logError(req, "listsV2.getClassification", error);
@ -300,13 +375,10 @@ exports.getClassification = async (req, res) => {
} }
}; };
/**
* Set/update item classification
* POST /households/:householdId/stores/:storeId/list/classification
*/
exports.setClassification = async (req, res) => { exports.setClassification = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const storeLocationId = getStoreLocationId(req);
const { item_name, classification } = req.body; const { item_name, classification } = req.body;
if (!item_name) { if (!item_name) {
@ -318,33 +390,17 @@ exports.setClassification = async (req, res) => {
return sendError(res, 400, "Classification is required"); return sendError(res, 400, "Classification is required");
} }
const { item_type, item_group, zone } = normalizedClassification; if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) {
return;
if (item_type && !isValidItemType(item_type)) {
return sendError(res, 400, "Invalid item_type");
} }
if (item_group && !item_type) { const item = await List.getItemByName(householdId, storeLocationId, item_name);
return sendError(res, 400, "Item type is required when item group is provided");
}
if (item_group && !isValidItemGroup(item_type, item_group)) {
return sendError(res, 400, "Invalid item_group for selected item_type");
}
if (zone && !isValidZone(zone)) {
return sendError(res, 400, "Invalid zone");
}
// Get item - add to master items if not exists
const item = await List.getItemByName(householdId, storeId, item_name);
let itemId; let itemId;
if (!item) { if (!item) {
// Item doesn't exist in list, need to get from items table or create
const itemResult = await List.ensureHouseholdStoreItem( const itemResult = await List.ensureHouseholdStoreItem(
householdId, householdId,
storeId, storeLocationId,
item_name item_name
); );
itemId = itemResult.id; itemId = itemResult.id;
@ -352,14 +408,43 @@ exports.setClassification = async (req, res) => {
itemId = item.item_id; itemId = item.item_id;
} }
await List.upsertClassification(householdId, storeId, itemId, { const updated = await List.upsertClassification(householdId, storeLocationId, itemId, {
item_type, ...normalizedClassification,
item_group,
zone,
confidence: 1.0, confidence: 1.0,
source: "user", source: "user",
}); });
await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: itemId,
householdListId: item?.id || null,
actorUserId: req.user.id,
eventType: "ITEM_CLASSIFICATION_CHANGED",
metadata: {
item_name,
item_type: normalizedClassification.item_type,
item_group: normalizedClassification.item_group,
zone: normalizedClassification.zone,
},
});
if (normalizedClassification.zone) {
await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: itemId,
householdListId: item?.id || null,
actorUserId: req.user.id,
eventType: "ITEM_ZONE_CHANGED",
metadata: {
item_name,
zone: normalizedClassification.zone,
zone_id: updated.zone_id || null,
},
});
}
res.json({ message: "Classification set", classification: normalizedClassification }); res.json({ message: "Classification set", classification: normalizedClassification });
} catch (error) { } catch (error) {
logError(req, "listsV2.setClassification", error); logError(req, "listsV2.setClassification", error);
@ -367,17 +452,13 @@ exports.setClassification = async (req, res) => {
} }
}; };
/**
* Update item image
* POST /households/:householdId/stores/:storeId/list/update-image
*/
exports.updateItemImage = async (req, res) => { exports.updateItemImage = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; const { householdId } = req.params;
const storeLocationId = getStoreLocationId(req);
const { item_name, quantity } = req.body; const { item_name, quantity } = req.body;
const userId = req.user.id; const userId = req.user.id;
// Get processed image
const imageBuffer = req.processedImage?.buffer || null; const imageBuffer = req.processedImage?.buffer || null;
const mimeType = req.processedImage?.mimeType || null; const mimeType = req.processedImage?.mimeType || null;
@ -385,8 +466,15 @@ exports.updateItemImage = async (req, res) => {
return sendError(res, 400, "No image provided"); return sendError(res, 400, "No image provided");
} }
// Update the item with new image await List.addOrUpdateItem(
await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType); householdId,
storeLocationId,
item_name,
quantity,
userId,
imageBuffer,
mimeType
);
res.json({ message: "Image updated successfully" }); res.json({ message: "Image updated successfully" });
} catch (error) { } catch (error) {

View File

@ -2,7 +2,20 @@ const storeModel = require("../models/store.model");
const { sendError } = require("../utils/http"); const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger"); const { logError } = require("../utils/logger");
// Get all available stores function parsePositiveInteger(value) {
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function getHouseholdId(req) {
return req.params.householdId || req.household?.id;
}
function getLocationId(req) {
return req.params.locationId || req.params.storeId;
}
// Legacy global store catalog. Kept for system-admin compatibility.
exports.getAllStores = async (req, res) => { exports.getAllStores = async (req, res) => {
try { try {
const stores = await storeModel.getAllStores(); const stores = await storeModel.getAllStores();
@ -13,78 +26,6 @@ exports.getAllStores = async (req, res) => {
} }
}; };
// Get stores for household
exports.getHouseholdStores = async (req, res) => {
try {
const stores = await storeModel.getHouseholdStores(req.params.householdId);
res.json(stores);
} catch (error) {
logError(req, "stores.getHouseholdStores", error);
sendError(res, 500, "Failed to fetch household stores");
}
};
// Add store to household
exports.addStoreToHousehold = async (req, res) => {
try {
const { storeId, isDefault } = req.body;
// console.log("Adding store to household:", { householdId: req.params.householdId, storeId, isDefault });
if (!storeId) {
return sendError(res, 400, "Store ID is required");
}
const store = await storeModel.getStoreById(storeId);
if (!store) return sendError(res, 404, "Store not found");
const foundStores = await storeModel.getHouseholdStores(req.params.householdId);
// if (foundStores.length == 0) isDefault = 'true';
await storeModel.addStoreToHousehold(
req.params.householdId,
storeId,
foundStores.length == 0 ? true : isDefault || false
);
res.status(201).json({
message: "Store added to household successfully",
store
});
} catch (error) {
logError(req, "stores.addStoreToHousehold", error);
sendError(res, 500, "Failed to add store to household");
}
};
// Remove store from household
exports.removeStoreFromHousehold = async (req, res) => {
try {
await storeModel.removeStoreFromHousehold(
req.params.householdId,
req.params.storeId
);
res.json({ message: "Store removed from household successfully" });
} catch (error) {
logError(req, "stores.removeStoreFromHousehold", error);
sendError(res, 500, "Failed to remove store from household");
}
};
// Set default store
exports.setDefaultStore = async (req, res) => {
try {
await storeModel.setDefaultStore(
req.params.householdId,
req.params.storeId
);
res.json({ message: "Default store updated successfully" });
} catch (error) {
logError(req, "stores.setDefaultStore", error);
sendError(res, 500, "Failed to set default store");
}
};
// Create store (system admin only)
exports.createStore = async (req, res) => { exports.createStore = async (req, res) => {
try { try {
const { name, default_zones } = req.body; const { name, default_zones } = req.body;
@ -97,25 +38,24 @@ exports.createStore = async (req, res) => {
res.status(201).json({ res.status(201).json({
message: "Store created successfully", message: "Store created successfully",
store store,
}); });
} catch (error) { } catch (error) {
logError(req, "stores.createStore", error); logError(req, "stores.createStore", error);
if (error.code === '23505') { // Unique violation if (error.code === "23505") {
return sendError(res, 400, "Store with this name already exists"); return sendError(res, 400, "Store with this name already exists");
} }
sendError(res, 500, "Failed to create store"); sendError(res, 500, "Failed to create store");
} }
}; };
// Update store (system admin only)
exports.updateStore = async (req, res) => { exports.updateStore = async (req, res) => {
try { try {
const { name, default_zones } = req.body; const { name, default_zones } = req.body;
const store = await storeModel.updateStore(req.params.storeId, { const store = await storeModel.updateStore(req.params.storeId, {
name: name?.trim(), name: name?.trim(),
default_zones default_zones,
}); });
if (!store) { if (!store) {
@ -124,7 +64,7 @@ exports.updateStore = async (req, res) => {
res.json({ res.json({
message: "Store updated successfully", message: "Store updated successfully",
store store,
}); });
} catch (error) { } catch (error) {
logError(req, "stores.updateStore", error); logError(req, "stores.updateStore", error);
@ -132,16 +72,341 @@ exports.updateStore = async (req, res) => {
} }
}; };
// Delete store (system admin only)
exports.deleteStore = async (req, res) => { exports.deleteStore = async (req, res) => {
try { try {
await storeModel.deleteStore(req.params.storeId); await storeModel.deleteStore(req.params.storeId);
res.json({ message: "Store deleted successfully" }); res.json({ message: "Store deleted successfully" });
} catch (error) { } catch (error) {
logError(req, "stores.deleteStore", error); logError(req, "stores.deleteStore", error);
if (error.message.includes('in use')) { if (error.message.includes("in use")) {
return sendError(res, 400, error.message); return sendError(res, 400, error.message);
} }
sendError(res, 500, "Failed to delete store"); sendError(res, 500, "Failed to delete store");
} }
}; };
// Household-owned store/location management.
exports.getHouseholdStores = async (req, res) => {
try {
const stores = await storeModel.getHouseholdStores(getHouseholdId(req));
res.json(stores);
} catch (error) {
logError(req, "stores.getHouseholdStores", error);
sendError(res, 500, "Failed to fetch household stores");
}
};
exports.createHouseholdStore = async (req, res) => {
try {
const householdId = getHouseholdId(req);
const { name, location_name, address } = req.body;
if (!name || name.trim().length === 0) {
return sendError(res, 400, "Store name is required");
}
const store = await storeModel.createHouseholdStore(
householdId,
name,
location_name || "Default Location",
address || null,
req.user.id
);
res.status(201).json({
message: "Store location created successfully",
store,
});
} catch (error) {
logError(req, "stores.createHouseholdStore", error);
if (error.code === "23505") {
return sendError(res, 400, "Store or location already exists for this household");
}
sendError(res, 500, "Failed to create store location");
}
};
exports.updateHouseholdStore = async (req, res) => {
try {
const { name } = req.body;
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
if (!householdStoreId) {
return sendError(res, 400, "Store ID must be a positive integer");
}
if (!name || name.trim().length === 0) {
return sendError(res, 400, "Store name is required");
}
const store = await storeModel.updateHouseholdStore(getHouseholdId(req), householdStoreId, {
name,
});
if (!store) {
return sendError(res, 404, "Store not found");
}
res.json({ message: "Store updated successfully", store });
} catch (error) {
logError(req, "stores.updateHouseholdStore", error);
sendError(res, 500, "Failed to update store");
}
};
exports.deleteHouseholdStore = async (req, res) => {
try {
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
if (!householdStoreId) {
return sendError(res, 400, "Store ID must be a positive integer");
}
const deleted = await storeModel.deleteHouseholdStore(getHouseholdId(req), householdStoreId);
if (!deleted) {
return sendError(res, 404, "Store not found");
}
res.json({ message: "Store deleted successfully" });
} catch (error) {
logError(req, "stores.deleteHouseholdStore", error);
if (error.message.includes("last store location")) {
return sendError(res, 400, error.message);
}
sendError(res, 500, "Failed to delete store");
}
};
exports.addLocationToStore = async (req, res) => {
try {
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
const { name, address } = req.body;
if (!householdStoreId) {
return sendError(res, 400, "Store ID must be a positive integer");
}
if (!name || name.trim().length === 0) {
return sendError(res, 400, "Location name is required");
}
const location = await storeModel.addLocationToStore(
getHouseholdId(req),
householdStoreId,
name,
address || null,
req.user.id
);
if (!location) {
return sendError(res, 404, "Store not found");
}
res.status(201).json({
message: "Location added successfully",
store: location,
});
} catch (error) {
logError(req, "stores.addLocationToStore", error);
if (error.code === "23505") {
return sendError(res, 400, "Location already exists for this store");
}
sendError(res, 500, "Failed to add location");
}
};
exports.updateLocation = async (req, res) => {
try {
const locationId = parsePositiveInteger(getLocationId(req));
const { name, address, map_data } = req.body;
if (!locationId) {
return sendError(res, 400, "Location ID must be a positive integer");
}
const location = await storeModel.updateLocation(getHouseholdId(req), locationId, {
name,
address,
map_data,
});
if (!location) {
return sendError(res, 404, "Location not found");
}
res.json({ message: "Location updated successfully", store: location });
} catch (error) {
logError(req, "stores.updateLocation", error);
sendError(res, 500, "Failed to update location");
}
};
exports.deleteLocation = async (req, res) => {
try {
const locationId = parsePositiveInteger(getLocationId(req));
if (!locationId) {
return sendError(res, 400, "Location ID must be a positive integer");
}
const deleted = await storeModel.deleteLocation(getHouseholdId(req), locationId);
if (!deleted) {
return sendError(res, 404, "Location not found");
}
res.json({ message: "Location removed successfully" });
} catch (error) {
logError(req, "stores.deleteLocation", error);
if (error.message.includes("last store location")) {
return sendError(res, 400, error.message);
}
sendError(res, 500, "Failed to remove location");
}
};
exports.setDefaultLocation = async (req, res) => {
try {
const locationId = parsePositiveInteger(getLocationId(req));
if (!locationId) {
return sendError(res, 400, "Location ID must be a positive integer");
}
await storeModel.setDefaultLocation(getHouseholdId(req), locationId);
res.json({ message: "Default location updated successfully" });
} catch (error) {
logError(req, "stores.setDefaultLocation", error);
sendError(res, 500, "Failed to set default location");
}
};
exports.getLocationZones = async (req, res) => {
try {
const locationId = parsePositiveInteger(getLocationId(req));
if (!locationId) {
return sendError(res, 400, "Location ID must be a positive integer");
}
const zones = await storeModel.listLocationZones(getHouseholdId(req), locationId);
res.json({ zones });
} catch (error) {
logError(req, "stores.getLocationZones", error);
sendError(res, 500, "Failed to load zones");
}
};
exports.createZone = async (req, res) => {
try {
const locationId = parsePositiveInteger(getLocationId(req));
const { name, sort_order, color, map_metadata } = req.body;
if (!locationId) {
return sendError(res, 400, "Location ID must be a positive integer");
}
if (!name || name.trim().length === 0) {
return sendError(res, 400, "Zone name is required");
}
const zone = await storeModel.createZone(getHouseholdId(req), locationId, {
name,
sort_order: Number.isInteger(sort_order) ? sort_order : Number.parseInt(sort_order, 10),
color,
map_metadata,
});
res.status(201).json({ message: "Zone created successfully", zone });
} catch (error) {
logError(req, "stores.createZone", error);
if (error.code === "23505") {
return sendError(res, 400, "Zone already exists for this location");
}
sendError(res, 500, "Failed to create zone");
}
};
exports.updateZone = async (req, res) => {
try {
const locationId = parsePositiveInteger(getLocationId(req));
const zoneId = parsePositiveInteger(req.params.zoneId);
if (!locationId || !zoneId) {
return sendError(res, 400, "Location ID and zone ID must be positive integers");
}
const sortOrder = req.body.sort_order;
const zone = await storeModel.updateZone(getHouseholdId(req), locationId, zoneId, {
name: req.body.name,
sort_order:
sortOrder === undefined
? undefined
: Number.isInteger(sortOrder)
? sortOrder
: Number.parseInt(sortOrder, 10),
color: req.body.color,
map_metadata: req.body.map_metadata,
is_active: req.body.is_active,
});
if (!zone) {
return sendError(res, 404, "Zone not found");
}
res.json({ message: "Zone updated successfully", zone });
} catch (error) {
logError(req, "stores.updateZone", error);
sendError(res, 500, "Failed to update zone");
}
};
exports.deleteZone = async (req, res) => {
try {
const locationId = parsePositiveInteger(getLocationId(req));
const zoneId = parsePositiveInteger(req.params.zoneId);
if (!locationId || !zoneId) {
return sendError(res, 400, "Location ID and zone ID must be positive integers");
}
const deleted = await storeModel.deleteZone(getHouseholdId(req), locationId, zoneId);
if (!deleted) {
return sendError(res, 404, "Zone not found");
}
res.json({ message: "Zone removed successfully" });
} catch (error) {
logError(req, "stores.deleteZone", error);
sendError(res, 500, "Failed to remove zone");
}
};
// Backward-compatible handlers for the old /stores/household routes.
exports.addStoreToHousehold = async (req, res) => {
try {
const { storeId } = req.body;
if (!storeId) {
return sendError(res, 400, "Store ID is required");
}
const legacyStore = await storeModel.getStoreById(storeId);
if (!legacyStore) {
return sendError(res, 404, "Store not found");
}
const store = await storeModel.createHouseholdStore(
getHouseholdId(req),
legacyStore.name,
"Default Location",
null,
req.user.id
);
res.status(201).json({
message: "Store added to household successfully",
store,
});
} catch (error) {
logError(req, "stores.addStoreToHousehold", error);
sendError(res, 500, "Failed to add store to household");
}
};
exports.removeStoreFromHousehold = exports.deleteLocation;
exports.setDefaultStore = exports.setDefaultLocation;

View File

@ -8,27 +8,30 @@ const { logError } = require("../utils/logger");
async function auth(req, res, next) { async function auth(req, res, next) {
const header = req.headers.authorization || ""; const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null; const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null;
const cookies = parseCookieHeader(req.headers.cookie);
const sid = cookies[cookieName()];
if (token) { if (token) {
const jwtSecret = process.env.JWT_SECRET; const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) { if (!jwtSecret && !sid) {
logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured")); logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured"));
return sendError(res, 500, "Authentication is unavailable"); return sendError(res, 500, "Authentication is unavailable");
} }
try { if (jwtSecret) {
const decoded = jwt.verify(token, jwtSecret); try {
req.user = decoded; // id + role const decoded = jwt.verify(token, jwtSecret);
return next(); req.user = decoded; // id + role
} catch (err) { return next();
return sendError(res, 401, "Invalid or expired token"); } catch (err) {
if (!sid) {
return sendError(res, 401, "Invalid or expired token");
}
}
} }
} }
try { try {
const cookies = parseCookieHeader(req.headers.cookie);
const sid = cookies[cookieName()];
if (!sid) { if (!sid) {
return sendError(res, 401, "Missing authentication"); return sendError(res, 401, "Missing authentication");
} }

View File

@ -90,6 +90,43 @@ exports.storeAccess = async (req, res, next) => {
} }
}; };
// Middleware to check location access (household must own the store location)
exports.locationAccess = async (req, res, next) => {
try {
const locationId = parseInt(req.params.locationId || req.params.storeId);
if (!locationId) {
return sendError(res, 400, "Location ID required");
}
if (!req.household) {
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
}
const storeModel = require("../models/store.model");
const hasLocation = await storeModel.householdHasLocation(req.household.id, locationId);
if (!hasLocation) {
return sendError(res, 403, "This household does not have access to this store location.");
}
req.storeLocation = {
id: locationId
};
// Keep req.store populated so older controller code and tests can continue
// to refer to the active shopping scope as a store.
req.store = {
id: locationId
};
next();
} catch (error) {
logError(req, "middleware.locationAccess", error);
sendError(res, 500, "Server error checking location access");
}
};
// Middleware to require system admin role // Middleware to require system admin role
exports.requireSystemAdmin = (req, res, next) => { exports.requireSystemAdmin = (req, res, next) => {
if (!req.user) { if (!req.user) {

View File

@ -10,16 +10,14 @@ async function optionalAuth(req, res, next) {
if (token) { if (token) {
const jwtSecret = process.env.JWT_SECRET; const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) { if (jwtSecret) {
return next(); try {
} const decoded = jwt.verify(token, jwtSecret);
req.user = decoded;
try { return next();
const decoded = jwt.verify(token, jwtSecret); } catch (err) {
req.user = decoded; // Continue to the session cookie fallback below.
return next(); }
} catch (err) {
return next();
} }
} }

View File

@ -1,81 +1,97 @@
const pool = require("../db/pool"); const pool = require("../db/pool");
const List = require("./list.model.v2");
function normalizeItemName(itemName) { function normalizeItemName(itemName) {
return String(itemName || "").trim().toLowerCase(); return String(itemName || "").trim().toLowerCase();
} }
async function getHouseholdStoreItemRecord(householdId, storeId, itemId) { async function getHouseholdStoreItemRecord(householdId, storeLocationId, itemId) {
const result = await pool.query( const result = await pool.query(
`WITH latest_list_items AS ( `WITH latest_list_items AS (
SELECT DISTINCT ON (hl.household_store_item_id) SELECT DISTINCT ON (hl.household_store_item_id)
hl.household_store_item_id, hl.household_store_item_id,
hl.image_id,
hl.custom_image, hl.custom_image,
hl.custom_image_mime_type, hl.custom_image_mime_type,
hl.modified_on, hl.modified_on,
hl.id hl.id
FROM household_lists hl FROM household_lists hl
WHERE hl.household_id = $1 WHERE hl.household_id = $1
AND hl.store_id = $2 AND hl.store_location_id = $2
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
) )
SELECT SELECT
hsi.id AS item_id, hsi.id AS item_id,
hsi.name AS item_name, hsi.name AS item_name,
ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image, ENCODE(
COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type, COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image),
'base64'
) AS item_image,
COALESCE(
catalog_img.mime_type,
hsi.custom_image_mime_type,
list_img.mime_type,
lli.custom_image_mime_type
) AS image_mime_type,
hic.item_type, hic.item_type,
hic.item_group, hic.item_group,
hic.zone COALESCE(slz.name, hic.zone) AS zone,
slz.sort_order AS zone_sort_order
FROM household_store_items hsi FROM household_store_items hsi
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id
LEFT JOIN household_item_classifications hic LEFT JOIN household_item_classifications hic
ON hic.household_id = hsi.household_id ON hic.household_id = hsi.household_id
AND hic.store_id = hsi.store_id AND hic.store_location_id = hsi.store_location_id
AND hic.household_store_item_id = hsi.id AND hic.household_store_item_id = hsi.id
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
WHERE hsi.household_id = $1 WHERE hsi.household_id = $1
AND hsi.store_id = $2 AND hsi.store_location_id = $2
AND hsi.id = $3`, AND hsi.id = $3`,
[householdId, storeId, itemId] [householdId, storeLocationId, itemId]
); );
return result.rows[0] || null; return result.rows[0] || null;
} }
async function findOrCreateHouseholdStoreItem(householdId, storeId, itemName) { async function findOrCreateHouseholdStoreItem(householdId, storeLocationId, itemName) {
const normalizedName = normalizeItemName(itemName); const normalizedName = normalizeItemName(itemName);
const existing = await pool.query( const existing = await pool.query(
`SELECT id, name `SELECT id, name
FROM household_store_items FROM household_store_items
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_location_id = $2
AND normalized_name = $3`, AND normalized_name = $3`,
[householdId, storeId, normalizedName] [householdId, storeLocationId, normalizedName]
); );
if (existing.rowCount > 0) { if (existing.rowCount > 0) {
return { return {
itemId: existing.rows[0].id, itemId: existing.rows[0].id,
itemName: existing.rows[0].name, itemName: existing.rows[0].name,
isNew: false,
}; };
} }
const created = await pool.query( const created = await pool.query(
`INSERT INTO household_store_items `INSERT INTO household_store_items
(household_id, store_id, name, normalized_name, updated_at) (household_id, store_location_id, name, normalized_name, updated_at)
VALUES ($1, $2, $3, $4, NOW()) VALUES ($1, $2, $3, $4, NOW())
RETURNING id, name`, RETURNING id, name`,
[householdId, storeId, normalizedName, normalizedName] [householdId, storeLocationId, normalizedName, normalizedName]
); );
return { return {
itemId: created.rows[0].id, itemId: created.rows[0].id,
itemName: created.rows[0].name, itemName: created.rows[0].name,
isNew: true,
}; };
} }
exports.listAvailableItems = async (householdId, storeId, query = "") => { exports.listAvailableItems = async (householdId, storeLocationId, query = "") => {
const trimmedQuery = String(query || "").trim(); const trimmedQuery = String(query || "").trim();
const values = [householdId, storeId]; const values = [householdId, storeLocationId];
let filterClause = ""; let filterClause = "";
if (trimmedQuery) { if (trimmedQuery) {
@ -87,35 +103,49 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => {
`WITH latest_list_items AS ( `WITH latest_list_items AS (
SELECT DISTINCT ON (hl.household_store_item_id) SELECT DISTINCT ON (hl.household_store_item_id)
hl.household_store_item_id, hl.household_store_item_id,
hl.image_id,
hl.custom_image, hl.custom_image,
hl.custom_image_mime_type, hl.custom_image_mime_type,
hl.modified_on, hl.modified_on,
hl.id hl.id
FROM household_lists hl FROM household_lists hl
WHERE hl.household_id = $1 WHERE hl.household_id = $1
AND hl.store_id = $2 AND hl.store_location_id = $2
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
) )
SELECT SELECT
hsi.id AS item_id, hsi.id AS item_id,
hsi.name AS item_name, hsi.name AS item_name,
ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image, ENCODE(
COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type, COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image),
'base64'
) AS item_image,
COALESCE(
catalog_img.mime_type,
hsi.custom_image_mime_type,
list_img.mime_type,
lli.custom_image_mime_type
) AS image_mime_type,
hic.item_type, hic.item_type,
hic.item_group, hic.item_group,
hic.zone, COALESCE(slz.name, hic.zone) AS zone,
slz.sort_order AS zone_sort_order,
( (
hsi.custom_image IS NOT NULL hsi.image_id IS NOT NULL
OR hsi.custom_image IS NOT NULL
OR hic.household_store_item_id IS NOT NULL OR hic.household_store_item_id IS NOT NULL
) AS has_managed_settings ) AS has_managed_settings
FROM household_store_items hsi FROM household_store_items hsi
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id
LEFT JOIN household_item_classifications hic LEFT JOIN household_item_classifications hic
ON hic.household_id = hsi.household_id ON hic.household_id = hsi.household_id
AND hic.store_id = hsi.store_id AND hic.store_location_id = hsi.store_location_id
AND hic.household_store_item_id = hsi.id AND hic.household_store_item_id = hsi.id
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
WHERE hsi.household_id = $1 WHERE hsi.household_id = $1
AND hsi.store_id = $2 AND hsi.store_location_id = $2
${filterClause} ${filterClause}
ORDER BY hsi.name ASC ORDER BY hsi.name ASC
LIMIT 100`, LIMIT 100`,
@ -125,22 +155,23 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => {
return result.rows; return result.rows;
}; };
exports.getAvailableItemById = async (householdId, storeId, itemId) => exports.getAvailableItemById = async (householdId, storeLocationId, itemId) =>
getHouseholdStoreItemRecord(householdId, storeId, itemId); getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => { exports.getAvailableItemImageByName = async (householdId, storeLocationId, itemName) => {
const normalizedName = normalizeItemName(itemName); const normalizedName = normalizeItemName(itemName);
const result = await pool.query( const result = await pool.query(
`SELECT `SELECT
id AS item_id, hsi.id AS item_id,
name AS item_name, hsi.name AS item_name,
custom_image, COALESCE(img.image, hsi.custom_image) AS custom_image,
custom_image_mime_type COALESCE(img.mime_type, hsi.custom_image_mime_type) AS custom_image_mime_type
FROM household_store_items FROM household_store_items hsi
WHERE household_id = $1 LEFT JOIN household_item_images img ON img.id = hsi.image_id
AND store_id = $2 WHERE hsi.household_id = $1
AND normalized_name = $3`, AND hsi.store_location_id = $2
[householdId, storeId, normalizedName] AND hsi.normalized_name = $3`,
[householdId, storeLocationId, normalizedName]
); );
return result.rows[0] || null; return result.rows[0] || null;
@ -148,39 +179,54 @@ exports.getAvailableItemImageByName = async (householdId, storeId, itemName) =>
exports.createAvailableItem = async ( exports.createAvailableItem = async (
householdId, householdId,
storeId, storeLocationId,
itemName, itemName,
imageBuffer = null, imageBuffer = null,
mimeType = null mimeType = null,
userId = null
) => { ) => {
const { itemId } = await findOrCreateHouseholdStoreItem(householdId, storeId, itemName); const { itemId, isNew } = await findOrCreateHouseholdStoreItem(
householdId,
storeLocationId,
itemName
);
if (imageBuffer && mimeType) { if (imageBuffer && mimeType) {
await pool.query( await List.setCatalogItemImage(
`UPDATE household_store_items householdId,
SET custom_image = $1, storeLocationId,
custom_image_mime_type = $2, itemId,
updated_at = NOW() imageBuffer,
WHERE id = $3 mimeType,
AND household_id = $4 userId
AND store_id = $5`,
[imageBuffer, mimeType, itemId, householdId, storeId]
); );
} }
return getHouseholdStoreItemRecord(householdId, storeId, itemId); if (isNew) {
await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: itemId,
actorUserId: userId,
eventType: "ITEM_ADDED",
metadata: { source: "catalog" },
});
}
return getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
}; };
exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => { exports.updateAvailableItem = async (householdId, storeLocationId, itemId, updates = {}) => {
const { const {
itemName, itemName,
imageBuffer, imageBuffer,
mimeType, mimeType,
removeImage = false, removeImage = false,
userId = null,
} = updates; } = updates;
const assignments = ["updated_at = NOW()"]; const assignments = ["updated_at = NOW()"];
const values = [householdId, storeId, itemId]; const values = [householdId, storeLocationId, itemId];
let parameterIndex = values.length; let parameterIndex = values.length;
if (itemName !== undefined && String(itemName).trim() !== "") { if (itemName !== undefined && String(itemName).trim() !== "") {
@ -195,22 +241,14 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {})
} }
if (removeImage) { if (removeImage) {
assignments.push("custom_image = NULL", "custom_image_mime_type = NULL"); assignments.push("image_id = NULL", "custom_image = NULL", "custom_image_mime_type = NULL");
} else if (imageBuffer && mimeType) {
parameterIndex += 1;
assignments.push(`custom_image = $${parameterIndex}`);
values.push(imageBuffer);
parameterIndex += 1;
assignments.push(`custom_image_mime_type = $${parameterIndex}`);
values.push(mimeType);
} }
const result = await pool.query( const result = await pool.query(
`UPDATE household_store_items `UPDATE household_store_items
SET ${assignments.join(", ")} SET ${assignments.join(", ")}
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_location_id = $2
AND id = $3 AND id = $3
RETURNING id`, RETURNING id`,
values values
@ -220,53 +258,75 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {})
return null; return null;
} }
return getHouseholdStoreItemRecord(householdId, storeId, result.rows[0].id); if (!removeImage && imageBuffer && mimeType) {
await List.setCatalogItemImage(
householdId,
storeLocationId,
result.rows[0].id,
imageBuffer,
mimeType,
userId
);
}
return getHouseholdStoreItemRecord(householdId, storeLocationId, result.rows[0].id);
}; };
exports.deleteAvailableItem = async (householdId, storeId, itemId) => { exports.deleteAvailableItem = async (householdId, storeLocationId, itemId, userId = null) => {
const item = await getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
const result = await pool.query( const result = await pool.query(
`DELETE FROM household_store_items `DELETE FROM household_store_items
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_location_id = $2
AND id = $3`, AND id = $3`,
[householdId, storeId, itemId] [householdId, storeLocationId, itemId]
); );
if (result.rowCount > 0) {
await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: itemId,
actorUserId: userId,
eventType: "ITEM_DELETED",
metadata: { item_name: item?.item_name || null },
});
}
return result.rowCount > 0; return result.rowCount > 0;
}; };
exports.importCurrentListItems = async (householdId, storeId) => { exports.importCurrentListItems = async (householdId, storeLocationId) => {
const result = await pool.query( const result = await pool.query(
`INSERT INTO household_store_items `INSERT INTO household_store_items
(household_id, store_id, name, normalized_name, custom_image, custom_image_mime_type, updated_at) (household_id, store_location_id, name, normalized_name, image_id, updated_at)
SELECT DISTINCT ON (hl.household_store_item_id) SELECT DISTINCT ON (hl.household_store_item_id)
hl.household_id, hl.household_id,
hl.store_id, hl.store_location_id,
hsi.name, hsi.name,
hsi.normalized_name, hsi.normalized_name,
hsi.custom_image, hsi.image_id,
hsi.custom_image_mime_type,
NOW() NOW()
FROM household_lists hl FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
WHERE hl.household_id = $1 WHERE hl.household_id = $1
AND hl.store_id = $2 AND hl.store_location_id = $2
ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING ON CONFLICT (household_id, store_location_id, normalized_name) DO NOTHING
RETURNING id`, RETURNING id`,
[householdId, storeId] [householdId, storeLocationId]
); );
return result.rowCount; return result.rowCount;
}; };
exports.hasAvailableItems = async (householdId, storeId) => { exports.hasAvailableItems = async (householdId, storeLocationId) => {
const result = await pool.query( const result = await pool.query(
`SELECT 1 `SELECT 1
FROM household_store_items FROM household_store_items
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_location_id = $2
LIMIT 1`, LIMIT 1`,
[householdId, storeId] [householdId, storeLocationId]
); );
return result.rowCount > 0; return result.rowCount > 0;

View File

@ -1,8 +1,7 @@
const pool = require("../db/pool"); const pool = require("../db/pool");
// Get all households a user belongs to async function queryUserHouseholds(db, userId) {
exports.getUserHouseholds = async (userId) => { const result = await db.query(
const result = await pool.query(
`SELECT `SELECT
h.id, h.id,
h.name, h.name,
@ -10,14 +9,65 @@ exports.getUserHouseholds = async (userId) => {
h.created_at, h.created_at,
hm.role, hm.role,
hm.joined_at, hm.joined_at,
hm.household_sort_order,
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count (SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
FROM households h FROM households h
JOIN household_members hm ON h.id = hm.household_id JOIN household_members hm ON h.id = hm.household_id
WHERE hm.user_id = $1 WHERE hm.user_id = $1
ORDER BY hm.joined_at DESC`, ORDER BY hm.household_sort_order ASC NULLS LAST, hm.joined_at DESC`,
[userId] [userId]
); );
return result.rows; return result.rows;
}
// Get all households a user belongs to
exports.getUserHouseholds = async (userId) => {
return queryUserHouseholds(pool, userId);
};
exports.reorderUserHouseholds = async (userId, householdIds) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const membershipResult = await client.query(
`SELECT household_id
FROM household_members
WHERE user_id = $1`,
[userId]
);
const currentIds = membershipResult.rows.map((row) => Number(row.household_id));
const currentIdSet = new Set(currentIds);
if (
householdIds.length !== currentIds.length ||
householdIds.some((householdId) => !currentIdSet.has(householdId))
) {
await client.query("ROLLBACK");
return null;
}
for (const [index, householdId] of householdIds.entries()) {
await client.query(
`UPDATE household_members
SET household_sort_order = $1
WHERE user_id = $2
AND household_id = $3`,
[index, userId, householdId]
);
}
const households = await queryUserHouseholds(client, userId);
await client.query("COMMIT");
return households;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}; };
// Get household by ID (with member check) // Get household by ID (with member check)
@ -169,18 +219,6 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse
try { try {
await client.query("BEGIN"); await client.query("BEGIN");
const promoteResult = await client.query(
`UPDATE household_members
SET role = 'owner'
WHERE household_id = $1 AND user_id = $2
RETURNING user_id, role`,
[householdId, nextOwnerUserId]
);
if (promoteResult.rows.length === 0) {
throw new Error("TARGET_MEMBER_NOT_FOUND");
}
const demoteResult = await client.query( const demoteResult = await client.query(
`UPDATE household_members `UPDATE household_members
SET role = 'admin' SET role = 'admin'
@ -193,6 +231,18 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse
throw new Error("CURRENT_OWNER_NOT_FOUND"); throw new Error("CURRENT_OWNER_NOT_FOUND");
} }
const promoteResult = await client.query(
`UPDATE household_members
SET role = 'owner'
WHERE household_id = $1 AND user_id = $2
RETURNING user_id, role`,
[householdId, nextOwnerUserId]
);
if (promoteResult.rows.length === 0) {
throw new Error("TARGET_MEMBER_NOT_FOUND");
}
await client.query("COMMIT"); await client.query("COMMIT");
return promoteResult.rows[0]; return promoteResult.rows[0];
} catch (error) { } catch (error) {

View File

@ -4,22 +4,104 @@ function normalizeItemName(itemName) {
return String(itemName || "").trim().toLowerCase(); return String(itemName || "").trim().toLowerCase();
} }
async function getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName) { function toPositiveInteger(value, fallback = 1) {
const numberValue = Number(value);
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : fallback;
}
const ACTIVE_ADDED_BY_USERS_SQL = `
(
SELECT ARRAY_AGG(
active_added_by_users.user_label
ORDER BY active_added_by_users.last_added_on DESC, active_added_by_users.user_label
)
FROM (
SELECT
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label,
MAX(active_history.added_on) AS last_added_on
FROM (
SELECT
hlh.*,
COALESCE(
SUM(hlh.quantity) OVER (
PARTITION BY hlh.household_list_id
ORDER BY hlh.added_on DESC, hlh.id DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
0
) AS newer_quantity
FROM household_list_history hlh
WHERE hlh.household_list_id = hl.id
) active_history
JOIN users u ON active_history.added_by = u.id
WHERE active_history.newer_quantity < GREATEST(hl.quantity, 0)
GROUP BY user_label
) active_added_by_users
) AS added_by_users`;
async function getHouseholdStoreItemByNormalizedName(householdId, storeLocationId, normalizedName) {
const result = await pool.query( const result = await pool.query(
`SELECT id, name, normalized_name, custom_image, custom_image_mime_type `SELECT id, name, normalized_name, image_id
FROM household_store_items FROM household_store_items
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_location_id = $2
AND normalized_name = $3`, AND normalized_name = $3`,
[householdId, storeId, normalizedName] [householdId, storeLocationId, normalizedName]
); );
return result.rows[0] || null; return result.rows[0] || null;
} }
exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => { async function createItemImage({
householdId,
storeLocationId,
householdStoreItemId,
householdListId = null,
imageScope,
imageBuffer,
mimeType,
userId = null,
}) {
if (!imageBuffer || !mimeType) {
return null;
}
const result = await pool.query(
`INSERT INTO household_item_images
(
household_id,
store_location_id,
household_store_item_id,
household_list_id,
image_scope,
image,
mime_type,
created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
[
householdId,
storeLocationId,
householdStoreItemId,
householdListId,
imageScope,
imageBuffer,
mimeType,
userId,
]
);
return result.rows[0].id;
}
exports.ensureHouseholdStoreItem = async (householdId, storeLocationId, itemName) => {
const normalizedName = normalizeItemName(itemName); const normalizedName = normalizeItemName(itemName);
let item = await getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName); let item = await getHouseholdStoreItemByNormalizedName(
householdId,
storeLocationId,
normalizedName
);
if (item) { if (item) {
return item; return item;
@ -27,23 +109,16 @@ exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => {
const result = await pool.query( const result = await pool.query(
`INSERT INTO household_store_items `INSERT INTO household_store_items
(household_id, store_id, name, normalized_name, updated_at) (household_id, store_location_id, name, normalized_name, updated_at)
VALUES ($1, $2, $3, $4, NOW()) VALUES ($1, $2, $3, $4, NOW())
RETURNING id, name, normalized_name, custom_image, custom_image_mime_type`, RETURNING id, name, normalized_name, image_id`,
[householdId, storeId, normalizedName, normalizedName] [householdId, storeLocationId, normalizedName, normalizedName]
); );
return result.rows[0]; return result.rows[0];
}; };
/** exports.getHouseholdStoreList = async (householdId, storeLocationId, includeHistory = true) => {
* 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( const result = await pool.query(
`SELECT `SELECT
hl.id, hl.id,
@ -52,130 +127,141 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
hsi.name AS item_name, hsi.name AS item_name,
hl.quantity, hl.quantity,
hl.bought, hl.bought,
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, hl.notes,
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
${includeHistory ? ` COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
( ${includeHistory ? `${ACTIVE_ADDED_BY_USERS_SQL},` : "NULL AS added_by_users,"}
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
FROM (
SELECT DISTINCT
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
FROM household_list_history hlh
JOIN users u ON hlh.added_by = u.id
WHERE hlh.household_list_id = hl.id
) added_by_labels
) AS added_by_users,
` : "NULL AS added_by_users,"}
hl.modified_on AS last_added_on, hl.modified_on AS last_added_on,
hic.item_type, hic.item_type,
hic.item_group, hic.item_group,
hic.zone COALESCE(slz.name, hic.zone) AS zone,
slz.sort_order AS zone_sort_order
FROM household_lists hl FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
LEFT JOIN household_item_classifications hic LEFT JOIN household_item_classifications hic
ON hic.household_id = hl.household_id ON hic.household_id = hl.household_id
AND hic.store_id = hl.store_id AND hic.store_location_id = hl.store_location_id
AND hic.household_store_item_id = hl.household_store_item_id AND hic.household_store_item_id = hl.household_store_item_id
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
WHERE hl.household_id = $1 WHERE hl.household_id = $1
AND hl.store_id = $2 AND hl.store_location_id = $2
AND hl.bought = FALSE AND hl.bought = FALSE
ORDER BY hl.id ASC`, ORDER BY slz.sort_order ASC NULLS LAST, hsi.name ASC`,
[householdId, storeId] [householdId, storeLocationId]
); );
return result.rows; return result.rows;
}; };
/** exports.getItemByName = async (householdId, storeLocationId, itemName) => {
* Get a specific item from household list by name
* @param {number} householdId - Household ID
* @param {number} storeId - Store ID
* @param {string} itemName - Item name to search for
* @returns {Promise<Object|null>} Item or null
*/
exports.getItemByName = async (householdId, storeId, itemName) => {
const normalizedName = normalizeItemName(itemName); const normalizedName = normalizeItemName(itemName);
const result = await pool.query( const result = await pool.query(
`SELECT `SELECT
hl.id, hl.id,
hl.household_id,
hl.store_location_id,
hl.household_store_item_id AS item_id, hl.household_store_item_id AS item_id,
hl.household_store_item_id, hl.household_store_item_id,
hsi.name AS item_name, hsi.name AS item_name,
hl.quantity, hl.quantity,
hl.bought, hl.bought,
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, hl.notes,
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
( COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) ${ACTIVE_ADDED_BY_USERS_SQL},
FROM (
SELECT DISTINCT
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
FROM household_list_history hlh
JOIN users u ON hlh.added_by = u.id
WHERE hlh.household_list_id = hl.id
) added_by_labels
) AS added_by_users,
hl.modified_on AS last_added_on, hl.modified_on AS last_added_on,
hic.item_type, hic.item_type,
hic.item_group, hic.item_group,
hic.zone COALESCE(slz.name, hic.zone) AS zone,
slz.sort_order AS zone_sort_order
FROM household_lists hl FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
LEFT JOIN household_item_classifications hic LEFT JOIN household_item_classifications hic
ON hic.household_id = hl.household_id ON hic.household_id = hl.household_id
AND hic.store_id = hl.store_id AND hic.store_location_id = hl.store_location_id
AND hic.household_store_item_id = hl.household_store_item_id AND hic.household_store_item_id = hl.household_store_item_id
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
WHERE hl.household_id = $1 WHERE hl.household_id = $1
AND hl.store_id = $2 AND hl.store_location_id = $2
AND hsi.normalized_name = $3`, AND hsi.normalized_name = $3`,
[householdId, storeId, normalizedName] [householdId, storeLocationId, normalizedName]
); );
return result.rows[0] || null; return result.rows[0] || null;
}; };
/**
* Add or update an item in household list
* @returns {Promise<{listId:number,itemId:number,householdStoreItemId:number,itemName:string,isNew:boolean}>}
*/
exports.addOrUpdateItem = async ( exports.addOrUpdateItem = async (
householdId, householdId,
storeId, storeLocationId,
itemName, itemName,
quantity, quantity,
userId, userId,
imageBuffer = null, imageBuffer = null,
mimeType = null mimeType = null,
notes = undefined
) => { ) => {
const householdStoreItem = await exports.ensureHouseholdStoreItem(householdId, storeId, itemName); const nextQuantity = toPositiveInteger(quantity);
const householdStoreItem = await exports.ensureHouseholdStoreItem(
householdId,
storeLocationId,
itemName
);
const listResult = await pool.query( const listResult = await pool.query(
`SELECT id, bought `SELECT id, bought, quantity
FROM household_lists FROM household_lists
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_location_id = $2
AND household_store_item_id = $3`, AND household_store_item_id = $3`,
[householdId, storeId, householdStoreItem.id] [householdId, storeLocationId, householdStoreItem.id]
); );
if (listResult.rowCount > 0) { if (listResult.rowCount > 0) {
const listId = listResult.rows[0].id; const listId = listResult.rows[0].id;
const previousQuantity = toPositiveInteger(listResult.rows[0].quantity, 0);
const wasBought = Boolean(listResult.rows[0].bought);
const historyQuantity =
!wasBought && nextQuantity > previousQuantity
? nextQuantity - previousQuantity
: nextQuantity;
let imageId = null;
if (imageBuffer && mimeType) { if (imageBuffer && mimeType) {
imageId = await createItemImage({
householdId,
storeLocationId,
householdStoreItemId: householdStoreItem.id,
householdListId: listId,
imageScope: "list",
imageBuffer,
mimeType,
userId,
});
}
if (imageId) {
await pool.query( await pool.query(
`UPDATE household_lists `UPDATE household_lists
SET quantity = $1, SET quantity = $1,
bought = FALSE, bought = FALSE,
custom_image = $2, image_id = $2,
custom_image_mime_type = $3, custom_image = NULL,
custom_image_mime_type = NULL,
notes = COALESCE($3, notes),
modified_on = NOW() modified_on = NOW()
WHERE id = $4`, WHERE id = $4`,
[quantity, imageBuffer, mimeType, listId] [nextQuantity, imageId, notes, listId]
); );
} else { } else {
await pool.query( await pool.query(
`UPDATE household_lists `UPDATE household_lists
SET quantity = $1, SET quantity = $1,
bought = FALSE, bought = FALSE,
notes = COALESCE($2, notes),
modified_on = NOW() modified_on = NOW()
WHERE id = $2`, WHERE id = $3`,
[quantity, listId] [nextQuantity, notes, listId]
); );
} }
@ -184,46 +270,87 @@ exports.addOrUpdateItem = async (
itemId: householdStoreItem.id, itemId: householdStoreItem.id,
householdStoreItemId: householdStoreItem.id, householdStoreItemId: householdStoreItem.id,
itemName: householdStoreItem.name, itemName: householdStoreItem.name,
quantity: nextQuantity,
previousQuantity,
historyQuantity,
wasBought,
isNew: false, isNew: false,
}; };
} }
const insert = await pool.query( const insert = await pool.query(
`INSERT INTO household_lists `INSERT INTO household_lists
(household_id, store_id, household_store_item_id, quantity, custom_image, custom_image_mime_type, added_by) (household_id, store_location_id, household_store_item_id, quantity, added_by, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`, RETURNING id`,
[householdId, storeId, householdStoreItem.id, quantity, imageBuffer, mimeType, userId] [householdId, storeLocationId, householdStoreItem.id, nextQuantity, userId, notes || null]
); );
if (imageBuffer && mimeType) {
const imageId = await createItemImage({
householdId,
storeLocationId,
householdStoreItemId: householdStoreItem.id,
householdListId: insert.rows[0].id,
imageScope: "list",
imageBuffer,
mimeType,
userId,
});
await pool.query(
`UPDATE household_lists
SET image_id = $1,
custom_image = NULL,
custom_image_mime_type = NULL
WHERE id = $2`,
[imageId, insert.rows[0].id]
);
}
return { return {
listId: insert.rows[0].id, listId: insert.rows[0].id,
itemId: householdStoreItem.id, itemId: householdStoreItem.id,
householdStoreItemId: householdStoreItem.id, householdStoreItemId: householdStoreItem.id,
itemName: householdStoreItem.name, itemName: householdStoreItem.name,
quantity: nextQuantity,
previousQuantity: 0,
historyQuantity: nextQuantity,
wasBought: false,
isNew: true, isNew: true,
}; };
}; };
exports.setBought = async (listId, bought, quantityBought = null) => { exports.setBought = async (listId, bought, quantityBought = null) => {
const item = await pool.query(
`SELECT id, household_id, store_location_id, household_store_item_id, quantity, bought
FROM household_lists
WHERE id = $1`,
[listId]
);
if (!item.rows[0]) return null;
const current = item.rows[0];
const currentQuantity = toPositiveInteger(current.quantity, 0);
if (bought === false) { if (bought === false) {
await pool.query( await pool.query(
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1", "UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
[listId] [listId]
); );
return; return {
...current,
eventType: "ITEM_UNBOUGHT",
quantityDelta: null,
quantityAfter: currentQuantity,
};
} }
if (quantityBought && quantityBought > 0) { const requestedQuantity = toPositiveInteger(quantityBought, 0);
const item = await pool.query( if (requestedQuantity > 0) {
"SELECT quantity FROM household_lists WHERE id = $1", const boughtQuantity = Math.min(requestedQuantity, currentQuantity);
[listId] const remainingQuantity = currentQuantity - boughtQuantity;
);
if (!item.rows[0]) return;
const currentQuantity = item.rows[0].quantity;
const remainingQuantity = currentQuantity - quantityBought;
if (remainingQuantity <= 0) { if (remainingQuantity <= 0) {
await pool.query( await pool.query(
@ -236,23 +363,90 @@ exports.setBought = async (listId, bought, quantityBought = null) => {
[remainingQuantity, listId] [remainingQuantity, listId]
); );
} }
} else {
await pool.query( return {
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", ...current,
[listId] eventType: "ITEM_BOUGHT",
); quantityDelta: -boughtQuantity,
quantityAfter: Math.max(remainingQuantity, 0),
};
} }
await pool.query(
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[listId]
);
return {
...current,
eventType: "ITEM_BOUGHT",
quantityDelta: -currentQuantity,
quantityAfter: 0,
};
}; };
exports.addHistoryRecord = async (listId, householdStoreItemId, quantity, userId) => { exports.addHistoryRecord = async (
listId,
householdStoreItemId,
quantity,
userId,
storeLocationId = null
) => {
await pool.query( await pool.query(
`INSERT INTO household_list_history (household_list_id, household_store_item_id, quantity, added_by, added_on) `INSERT INTO household_list_history
VALUES ($1, $2, $3, $4, NOW())`, (household_list_id, store_location_id, household_store_item_id, quantity, added_by, added_on)
[listId, householdStoreItemId, quantity, userId] VALUES (
$1,
COALESCE($5, (SELECT store_location_id FROM household_lists WHERE id = $1)),
$2,
$3,
$4,
NOW()
)`,
[listId, householdStoreItemId, quantity, userId, storeLocationId]
); );
}; };
exports.getSuggestions = async (query, householdId, storeId) => { exports.recordItemEvent = async ({
householdId,
storeLocationId,
householdStoreItemId,
householdListId = null,
actorUserId = null,
eventType,
quantityDelta = null,
quantityAfter = null,
metadata = {},
}) => {
await pool.query(
`INSERT INTO household_item_events
(
household_id,
store_location_id,
household_store_item_id,
household_list_id,
actor_user_id,
event_type,
quantity_delta,
quantity_after,
metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
[
householdId,
storeLocationId,
householdStoreItemId,
householdListId,
actorUserId,
eventType,
quantityDelta,
quantityAfter,
JSON.stringify(metadata || {}),
]
);
};
exports.getSuggestions = async (query, householdId, storeLocationId) => {
const result = await pool.query( const result = await pool.query(
`SELECT DISTINCT `SELECT DISTINCT
hsi.name AS item_name, hsi.name AS item_name,
@ -261,18 +455,18 @@ exports.getSuggestions = async (query, householdId, storeId) => {
LEFT JOIN household_lists hl LEFT JOIN household_lists hl
ON hl.household_store_item_id = hsi.id ON hl.household_store_item_id = hsi.id
AND hl.household_id = $2 AND hl.household_id = $2
AND hl.store_id = $3 AND hl.store_location_id = $3
WHERE hsi.household_id = $2 WHERE hsi.household_id = $2
AND hsi.store_id = $3 AND hsi.store_location_id = $3
AND hsi.name ILIKE $1 AND hsi.name ILIKE $1
ORDER BY sort_order, hsi.name ORDER BY sort_order, hsi.name
LIMIT 10`, LIMIT 10`,
[`%${query}%`, householdId, storeId] [`%${query}%`, householdId, storeLocationId]
); );
return result.rows; return result.rows;
}; };
exports.getRecentlyBoughtItems = async (householdId, storeId) => { exports.getRecentlyBoughtItems = async (householdId, storeLocationId) => {
const result = await pool.query( const result = await pool.query(
`SELECT `SELECT
hl.id, hl.id,
@ -281,73 +475,121 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
hsi.name AS item_name, hsi.name AS item_name,
hl.quantity, hl.quantity,
hl.bought, hl.bought,
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
( ${ACTIVE_ADDED_BY_USERS_SQL},
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
FROM (
SELECT DISTINCT
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
FROM household_list_history hlh
JOIN users u ON hlh.added_by = u.id
WHERE hlh.household_list_id = hl.id
) added_by_labels
) AS added_by_users,
hl.modified_on AS last_added_on hl.modified_on AS last_added_on
FROM household_lists hl FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
WHERE hl.household_id = $1 WHERE hl.household_id = $1
AND hl.store_id = $2 AND hl.store_location_id = $2
AND hl.bought = TRUE AND hl.bought = TRUE
AND hl.modified_on >= NOW() - INTERVAL '24 hours' AND hl.modified_on >= NOW() - INTERVAL '24 hours'
ORDER BY hl.modified_on DESC`, ORDER BY hl.modified_on DESC`,
[householdId, storeId] [householdId, storeLocationId]
); );
return result.rows; return result.rows;
}; };
exports.getClassification = async (householdId, storeId, itemId) => { exports.getZoneByName = async (householdId, storeLocationId, zoneName) => {
const result = await pool.query( const result = await pool.query(
`SELECT item_type, item_group, zone, confidence, source `SELECT id, name, sort_order
FROM household_item_classifications FROM store_location_zones
WHERE household_id = $1 AND store_id = $2 AND household_store_item_id = $3`, WHERE household_id = $1
[householdId, storeId, itemId] AND store_location_id = $2
AND normalized_name = $3
AND is_active = TRUE`,
[householdId, storeLocationId, normalizeItemName(zoneName)]
); );
return result.rows[0] || null; return result.rows[0] || null;
}; };
exports.upsertClassification = async (householdId, storeId, itemId, classification) => { exports.getClassification = async (householdId, storeLocationId, itemId) => {
const result = await pool.query(
`SELECT
hic.item_type,
hic.item_group,
COALESCE(slz.name, hic.zone) AS zone,
hic.confidence,
hic.source
FROM household_item_classifications hic
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
WHERE hic.household_id = $1
AND hic.store_location_id = $2
AND hic.household_store_item_id = $3`,
[householdId, storeLocationId, itemId]
);
return result.rows[0] || null;
};
exports.upsertClassification = async (householdId, storeLocationId, itemId, classification) => {
const { item_type, item_group, zone, confidence, source } = classification; const { item_type, item_group, zone, confidence, source } = classification;
const zoneRecord = zone ? await exports.getZoneByName(householdId, storeLocationId, zone) : null;
const result = await pool.query( const result = await pool.query(
`INSERT INTO household_item_classifications `INSERT INTO household_item_classifications
(household_id, store_id, household_store_item_id, item_type, item_group, zone, confidence, source) (
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) household_id,
ON CONFLICT (household_id, store_id, household_store_item_id) store_location_id,
household_store_item_id,
item_type,
item_group,
zone,
zone_id,
confidence,
source
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (household_id, store_location_id, household_store_item_id)
DO UPDATE SET DO UPDATE SET
item_type = EXCLUDED.item_type, item_type = EXCLUDED.item_type,
item_group = EXCLUDED.item_group, item_group = EXCLUDED.item_group,
zone = EXCLUDED.zone, zone = EXCLUDED.zone,
zone_id = EXCLUDED.zone_id,
confidence = EXCLUDED.confidence, confidence = EXCLUDED.confidence,
source = EXCLUDED.source source = EXCLUDED.source,
updated_at = NOW()
RETURNING *`, RETURNING *`,
[householdId, storeId, itemId, item_type, item_group, zone, confidence, source] [
householdId,
storeLocationId,
itemId,
item_type,
item_group,
zone,
zoneRecord?.id || null,
confidence,
source,
]
); );
return result.rows[0]; return result.rows[0];
}; };
exports.deleteClassification = async (householdId, storeId, itemId) => { exports.deleteClassification = async (householdId, storeLocationId, itemId) => {
const result = await pool.query( const result = await pool.query(
`DELETE FROM household_item_classifications `DELETE FROM household_item_classifications
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_location_id = $2
AND household_store_item_id = $3`, AND household_store_item_id = $3`,
[householdId, storeId, itemId] [householdId, storeLocationId, itemId]
); );
return result.rowCount > 0; return result.rowCount > 0;
}; };
exports.updateItem = async (listId, itemName, quantity, notes) => { exports.updateItem = async (listId, itemName, quantity, notes) => {
const existing = await pool.query(
`SELECT id, household_id, store_location_id, household_store_item_id, quantity, notes
FROM household_lists
WHERE id = $1`,
[listId]
);
if (existing.rowCount === 0) {
return null;
}
const updates = []; const updates = [];
const values = [listId]; const values = [listId];
let paramCount = 1; let paramCount = 1;
@ -366,22 +608,56 @@ exports.updateItem = async (listId, itemName, quantity, notes) => {
updates.push("modified_on = NOW()"); updates.push("modified_on = NOW()");
if (updates.length === 1) {
const result = await pool.query(
"UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *",
[listId]
);
return result.rows[0];
}
const result = await pool.query( const result = await pool.query(
`UPDATE household_lists SET ${updates.join(", ")} WHERE id = $1 RETURNING *`, `UPDATE household_lists SET ${updates.join(", ")} WHERE id = $1 RETURNING *`,
values values
); );
return result.rows[0]; return {
previous: existing.rows[0],
updated: result.rows[0],
};
}; };
exports.deleteItem = async (listId) => { exports.deleteItem = async (listId) => {
await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]); const result = await pool.query(
`DELETE FROM household_lists
WHERE id = $1
RETURNING id, household_id, store_location_id, household_store_item_id, quantity`,
[listId]
);
return result.rows[0] || null;
};
exports.setCatalogItemImage = async (
householdId,
storeLocationId,
householdStoreItemId,
imageBuffer,
mimeType,
userId = null
) => {
const imageId = await createItemImage({
householdId,
storeLocationId,
householdStoreItemId,
imageScope: "catalog",
imageBuffer,
mimeType,
userId,
});
await pool.query(
`UPDATE household_store_items
SET image_id = $1,
custom_image = NULL,
custom_image_mime_type = NULL,
updated_at = NOW()
WHERE household_id = $2
AND store_location_id = $3
AND id = $4`,
[imageId, householdId, storeLocationId, householdStoreItemId]
);
return imageId;
}; };

View File

@ -1,6 +1,70 @@
const pool = require("../db/pool"); const pool = require("../db/pool");
const { ZONE_FLOW } = require("../constants/classifications");
// Get all available stores const DEFAULT_LOCATION_NAME = "Default Location";
function normalizeName(value) {
return String(value || "").trim().toLowerCase();
}
function displayLocationName(storeName, locationName) {
if (!locationName || locationName === DEFAULT_LOCATION_NAME) {
return storeName;
}
return `${storeName} - ${locationName}`;
}
function mapLocationRow(row) {
if (!row) return null;
return {
...row,
id: row.location_id,
display_name: row.display_name || displayLocationName(row.name, row.location_name),
};
}
async function queryLocationById(db, householdId, locationId) {
const result = await db.query(
`SELECT
sl.id AS location_id,
sl.id,
sl.household_id,
sl.household_store_id,
hcs.name,
sl.name AS location_name,
sl.address,
sl.is_default,
sl.map_data,
sl.created_at,
sl.updated_at,
CASE
WHEN sl.name = $3 THEN hcs.name
ELSE hcs.name || ' - ' || sl.name
END AS display_name
FROM store_locations sl
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
WHERE sl.household_id = $1
AND sl.id = $2`,
[householdId, locationId, DEFAULT_LOCATION_NAME]
);
return mapLocationRow(result.rows[0]);
}
async function seedDefaultZones(db, householdId, locationId) {
for (let index = 0; index < ZONE_FLOW.length; index += 1) {
const zoneName = ZONE_FLOW[index];
await db.query(
`INSERT INTO store_location_zones
(household_id, store_location_id, name, normalized_name, sort_order)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (store_location_id, normalized_name) DO NOTHING`,
[householdId, locationId, zoneName, normalizeName(zoneName), (index + 1) * 10]
);
}
}
// Legacy global store catalog. Kept for system-admin compatibility only.
exports.getAllStores = async () => { exports.getAllStores = async () => {
const result = await pool.query( const result = await pool.query(
`SELECT id, name, default_zones, created_at `SELECT id, name, default_zones, created_at
@ -10,7 +74,6 @@ exports.getAllStores = async () => {
return result.rows; return result.rows;
}; };
// Get store by ID
exports.getStoreById = async (storeId) => { exports.getStoreById = async (storeId) => {
const result = await pool.query( const result = await pool.query(
`SELECT id, name, default_zones, created_at `SELECT id, name, default_zones, created_at
@ -21,77 +84,6 @@ exports.getStoreById = async (storeId) => {
return result.rows[0]; 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) => { exports.createStore = async (name, defaultZones) => {
const result = await pool.query( const result = await pool.query(
`INSERT INTO stores (name, default_zones) `INSERT INTO stores (name, default_zones)
@ -102,12 +94,11 @@ exports.createStore = async (name, defaultZones) => {
return result.rows[0]; return result.rows[0];
}; };
// Update store (system admin only)
exports.updateStore = async (storeId, updates) => { exports.updateStore = async (storeId, updates) => {
const { name, default_zones } = updates; const { name, default_zones } = updates;
const result = await pool.query( const result = await pool.query(
`UPDATE stores `UPDATE stores
SET SET
name = COALESCE($1, name), name = COALESCE($1, name),
default_zones = COALESCE($2, default_zones) default_zones = COALESCE($2, default_zones)
WHERE id = $3 WHERE id = $3
@ -117,27 +108,452 @@ exports.updateStore = async (storeId, updates) => {
return result.rows[0]; return result.rows[0];
}; };
// Delete store (system admin only, only if not in use)
exports.deleteStore = async (storeId) => { exports.deleteStore = async (storeId) => {
// Check if store is in use
const usage = await pool.query( const usage = await pool.query(
`SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`, `SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`,
[storeId] [storeId]
); );
if (parseInt(usage.rows[0].count) > 0) { if (parseInt(usage.rows[0].count, 10) > 0) {
throw new Error('Cannot delete store that is in use by households'); throw new Error("Cannot delete store that is in use by households");
} }
await pool.query('DELETE FROM stores WHERE id = $1', [storeId]); await pool.query("DELETE FROM stores WHERE id = $1", [storeId]);
}; };
// Check if household has store // Household-owned store locations.
exports.getHouseholdStores = async (householdId) => {
const result = await pool.query(
`SELECT
sl.id AS location_id,
sl.id,
sl.household_id,
sl.household_store_id,
hcs.name,
sl.name AS location_name,
sl.address,
sl.is_default,
sl.map_data,
sl.created_at,
sl.updated_at,
CASE
WHEN sl.name = $2 THEN hcs.name
ELSE hcs.name || ' - ' || sl.name
END AS display_name
FROM store_locations sl
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
WHERE sl.household_id = $1
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
[householdId, DEFAULT_LOCATION_NAME]
);
return result.rows.map(mapLocationRow);
};
exports.createHouseholdStore = async (
householdId,
name,
locationName = DEFAULT_LOCATION_NAME,
address = null,
createdBy = null
) => {
const client = await pool.connect();
const normalizedStoreName = normalizeName(name);
const normalizedLocationName = normalizeName(locationName || DEFAULT_LOCATION_NAME);
try {
await client.query("BEGIN");
const storeResult = await client.query(
`INSERT INTO household_custom_stores
(household_id, name, normalized_name, created_by, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (household_id, normalized_name)
DO UPDATE SET name = EXCLUDED.name, updated_at = NOW()
RETURNING id, name`,
[householdId, name.trim(), normalizedStoreName, createdBy]
);
const hasDefault = await client.query(
`SELECT 1 FROM store_locations
WHERE household_id = $1 AND is_default = TRUE
LIMIT 1`,
[householdId]
);
const locationResult = await client.query(
`INSERT INTO store_locations
(household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (household_store_id, normalized_name)
DO UPDATE SET
name = EXCLUDED.name,
address = COALESCE(EXCLUDED.address, store_locations.address),
updated_at = NOW()
RETURNING id`,
[
householdId,
storeResult.rows[0].id,
(locationName || DEFAULT_LOCATION_NAME).trim(),
normalizedLocationName,
address || null,
hasDefault.rowCount === 0,
createdBy,
]
);
await seedDefaultZones(client, householdId, locationResult.rows[0].id);
const location = await queryLocationById(client, householdId, locationResult.rows[0].id);
await client.query("COMMIT");
return location;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
exports.updateHouseholdStore = async (householdId, householdStoreId, updates = {}) => {
const { name } = updates;
const result = await pool.query(
`UPDATE household_custom_stores
SET name = COALESCE($1, name),
normalized_name = COALESCE($2, normalized_name),
updated_at = NOW()
WHERE household_id = $3
AND id = $4
RETURNING id, household_id, name, created_at, updated_at`,
[
name?.trim() || null,
name ? normalizeName(name) : null,
householdId,
householdStoreId,
]
);
return result.rows[0] || null;
};
exports.deleteHouseholdStore = async (householdId, householdStoreId) => {
const countResult = await pool.query(
`SELECT COUNT(*)::int AS count
FROM store_locations
WHERE household_id = $1`,
[householdId]
);
const storeLocationCount = countResult.rows[0]?.count || 0;
const targetLocations = await pool.query(
`SELECT COUNT(*)::int AS count
FROM store_locations
WHERE household_id = $1
AND household_store_id = $2`,
[householdId, householdStoreId]
);
if (storeLocationCount <= targetLocations.rows[0]?.count) {
throw new Error("Cannot remove the last store location for a household");
}
const result = await pool.query(
`DELETE FROM household_custom_stores
WHERE household_id = $1
AND id = $2`,
[householdId, householdStoreId]
);
return result.rowCount > 0;
};
exports.addLocationToStore = async (
householdId,
householdStoreId,
name,
address = null,
createdBy = null
) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const storeResult = await client.query(
`SELECT id FROM household_custom_stores
WHERE household_id = $1
AND id = $2`,
[householdId, householdStoreId]
);
if (storeResult.rowCount === 0) {
await client.query("ROLLBACK");
return null;
}
const hasDefault = await client.query(
`SELECT 1 FROM store_locations
WHERE household_id = $1 AND is_default = TRUE
LIMIT 1`,
[householdId]
);
const locationResult = await client.query(
`INSERT INTO store_locations
(household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
RETURNING id`,
[
householdId,
householdStoreId,
name.trim(),
normalizeName(name),
address || null,
hasDefault.rowCount === 0,
createdBy,
]
);
await seedDefaultZones(client, householdId, locationResult.rows[0].id);
const location = await queryLocationById(client, householdId, locationResult.rows[0].id);
await client.query("COMMIT");
return location;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
exports.updateLocation = async (householdId, locationId, updates = {}) => {
const { name, address, map_data } = updates;
const result = await pool.query(
`UPDATE store_locations
SET name = COALESCE($1, name),
normalized_name = COALESCE($2, normalized_name),
address = COALESCE($3, address),
map_data = COALESCE($4::jsonb, map_data),
updated_at = NOW()
WHERE household_id = $5
AND id = $6
RETURNING id`,
[
name?.trim() || null,
name ? normalizeName(name) : null,
address === undefined ? null : address,
map_data ? JSON.stringify(map_data) : null,
householdId,
locationId,
]
);
if (result.rowCount === 0) return null;
return queryLocationById(pool, householdId, locationId);
};
exports.deleteLocation = async (householdId, locationId) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const countResult = await client.query(
`SELECT COUNT(*)::int AS count
FROM store_locations
WHERE household_id = $1`,
[householdId]
);
if ((countResult.rows[0]?.count || 0) <= 1) {
throw new Error("Cannot remove the last store location for a household");
}
const deleted = await client.query(
`DELETE FROM store_locations
WHERE household_id = $1
AND id = $2
RETURNING is_default`,
[householdId, locationId]
);
if (deleted.rowCount === 0) {
await client.query("COMMIT");
return false;
}
if (deleted.rows[0].is_default) {
await client.query(
`UPDATE store_locations
SET is_default = TRUE, updated_at = NOW()
WHERE id = (
SELECT id
FROM store_locations
WHERE household_id = $1
ORDER BY created_at ASC, id ASC
LIMIT 1
)`,
[householdId]
);
}
await client.query("COMMIT");
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
exports.setDefaultLocation = async (householdId, locationId) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(
`UPDATE store_locations
SET is_default = FALSE, updated_at = NOW()
WHERE household_id = $1`,
[householdId]
);
const result = await client.query(
`UPDATE store_locations
SET is_default = TRUE, updated_at = NOW()
WHERE household_id = $1
AND id = $2
RETURNING id`,
[householdId, locationId]
);
if (result.rowCount === 0) {
throw new Error("Location not found");
}
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
exports.householdHasLocation = async (householdId, locationId) => {
const result = await pool.query(
`SELECT 1
FROM store_locations
WHERE household_id = $1
AND id = $2`,
[householdId, locationId]
);
return result.rowCount > 0;
};
exports.getLocationById = async (householdId, locationId) =>
queryLocationById(pool, householdId, locationId);
exports.listLocationZones = async (householdId, locationId, includeInactive = false) => {
const values = [householdId, locationId];
const inactiveClause = includeInactive ? "" : "AND is_active = TRUE";
const result = await pool.query(
`SELECT id, name, sort_order, color, map_metadata, is_active, created_at, updated_at
FROM store_location_zones
WHERE household_id = $1
AND store_location_id = $2
${inactiveClause}
ORDER BY sort_order ASC, name ASC`,
values
);
return result.rows;
};
exports.getZoneByName = async (householdId, locationId, zoneName) => {
const result = await pool.query(
`SELECT id, name, sort_order, color, is_active
FROM store_location_zones
WHERE household_id = $1
AND store_location_id = $2
AND normalized_name = $3
AND is_active = TRUE`,
[householdId, locationId, normalizeName(zoneName)]
);
return result.rows[0] || null;
};
exports.createZone = async (householdId, locationId, zone) => {
const { name, sort_order, color, map_metadata } = zone;
const result = await pool.query(
`INSERT INTO store_location_zones
(household_id, store_location_id, name, normalized_name, sort_order, color, map_metadata)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7::jsonb, '{}'::jsonb))
ON CONFLICT (store_location_id, normalized_name)
DO UPDATE SET
name = EXCLUDED.name,
sort_order = EXCLUDED.sort_order,
color = EXCLUDED.color,
map_metadata = EXCLUDED.map_metadata,
is_active = TRUE,
updated_at = NOW()
RETURNING id, name, sort_order, color, map_metadata, is_active`,
[
householdId,
locationId,
name.trim(),
normalizeName(name),
Number.isInteger(sort_order) ? sort_order : 0,
color || null,
map_metadata ? JSON.stringify(map_metadata) : null,
]
);
return result.rows[0];
};
exports.updateZone = async (householdId, locationId, zoneId, updates = {}) => {
const { name, sort_order, color, map_metadata, is_active } = updates;
const result = await pool.query(
`UPDATE store_location_zones
SET name = COALESCE($1, name),
normalized_name = COALESCE($2, normalized_name),
sort_order = COALESCE($3, sort_order),
color = COALESCE($4, color),
map_metadata = COALESCE($5::jsonb, map_metadata),
is_active = COALESCE($6, is_active),
updated_at = NOW()
WHERE household_id = $7
AND store_location_id = $8
AND id = $9
RETURNING id, name, sort_order, color, map_metadata, is_active`,
[
name?.trim() || null,
name ? normalizeName(name) : null,
Number.isInteger(sort_order) ? sort_order : null,
color === undefined ? null : color,
map_metadata ? JSON.stringify(map_metadata) : null,
typeof is_active === "boolean" ? is_active : null,
householdId,
locationId,
zoneId,
]
);
return result.rows[0] || null;
};
exports.deleteZone = async (householdId, locationId, zoneId) => {
const result = await pool.query(
`UPDATE store_location_zones
SET is_active = FALSE, updated_at = NOW()
WHERE household_id = $1
AND store_location_id = $2
AND id = $3`,
[householdId, locationId, zoneId]
);
return result.rowCount > 0;
};
// Backward-compatible check for legacy routes. Prefer householdHasLocation.
exports.householdHasStore = async (householdId, storeId) => { exports.householdHasStore = async (householdId, storeId) => {
const result = await pool.query( const result = await pool.query(
`SELECT 1 FROM household_stores `SELECT 1 FROM household_stores
WHERE household_id = $1 AND store_id = $2`, WHERE household_id = $1 AND store_id = $2`,
[householdId, storeId] [householdId, storeId]
); );
return result.rows.length > 0; return result.rowCount > 0;
}; };

View File

@ -3,9 +3,11 @@ const router = express.Router();
const controller = require("../controllers/households.controller"); const controller = require("../controllers/households.controller");
const listsController = require("../controllers/lists.controller.v2"); const listsController = require("../controllers/lists.controller.v2");
const availableItemsController = require("../controllers/available-items.controller"); const availableItemsController = require("../controllers/available-items.controller");
const storesController = require("../controllers/stores.controller");
const auth = require("../middleware/auth"); const auth = require("../middleware/auth");
const { const {
householdAccess, householdAccess,
locationAccess,
requireHouseholdAdmin, requireHouseholdAdmin,
storeAccess, storeAccess,
} = require("../middleware/household"); } = require("../middleware/household");
@ -14,6 +16,7 @@ const { upload, processImage } = require("../middleware/image");
// Public routes (authenticated only) // Public routes (authenticated only)
router.get("/", auth, controller.getUserHouseholds); router.get("/", auth, controller.getUserHouseholds);
router.post("/", auth, controller.createHousehold); router.post("/", auth, controller.createHousehold);
router.patch("/order", auth, controller.reorderHouseholds);
router.post("/join/:inviteCode", auth, controller.joinHousehold); router.post("/join/:inviteCode", auth, controller.joinHousehold);
// Household-scoped routes (member access required) // Household-scoped routes (member access required)
@ -40,6 +43,139 @@ router.post(
controller.refreshInviteCode controller.refreshInviteCode
); );
// Household-owned stores and locations
router.get(
"/:householdId/stores",
auth,
householdAccess,
storesController.getHouseholdStores
);
router.post(
"/:householdId/stores",
auth,
householdAccess,
requireHouseholdAdmin,
storesController.createHouseholdStore
);
router.patch(
"/:householdId/stores/:householdStoreId",
auth,
householdAccess,
requireHouseholdAdmin,
storesController.updateHouseholdStore
);
router.delete(
"/:householdId/stores/:householdStoreId",
auth,
householdAccess,
requireHouseholdAdmin,
storesController.deleteHouseholdStore
);
router.post(
"/:householdId/stores/:householdStoreId/locations",
auth,
householdAccess,
requireHouseholdAdmin,
storesController.addLocationToStore
);
router.patch(
"/:householdId/locations/:locationId",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
storesController.updateLocation
);
router.delete(
"/:householdId/locations/:locationId",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
storesController.deleteLocation
);
router.patch(
"/:householdId/locations/:locationId/default",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
storesController.setDefaultLocation
);
router.get(
"/:householdId/locations/:locationId/zones",
auth,
householdAccess,
locationAccess,
storesController.getLocationZones
);
router.post(
"/:householdId/locations/:locationId/zones",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
storesController.createZone
);
router.patch(
"/:householdId/locations/:locationId/zones/:zoneId",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
storesController.updateZone
);
router.delete(
"/:householdId/locations/:locationId/zones/:zoneId",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
storesController.deleteZone
);
router.get(
"/:householdId/locations/:locationId/available-items",
auth,
householdAccess,
locationAccess,
availableItemsController.getAvailableItems
);
router.post(
"/:householdId/locations/:locationId/available-items",
auth,
householdAccess,
locationAccess,
upload.single("image"),
processImage,
availableItemsController.createAvailableItem
);
router.patch(
"/:householdId/locations/:locationId/available-items/:itemId",
auth,
householdAccess,
locationAccess,
upload.single("image"),
processImage,
availableItemsController.updateAvailableItem
);
router.delete(
"/:householdId/locations/:locationId/available-items/:itemId",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
availableItemsController.deleteAvailableItem
);
router.post(
"/:householdId/locations/:locationId/available-items/import-current",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
availableItemsController.importCurrentItems
);
router.get( router.get(
"/:householdId/stores/:storeId/available-items", "/:householdId/stores/:storeId/available-items",
auth, auth,
@ -109,6 +245,88 @@ router.delete(
// All list routes require household access AND store access // All list routes require household access AND store access
// Get grocery list // Get grocery list
router.get(
"/:householdId/locations/:locationId/list",
auth,
householdAccess,
locationAccess,
listsController.getList
);
router.get(
"/:householdId/locations/:locationId/list/item",
auth,
householdAccess,
locationAccess,
listsController.getItemByName
);
router.post(
"/:householdId/locations/:locationId/list/add",
auth,
householdAccess,
locationAccess,
upload.single("image"),
processImage,
listsController.addItem
);
router.patch(
"/:householdId/locations/:locationId/list/item",
auth,
householdAccess,
locationAccess,
listsController.markBought
);
router.put(
"/:householdId/locations/:locationId/list/item",
auth,
householdAccess,
locationAccess,
listsController.updateItem
);
router.delete(
"/:householdId/locations/:locationId/list/item",
auth,
householdAccess,
locationAccess,
listsController.deleteItem
);
router.get(
"/:householdId/locations/:locationId/list/suggestions",
auth,
householdAccess,
locationAccess,
listsController.getSuggestions
);
router.get(
"/:householdId/locations/:locationId/list/recent",
auth,
householdAccess,
locationAccess,
listsController.getRecentlyBought
);
router.get(
"/:householdId/locations/:locationId/list/classification",
auth,
householdAccess,
locationAccess,
listsController.getClassification
);
router.post(
"/:householdId/locations/:locationId/list/classification",
auth,
householdAccess,
locationAccess,
listsController.setClassification
);
router.post(
"/:householdId/locations/:locationId/list/update-image",
auth,
householdAccess,
locationAccess,
upload.single("image"),
processImage,
listsController.updateItemImage
);
router.get( router.get(
"/:householdId/stores/:storeId/list", "/:householdId/stores/:storeId/list",
auth, auth,

View File

@ -0,0 +1,118 @@
jest.mock("jsonwebtoken", () => ({
verify: jest.fn(),
}));
jest.mock("../models/session.model", () => ({
getActiveSessionWithUser: jest.fn(),
}));
jest.mock("../utils/logger", () => ({
logError: jest.fn(),
}));
const jwt = require("jsonwebtoken");
const Session = require("../models/session.model");
const auth = require("../middleware/auth");
function createResponse() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
describe("auth middleware", () => {
const originalJwtSecret = process.env.JWT_SECRET;
beforeEach(() => {
process.env.JWT_SECRET = "test-secret";
jest.clearAllMocks();
});
afterAll(() => {
if (originalJwtSecret === undefined) {
delete process.env.JWT_SECRET;
} else {
process.env.JWT_SECRET = originalJwtSecret;
}
});
test("uses a valid bearer token without reading the session cookie", async () => {
jwt.verify.mockReturnValue({ id: 5, role: "admin" });
const req = {
headers: {
authorization: "Bearer valid-token",
cookie: "sid=session-id",
},
};
const res = createResponse();
const next = jest.fn();
await auth(req, res, next);
expect(jwt.verify).toHaveBeenCalledWith("valid-token", "test-secret");
expect(Session.getActiveSessionWithUser).not.toHaveBeenCalled();
expect(req.user).toEqual({ id: 5, role: "admin" });
expect(next).toHaveBeenCalled();
});
test("falls back to a valid session cookie when the bearer token is stale", async () => {
jwt.verify.mockImplementation(() => {
throw new Error("stale token");
});
Session.getActiveSessionWithUser.mockResolvedValue({
id: "session-id",
user_id: 7,
role: "user",
username: "shopper",
});
const req = {
headers: {
authorization: "Bearer stale-token",
cookie: "sid=session-id",
},
};
const res = createResponse();
const next = jest.fn();
await auth(req, res, next);
expect(Session.getActiveSessionWithUser).toHaveBeenCalledWith("session-id");
expect(req.user).toEqual({
id: 7,
role: "user",
username: "shopper",
});
expect(req.session_id).toBe("session-id");
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test("rejects a stale bearer token when no session cookie is present", async () => {
jwt.verify.mockImplementation(() => {
throw new Error("stale token");
});
const req = {
headers: {
authorization: "Bearer stale-token",
},
};
const res = createResponse();
const next = jest.fn();
await auth(req, res, next);
expect(Session.getActiveSessionWithUser).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: {
code: "unauthorized",
message: "Invalid or expired token",
},
});
});
});

View File

@ -2,12 +2,20 @@ jest.mock("../db/pool", () => ({
query: jest.fn(), query: jest.fn(),
})); }));
jest.mock("../models/list.model.v2", () => ({
recordItemEvent: jest.fn(),
setCatalogItemImage: jest.fn(),
}));
const pool = require("../db/pool"); const pool = require("../db/pool");
const List = require("../models/list.model.v2");
const AvailableItems = require("../models/available-item.model"); const AvailableItems = require("../models/available-item.model");
describe("available-item.model", () => { describe("available-item.model", () => {
beforeEach(() => { beforeEach(() => {
pool.query.mockReset(); pool.query.mockReset();
List.recordItemEvent.mockReset();
List.setCatalogItemImage.mockReset();
}); });
test("lists household store items", async () => { test("lists household store items", async () => {
@ -58,6 +66,14 @@ describe("available-item.model", () => {
expect.stringContaining("INSERT INTO household_store_items"), expect.stringContaining("INSERT INTO household_store_items"),
[1, 2, "granola", "granola"] [1, 2, "granola", "granola"]
); );
expect(List.recordItemEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: "ITEM_ADDED",
householdId: 1,
storeLocationId: 2,
householdStoreItemId: 77,
})
);
}); });
test("updates household store item images and returns refreshed data", async () => { test("updates household store item images and returns refreshed data", async () => {
@ -78,19 +94,37 @@ describe("available-item.model", () => {
expect(pool.query).toHaveBeenNthCalledWith( expect(pool.query).toHaveBeenNthCalledWith(
1, 1,
expect.stringContaining("UPDATE household_store_items"), expect.stringContaining("UPDATE household_store_items"),
[1, 2, 55, imageBuffer, "image/jpeg"] [1, 2, 55]
);
expect(List.setCatalogItemImage).toHaveBeenCalledWith(
1,
2,
55,
imageBuffer,
"image/jpeg",
null
); );
}); });
test("deletes the household store item", async () => { test("deletes the household store item", async () => {
pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [] }); pool.query
.mockResolvedValueOnce({ rowCount: 1, rows: [{ item_id: 55, item_name: "milk" }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55); const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55);
expect(deleted).toBe(true); expect(deleted).toBe(true);
expect(pool.query).toHaveBeenCalledWith( expect(pool.query).toHaveBeenLastCalledWith(
expect.stringContaining("DELETE FROM household_store_items"), expect.stringContaining("DELETE FROM household_store_items"),
[1, 2, 55] [1, 2, 55]
); );
expect(List.recordItemEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: "ITEM_DELETED",
householdId: 1,
storeLocationId: 2,
householdStoreItemId: 55,
})
);
}); });
}); });

View File

@ -9,6 +9,8 @@ jest.mock("../models/available-item.model", () => ({
jest.mock("../models/list.model.v2", () => ({ jest.mock("../models/list.model.v2", () => ({
deleteClassification: jest.fn(), deleteClassification: jest.fn(),
getZoneByName: jest.fn(),
recordItemEvent: jest.fn(),
upsertClassification: jest.fn(), upsertClassification: jest.fn(),
})); }));
@ -42,7 +44,9 @@ describe("available-items.controller", () => {
AvailableItems.deleteAvailableItem.mockResolvedValue(true); AvailableItems.deleteAvailableItem.mockResolvedValue(true);
AvailableItems.importCurrentListItems.mockResolvedValue(2); AvailableItems.importCurrentListItems.mockResolvedValue(2);
AvailableItems.listAvailableItems.mockResolvedValue([]); AvailableItems.listAvailableItems.mockResolvedValue([]);
List.upsertClassification.mockResolvedValue(undefined); List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" });
List.recordItemEvent.mockResolvedValue(undefined);
List.upsertClassification.mockResolvedValue({ zone_id: 5 });
List.deleteClassification.mockResolvedValue(false); List.deleteClassification.mockResolvedValue(false);
}); });
@ -58,12 +62,13 @@ describe("available-items.controller", () => {
}), }),
}, },
processedImage: null, processedImage: null,
user: { id: 7 },
}; };
const res = createResponse(); const res = createResponse();
await controller.createAvailableItem(req, res); await controller.createAvailableItem(req, res);
expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null); expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null, 7);
expect(List.upsertClassification).toHaveBeenCalledWith( expect(List.upsertClassification).toHaveBeenCalledWith(
"1", "1",
"2", "2",
@ -87,6 +92,7 @@ describe("available-items.controller", () => {
item_group: "Bread", item_group: "Bread",
}), }),
}, },
user: { id: 7 },
}; };
const res = createResponse(); const res = createResponse();
@ -110,6 +116,7 @@ describe("available-items.controller", () => {
classification: "null", classification: "null",
}, },
processedImage: null, processedImage: null,
user: { id: 7 },
}; };
const res = createResponse(); const res = createResponse();
@ -122,6 +129,7 @@ describe("available-items.controller", () => {
test("imports current list items and reports the import count", async () => { test("imports current list items and reports the import count", async () => {
const req = { const req = {
params: { householdId: "1", storeId: "2" }, params: { householdId: "1", storeId: "2" },
user: { id: 7 },
}; };
const res = createResponse(); const res = createResponse();
@ -138,12 +146,13 @@ describe("available-items.controller", () => {
test("deletes a store item", async () => { test("deletes a store item", async () => {
const req = { const req = {
params: { householdId: "1", storeId: "2", itemId: "99" }, params: { householdId: "1", storeId: "2", itemId: "99" },
user: { id: 7 },
}; };
const res = createResponse(); const res = createResponse();
await controller.deleteAvailableItem(req, res); await controller.deleteAvailableItem(req, res);
expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99); expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99, 7);
expect(List.deleteClassification).not.toHaveBeenCalled(); expect(List.deleteClassification).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" }); expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" });
}); });
@ -152,6 +161,7 @@ describe("available-items.controller", () => {
const req = { const req = {
params: { householdId: "1", storeId: "2" }, params: { householdId: "1", storeId: "2" },
query: {}, query: {},
user: { id: 7 },
}; };
const res = createResponse(); const res = createResponse();
@ -177,6 +187,7 @@ describe("available-items.controller", () => {
item_name: "milk", item_name: "milk",
}, },
processedImage: null, processedImage: null,
user: { id: 7 },
}; };
const res = createResponse(); const res = createResponse();

View File

@ -11,6 +11,10 @@ jest.mock("../middleware/household", () => ({
}; };
next(); next();
}, },
locationAccess: (req, res, next) => {
req.storeLocation = { id: Number.parseInt(req.params.locationId, 10) };
next();
},
requireHouseholdAdmin: (req, res, next) => { requireHouseholdAdmin: (req, res, next) => {
if (["owner", "admin"].includes(req.household?.role)) { if (["owner", "admin"].includes(req.household?.role)) {
return next(); return next();
@ -39,6 +43,7 @@ jest.mock("../controllers/households.controller", () => ({
joinHousehold: jest.fn(), joinHousehold: jest.fn(),
refreshInviteCode: jest.fn(), refreshInviteCode: jest.fn(),
removeMember: jest.fn(), removeMember: jest.fn(),
reorderHouseholds: jest.fn(),
updateHousehold: jest.fn(), updateHousehold: jest.fn(),
updateMemberRole: jest.fn(), updateMemberRole: jest.fn(),
})); }));
@ -65,6 +70,21 @@ jest.mock("../controllers/available-items.controller", () => ({
updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })), updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })),
})); }));
jest.mock("../controllers/stores.controller", () => ({
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
createZone: jest.fn((req, res) => res.status(201).json({ message: "zone" })),
deleteHouseholdStore: jest.fn((req, res) => res.json({ message: "deleted store" })),
deleteLocation: jest.fn((req, res) => res.json({ message: "deleted location" })),
deleteZone: jest.fn((req, res) => res.json({ message: "deleted zone" })),
getHouseholdStores: jest.fn((req, res) => res.json([])),
getLocationZones: jest.fn((req, res) => res.json({ zones: [] })),
setDefaultLocation: jest.fn((req, res) => res.json({ message: "default" })),
updateHouseholdStore: jest.fn((req, res) => res.json({ message: "updated store" })),
updateLocation: jest.fn((req, res) => res.json({ message: "updated location" })),
updateZone: jest.fn((req, res) => res.json({ message: "updated zone" })),
}));
const express = require("express"); const express = require("express");
const request = require("supertest"); const request = require("supertest");
const router = require("../routes/households.routes"); const router = require("../routes/households.routes");
@ -106,4 +126,23 @@ describe("available-items routes", () => {
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(availableItemsController.createAvailableItem).toHaveBeenCalled(); expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
}); });
test("members can create available items on location-scoped routes", async () => {
const response = await request(app)
.post("/households/1/locations/2/available-items")
.set("x-household-role", "member")
.send({ item_name: "milk" });
expect(response.status).toBe(201);
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
});
test("members cannot delete available items on location-scoped routes", async () => {
const response = await request(app)
.delete("/households/1/locations/2/available-items/3")
.set("x-household-role", "member");
expect(response.status).toBe(403);
expect(availableItemsController.deleteAvailableItem).not.toHaveBeenCalled();
});
}); });

View File

@ -0,0 +1,75 @@
jest.mock("../db/pool", () => ({
connect: jest.fn(),
query: jest.fn(),
}));
const pool = require("../db/pool");
const Household = require("../models/household.model");
describe("household.model household ordering", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("loads households using the user's saved sort order", async () => {
pool.query.mockResolvedValueOnce({
rows: [{ id: 2, name: "Second", household_sort_order: 0 }],
});
const households = await Household.getUserHouseholds(9);
expect(households).toEqual([{ id: 2, name: "Second", household_sort_order: 0 }]);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("ORDER BY hm.household_sort_order ASC NULLS LAST"),
[9]
);
});
test("persists a full household order for the current user", async () => {
const client = {
query: jest.fn()
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ rows: [{ household_id: 1 }, { household_id: 2 }] })
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ rows: [{ id: 2 }, { id: 1 }] })
.mockResolvedValueOnce({}),
release: jest.fn(),
};
pool.connect.mockResolvedValueOnce(client);
const households = await Household.reorderUserHouseholds(9, [2, 1]);
expect(households).toEqual([{ id: 2 }, { id: 1 }]);
expect(client.query).toHaveBeenNthCalledWith(1, "BEGIN");
expect(client.query).toHaveBeenNthCalledWith(
3,
expect.stringContaining("SET household_sort_order = $1"),
[0, 9, 2]
);
expect(client.query).toHaveBeenNthCalledWith(
4,
expect.stringContaining("SET household_sort_order = $1"),
[1, 9, 1]
);
expect(client.query).toHaveBeenLastCalledWith("COMMIT");
expect(client.release).toHaveBeenCalled();
});
test("rejects an order that does not match the user's memberships", async () => {
const client = {
query: jest.fn()
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ rows: [{ household_id: 1 }] })
.mockResolvedValueOnce({}),
release: jest.fn(),
};
pool.connect.mockResolvedValueOnce(client);
const households = await Household.reorderUserHouseholds(9, [1, 2]);
expect(households).toBeNull();
expect(client.query).toHaveBeenNthCalledWith(3, "ROLLBACK");
expect(client.release).toHaveBeenCalled();
});
});

View File

@ -1,5 +1,6 @@
jest.mock("../models/household.model", () => ({ jest.mock("../models/household.model", () => ({
getUserRole: jest.fn(), getUserRole: jest.fn(),
reorderUserHouseholds: jest.fn(),
transferOwnership: jest.fn(), transferOwnership: jest.fn(),
updateMemberRole: jest.fn(), updateMemberRole: jest.fn(),
})); }));
@ -86,3 +87,72 @@ describe("households.controller updateMemberRole", () => {
}); });
}); });
}); });
describe("households.controller reorderHouseholds", () => {
beforeEach(() => {
jest.clearAllMocks();
householdModel.reorderUserHouseholds.mockResolvedValue([
{ id: 3, name: "Third" },
{ id: 1, name: "First" },
]);
});
test("updates the current user's household order", async () => {
const req = {
body: { household_ids: [3, 1] },
user: { id: 9 },
};
const res = createResponse();
await controller.reorderHouseholds(req, res);
expect(householdModel.reorderUserHouseholds).toHaveBeenCalledWith(9, [3, 1]);
expect(res.json).toHaveBeenCalledWith({
message: "Household order updated successfully",
households: [
{ id: 3, name: "Third" },
{ id: 1, name: "First" },
],
});
});
test("rejects duplicate household IDs", async () => {
const req = {
body: { household_ids: [3, 3] },
user: { id: 9 },
};
const res = createResponse();
await controller.reorderHouseholds(req, res);
expect(householdModel.reorderUserHouseholds).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "household_ids must contain unique positive household IDs",
}),
})
);
});
test("rejects orders that do not match current memberships", async () => {
householdModel.reorderUserHouseholds.mockResolvedValue(null);
const req = {
body: { household_ids: [999] },
user: { id: 9 },
};
const res = createResponse();
await controller.reorderHouseholds(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Household order must include every household you belong to",
}),
})
);
});
});

View File

@ -24,6 +24,10 @@ describe("list.model.v2 addOrUpdateItem", () => {
itemId: 55, itemId: 55,
householdStoreItemId: 55, householdStoreItemId: 55,
itemName: "milk", itemName: "milk",
quantity: 3,
previousQuantity: 0,
historyQuantity: 3,
wasBought: false,
isNew: true, isNew: true,
}); });
expect(pool.query).toHaveBeenNthCalledWith( expect(pool.query).toHaveBeenNthCalledWith(
@ -41,7 +45,7 @@ describe("list.model.v2 addOrUpdateItem", () => {
test("returns household store item metadata when updating an existing list item", async () => { test("returns household store item metadata when updating an existing list item", async () => {
pool.query pool.query
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] }) .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false }] }) .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false, quantity: 2 }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [] }); .mockResolvedValueOnce({ rowCount: 1, rows: [] });
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7); const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
@ -51,14 +55,48 @@ describe("list.model.v2 addOrUpdateItem", () => {
itemId: 55, itemId: 55,
householdStoreItemId: 55, householdStoreItemId: 55,
itemName: "milk", itemName: "milk",
quantity: 4,
previousQuantity: 2,
historyQuantity: 2,
wasBought: false,
isNew: false, isNew: false,
}); });
expect(pool.query).toHaveBeenNthCalledWith( expect(pool.query).toHaveBeenNthCalledWith(
3, 3,
expect.stringContaining("UPDATE household_lists"), expect.stringContaining("UPDATE household_lists"),
[4, 88] [4, undefined, 88]
); );
}); });
test("uses the full requested quantity when reopening a bought list item", async () => {
pool.query
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: true, quantity: 2 }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
expect(result).toEqual(
expect.objectContaining({
listId: 88,
quantity: 4,
previousQuantity: 2,
historyQuantity: 4,
wasBought: true,
isNew: false,
})
);
});
test("limits added_by_users to history entries that account for current quantity", async () => {
pool.query.mockResolvedValueOnce({ rowCount: 0, rows: [] });
await List.getHouseholdStoreList(1, 2);
const sql = pool.query.mock.calls[0][0];
expect(sql).toContain("ORDER BY hlh.added_on DESC, hlh.id DESC");
expect(sql).toContain("active_history.newer_quantity < GREATEST(hl.quantity, 0)");
});
}); });
describe("list.model.v2 classification helpers", () => { describe("list.model.v2 classification helpers", () => {
@ -66,7 +104,7 @@ describe("list.model.v2 classification helpers", () => {
pool.query.mockReset(); pool.query.mockReset();
}); });
test("gets classification using household, store, and household-store item ids", async () => { test("gets classification using household, location, and household-store item ids", async () => {
pool.query.mockResolvedValueOnce({ pool.query.mockResolvedValueOnce({
rowCount: 1, rowCount: 1,
rows: [ rows: [
@ -95,17 +133,23 @@ describe("list.model.v2 classification helpers", () => {
); );
}); });
test("upserts classification using household-store item conflict target", async () => { test("upserts classification using household-location item conflict target", async () => {
pool.query.mockResolvedValueOnce({ pool.query
.mockResolvedValueOnce({
rowCount: 1,
rows: [{ id: 12, name: "Dairy & Refrigerated", sort_order: 60 }],
})
.mockResolvedValueOnce({
rowCount: 1, rowCount: 1,
rows: [ rows: [
{ {
household_id: 1, household_id: 1,
store_id: 2, store_location_id: 2,
household_store_item_id: 55, household_store_item_id: 55,
item_type: "dairy", item_type: "dairy",
item_group: "Milk", item_group: "Milk",
zone: "Dairy & Refrigerated", zone: "Dairy & Refrigerated",
zone_id: 12,
confidence: 1, confidence: 1,
source: "user", source: "user",
}, },
@ -123,14 +167,14 @@ describe("list.model.v2 classification helpers", () => {
expect(result).toEqual( expect(result).toEqual(
expect.objectContaining({ expect.objectContaining({
household_id: 1, household_id: 1,
store_id: 2, store_location_id: 2,
household_store_item_id: 55, household_store_item_id: 55,
item_type: "dairy", item_type: "dairy",
}) })
); );
expect(pool.query).toHaveBeenCalledWith( expect(pool.query).toHaveBeenLastCalledWith(
expect.stringContaining("ON CONFLICT (household_id, store_id, household_store_item_id)"), expect.stringContaining("ON CONFLICT (household_id, store_location_id, household_store_item_id)"),
[1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"] [1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 12, 1, "user"]
); );
}); });
}); });

View File

@ -3,6 +3,8 @@ jest.mock("../models/list.model.v2", () => ({
addOrUpdateItem: jest.fn(), addOrUpdateItem: jest.fn(),
ensureHouseholdStoreItem: jest.fn(), ensureHouseholdStoreItem: jest.fn(),
getItemByName: jest.fn(), getItemByName: jest.fn(),
getZoneByName: jest.fn(),
recordItemEvent: jest.fn(),
upsertClassification: jest.fn(), upsertClassification: jest.fn(),
})); }));
@ -37,7 +39,9 @@ describe("lists.controller.v2 addItem", () => {
}); });
List.addHistoryRecord.mockResolvedValue(undefined); List.addHistoryRecord.mockResolvedValue(undefined);
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" }); List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
List.upsertClassification.mockResolvedValue(undefined); List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" });
List.recordItemEvent.mockResolvedValue(undefined);
List.upsertClassification.mockResolvedValue({ zone_id: 5 });
householdModel.isHouseholdMember.mockResolvedValue(true); householdModel.isHouseholdMember.mockResolvedValue(true);
}); });
@ -54,7 +58,15 @@ describe("lists.controller.v2 addItem", () => {
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9); expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
expect(List.addOrUpdateItem).toHaveBeenCalled(); expect(List.addOrUpdateItem).toHaveBeenCalled();
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 9); expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 9, "2");
expect(List.recordItemEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventType: "ITEM_ADDED",
householdId: "1",
storeLocationId: "2",
householdStoreItemId: 99,
})
);
expect(res.status).not.toHaveBeenCalledWith(400); expect(res.status).not.toHaveBeenCalledWith(400);
}); });
@ -71,10 +83,42 @@ describe("lists.controller.v2 addItem", () => {
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled(); expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
expect(List.addOrUpdateItem).toHaveBeenCalled(); expect(List.addOrUpdateItem).toHaveBeenCalled();
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7); expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7, "2");
expect(res.status).not.toHaveBeenCalledWith(400); expect(res.status).not.toHaveBeenCalledWith(400);
}); });
test("records duplicate-add history with the added quantity instead of the new total", async () => {
List.addOrUpdateItem.mockResolvedValueOnce({
listId: 42,
itemId: 99,
householdStoreItemId: 99,
itemName: "milk",
quantity: 3,
previousQuantity: 1,
historyQuantity: 2,
isNew: false,
});
const req = {
params: { householdId: "1", storeId: "2" },
body: { item_name: "milk", quantity: "3" },
user: { id: 7 },
processedImage: null,
};
const res = createResponse();
await controller.addItem(req, res);
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, 2, 7, "2");
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
item: expect.objectContaining({
quantity: 3,
}),
})
);
});
test("records history using request user when added_for_user_id is blank", async () => { test("records history using request user when added_for_user_id is blank", async () => {
const req = { const req = {
params: { householdId: "1", storeId: "2" }, params: { householdId: "1", storeId: "2" },
@ -88,7 +132,7 @@ describe("lists.controller.v2 addItem", () => {
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled(); expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
expect(List.addOrUpdateItem).toHaveBeenCalled(); expect(List.addOrUpdateItem).toHaveBeenCalled();
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7); expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7, "2");
expect(res.status).not.toHaveBeenCalledWith(400); expect(res.status).not.toHaveBeenCalledWith(400);
}); });
@ -169,7 +213,9 @@ describe("lists.controller.v2 setClassification", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" }); List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
List.upsertClassification.mockResolvedValue(undefined); List.upsertClassification.mockResolvedValue({ zone_id: 5 });
List.recordItemEvent.mockResolvedValue(undefined);
List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" });
List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" }); List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" });
}); });
@ -216,6 +262,7 @@ describe("lists.controller.v2 setClassification", () => {
}); });
test("accepts zone-only classification updates", async () => { test("accepts zone-only classification updates", async () => {
List.getZoneByName.mockResolvedValueOnce({ id: 6, name: "Checkout Area" });
const req = { const req = {
params: { householdId: "1", storeId: "2" }, params: { householdId: "1", storeId: "2" },
body: { body: {
@ -297,6 +344,7 @@ describe("lists.controller.v2 setClassification", () => {
}); });
test("rejects invalid zone", async () => { test("rejects invalid zone", async () => {
List.getZoneByName.mockResolvedValueOnce(null);
const req = { const req = {
params: { householdId: "1", storeId: "2" }, params: { householdId: "1", storeId: "2" },
body: { body: {
@ -350,6 +398,7 @@ describe("lists.controller.v2 setClassification", () => {
test("creates a household store item when classification target is not yet on the list", async () => { test("creates a household store item when classification target is not yet on the list", async () => {
List.getItemByName.mockResolvedValueOnce(null); List.getItemByName.mockResolvedValueOnce(null);
List.getZoneByName.mockResolvedValueOnce({ id: 7, name: "Snacks & Candy" });
const req = { const req = {
params: { householdId: "1", storeId: "2" }, params: { householdId: "1", storeId: "2" },

View File

@ -0,0 +1,164 @@
jest.mock("../middleware/auth", () => (req, res, next) => {
req.user = { id: 42, role: "user" };
next();
});
jest.mock("../middleware/household", () => ({
householdAccess: (req, res, next) => {
req.household = {
id: Number.parseInt(req.params.householdId, 10),
role: req.headers["x-household-role"] || "member",
};
next();
},
locationAccess: (req, res, next) => {
req.storeLocation = { id: Number.parseInt(req.params.locationId, 10) };
next();
},
requireHouseholdAdmin: (req, res, next) => {
if (["owner", "admin"].includes(req.household?.role)) {
return next();
}
return res.status(403).json({
error: { code: "FORBIDDEN", message: "Admin role required" },
request_id: req.request_id,
});
},
storeAccess: (req, res, next) => next(),
}));
jest.mock("../middleware/image", () => ({
upload: {
single: () => (req, res, next) => next(),
},
processImage: (req, res, next) => next(),
}));
jest.mock("../controllers/households.controller", () => ({
createHousehold: jest.fn(),
deleteHousehold: jest.fn(),
getHousehold: jest.fn(),
getMembers: jest.fn(),
getUserHouseholds: jest.fn(),
joinHousehold: jest.fn(),
refreshInviteCode: jest.fn(),
removeMember: jest.fn(),
reorderHouseholds: jest.fn((req, res) => res.json({ message: "ordered" })),
updateHousehold: jest.fn(),
updateMemberRole: jest.fn(),
}));
jest.mock("../controllers/lists.controller.v2", () => ({
addItem: jest.fn(),
deleteItem: jest.fn(),
getClassification: jest.fn(),
getItemByName: jest.fn(),
getList: jest.fn(),
getRecentlyBought: jest.fn(),
getSuggestions: jest.fn(),
markBought: jest.fn(),
setClassification: jest.fn(),
updateItem: jest.fn(),
updateItemImage: jest.fn(),
}));
jest.mock("../controllers/available-items.controller", () => ({
createAvailableItem: jest.fn(),
deleteAvailableItem: jest.fn(),
getAvailableItems: jest.fn(),
importCurrentItems: jest.fn(),
updateAvailableItem: jest.fn(),
}));
jest.mock("../controllers/stores.controller", () => ({
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
createZone: jest.fn((req, res) => res.status(201).json({ message: "zone" })),
deleteHouseholdStore: jest.fn((req, res) => res.json({ message: "deleted store" })),
deleteLocation: jest.fn((req, res) => res.json({ message: "deleted location" })),
deleteZone: jest.fn((req, res) => res.json({ message: "deleted zone" })),
getHouseholdStores: jest.fn((req, res) => res.json([{ id: 2, name: "Costco" }])),
getLocationZones: jest.fn((req, res) => res.json({ zones: [] })),
setDefaultLocation: jest.fn((req, res) => res.json({ message: "default" })),
updateHouseholdStore: jest.fn((req, res) => res.json({ message: "updated store" })),
updateLocation: jest.fn((req, res) => res.json({ message: "updated location" })),
updateZone: jest.fn((req, res) => res.json({ message: "updated zone" })),
}));
const express = require("express");
const request = require("supertest");
const router = require("../routes/households.routes");
const householdsController = require("../controllers/households.controller");
const storesController = require("../controllers/stores.controller");
describe("store location routes", () => {
let app;
beforeEach(() => {
app = express();
app.use(express.json());
app.use("/households", router);
jest.clearAllMocks();
});
test("members can list household store locations", async () => {
const response = await request(app).get("/households/1/stores");
expect(response.status).toBe(200);
expect(storesController.getHouseholdStores).toHaveBeenCalled();
});
test("users can reorder their household switcher list", async () => {
const response = await request(app)
.patch("/households/order")
.send({ household_ids: [3, 1, 2] });
expect(response.status).toBe(200);
expect(householdsController.reorderHouseholds).toHaveBeenCalled();
});
test("members cannot create household stores", async () => {
const response = await request(app)
.post("/households/1/stores")
.set("x-household-role", "member")
.send({ name: "Costco" });
expect(response.status).toBe(403);
expect(storesController.createHouseholdStore).not.toHaveBeenCalled();
});
test("admins can create household stores", async () => {
const response = await request(app)
.post("/households/1/stores")
.set("x-household-role", "admin")
.send({ name: "Costco", location_name: "Fontana" });
expect(response.status).toBe(201);
expect(storesController.createHouseholdStore).toHaveBeenCalled();
});
test("members can list zones but cannot create zones", async () => {
const listResponse = await request(app)
.get("/households/1/locations/2/zones")
.set("x-household-role", "member");
const createResponse = await request(app)
.post("/households/1/locations/2/zones")
.set("x-household-role", "member")
.send({ name: "Produce", sort_order: 10 });
expect(listResponse.status).toBe(200);
expect(createResponse.status).toBe(403);
expect(storesController.getLocationZones).toHaveBeenCalled();
expect(storesController.createZone).not.toHaveBeenCalled();
});
test("admins can update zone order", async () => {
const response = await request(app)
.patch("/households/1/locations/2/zones/9")
.set("x-household-role", "admin")
.send({ sort_order: 20 });
expect(response.status).toBe(200);
expect(storesController.updateZone).toHaveBeenCalled();
});
});

71
docs/GITEA_PR_WORKFLOW.md Normal file
View File

@ -0,0 +1,71 @@
# Gitea PR Workflow
Use this workflow when creating or merging PRs for this repo. It is designed for Codex and local operators to use the same commands without storing secrets in git.
## One-time token setup
Create a Gitea access token with repository pull request permissions, then set it only in the shell or user environment:
```powershell
$env:GITEA_BASE_URL = "http://192.168.7.78:3000"
$env:GITEA_TOKEN = "<token>"
```
For Codex sandbox sessions, use the ignored local env file when inherited environment variables are not visible:
```powershell
@"
GITEA_BASE_URL=http://192.168.7.78:3000
GITEA_TOKEN=<token>
"@ | Set-Content .codex-local.env
```
Do not commit tokens, paste them into docs, or print them in logs.
Check auth:
```powershell
npm run pr:auth
```
## Create a PR
1. Push the branch first.
2. Inspect the cumulative branch diff against the PR target:
```powershell
git log <base>..HEAD --oneline --decorate
git diff --stat <base>..HEAD
```
3. Write the PR body to a temporary local file. Include the coordination record required by `PROJECT_INSTRUCTIONS.md`.
4. Create the PR:
```powershell
npm run pr:create -- --base <base> --title "<title>" --body-file <body-file>
```
The helper checks for an existing open PR with the same base/head and returns it instead of creating a duplicate.
If the PR body needs to be changed after creation, update it from a body file:
```powershell
npm run pr:update -- --number <pr-number> --body-file <body-file>
```
For stacked work, pass the parent PR branch as `<base>`. For standalone work, pass `main`.
## View or merge a PR
View:
```powershell
npm run pr:view -- --number <pr-number>
```
Merge after explicit operator approval and required checks:
```powershell
npm run pr:merge -- --number <pr-number> --method merge --delete-branch --yes
```
The helper refuses to merge without `--yes`. Use `--method squash` or `--method rebase` only when that is the intended repo workflow.

View File

@ -4,6 +4,7 @@ Base URL: `http://localhost:5000/api`
## Table of Contents ## Table of Contents
- [Authentication](#authentication) - [Authentication](#authentication)
- [Current Household Location Scope](#current-household-location-scope)
- [Grocery List Management](#grocery-list-management) - [Grocery List Management](#grocery-list-management)
- [User Management](#user-management) - [User Management](#user-management)
- [Admin Operations](#admin-operations) - [Admin Operations](#admin-operations)
@ -12,6 +13,38 @@ Base URL: `http://localhost:5000/api`
--- ---
## Current Household Location Scope
The active grocery flow is scoped by household-owned store locations, not the legacy global store catalog.
- List household store locations: `GET /households/:householdId/stores`
- Create a household-owned store with an initial location: `POST /households/:householdId/stores`
- Add a location to a household store: `POST /households/:householdId/stores/:householdStoreId/locations`
- Set the default shopping location: `PATCH /households/:householdId/locations/:locationId/default`
- Manage ordered zones for a location: `GET|POST /households/:householdId/locations/:locationId/zones`
- Update/remove a zone: `PATCH|DELETE /households/:householdId/locations/:locationId/zones/:zoneId`
- Location-scoped list APIs: `/households/:householdId/locations/:locationId/list...`
- Location-scoped item catalog APIs: `/households/:householdId/locations/:locationId/available-items...`
Owners/admins manage stores, locations, zones, and catalog deletion. Members can add/update list items and catalog item details.
### Household Switcher Order
The current user can persist their own household switcher order:
- Update household order: `PATCH /households/order`
Request body:
```json
{
"household_ids": [3, 1, 2]
}
```
The list must contain each household the current user belongs to exactly once. The response returns the reordered household list.
---
## Authentication ## Authentication
All authenticated endpoints require a JWT token in the `Authorization` header: All authenticated endpoints require a JWT token in the `Authorization` header:
@ -129,7 +162,7 @@ Retrieve all unbought grocery items.
- `bought` - Purchase status (always false for this endpoint) - `bought` - Purchase status (always false for this endpoint)
- `item_image` - Base64 encoded image (nullable) - `item_image` - Base64 encoded image (nullable)
- `image_mime_type` - MIME type of image (nullable) - `image_mime_type` - MIME type of image (nullable)
- `added_by_users` - Array of user names who added/modified this item - `added_by_users` - Array of user names whose additions account for the current listed quantity
- `modified_on` - Last modification timestamp - `modified_on` - Last modification timestamp
- `item_type` - Classification type (nullable) - `item_type` - Classification type (nullable)
- `item_group` - Classification group (nullable) - `item_group` - Classification group (nullable)

View File

@ -9,7 +9,7 @@ function appendClassification(formData, classification) {
} }
export const getAvailableItems = (householdId, storeId, query = "") => export const getAvailableItems = (householdId, storeId, query = "") =>
api.get(`/households/${householdId}/stores/${storeId}/available-items`, { api.get(`/households/${householdId}/locations/${storeId}/available-items`, {
params: query ? { query } : undefined, params: query ? { query } : undefined,
}); });
@ -21,7 +21,7 @@ export const createAvailableItem = (householdId, storeId, payload) => {
formData.append("image", payload.imageFile); formData.append("image", payload.imageFile);
} }
return api.post(`/households/${householdId}/stores/${storeId}/available-items`, formData, { return api.post(`/households/${householdId}/locations/${storeId}/available-items`, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
@ -41,7 +41,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => {
formData.append("image", payload.imageFile); formData.append("image", payload.imageFile);
} }
return api.patch(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`, formData, { return api.patch(`/households/${householdId}/locations/${storeId}/available-items/${itemId}`, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
@ -49,7 +49,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => {
}; };
export const deleteAvailableItem = (householdId, storeId, itemId) => export const deleteAvailableItem = (householdId, storeId, itemId) =>
api.delete(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`); api.delete(`/households/${householdId}/locations/${storeId}/available-items/${itemId}`);
export const importCurrentAvailableItems = (householdId, storeId) => export const importCurrentAvailableItems = (householdId, storeId) =>
api.post(`/households/${householdId}/stores/${storeId}/available-items/import-current`); api.post(`/households/${householdId}/locations/${storeId}/available-items/import-current`);

View File

@ -1,5 +1,10 @@
import axios from "axios"; import axios from "axios";
import { API_BASE_URL } from "../config"; import { API_BASE_URL } from "../config";
import {
cacheApiResponse,
getCachedApiResponse,
isTransientApiError,
} from "./offlineCache";
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
@ -31,6 +36,7 @@ api.interceptors.response.use(
response.request_id = payload.request_id; response.request_id = payload.request_id;
response.data = payload.data; response.data = payload.data;
} }
cacheApiResponse(response.config, response.data);
return response; return response;
}, },
error => { error => {
@ -55,8 +61,25 @@ api.interceptors.response.use(
window.location.href = "/login"; window.location.href = "/login";
alert("Your session has expired. Please log in again."); alert("Your session has expired. Please log in again.");
} }
return Promise.reject(error);
} if (isTransientApiError(error)) {
); const cached = getCachedApiResponse(error.config);
if (cached) {
return Promise.resolve({
data: cached.data,
status: 200,
statusText: "OK (stale cache)",
headers: {},
config: error.config,
request: error.request,
stale: true,
cachedAt: cached.cachedAt,
});
}
}
return Promise.reject(error);
}
);
export default api; export default api;

View File

@ -5,6 +5,12 @@ import api from "./axios";
*/ */
export const getUserHouseholds = () => api.get("/households"); export const getUserHouseholds = () => api.get("/households");
/**
* Update the current user's household switcher order
*/
export const reorderHouseholds = (householdIds) =>
api.patch("/households/order", { household_ids: householdIds });
/** /**
* Get details of a specific household * Get details of a specific household
*/ */

View File

@ -3,14 +3,14 @@ import api from "./axios";
/** /**
* Get grocery list for household and store * Get grocery list for household and store
*/ */
export const getList = (householdId, storeId) => export const getList = (householdId, storeId) =>
api.get(`/households/${householdId}/stores/${storeId}/list`); api.get(`/households/${householdId}/locations/${storeId}/list`);
/** /**
* Get specific item by name * Get specific item by name
*/ */
export const getItemByName = (householdId, storeId, itemName) => export const getItemByName = (householdId, storeId, itemName) =>
api.get(`/households/${householdId}/stores/${storeId}/list/item`, { api.get(`/households/${householdId}/locations/${storeId}/list/item`, {
params: { item_name: itemName } params: { item_name: itemName }
}); });
@ -39,7 +39,7 @@ export const addItem = (
formData.append("image", imageFile); formData.append("image", imageFile);
} }
return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, { return api.post(`/households/${householdId}/locations/${storeId}/list/add`, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
@ -49,8 +49,8 @@ export const addItem = (
/** /**
* Get item classification * Get item classification
*/ */
export const getClassification = (householdId, storeId, itemName) => export const getClassification = (householdId, storeId, itemName) =>
api.get(`/households/${householdId}/stores/${storeId}/list/classification`, { api.get(`/households/${householdId}/locations/${storeId}/list/classification`, {
params: { item_name: itemName } params: { item_name: itemName }
}); });
@ -58,7 +58,7 @@ export const getClassification = (householdId, storeId, itemName) =>
* Set item classification * Set item classification
*/ */
export const setClassification = (householdId, storeId, itemName, classification) => export const setClassification = (householdId, storeId, itemName, classification) =>
api.post(`/households/${householdId}/stores/${storeId}/list/classification`, { api.post(`/households/${householdId}/locations/${storeId}/list/classification`, {
item_name: itemName, item_name: itemName,
classification classification
}); });
@ -103,8 +103,8 @@ export const updateItemWithClassification = (householdId, storeId, itemName, qua
/** /**
* Update item details (quantity, notes) * Update item details (quantity, notes)
*/ */
export const updateItem = (householdId, storeId, itemName, quantity, notes) => export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
api.put(`/households/${householdId}/stores/${storeId}/list/item`, { api.put(`/households/${householdId}/locations/${storeId}/list/item`, {
item_name: itemName, item_name: itemName,
quantity, quantity,
notes notes
@ -113,8 +113,8 @@ export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
/** /**
* Mark item as bought or unbought * Mark item as bought or unbought
*/ */
export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) => export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) =>
api.patch(`/households/${householdId}/stores/${storeId}/list/item`, { api.patch(`/households/${householdId}/locations/${storeId}/list/item`, {
item_name: itemName, item_name: itemName,
bought, bought,
quantity_bought: quantityBought quantity_bought: quantityBought
@ -123,24 +123,24 @@ export const markBought = (householdId, storeId, itemName, quantityBought = null
/** /**
* Delete item from list * Delete item from list
*/ */
export const deleteItem = (householdId, storeId, itemName) => export const deleteItem = (householdId, storeId, itemName) =>
api.delete(`/households/${householdId}/stores/${storeId}/list/item`, { api.delete(`/households/${householdId}/locations/${storeId}/list/item`, {
data: { item_name: itemName } data: { item_name: itemName }
}); });
/** /**
* Get suggestions based on query * Get suggestions based on query
*/ */
export const getSuggestions = (householdId, storeId, query) => export const getSuggestions = (householdId, storeId, query) =>
api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, { api.get(`/households/${householdId}/locations/${storeId}/list/suggestions`, {
params: { query } params: { query }
}); });
/** /**
* Get recently bought items * Get recently bought items
*/ */
export const getRecentlyBought = (householdId, storeId) => export const getRecentlyBought = (householdId, storeId) =>
api.get(`/households/${householdId}/stores/${storeId}/list/recent`); api.get(`/households/${householdId}/locations/${storeId}/list/recent`);
/** /**
* Update item image * Update item image
@ -158,7 +158,7 @@ export const updateItemImage = (
formData.append("quantity", quantity); formData.append("quantity", quantity);
formData.append("image", imageFile); formData.append("image", imageFile);
return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, { return api.post(`/households/${householdId}/locations/${storeId}/list/update-image`, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },

View File

@ -0,0 +1,186 @@
const CACHE_PREFIX = "fiddy:api-cache:v1:";
const CACHE_INDEX_KEY = "fiddy:api-cache-index:v1";
const MAX_CACHE_ENTRIES = 80;
const TRANSIENT_STATUS_CODES = new Set([408, 429, 502, 503, 504]);
const SENSITIVE_URL_PATTERN = /(receipt|download|update-image)/i;
const BINARY_FIELD_PATTERN = /(image|receipt|bytes|buffer|blob|base64)/i;
function getStorage() {
if (typeof window === "undefined" || !window.localStorage) return null;
return window.localStorage;
}
function getCurrentUserScope() {
const storage = getStorage();
if (!storage) return null;
try {
return storage.getItem("userId") || null;
} catch (_) {
return null;
}
}
function stableStringify(value) {
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(",")}]`;
}
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(",")}}`;
}
function hashString(value) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash << 5) - hash + value.charCodeAt(index);
hash |= 0;
}
return Math.abs(hash).toString(36);
}
function requestFingerprint(config) {
if (!config || String(config.method || "get").toLowerCase() !== "get") {
return null;
}
if (config.responseType && config.responseType !== "json") {
return null;
}
const url = config.url || "";
if (!url || SENSITIVE_URL_PATTERN.test(url)) {
return null;
}
return stableStringify({
baseURL: config.baseURL || "",
method: "get",
params: config.params || null,
url,
});
}
function cacheKeyForConfig(config) {
const scope = getCurrentUserScope();
const fingerprint = requestFingerprint(config);
if (!scope || !fingerprint) return null;
return `${CACHE_PREFIX}${scope}:${hashString(fingerprint)}`;
}
function readIndex(storage) {
try {
const parsed = JSON.parse(storage.getItem(CACHE_INDEX_KEY) || "[]");
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
function writeIndex(storage, index) {
try {
storage.setItem(CACHE_INDEX_KEY, JSON.stringify(index.slice(-MAX_CACHE_ENTRIES)));
} catch (_) {
// Ignore cache index write failures; the live request already succeeded.
}
}
function rememberCacheKey(storage, key, scope) {
const now = Date.now();
const nextIndex = readIndex(storage)
.filter((entry) => entry?.key !== key)
.concat({ key, scope, touchedAt: now });
const trimmedIndex = nextIndex.slice(-MAX_CACHE_ENTRIES);
for (const staleEntry of nextIndex.slice(0, -MAX_CACHE_ENTRIES)) {
try {
storage.removeItem(staleEntry.key);
} catch (_) {
// Best-effort cache pruning only.
}
}
writeIndex(storage, trimmedIndex);
}
function sanitizeForCache(value) {
if (Array.isArray(value)) {
return value.map(sanitizeForCache);
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, entryValue]) => [
key,
BINARY_FIELD_PATTERN.test(key) ? null : sanitizeForCache(entryValue),
])
);
}
export function cacheApiResponse(config, data) {
const storage = getStorage();
const key = cacheKeyForConfig(config);
const scope = getCurrentUserScope();
if (!storage || !key || !scope) return;
try {
storage.setItem(
key,
JSON.stringify({
cachedAt: new Date().toISOString(),
data: sanitizeForCache(data),
})
);
rememberCacheKey(storage, key, scope);
} catch (_) {
// Cache is an opportunistic fallback. Quota/private-mode failures are non-fatal.
}
}
export function getCachedApiResponse(config) {
const storage = getStorage();
const key = cacheKeyForConfig(config);
if (!storage || !key) return null;
try {
const cached = JSON.parse(storage.getItem(key) || "null");
if (!cached || !Object.prototype.hasOwnProperty.call(cached, "data")) {
return null;
}
return cached;
} catch (_) {
return null;
}
}
export function isTransientApiError(error) {
if (!error?.response) return true;
return TRANSIENT_STATUS_CODES.has(error.response.status);
}
export function clearApiCacheForCurrentUser() {
const storage = getStorage();
const scope = getCurrentUserScope();
if (!storage || !scope) return;
const nextIndex = readIndex(storage).filter((entry) => {
if (entry?.scope !== scope) return true;
try {
storage.removeItem(entry.key);
} catch (_) {
// Best effort only.
}
return false;
});
writeIndex(storage, nextIndex);
}

View File

@ -1,48 +1,55 @@
import api from "./axios"; import api from "./axios";
/** // Legacy global store catalog for the system-admin page.
* Get all stores in the system
*/
export const getAllStores = () => api.get("/stores"); export const getAllStores = () => api.get("/stores");
export const createStore = (name, default_zones) =>
/** api.post("/stores", { name, default_zones });
* Get stores linked to a household export const updateStore = (storeId, name, default_zones) =>
*/ api.patch(`/stores/${storeId}`, { name, default_zones });
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) => export const deleteStore = (storeId) =>
api.delete(`/stores/${storeId}`); api.delete(`/stores/${storeId}`);
// Household-owned store locations used by the grocery flow.
export const getHouseholdStores = (householdId) =>
api.get(`/households/${householdId}/stores`);
export const createHouseholdStore = (householdId, payload) =>
api.post(`/households/${householdId}/stores`, payload);
export const updateHouseholdStore = (householdId, householdStoreId, payload) =>
api.patch(`/households/${householdId}/stores/${householdStoreId}`, payload);
export const deleteHouseholdStore = (householdId, householdStoreId) =>
api.delete(`/households/${householdId}/stores/${householdStoreId}`);
export const addLocationToStore = (householdId, householdStoreId, payload) =>
api.post(`/households/${householdId}/stores/${householdStoreId}/locations`, payload);
export const updateLocation = (householdId, locationId, payload) =>
api.patch(`/households/${householdId}/locations/${locationId}`, payload);
export const removeLocation = (householdId, locationId) =>
api.delete(`/households/${householdId}/locations/${locationId}`);
export const setDefaultLocation = (householdId, locationId) =>
api.patch(`/households/${householdId}/locations/${locationId}/default`);
export const getLocationZones = (householdId, locationId) =>
api.get(`/households/${householdId}/locations/${locationId}/zones`);
export const createLocationZone = (householdId, locationId, payload) =>
api.post(`/households/${householdId}/locations/${locationId}/zones`, payload);
export const updateLocationZone = (householdId, locationId, zoneId, payload) =>
api.patch(`/households/${householdId}/locations/${locationId}/zones/${zoneId}`, payload);
export const deleteLocationZone = (householdId, locationId, zoneId) =>
api.delete(`/households/${householdId}/locations/${locationId}/zones/${zoneId}`);
// Compatibility aliases for older callers.
export const addStoreToHousehold = (householdId, storeId, isDefault = false) =>
api.post(`/stores/household/${householdId}`, { storeId, isDefault });
export const removeStoreFromHousehold = (householdId, storeId) =>
api.delete(`/stores/household/${householdId}/${storeId}`);
export const setDefaultStore = (householdId, storeId) =>
api.patch(`/stores/household/${householdId}/${storeId}/default`);

View File

@ -0,0 +1,35 @@
export default function ListSearchInput({ value, onChange, resultCount, totalCount }) {
const hasSearch = value.trim().length > 0;
return (
<div className="glist-search">
<div className="glist-search-row">
<input
id="grocery-list-search"
className="glist-search-input"
type="search"
aria-label="Search list"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Search list"
autoComplete="off"
/>
{hasSearch && (
<button
className="glist-search-clear"
type="button"
onClick={() => onChange("")}
aria-label="Clear list search"
>
Clear
</button>
)}
</div>
{hasSearch && (
<p className="glist-search-meta">
{resultCount} of {totalCount} item{totalCount === 1 ? "" : "s"}
</p>
)}
</div>
);
}

View File

@ -1,11 +0,0 @@
export default function SortDropdown({ value, onChange }) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} className="glist-sort">
<option value="az">A Z</option>
<option value="za">Z A</option>
<option value="qty-high">Quantity: High Low</option>
<option value="qty-low">Quantity: Low High</option>
<option value="zone">By Zone</option>
</select>
);
}

View File

@ -2,7 +2,7 @@
export { default as ErrorMessage } from './ErrorMessage.jsx'; export { default as ErrorMessage } from './ErrorMessage.jsx';
export { default as FloatingActionButton } from './FloatingActionButton.jsx'; export { default as FloatingActionButton } from './FloatingActionButton.jsx';
export { default as FormInput } from './FormInput.jsx'; export { default as FormInput } from './FormInput.jsx';
export { default as SortDropdown } from './SortDropdown.jsx'; export { default as ListSearchInput } from './ListSearchInput.jsx';
export { default as ToggleButtonGroup } from './ToggleButtonGroup.jsx'; export { default as ToggleButtonGroup } from './ToggleButtonGroup.jsx';
export { default as UserRoleCard } from './UserRoleCard.jsx'; export { default as UserRoleCard } from './UserRoleCard.jsx';

View File

@ -21,11 +21,15 @@ export default function ClassificationSection({
onItemTypeChange, onItemTypeChange,
onItemGroupChange, onItemGroupChange,
onZoneChange, onZoneChange,
zones = null,
title = "Item Classification (Optional)", title = "Item Classification (Optional)",
fieldClass = "classification-field", fieldClass = "classification-field",
selectClass = "classification-select" selectClass = "classification-select"
}) { }) {
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : []; const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
const zoneOptions = Array.isArray(zones) && zones.length > 0
? zones.map((candidate) => candidate.name || candidate).filter(Boolean)
: getZoneValues();
const handleTypeChange = (e) => { const handleTypeChange = (e) => {
const newType = e.target.value; const newType = e.target.value;
@ -35,7 +39,7 @@ export default function ClassificationSection({
return ( return (
<div className="classification-section"> <div className="classification-section">
<h3 className="classification-title">{title}</h3> {title && <h3 className="classification-title">{title}</h3>}
<div className={fieldClass}> <div className={fieldClass}>
<label>Item Type</label> <label>Item Type</label>
@ -79,7 +83,7 @@ export default function ClassificationSection({
className={selectClass} className={selectClass}
> >
<option value="">-- Select Zone --</option> <option value="">-- Select Zone --</option>
{getZoneValues().map((z) => ( {zoneOptions.map((z) => (
<option key={z} value={z}> <option key={z} value={z}>
{z} {z}
</option> </option>

View File

@ -9,12 +9,16 @@ import "../../styles/components/ImageUploadSection.css";
* @param {Function} props.onImageChange - Callback when image is selected (file) * @param {Function} props.onImageChange - Callback when image is selected (file)
* @param {Function} props.onImageRemove - Callback to remove image * @param {Function} props.onImageRemove - Callback to remove image
* @param {string} props.title - Section title (optional) * @param {string} props.title - Section title (optional)
* @param {string} props.cameraLabel - Camera button label (optional)
* @param {string} props.galleryLabel - Gallery button label (optional)
*/ */
export default function ImageUploadSection({ export default function ImageUploadSection({
imagePreview, imagePreview,
onImageChange, onImageChange,
onImageRemove, onImageRemove,
title = "Item Image (Optional)" title = "Item Image (Optional)",
cameraLabel = "Use Camera",
galleryLabel = "Choose from Gallery"
}) { }) {
const cameraInputRef = useRef(null); const cameraInputRef = useRef(null);
const galleryInputRef = useRef(null); const galleryInputRef = useRef(null);
@ -51,7 +55,7 @@ export default function ImageUploadSection({
return ( return (
<div className="image-upload-section"> <div className="image-upload-section">
<h3 className="image-upload-title">{title}</h3> {title && <h3 className="image-upload-title">{title}</h3>}
{sizeError && ( {sizeError && (
<div className="image-upload-error"> <div className="image-upload-error">
{sizeError} {sizeError}
@ -60,10 +64,20 @@ export default function ImageUploadSection({
<div className="image-upload-content"> <div className="image-upload-content">
{!imagePreview ? ( {!imagePreview ? (
<div className="image-upload-options"> <div className="image-upload-options">
<button onClick={handleCameraClick} className="image-upload-btn camera" type="button"> <button
onClick={handleCameraClick}
className="image-upload-btn camera"
type="button"
aria-label={cameraLabel}
>
📷 Use Camera 📷 Use Camera
</button> </button>
<button onClick={handleGalleryClick} className="image-upload-btn gallery" type="button"> <button
onClick={handleGalleryClick}
className="image-upload-btn gallery"
type="button"
aria-label={galleryLabel}
>
🖼 Choose from Gallery 🖼 Choose from Gallery
</button> </button>
</div> </div>

View File

@ -1,5 +1,7 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/HouseholdSwitcher.css"; import "../../styles/components/HouseholdSwitcher.css";
import CreateJoinHousehold from "../manage/CreateJoinHousehold"; import CreateJoinHousehold from "../manage/CreateJoinHousehold";
@ -8,10 +10,13 @@ export default function HouseholdSwitcher() {
households, households,
activeHousehold, activeHousehold,
setActiveHousehold, setActiveHousehold,
reorderHouseholds,
loading, loading,
hasLoaded, hasLoaded,
} = useContext(HouseholdContext); } = useContext(HouseholdContext);
const toast = useActionToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isReordering, setIsReordering] = useState(false);
const [showCreateJoin, setShowCreateJoin] = useState(false); const [showCreateJoin, setShowCreateJoin] = useState(false);
if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) { if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) {
@ -49,6 +54,28 @@ export default function HouseholdSwitcher() {
setIsOpen(false); setIsOpen(false);
}; };
const handleMove = async (household, currentIndex, direction) => {
const nextIndex = currentIndex + direction;
if (nextIndex < 0 || nextIndex >= households.length || isReordering) return;
const nextHouseholds = [...households];
[nextHouseholds[currentIndex], nextHouseholds[nextIndex]] = [
nextHouseholds[nextIndex],
nextHouseholds[currentIndex],
];
setIsReordering(true);
try {
await reorderHouseholds(nextHouseholds.map((nextHousehold) => nextHousehold.id));
toast.success("Updated household order", `Moved ${household.name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update household order");
toast.error("Household order failed", `Household order failed: ${message}`);
} finally {
setIsReordering(false);
}
};
return ( return (
<div className="household-switcher"> <div className="household-switcher">
<button <button
@ -65,18 +92,48 @@ export default function HouseholdSwitcher() {
<> <>
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} /> <div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
<div className="household-switcher-dropdown"> <div className="household-switcher-dropdown">
{households.map((household) => ( {households.map((household, index) => (
<button <div
key={household.id} key={household.id}
className={`household-option ${household.id === activeHousehold.id ? "active" : ""}`} className={
type="button" `household-option-row ${household.id === activeHousehold.id ? "active" : ""}`
onClick={() => handleSelect(household)} }
> >
{household.name} <button
{household.id === activeHousehold.id && ( className="household-option"
<span className="check-mark">&#10003;</span> type="button"
onClick={() => handleSelect(household)}
>
<span className="household-option-name">{household.name}</span>
</button>
{households.length > 1 && (
<div
className="household-reorder-controls"
aria-label={`Reorder ${household.name}`}
>
<button
className="household-reorder-button"
type="button"
onClick={() => handleMove(household, index, -1)}
disabled={index === 0 || isReordering}
aria-label={`Move ${household.name} up`}
title={`Move ${household.name} up`}
>
&#9650;
</button>
<button
className="household-reorder-button"
type="button"
onClick={() => handleMove(household, index, 1)}
disabled={index === households.length - 1 || isReordering}
aria-label={`Move ${household.name} down`}
title={`Move ${household.name} down`}
>
&#9660;
</button>
</div>
)} )}
</button> </div>
))} ))}
<div className="household-divider"></div> <div className="household-divider"></div>
<button <button

View File

@ -70,6 +70,7 @@ export default function ManageHousehold() {
const [pendingDecisionId, setPendingDecisionId] = useState(null); const [pendingDecisionId, setPendingDecisionId] = useState(null);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [pendingRoleChange, setPendingRoleChange] = useState(null); const [pendingRoleChange, setPendingRoleChange] = useState(null);
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
const isManager = ["owner", "admin"].includes(activeHousehold?.role); const isManager = ["owner", "admin"].includes(activeHousehold?.role);
const isOwner = activeHousehold?.role === "owner"; const isOwner = activeHousehold?.role === "owner";
@ -307,12 +308,19 @@ export default function ManageHousehold() {
setPendingRoleChange({ memberId, nextRole, memberName }); setPendingRoleChange({ memberId, nextRole, memberName });
}; };
const handleRemoveMember = async (memberId, username) => { const handleRemoveMember = (memberId, username) => {
if (!confirm(`Remove ${username} from this household?`)) return; setPendingMemberRemoval({ memberId, username });
};
const handleConfirmRemoveMember = async () => {
if (!pendingMemberRemoval) return;
const { memberId, username } = pendingMemberRemoval;
try { try {
await removeMember(activeHousehold.id, memberId); await removeMember(activeHousehold.id, memberId);
await loadMembers(); await loadMembers();
setPendingMemberRemoval(null);
toast.success("Removed member", `Removed member ${username}`); toast.success("Removed member", `Removed member ${username}`);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to remove member"); const message = getApiErrorMessage(error, "Failed to remove member");
@ -360,9 +368,6 @@ export default function ManageHousehold() {
<div> <div>
<p className="manage-section-eyebrow">Household</p> <p className="manage-section-eyebrow">Household</p>
<h2>Identity</h2> <h2>Identity</h2>
<p className="section-description">
Keep the household name crisp and easy to recognize across invites and shared lists.
</p>
</div> </div>
</div> </div>
{editingName ? ( {editingName ? (
@ -408,9 +413,6 @@ export default function ManageHousehold() {
<div> <div>
<p className="manage-section-eyebrow">Entry Rules</p> <p className="manage-section-eyebrow">Entry Rules</p>
<h2>Invite Links</h2> <h2>Invite Links</h2>
<p className="section-description">
Decide how new people can enter, review manual approvals, then create invite links for the flow you want.
</p>
</div> </div>
</div> </div>
{inviteError && <p className="section-error">{inviteError}</p>} {inviteError && <p className="section-error">{inviteError}</p>}
@ -547,9 +549,6 @@ export default function ManageHousehold() {
<div> <div>
<p className="manage-section-eyebrow">People</p> <p className="manage-section-eyebrow">People</p>
<h2>Members ({members.length})</h2> <h2>Members ({members.length})</h2>
<p className="section-description">
Role badges and compact actions make it easier to see who runs the household and who just shops.
</p>
</div> </div>
</div> </div>
{loading ? ( {loading ? (
@ -563,16 +562,12 @@ export default function ManageHousehold() {
return ( return (
<div key={member.id} className="member-card"> <div key={member.id} className="member-card">
<div className="member-main"> <div className="member-main">
<div className="member-avatar" aria-hidden="true">{roleMeta.icon}</div>
<div className="member-info"> <div className="member-info">
<div className="member-topline">
<span className={`member-role member-role-${member.role}`}> <span className={`member-role member-role-${member.role}`}>
{roleMeta.icon} {roleMeta.label} {roleMeta.icon} {roleMeta.label}
</span> </span>
{isSelf && <span className="member-self-pill"> You</span>}
</div>
<span className="member-name">{member.username}</span> <span className="member-name">{member.username}</span>
<span className="member-meta">ID #{member.id}</span> {isSelf && <span className="member-self-pill">You</span>}
</div> </div>
</div> </div>
{isManager && !isSelf && member.role !== "owner" && ( {isManager && !isSelf && member.role !== "owner" && (
@ -616,11 +611,6 @@ export default function ManageHousehold() {
<div> <div>
<p className="manage-section-eyebrow">Final Actions</p> <p className="manage-section-eyebrow">Final Actions</p>
<h2>Danger Zone</h2> <h2>Danger Zone</h2>
<p className="section-description">
{isMemberOnly
? "Leaving removes your access to this household."
: "Deleting a household is permanent and will delete all lists, items, and history."}
</p>
</div> </div>
{isMemberOnly ? ( {isMemberOnly ? (
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger"> <button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
@ -664,6 +654,15 @@ export default function ManageHousehold() {
onClose={() => setPendingRoleChange(null)} onClose={() => setPendingRoleChange(null)}
onConfirm={handleConfirmRoleChange} onConfirm={handleConfirmRoleChange}
/> />
<ConfirmSlideModal
isOpen={Boolean(pendingMemberRemoval)}
title={`Remove ${pendingMemberRemoval?.username || "this member"}?`}
description="Slide to confirm. They will lose access to this household."
confirmLabel="Remove Member"
onClose={() => setPendingMemberRemoval(null)}
onConfirm={handleConfirmRemoveMember}
/>
</div> </div>
); );
} }

View File

@ -1,9 +1,13 @@
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
import { import {
addStoreToHousehold, addLocationToStore,
getAllStores, createHouseholdStore,
removeStoreFromHousehold, createLocationZone,
setDefaultStore deleteLocationZone,
getLocationZones,
removeLocation,
setDefaultLocation,
updateLocationZone,
} from "../../api/stores"; } from "../../api/stores";
import StoreAvailableItemsManager from "./StoreAvailableItemsManager"; import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
@ -13,172 +17,427 @@ import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/manage/ManageStores.css"; import "../../styles/components/manage/ManageStores.css";
import "../../styles/components/manage/StoreAvailableItemsManager.css"; import "../../styles/components/manage/StoreAvailableItemsManager.css";
export default function ManageStores() { function groupLocationsByStore(locations) {
const { activeHousehold } = useContext(HouseholdContext); const grouped = new Map();
const { stores: householdStores, refreshStores } = useContext(StoreContext);
for (const location of locations) {
const key = location.household_store_id;
if (!grouped.has(key)) {
grouped.set(key, {
household_store_id: location.household_store_id,
name: location.name,
locations: [],
});
}
grouped.get(key).locations.push(location);
}
return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name));
}
function ZoneManager({ householdId, location, canManage, refreshActiveZones }) {
const toast = useActionToast(); const toast = useActionToast();
const [allStores, setAllStores] = useState([]); const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(true); const [zones, setZones] = useState([]);
const [showAddStore, setShowAddStore] = useState(false); const [loading, setLoading] = useState(false);
const [newZoneName, setNewZoneName] = useState("");
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role); const loadZones = async () => {
if (!householdId || !location?.id) return;
useEffect(() => {
loadAllStores();
}, []);
const loadAllStores = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await getAllStores(); const response = await getLocationZones(householdId, location.id);
setAllStores(response.data); setZones(response.data?.zones || []);
} catch (error) { } catch (error) {
console.error("Failed to load stores:", error); const message = getApiErrorMessage(error, "Failed to load zones");
toast.error("Load zones failed", `Load zones failed: ${message}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleAddStore = async (storeId) => { useEffect(() => {
const storeName = allStores.find((store) => store.id === storeId)?.name || `store #${storeId}`; if (isOpen) {
loadZones();
}
}, [isOpen, householdId, location?.id]);
const handleCreateZone = async () => {
const name = newZoneName.trim();
if (!name) return;
try { try {
console.log("Adding store with ID:", storeId); const nextSortOrder =
await addStoreToHousehold(activeHousehold.id, storeId, false); zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10;
await refreshStores(); await createLocationZone(householdId, location.id, {
toast.success("Added store", `Added store ${storeName}`); name,
setShowAddStore(false); sort_order: nextSortOrder,
});
setNewZoneName("");
await loadZones();
await refreshActiveZones();
toast.success("Added zone", `Added zone ${name}`);
} catch (error) { } catch (error) {
console.error("Failed to add store:", error); const message = getApiErrorMessage(error, "Failed to add zone");
const message = getApiErrorMessage(error, "Failed to add store"); toast.error("Add zone failed", `Add zone failed: ${message}`);
toast.error("Add store failed", `Add store failed: ${message}`);
} }
}; };
const handleRemoveStore = async (storeId, storeName) => { const handleMoveZone = async (zone, direction) => {
if (!confirm(`Remove ${storeName} from this household?`)) return; const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
const swapIndex = currentIndex + direction;
if (currentIndex < 0 || swapIndex < 0 || swapIndex >= zones.length) return;
const other = zones[swapIndex];
try { try {
await removeStoreFromHousehold(activeHousehold.id, storeId); await Promise.all([
await refreshStores(); updateLocationZone(householdId, location.id, zone.id, {
toast.success("Removed store", `Removed store ${storeName}`); sort_order: other.sort_order,
}),
updateLocationZone(householdId, location.id, other.id, {
sort_order: zone.sort_order,
}),
]);
await loadZones();
await refreshActiveZones();
} catch (error) { } catch (error) {
console.error("Failed to remove store:", error); const message = getApiErrorMessage(error, "Failed to reorder zones");
const message = getApiErrorMessage(error, "Failed to remove store"); toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
toast.error("Remove store failed", `Remove store failed: ${message}`);
} }
}; };
const handleSetDefault = async (storeId) => { const handleDeleteZone = async (zone) => {
const storeName = if (!confirm(`Remove zone "${zone.name}" from ${location.display_name || location.name}?`)) {
householdStores.find((store) => store.id === storeId)?.name || `store #${storeId}`; return;
}
try { try {
await setDefaultStore(activeHousehold.id, storeId); await deleteLocationZone(householdId, location.id, zone.id);
await refreshStores(); await loadZones();
toast.success("Updated default store", `Default store set to ${storeName}`); await refreshActiveZones();
toast.success("Removed zone", `Removed zone ${zone.name}`);
} catch (error) { } catch (error) {
console.error("Failed to set default store:", error); const message = getApiErrorMessage(error, "Failed to remove zone");
const message = getApiErrorMessage(error, "Failed to set default store"); toast.error("Remove zone failed", `Remove zone failed: ${message}`);
toast.error("Set default store failed", `Set default store failed: ${message}`);
} }
}; };
const availableStores = allStores.filter( return (
store => !householdStores.some(hs => hs.id === store.id) <div className="store-zones-panel">
<button
type="button"
className="btn-secondary btn-small"
onClick={() => setIsOpen((current) => !current)}
>
{isOpen ? "Hide Zones" : "Manage Zones"}
</button>
{isOpen ? (
<div className="store-zones-content">
{canManage ? (
<div className="store-zone-create-row">
<input
value={newZoneName}
onChange={(event) => setNewZoneName(event.target.value)}
placeholder="New zone name"
/>
<button type="button" className="btn-primary btn-small" onClick={handleCreateZone}>
Add Zone
</button>
</div>
) : null}
{loading ? (
<p className="empty-message">Loading zones...</p>
) : zones.length === 0 ? (
<p className="empty-message">No zones for this location.</p>
) : (
<div className="store-zone-list">
{zones.map((zone, index) => (
<div key={zone.id} className="store-zone-row">
<span className="store-zone-order">{index + 1}</span>
<span className="store-zone-name">{zone.name}</span>
{canManage ? (
<div className="store-zone-actions">
<button
type="button"
className="btn-secondary btn-small"
disabled={index === 0}
onClick={() => handleMoveZone(zone, -1)}
>
Up
</button>
<button
type="button"
className="btn-secondary btn-small"
disabled={index === zones.length - 1}
onClick={() => handleMoveZone(zone, 1)}
>
Down
</button>
<button
type="button"
className="btn-danger btn-small"
onClick={() => handleDeleteZone(zone)}
>
Remove
</button>
</div>
) : null}
</div>
))}
</div>
)}
</div>
) : null}
</div>
); );
}
export default function ManageStores() {
const { activeHousehold } = useContext(HouseholdContext);
const {
activeStore,
stores: householdStores,
refreshStores,
refreshZones,
} = useContext(StoreContext);
const toast = useActionToast();
const [createForm, setCreateForm] = useState({
name: "",
location_name: "",
address: "",
});
const [locationDrafts, setLocationDrafts] = useState({});
const [saving, setSaving] = useState(false);
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
const groupedStores = useMemo(
() => groupLocationsByStore(householdStores),
[householdStores]
);
const refreshAfterStoreChange = async () => {
await refreshStores();
await refreshZones();
};
const handleCreateStore = async (event) => {
event.preventDefault();
if (!createForm.name.trim()) return;
setSaving(true);
try {
await createHouseholdStore(activeHousehold.id, {
name: createForm.name.trim(),
location_name: createForm.location_name.trim() || "Default Location",
address: createForm.address.trim() || null,
});
setCreateForm({ name: "", location_name: "", address: "" });
await refreshAfterStoreChange();
toast.success("Created store", `Created store ${createForm.name.trim()}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to create store");
toast.error("Create store failed", `Create store failed: ${message}`);
} finally {
setSaving(false);
}
};
const handleAddLocation = async (householdStoreId, storeName) => {
const draft = locationDrafts[householdStoreId] || {};
const name = String(draft.name || "").trim();
if (!name) return;
try {
await addLocationToStore(activeHousehold.id, householdStoreId, {
name,
address: String(draft.address || "").trim() || null,
});
setLocationDrafts((current) => ({
...current,
[householdStoreId]: { name: "", address: "" },
}));
await refreshAfterStoreChange();
toast.success("Added location", `Added ${name} to ${storeName}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add location");
toast.error("Add location failed", `Add location failed: ${message}`);
}
};
const handleSetDefault = async (location) => {
try {
await setDefaultLocation(activeHousehold.id, location.id);
await refreshAfterStoreChange();
toast.success("Updated default location", `Default location set to ${location.display_name || location.name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to set default location");
toast.error("Set default failed", `Set default failed: ${message}`);
}
};
const handleRemoveLocation = async (location) => {
const label = location.display_name || location.name;
if (!confirm(`Remove ${label} from this household?`)) return;
try {
await removeLocation(activeHousehold.id, location.id);
await refreshAfterStoreChange();
toast.success("Removed location", `Removed ${label}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to remove location");
toast.error("Remove location failed", `Remove location failed: ${message}`);
}
};
return ( return (
<div className="manage-stores"> <div className="manage-stores">
{/* Current Stores Section */}
<section className="manage-section"> <section className="manage-section">
<h2>Your Stores ({householdStores.length})</h2> <h2>Store Locations ({householdStores.length})</h2>
<p className="manage-stores-help"> <p className="manage-stores-help">
Use each store card's Manage Items button to edit or delete the household/store item list. Stores and locations are private to this household. Each location has its own zones,
item defaults, and shopping order.
</p> </p>
{!isAdmin && (
<p className="manage-stores-note">
Only household owners and admins can manage store item catalogs.
</p>
)}
{householdStores.length === 0 ? ( {householdStores.length === 0 ? (
<p className="empty-message">No stores added yet.</p> <p className="empty-message">No store locations added yet.</p>
) : ( ) : (
<div className="stores-list"> <div className="stores-list">
{householdStores.map((store) => ( {groupedStores.map((storeGroup) => (
<div key={store.id} className="store-card"> <div key={storeGroup.household_store_id} className="store-card">
<div className="store-info"> <div className="store-info">
<h3>{store.name}</h3> <h3>{storeGroup.name}</h3>
{store.location && <p className="store-location">{store.location}</p>}
</div> </div>
{isAdmin && (
<div className="store-actions"> <div className="store-location-list">
{!store.is_default && ( {storeGroup.locations.map((location) => (
<button <div key={location.id} className="store-location-row">
onClick={() => handleSetDefault(store.id)} <div className="store-info">
className="btn-secondary btn-small" <strong>{location.display_name || location.name}</strong>
> {location.address ? (
Set as Default <p className="store-location">{location.address}</p>
</button> ) : null}
)} {location.is_default ? (
<p className="store-location">Default shopping location</p>
) : null}
</div>
<div className="store-actions">
{isAdmin && !location.is_default ? (
<button
type="button"
onClick={() => handleSetDefault(location)}
className="btn-secondary btn-small"
>
Set Default
</button>
) : null}
{isAdmin ? (
<button
type="button"
onClick={() => handleRemoveLocation(location)}
className="btn-danger btn-small"
disabled={householdStores.length === 1}
title={householdStores.length === 1 ? "Cannot remove last location" : ""}
>
Remove
</button>
) : null}
</div>
<ZoneManager
householdId={activeHousehold.id}
location={location}
canManage={isAdmin}
refreshActiveZones={refreshZones}
/>
<StoreAvailableItemsManager
householdId={activeHousehold.id}
store={location}
isAdmin={isAdmin}
/>
</div>
))}
</div>
{isAdmin ? (
<div className="add-location-panel">
<input
value={locationDrafts[storeGroup.household_store_id]?.name || ""}
onChange={(event) =>
setLocationDrafts((current) => ({
...current,
[storeGroup.household_store_id]: {
...(current[storeGroup.household_store_id] || {}),
name: event.target.value,
},
}))
}
placeholder="Location name"
/>
<input
value={locationDrafts[storeGroup.household_store_id]?.address || ""}
onChange={(event) =>
setLocationDrafts((current) => ({
...current,
[storeGroup.household_store_id]: {
...(current[storeGroup.household_store_id] || {}),
address: event.target.value,
},
}))
}
placeholder="Address or notes"
/>
<button <button
onClick={() => handleRemoveStore(store.id, store.name)} type="button"
className="btn-danger btn-small" className="btn-primary btn-small"
disabled={householdStores.length === 1} onClick={() => handleAddLocation(storeGroup.household_store_id, storeGroup.name)}
title={householdStores.length === 1 ? "Cannot remove last store" : ""}
> >
Remove Add Location
</button> </button>
</div> </div>
)} ) : null}
<StoreAvailableItemsManager
householdId={activeHousehold.id}
store={store}
isAdmin={isAdmin}
/>
</div> </div>
))} ))}
</div> </div>
)} )}
</section> </section>
{/* Add Store Section */} {isAdmin ? (
{isAdmin && (
<section className="manage-section"> <section className="manage-section">
<h2>Add Store</h2> <h2>Add Store</h2>
{!showAddStore ? ( <form className="add-store-panel" onSubmit={handleCreateStore}>
<button onClick={() => setShowAddStore(true)} className="btn-primary"> <input
+ Add Store value={createForm.name}
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
placeholder="Store name, e.g. Costco"
required
/>
<input
value={createForm.location_name}
onChange={(event) =>
setCreateForm((current) => ({ ...current, location_name: event.target.value }))
}
placeholder="Location name, e.g. Fontana"
/>
<input
value={createForm.address}
onChange={(event) => setCreateForm((current) => ({ ...current, address: event.target.value }))}
placeholder="Address or notes"
/>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? "Adding..." : "+ Add Store"}
</button> </button>
) : ( </form>
<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> </section>
)} ) : activeStore ? (
<p className="manage-stores-note">
Household members can manage item defaults. Only owners and admins can manage stores,
locations, zones, and item deletion.
</p>
) : null}
</div> </div>
); );
} }

View File

@ -1,9 +1,11 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
createAvailableItem,
deleteAvailableItem, deleteAvailableItem,
getAvailableItems, getAvailableItems,
updateAvailableItem, updateAvailableItem,
} from "../../api/availableItems"; } from "../../api/availableItems";
import { getLocationZones } from "../../api/stores";
import useActionToast from "../../hooks/useActionToast"; import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage"; import getApiErrorMessage from "../../lib/getApiErrorMessage";
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal"; import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
@ -22,6 +24,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
const toast = useActionToast(); const toast = useActionToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [zones, setZones] = useState([]);
const [catalogReady, setCatalogReady] = useState(true); const [catalogReady, setCatalogReady] = useState(true);
const [catalogMessage, setCatalogMessage] = useState(""); const [catalogMessage, setCatalogMessage] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -53,13 +56,29 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
} }
}, [householdId, query, store?.id, toast]); }, [householdId, query, store?.id, toast]);
const loadZones = useCallback(async () => {
if (!householdId || !store?.id) {
setZones([]);
return;
}
try {
const response = await getLocationZones(householdId, store.id);
setZones(response.data?.zones || []);
} catch (error) {
console.error("Failed to load location zones:", error);
setZones([]);
}
}, [householdId, store?.id]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
return; return;
} }
loadItems(query); loadItems(query);
}, [isOpen, query, loadItems]); loadZones();
}, [isOpen, query, loadItems, loadZones]);
const closeManager = () => { const closeManager = () => {
setIsOpen(false); setIsOpen(false);
@ -76,8 +95,16 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
} }
try { try {
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload); if (editorItem?.item_id) {
toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.name}`); await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.display_name || store.name}`);
} else {
const response = await createAvailableItem(householdId, store.id, payload);
toast.success(
"Created store item",
`Created ${response.data?.item?.item_name || payload.itemName} for ${store.display_name || store.name}`
);
}
setShowEditor(false); setShowEditor(false);
setEditorItem(null); setEditorItem(null);
await loadItems(query); await loadItems(query);
@ -95,7 +122,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
try { try {
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id); await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.name}`); toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`);
setPendingDeleteItem(null); setPendingDeleteItem(null);
await loadItems(query); await loadItems(query);
} catch (error) { } catch (error) {
@ -104,10 +131,6 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
} }
}; };
if (!isAdmin) {
return null;
}
return ( return (
<> <>
<button <button
@ -123,8 +146,8 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}> <div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header"> <div className="store-items-modal-header">
<div> <div>
<h3>{store.name} Items</h3> <h3>{store.display_name || store.name} Items</h3>
<p>Manage the household/store items used for suggestions and store defaults.</p> <p>Manage location-specific items used for suggestions and defaults.</p>
</div> </div>
<button <button
type="button" type="button"
@ -150,6 +173,17 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
placeholder="Search household/store items" placeholder="Search household/store items"
disabled={!catalogReady} disabled={!catalogReady}
/> />
<button
type="button"
className="btn-primary btn-small"
disabled={!catalogReady}
onClick={() => {
setEditorItem(null);
setShowEditor(true);
}}
>
Add Item
</button>
</div> </div>
<div className="store-items-modal-body"> <div className="store-items-modal-body">
@ -209,13 +243,15 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
> >
Edit Settings Edit Settings
</button> </button>
<button {isAdmin ? (
type="button" <button
className="btn-danger btn-small" type="button"
onClick={() => setPendingDeleteItem(item)} className="btn-danger btn-small"
> onClick={() => setPendingDeleteItem(item)}
Delete Item >
</button> Delete Item
</button>
) : null}
</div> </div>
</div> </div>
</div> </div>
@ -232,6 +268,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
<AvailableItemEditorModal <AvailableItemEditorModal
isOpen={showEditor} isOpen={showEditor}
item={editorItem} item={editorItem}
zones={zones}
onCancel={() => { onCancel={() => {
setShowEditor(false); setShowEditor(false);
setEditorItem(null); setEditorItem(null);
@ -244,7 +281,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"} title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
description={ description={
pendingDeleteItem pendingDeleteItem
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.name} for this household, including current list entries and history.` ? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.`
: "" : ""
} }
confirmLabel="Delete Item" confirmLabel="Delete Item"

View File

@ -4,7 +4,7 @@ import ClassificationSection from "../forms/ClassificationSection";
import useActionToast from "../../hooks/useActionToast"; import useActionToast from "../../hooks/useActionToast";
import ImageUploadSection from "../forms/ImageUploadSection"; import ImageUploadSection from "../forms/ImageUploadSection";
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) { export default function AddItemWithDetailsModal({ itemName, zones = [], onConfirm, onCancel }) {
const toast = useActionToast(); const toast = useActionToast();
const [selectedImage, setSelectedImage] = useState(null); const [selectedImage, setSelectedImage] = useState(null);
const [imagePreview, setImagePreview] = useState(null); const [imagePreview, setImagePreview] = useState(null);
@ -47,15 +47,12 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
onConfirm(selectedImage, classification); onConfirm(selectedImage, classification);
}; };
const handleSkip = () => {
onSkip();
};
return ( return (
<div className="add-item-details-overlay" onClick={onCancel}> <div className="add-item-details-overlay" onClick={onCancel}>
<div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}> <div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}>
<h2 className="add-item-details-title">Add Details for "{itemName}"</h2> <div className="add-item-details-item-name">
<p className="add-item-details-subtitle">Add an image and classification to help organize your list</p> {itemName}
</div>
{/* Image Section */} {/* Image Section */}
<div className="add-item-details-section"> <div className="add-item-details-section">
@ -63,6 +60,9 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
imagePreview={imagePreview} imagePreview={imagePreview}
onImageChange={handleImageChange} onImageChange={handleImageChange}
onImageRemove={handleImageRemove} onImageRemove={handleImageRemove}
title={null}
cameraLabel="Use Image"
galleryLabel="Choose Photo"
/> />
</div> </div>
@ -75,6 +75,8 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
onItemTypeChange={handleItemTypeChange} onItemTypeChange={handleItemTypeChange}
onItemGroupChange={setItemGroup} onItemGroupChange={setItemGroup}
onZoneChange={setZone} onZoneChange={setZone}
zones={zones}
title={null}
fieldClass="add-item-details-field" fieldClass="add-item-details-field"
selectClass="add-item-details-select" selectClass="add-item-details-select"
/> />
@ -85,9 +87,6 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
<button onClick={onCancel} className="add-item-details-btn cancel"> <button onClick={onCancel} className="add-item-details-btn cancel">
Cancel Cancel
</button> </button>
<button onClick={handleSkip} className="add-item-details-btn skip">
Skip All
</button>
<button onClick={handleConfirm} className="add-item-details-btn confirm"> <button onClick={handleConfirm} className="add-item-details-btn confirm">
Add Item Add Item
</button> </button>

View File

@ -13,7 +13,7 @@ function buildPreview(item) {
return `data:${mimeType};base64,${item.item_image}`; return `data:${mimeType};base64,${item.item_image}`;
} }
export default function AvailableItemEditorModal({ isOpen, item = null, onCancel, onSave }) { export default function AvailableItemEditorModal({ isOpen, item = null, zones = [], onCancel, onSave }) {
const toast = useActionToast(); const toast = useActionToast();
const [itemName, setItemName] = useState(""); const [itemName, setItemName] = useState("");
const [itemType, setItemType] = useState(""); const [itemType, setItemType] = useState("");
@ -136,6 +136,7 @@ export default function AvailableItemEditorModal({ isOpen, item = null, onCancel
onItemTypeChange={handleItemTypeChange} onItemTypeChange={handleItemTypeChange}
onItemGroupChange={setItemGroup} onItemGroupChange={setItemGroup}
onZoneChange={setZone} onZoneChange={setZone}
zones={zones}
fieldClass="available-item-editor-field" fieldClass="available-item-editor-field"
selectClass="available-item-editor-select" selectClass="available-item-editor-select"
title="Store Classification (Optional)" title="Store Classification (Optional)"

View File

@ -4,7 +4,7 @@ import useActionToast from "../../hooks/useActionToast";
import "../../styles/components/EditItemModal.css"; import "../../styles/components/EditItemModal.css";
import AddImageModal from "./AddImageModal"; import AddImageModal from "./AddImageModal";
export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) { export default function EditItemModal({ item, zones = [], onSave, onCancel, onImageUpdate }) {
const toast = useActionToast(); const toast = useActionToast();
const [itemName, setItemName] = useState(item.item_name || ""); const [itemName, setItemName] = useState(item.item_name || "");
const [quantity, setQuantity] = useState(item.quantity || 1); const [quantity, setQuantity] = useState(item.quantity || 1);
@ -89,6 +89,9 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
}; };
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : []; const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
const zoneOptions = Array.isArray(zones) && zones.length > 0
? zones.map((candidateZone) => candidateZone.name || candidateZone).filter(Boolean)
: getZoneValues();
return ( return (
<div className="edit-modal-overlay" onClick={onCancel}> <div className="edit-modal-overlay" onClick={onCancel}>
@ -172,7 +175,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
className="edit-modal-select" className="edit-modal-select"
> >
<option value="">-- Select Zone --</option> <option value="">-- Select Zone --</option>
{getZoneValues().map((candidateZone) => ( {zoneOptions.map((candidateZone) => (
<option key={candidateZone} value={candidateZone}> <option key={candidateZone} value={candidateZone}>
{candidateZone} {candidateZone}
</option> </option>

View File

@ -25,7 +25,7 @@ export default function StoreTabs() {
onClick={() => setActiveStore(store)} onClick={() => setActiveStore(store)}
disabled={loading} disabled={loading}
> >
<span className="store-name">{store.name}</span> <span className="store-name">{store.display_name || store.name}</span>
</button> </button>
))} ))}
</div> </div>

View File

@ -1,4 +1,5 @@
import { createContext, useState } from 'react'; import { createContext, useState } from 'react';
import { clearApiCacheForCurrentUser } from '../api/offlineCache';
export const AuthContext = createContext({ export const AuthContext = createContext({
token: null, token: null,
@ -16,6 +17,7 @@ export const AuthProvider = ({ children }) => {
const [username, setUsername] = useState(localStorage.getItem('username') || null); const [username, setUsername] = useState(localStorage.getItem('username') || null);
const clearAuthStorage = () => { const clearAuthStorage = () => {
clearApiCacheForCurrentUser();
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("userId"); localStorage.removeItem("userId");
localStorage.removeItem("role"); localStorage.removeItem("role");

View File

@ -1,5 +1,10 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households'; import {
createHousehold as createHouseholdApi,
getUserHouseholds,
reorderHouseholds as reorderHouseholdsApi,
} from '../api/households';
import { isTransientApiError } from '../api/offlineCache';
import { AuthContext } from './AuthContext'; import { AuthContext } from './AuthContext';
const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId'; const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId';
@ -13,6 +18,7 @@ export const HouseholdContext = createContext({
setActiveHousehold: () => { }, setActiveHousehold: () => { },
refreshHouseholds: () => { }, refreshHouseholds: () => { },
createHousehold: () => { }, createHousehold: () => { },
reorderHouseholds: () => { },
}); });
export const HouseholdProvider = ({ children }) => { export const HouseholdProvider = ({ children }) => {
@ -43,9 +49,15 @@ export const HouseholdProvider = ({ children }) => {
} }
} catch (err) { } catch (err) {
console.error('[HouseholdContext] Failed to load households:', err); console.error('[HouseholdContext] Failed to load households:', err);
setError(err.response?.data?.message || 'Failed to load households'); setError(
setHouseholds([]); err.response?.data?.error?.message ||
clearActiveHousehold(); err.response?.data?.message ||
'Failed to load households'
);
if (!isTransientApiError(err)) {
setHouseholds([]);
clearActiveHousehold();
}
} finally { } finally {
setLoading(false); setLoading(false);
setHasLoaded(true); setHasLoaded(true);
@ -114,6 +126,57 @@ export const HouseholdProvider = ({ children }) => {
} }
}; };
const reorderHouseholds = async (orderedHouseholdIds) => {
const householdById = new Map(
households.map((household) => [String(household.id), household])
);
const nextHouseholds = orderedHouseholdIds
.map((householdId) => householdById.get(String(householdId)))
.filter(Boolean);
if (nextHouseholds.length !== households.length) {
throw new Error("Household order is out of date");
}
const previousHouseholds = households;
setHouseholds(nextHouseholds);
if (activeHousehold) {
const nextActiveHousehold = nextHouseholds.find(
(household) => String(household.id) === String(activeHousehold.id)
);
if (nextActiveHousehold) {
setActiveHouseholdState(nextActiveHousehold);
}
}
try {
const response = await reorderHouseholdsApi(orderedHouseholdIds);
const savedHouseholds = Array.isArray(response.data.households)
? response.data.households
: [];
if (savedHouseholds.length > 0) {
setHouseholds(savedHouseholds);
if (activeHousehold) {
const savedActiveHousehold = savedHouseholds.find(
(household) => String(household.id) === String(activeHousehold.id)
);
if (savedActiveHousehold) {
setActiveHouseholdState(savedActiveHousehold);
}
}
}
return savedHouseholds;
} catch (err) {
setHouseholds(previousHouseholds);
if (activeHousehold) {
setActiveHouseholdState(activeHousehold);
}
throw err;
}
};
const value = { const value = {
households, households,
activeHousehold, activeHousehold,
@ -123,6 +186,7 @@ export const HouseholdProvider = ({ children }) => {
setActiveHousehold, setActiveHousehold,
refreshHouseholds: loadHouseholds, refreshHouseholds: loadHouseholds,
createHousehold, createHousehold,
reorderHouseholds,
}; };
return ( return (

View File

@ -8,7 +8,6 @@ const DEFAULT_SETTINGS = {
compactView: false, compactView: false,
// List Display // List Display
defaultSortMode: "zone",
showRecentlyBought: true, showRecentlyBought: true,
recentlyBoughtCount: 10, recentlyBoughtCount: 10,
recentlyBoughtCollapsed: false, recentlyBoughtCollapsed: false,
@ -22,6 +21,17 @@ const DEFAULT_SETTINGS = {
debugMode: false, debugMode: false,
}; };
const SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS);
function normalizeSettings(savedSettings = {}) {
return SETTINGS_KEYS.reduce((normalized, key) => {
normalized[key] = Object.prototype.hasOwnProperty.call(savedSettings, key)
? savedSettings[key]
: DEFAULT_SETTINGS[key];
return normalized;
}, {});
}
export const SettingsContext = createContext({ export const SettingsContext = createContext({
settings: DEFAULT_SETTINGS, settings: DEFAULT_SETTINGS,
@ -48,7 +58,9 @@ export const SettingsProvider = ({ children }) => {
if (savedSettings) { if (savedSettings) {
try { try {
const parsed = JSON.parse(savedSettings); const parsed = JSON.parse(savedSettings);
setSettings({ ...DEFAULT_SETTINGS, ...parsed }); const normalized = normalizeSettings(parsed);
setSettings(normalized);
localStorage.setItem(storageKey, JSON.stringify(normalized));
} catch (error) { } catch (error) {
console.error("Failed to parse settings:", error); console.error("Failed to parse settings:", error);
setSettings(DEFAULT_SETTINGS); setSettings(DEFAULT_SETTINGS);
@ -88,7 +100,7 @@ export const SettingsProvider = ({ children }) => {
const updateSettings = (newSettings) => { const updateSettings = (newSettings) => {
if (!username) return; if (!username) return;
const updated = { ...settings, ...newSettings }; const updated = normalizeSettings({ ...settings, ...newSettings });
setSettings(updated); setSettings(updated);
const storageKey = `user_preferences_${username}`; const storageKey = `user_preferences_${username}`;

View File

@ -1,15 +1,19 @@
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
import { getHouseholdStores } from '../api/stores'; import { isTransientApiError } from '../api/offlineCache';
import { getHouseholdStores, getLocationZones } from '../api/stores';
import { AuthContext } from './AuthContext'; import { AuthContext } from './AuthContext';
import { HouseholdContext } from './HouseholdContext'; import { HouseholdContext } from './HouseholdContext';
export const StoreContext = createContext({ export const StoreContext = createContext({
stores: [], stores: [],
activeStore: null, activeStore: null,
zones: [],
loading: false, loading: false,
zonesLoading: false,
error: null, error: null,
setActiveStore: () => { }, setActiveStore: () => { },
refreshStores: () => { }, refreshStores: () => { },
refreshZones: () => { },
}); });
export const StoreProvider = ({ children }) => { export const StoreProvider = ({ children }) => {
@ -17,7 +21,9 @@ export const StoreProvider = ({ children }) => {
const { activeHousehold } = useContext(HouseholdContext); const { activeHousehold } = useContext(HouseholdContext);
const [stores, setStores] = useState([]); const [stores, setStores] = useState([]);
const [activeStore, setActiveStoreState] = useState(null); const [activeStore, setActiveStoreState] = useState(null);
const [zones, setZones] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [zonesLoading, setZonesLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Load stores when household changes // Load stores when household changes
@ -28,6 +34,7 @@ export const StoreProvider = ({ children }) => {
// Clear state when logged out or no household // Clear state when logged out or no household
setStores([]); setStores([]);
setActiveStoreState(null); setActiveStoreState(null);
setZones([]);
} }
}, [token, activeHousehold?.id]); }, [token, activeHousehold?.id]);
@ -40,7 +47,7 @@ export const StoreProvider = ({ children }) => {
const savedStoreId = localStorage.getItem(storageKey); const savedStoreId = localStorage.getItem(storageKey);
if (savedStoreId) { if (savedStoreId) {
const store = stores.find(s => s.id === parseInt(savedStoreId)); const store = stores.find(s => String(s.id) === String(savedStoreId));
if (store) { if (store) {
console.log('[StoreContext] Found saved store:', store); console.log('[StoreContext] Found saved store:', store);
setActiveStoreState(store); setActiveStoreState(store);
@ -55,6 +62,14 @@ export const StoreProvider = ({ children }) => {
localStorage.setItem(storageKey, defaultStore.id); localStorage.setItem(storageKey, defaultStore.id);
}, [stores, activeHousehold]); }, [stores, activeHousehold]);
useEffect(() => {
if (token && activeHousehold?.id && activeStore?.id) {
loadZones();
} else {
setZones([]);
}
}, [token, activeHousehold?.id, activeStore?.id]);
const loadStores = async () => { const loadStores = async () => {
if (!token || !activeHousehold) return; if (!token || !activeHousehold) return;
@ -67,8 +82,15 @@ export const StoreProvider = ({ children }) => {
setStores(response.data); setStores(response.data);
} catch (err) { } catch (err) {
console.error('[StoreContext] Failed to load stores:', err); console.error('[StoreContext] Failed to load stores:', err);
setError(err.response?.data?.message || 'Failed to load stores'); setError(
setStores([]); err.response?.data?.error?.message ||
err.response?.data?.message ||
'Failed to load stores'
);
if (!isTransientApiError(err)) {
setStores([]);
setActiveStoreState(null);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -78,17 +100,37 @@ export const StoreProvider = ({ children }) => {
setActiveStoreState(store); setActiveStoreState(store);
if (store && activeHousehold) { if (store && activeHousehold) {
const storageKey = `activeStoreId_${activeHousehold.id}`; const storageKey = `activeStoreId_${activeHousehold.id}`;
localStorage.setItem(storageKey, store.id); localStorage.setItem(storageKey, String(store.id));
}
};
const loadZones = async () => {
if (!token || !activeHousehold?.id || !activeStore?.id) return;
setZonesLoading(true);
try {
const response = await getLocationZones(activeHousehold.id, activeStore.id);
setZones(response.data?.zones || []);
} catch (err) {
console.error('[StoreContext] Failed to load zones:', err);
if (!isTransientApiError(err)) {
setZones([]);
}
} finally {
setZonesLoading(false);
} }
}; };
const value = { const value = {
stores, stores,
activeStore, activeStore,
zones,
loading, loading,
zonesLoading,
error, error,
setActiveStore, setActiveStore,
refreshStores: loadStores, refreshStores: loadStores,
refreshZones: loadZones,
}; };
return ( return (

View File

@ -11,7 +11,7 @@ import {
updateItemWithClassification updateItemWithClassification
} from "../api/list"; } from "../api/list";
import { getHouseholdMembers } from "../api/households"; import { getHouseholdMembers } from "../api/households";
import SortDropdown from "../components/common/SortDropdown"; import ListSearchInput from "../components/common/ListSearchInput";
import AddItemForm from "../components/forms/AddItemForm"; import AddItemForm from "../components/forms/AddItemForm";
import NoHouseholdState from "../components/household/NoHouseholdState"; import NoHouseholdState from "../components/household/NoHouseholdState";
import GroceryListItem from "../components/items/GroceryListItem"; import GroceryListItem from "../components/items/GroceryListItem";
@ -33,40 +33,54 @@ import getApiErrorMessage from "../lib/getApiErrorMessage";
import "../styles/pages/GroceryList.css"; import "../styles/pages/GroceryList.css";
import { findSimilarItems } from "../utils/stringSimilarity"; import { findSimilarItems } from "../utils/stringSimilarity";
function sortItemsForMode(items, sortMode) { function sortItemsByZone(items) {
const sorted = [...items]; const sorted = [...items];
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name)); sorted.sort((a, b) => {
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name)); if (!a.zone && b.zone) return 1;
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity); if (a.zone && !b.zone) return -1;
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity); if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
if (sortMode === "zone") {
sorted.sort((a, b) => {
if (!a.zone && b.zone) return 1;
if (a.zone && !b.zone) return -1;
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
const aZoneIndex = ZONE_FLOW.indexOf(a.zone); const aZoneIndex = Number.isInteger(a.zone_sort_order) ? a.zone_sort_order : ZONE_FLOW.indexOf(a.zone);
const bZoneIndex = ZONE_FLOW.indexOf(b.zone); const bZoneIndex = Number.isInteger(b.zone_sort_order) ? b.zone_sort_order : ZONE_FLOW.indexOf(b.zone);
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex; const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex; const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
const zoneCompare = aIndex - bIndex; const zoneCompare = aIndex - bIndex;
if (zoneCompare !== 0) return zoneCompare; if (zoneCompare !== 0) return zoneCompare;
const typeCompare = (a.item_type || "").localeCompare(b.item_type || ""); const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
if (typeCompare !== 0) return typeCompare; if (typeCompare !== 0) return typeCompare;
const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
if (groupCompare !== 0) return groupCompare; if (groupCompare !== 0) return groupCompare;
return a.item_name.localeCompare(b.item_name); return a.item_name.localeCompare(b.item_name);
}); });
}
return sorted; return sorted;
} }
function getSearchableItemText(item) {
return [
item.item_name,
item.item_type,
item.item_group,
item.zone,
...(Array.isArray(item.added_by_users) ? item.added_by_users : []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
}
function filterItemsForSearch(items, query) {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return items;
return items.filter((item) => getSearchableItemText(item).includes(normalizedQuery));
}
function getNextModalItem(sortedItems, currentIndex, excludedItemId) { function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId); const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId);
@ -79,7 +93,6 @@ function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
export default function GroceryList() { export default function GroceryList() {
const pageTitle = "Grocery List";
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const { const {
activeHousehold, activeHousehold,
@ -87,7 +100,7 @@ export default function GroceryList() {
loading: householdLoading, loading: householdLoading,
hasLoaded: householdsLoaded hasLoaded: householdsLoaded
} = useContext(HouseholdContext); } = useContext(HouseholdContext);
const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); const { activeStore, stores, zones, loading: storeLoading } = useContext(StoreContext);
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const toast = useActionToast(); const toast = useActionToast();
const { enqueueImageUpload } = useUploadQueue(); const { enqueueImageUpload } = useUploadQueue();
@ -103,7 +116,7 @@ export default function GroceryList() {
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
const [householdMembers, setHouseholdMembers] = useState([]); const [householdMembers, setHouseholdMembers] = useState([]);
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount); const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
const [sortMode, setSortMode] = useState(settings.defaultSortMode); const [listSearchQuery, setListSearchQuery] = useState("");
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [buttonText, setButtonText] = useState("Add Item"); const [buttonText, setButtonText] = useState("Add Item");
@ -238,10 +251,13 @@ export default function GroceryList() {
})); }));
}; };
// === Sorted Items Computation === // === Visible Items Computation ===
const normalizedListSearchQuery = listSearchQuery.trim().toLowerCase();
const isListSearchActive = normalizedListSearchQuery.length > 0;
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
return sortItemsForMode(items, sortMode); return sortItemsByZone(filterItemsForSearch(items, normalizedListSearchQuery));
}, [items, sortMode]); }, [items, normalizedListSearchQuery]);
const visibleRecentlyBoughtItems = useMemo( const visibleRecentlyBoughtItems = useMemo(
() => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount), () => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount),
@ -538,45 +554,9 @@ export default function GroceryList() {
} }
}, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]); }, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]);
const handleAddDetailsSkip = useCallback(async () => { const handleAddDetailsCancel = useCallback(() => {
if (!pendingItem) return; setShowAddDetailsModal(false);
if (!activeHousehold?.id || !activeStore?.id) return; setPendingItem(null);
try {
await addItem(
activeHousehold.id,
activeStore.id,
pendingItem.itemName,
pendingItem.quantity,
null,
null,
pendingItem.addedForUserId || 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([]);
setButtonText("Add Item");
if (newItem) {
setItems(prevItems => [...prevItems, newItem]);
toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`);
}
} catch (error) {
console.error("Failed to add item:", error);
const message = getApiErrorMessage(error, "Failed to add item");
toast.error("Add item failed", `Add item failed: ${message}`);
}
}, [activeHousehold?.id, activeStore?.id, pendingItem, toast]);
const handleAddDetailsCancel = useCallback(() => {
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
}, []); }, []);
@ -614,7 +594,9 @@ export default function GroceryList() {
setItems(nextItems); setItems(nextItems);
const nextSortedItems = sortItemsForMode(nextItems, sortMode); const nextSortedItems = sortItemsByZone(
filterItemsForSearch(nextItems, normalizedListSearchQuery)
);
const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id); const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id);
setBuyModalState( setBuyModalState(
@ -633,7 +615,7 @@ export default function GroceryList() {
const message = getApiErrorMessage(error, "Failed to mark item as bought"); const message = getApiErrorMessage(error, "Failed to mark item as bought");
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`); toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
} }
}, [activeHousehold?.id, activeStore?.id, buyModalState, items, sortMode, sortedItems, toast]); }, [activeHousehold?.id, activeStore?.id, buyModalState, items, normalizedListSearchQuery, sortedItems, toast]);
const openActiveBuyModal = useCallback((item) => { const openActiveBuyModal = useCallback((item) => {
setBuyModalState({ setBuyModalState({
@ -772,7 +754,6 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}> <p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}>
Loading households... Loading households...
</p> </p>
@ -785,7 +766,6 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<NoHouseholdState /> <NoHouseholdState />
</div> </div>
</div> </div>
@ -796,8 +776,7 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1> <p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
Loading stores... Loading stores...
</p> </p>
</div> </div>
@ -809,8 +788,7 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1> <div className="glist-empty-state">
<div className="glist-empty-state">
<h2 className="glist-empty-title">No stores found</h2> <h2 className="glist-empty-title">No stores found</h2>
<p className="glist-empty-text"> <p className="glist-empty-text">
This household doesnt have any stores yet. This household doesnt have any stores yet.
@ -837,8 +815,7 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1> <StoreTabs />
<StoreTabs />
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}> <p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
Loading stores... Loading stores...
</p> </p>
@ -851,8 +828,7 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1> <StoreTabs />
<StoreTabs />
<p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p> <p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p>
</div> </div>
</div> </div>
@ -863,9 +839,7 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1> <StoreTabs />
<StoreTabs />
{canEditList && ( {canEditList && (
<AddItemForm <AddItemForm
@ -878,13 +852,24 @@ export default function GroceryList() {
/> />
)} )}
<SortDropdown value={sortMode} onChange={setSortMode} /> <ListSearchInput
value={listSearchQuery}
{sortMode === "zone" ? ( onChange={setListSearchQuery}
resultCount={sortedItems.length}
totalCount={items.length}
/>
{sortedItems.length === 0 ? (
<p className="glist-empty-search">
{isListSearchActive
? `No list items match "${listSearchQuery.trim()}".`
: "No items in this store yet."}
</p>
) : (
(() => { (() => {
const grouped = groupItemsByZone(sortedItems); const grouped = groupItemsByZone(sortedItems);
return Object.keys(grouped).map(zone => { return Object.keys(grouped).map(zone => {
const isCollapsed = collapsedZones[zone]; const isCollapsed = isListSearchActive ? false : collapsedZones[zone];
const itemCount = grouped[zone].length; const itemCount = grouped[zone].length;
return ( return (
<div key={zone} className="glist-classification-group"> <div key={zone} className="glist-classification-group">
@ -923,25 +908,7 @@ export default function GroceryList() {
); );
}); });
})() })()
) : ( )}
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
{sortedItems.map((item) => (
<GroceryListItem
key={item.id}
item={item}
compact={settings.compactView}
onClick={canEditList ? openActiveBuyModal : null}
onOpenBuyModal={openActiveBuyModal}
onImageAdded={
canEditList ? handleImageAdded : null
}
onLongPress={
canEditList ? handleLongPress : null
}
/>
))}
</ul>
)}
{recentlyBoughtItems.length > 0 && settings.showRecentlyBought && ( {recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
<> <>
@ -992,11 +959,11 @@ export default function GroceryList() {
{showAddDetailsModal && pendingItem && ( {showAddDetailsModal && pendingItem && (
<AddItemWithDetailsModal <AddItemWithDetailsModal
itemName={pendingItem.itemName} itemName={pendingItem.itemName}
onConfirm={handleAddWithDetails} zones={zones}
onSkip={handleAddDetailsSkip} onConfirm={handleAddWithDetails}
onCancel={handleAddDetailsCancel} onCancel={handleAddDetailsCancel}
/> />
)} )}
{showSimilarModal && similarItemSuggestion && ( {showSimilarModal && similarItemSuggestion && (
@ -1012,8 +979,9 @@ export default function GroceryList() {
{showEditModal && editingItem && ( {showEditModal && editingItem && (
<EditItemModal <EditItemModal
item={editingItem} item={editingItem}
onSave={handleEditSave} zones={zones}
onCancel={handleEditCancel} onSave={handleEditSave}
onCancel={handleEditCancel}
onImageUpdate={handleImageAdded} onImageUpdate={handleImageAdded}
/> />
)} )}

View File

@ -137,12 +137,6 @@ export default function Settings() {
updateSettings({ [key]: parseInt(value, 10) }); updateSettings({ [key]: parseInt(value, 10) });
}; };
const handleSelectChange = (key, value) => {
updateSettings({ [key]: value });
};
const handleReset = () => { const handleReset = () => {
if (window.confirm("Reset all settings to defaults?")) { if (window.confirm("Reset all settings to defaults?")) {
resetSettings(); resetSettings();
@ -252,24 +246,6 @@ export default function Settings() {
<div className="settings-section"> <div className="settings-section">
<h2 className="text-xl font-semibold mb-4">List Display</h2> <h2 className="text-xl font-semibold mb-4">List Display</h2>
<div className="settings-group">
<label className="settings-label">Default Sort Mode</label>
<select
value={settings.defaultSortMode}
onChange={(e) => handleSelectChange("defaultSortMode", e.target.value)}
className="form-select mt-2"
>
<option value="zone">By Zone</option>
<option value="az">A Z</option>
<option value="za">Z A</option>
<option value="qty-high">Quantity: High Low</option>
<option value="qty-low">Quantity: Low High</option>
</select>
<p className="settings-description">
Your preferred sorting method when opening the list
</p>
</div>
<div className="settings-group"> <div className="settings-group">
<label className="settings-label"> <label className="settings-label">
<input <input

View File

@ -15,14 +15,28 @@
.add-item-details-modal { .add-item-details-modal {
background: var(--modal-bg); background: var(--modal-bg);
border-radius: var(--border-radius-xl); border-radius: var(--border-radius-xl);
padding: var(--spacing-xl); padding: var(--spacing-lg);
max-width: 500px; max-width: 520px;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-xl);
} }
.add-item-details-item-name {
margin: 0 0 var(--spacing-md);
padding: 0.65rem 0.85rem;
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-md);
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
text-align: center;
overflow-wrap: anywhere;
}
.add-item-details-title { .add-item-details-title {
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
margin: 0 0 var(--spacing-xs) 0; margin: 0 0 var(--spacing-xs) 0;
@ -38,13 +52,15 @@
} }
.add-item-details-section { .add-item-details-section {
margin-bottom: var(--spacing-xl); margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-xl); padding-bottom: var(--spacing-md);
border-bottom: var(--border-width-thin) solid var(--color-border-light); border-bottom: var(--border-width-thin) solid var(--color-border-light);
} }
.add-item-details-section:last-of-type { .add-item-details-section:last-of-type {
border-bottom: none; border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
} }
.add-item-details-section-title { .add-item-details-section-title {
@ -55,6 +71,68 @@
} }
/* Image Upload Section */ /* Image Upload Section */
.add-item-details-modal .image-upload-section {
margin: 0;
}
.add-item-details-modal .image-upload-content {
background: var(--color-bg-surface);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-md);
padding: var(--spacing-sm);
}
.add-item-details-modal .image-upload-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-sm);
}
.add-item-details-modal .image-upload-btn {
min-width: 0;
min-height: 44px;
padding: 0.7rem 0.75rem;
border-radius: var(--button-border-radius);
border: var(--border-width-thin) solid transparent;
font-size: 0;
font-weight: var(--button-font-weight);
line-height: 1;
white-space: nowrap;
}
.add-item-details-modal .image-upload-btn::after {
content: attr(aria-label);
font-size: var(--font-size-sm);
}
.add-item-details-modal .image-upload-btn.camera {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
color: var(--color-text-inverse);
}
.add-item-details-modal .image-upload-btn.camera:hover {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
color: var(--color-text-inverse);
}
.add-item-details-modal .image-upload-btn.gallery {
background: var(--button-secondary-bg);
border-color: var(--button-secondary-border);
color: var(--button-secondary-text);
}
.add-item-details-modal .image-upload-btn.gallery:hover {
background: var(--button-secondary-hover-bg);
border-color: var(--button-secondary-border-hover);
color: var(--button-secondary-text);
}
.add-item-details-modal .image-upload-preview {
max-width: 100%;
}
.add-item-details-image-content { .add-item-details-image-content {
min-height: 120px; min-height: 120px;
} }
@ -119,22 +197,33 @@
} }
/* Classification Section */ /* Classification Section */
.add-item-details-modal .classification-section {
margin: 0;
}
.add-item-details-field { .add-item-details-field {
margin-bottom: var(--spacing-md); display: grid;
grid-template-columns: 6.75rem minmax(0, 1fr);
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
} }
.add-item-details-field label { .add-item-details-field label {
display: block; display: block;
margin-bottom: var(--spacing-sm); margin: 0;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
line-height: var(--line-height-tight);
white-space: nowrap;
} }
.add-item-details-select { .add-item-details-select {
width: 100%; width: 100%;
padding: var(--input-padding-y) var(--input-padding-x); min-height: 2.5rem;
font-size: var(--font-size-base); padding: 0.55rem 0.75rem;
font-size: var(--font-size-sm);
border: var(--border-width-thin) solid var(--input-border-color); border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius); border-radius: var(--input-border-radius);
box-sizing: border-box; box-sizing: border-box;
@ -153,7 +242,7 @@
.add-item-details-actions { .add-item-details-actions {
display: flex; display: flex;
gap: var(--spacing-sm); gap: var(--spacing-sm);
margin-top: var(--spacing-lg); margin-top: var(--spacing-md);
padding-top: var(--spacing-md); padding-top: var(--spacing-md);
border-top: var(--border-width-thin) solid var(--color-border-light); border-top: var(--border-width-thin) solid var(--color-border-light);
} }
@ -162,38 +251,36 @@
flex: 1; flex: 1;
padding: var(--button-padding-y) var(--button-padding-x); padding: var(--button-padding-y) var(--button-padding-x);
font-size: var(--font-size-base); font-size: var(--font-size-base);
border: none; border: var(--border-width-thin) solid transparent;
border-radius: var(--button-border-radius); border-radius: var(--button-border-radius);
cursor: pointer; cursor: pointer;
font-weight: var(--button-font-weight); font-weight: var(--button-font-weight);
transition: var(--transition-base); transition: var(--transition-base);
min-height: 44px;
} }
.add-item-details-btn.cancel { .add-item-details-btn.cancel {
background: var(--color-secondary); background: var(--button-secondary-bg);
color: var(--color-text-inverse); border-color: var(--button-secondary-border);
color: var(--button-secondary-text);
} }
.add-item-details-btn.cancel:hover { .add-item-details-btn.cancel:hover {
background: var(--color-secondary-hover); background: var(--button-secondary-hover-bg);
} border-color: var(--button-secondary-border-hover);
color: var(--button-secondary-text);
.add-item-details-btn.skip {
background: var(--color-warning);
color: var(--color-text-primary);
}
.add-item-details-btn.skip:hover {
background: var(--color-warning-hover);
} }
.add-item-details-btn.confirm { .add-item-details-btn.confirm {
background: var(--color-primary); background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
color: var(--color-text-inverse); color: var(--color-text-inverse);
} }
.add-item-details-btn.confirm:hover { .add-item-details-btn.confirm:hover {
background: var(--color-primary-hover); background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
color: var(--color-text-inverse);
} }
/* Mobile responsiveness */ /* Mobile responsiveness */
@ -225,6 +312,10 @@
min-height: 44px; min-height: 44px;
} }
.add-item-details-field {
grid-template-columns: 6rem minmax(0, 1fr);
}
.add-item-details-actions { .add-item-details-actions {
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;

View File

@ -70,8 +70,10 @@
position: absolute; position: absolute;
top: calc(100% + 0.5rem); top: calc(100% + 0.5rem);
left: 0; left: 0;
right: 0; right: auto;
width: 100%; width: 100%;
min-width: 100%;
max-width: min(320px, calc(100vw - 2rem));
overflow: hidden; overflow: hidden;
background: var(--card-bg); background: var(--card-bg);
border: 2px solid var(--border); border: 2px solid var(--border);
@ -80,15 +82,32 @@
z-index: 1000; z-index: 1000;
} }
.household-option-row {
display: flex;
align-items: stretch;
min-height: 48px;
background: var(--card-bg);
border-bottom: 1px solid var(--border);
}
.household-option-row:hover {
background: var(--button-secondary-bg);
}
.household-option-row.active {
background: color-mix(in srgb, var(--primary) 15%, transparent);
}
.household-option { .household-option {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex: 1;
min-width: 0;
width: 100%; width: 100%;
padding: 0.875rem 1rem; padding: 0.875rem 0.625rem 0.875rem 1rem;
background: var(--card-bg); background: transparent;
border: none; border: none;
border-bottom: 1px solid var(--border);
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem; font-size: 1rem;
text-align: left; text-align: left;
@ -96,25 +115,59 @@
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.household-option:last-child { .household-option-name {
border-bottom: none; min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.household-option:hover { .household-option:hover {
background: var(--button-secondary-bg); color: var(--primary);
border-color: var(--primary);
} }
.household-option.active { .household-option-row.active .household-option {
background: color-mix(in srgb, var(--primary) 15%, transparent);
color: var(--primary); color: var(--primary);
font-weight: 600; font-weight: 600;
} }
.check-mark { .household-reorder-controls {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
justify-content: center;
gap: 0.125rem;
width: 28px;
padding: 0.25rem 0.25rem 0.25rem 0;
}
.household-reorder-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 18px;
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.55rem;
line-height: 1;
}
.household-reorder-button:hover:not(:disabled),
.household-reorder-button:focus-visible {
border-color: var(--primary);
color: var(--primary); color: var(--primary);
font-size: 1.1rem; outline: none;
font-weight: bold; }
.household-reorder-button:disabled {
opacity: 0.35;
cursor: not-allowed;
} }
.household-divider { .household-divider {
@ -124,6 +177,7 @@
} }
.create-household-btn { .create-household-btn {
border-bottom: none;
color: var(--primary); color: var(--primary);
font-weight: 600; font-weight: 600;
} }

View File

@ -383,8 +383,8 @@ body.dark-mode .invite-status-badge.is-used {
.member-card { .member-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.9rem; gap: 0.7rem;
padding: 0.95rem 1rem; padding: 0.85rem 1rem;
background: rgba(255, 255, 255, 1); background: rgba(255, 255, 255, 1);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
@ -408,74 +408,48 @@ body.dark-mode .member-card:hover {
background: rgba(20, 32, 48, 0.98); background: rgba(20, 32, 48, 0.98);
} }
.member-avatar {
width: 2.6rem;
height: 2.6rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--primary-light);
font-size: 1.15rem;
}
.member-main { .member-main {
display: grid; min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.85rem;
align-items: flex-start;
} }
.member-info { .member-info {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.member-topline {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
flex-wrap: wrap; min-width: 0;
max-width: 100%;
white-space: nowrap;
} }
.member-name { .member-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem; font-size: 1rem;
} }
.member-meta {
color: var(--text-secondary);
font-size: 0.82rem;
}
.member-role { .member-role {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
font-size: 0.78rem; flex: 0 0 auto;
padding: 0.24rem 0.55rem; font-size: 0.88rem;
border-radius: var(--border-radius-full);
width: fit-content;
text-transform: capitalize; text-transform: capitalize;
font-weight: 700; font-weight: 700;
} }
.member-role-owner { .member-role-owner {
background: rgba(245, 158, 11, 0.18);
color: #b45309; color: #b45309;
} }
.member-role-admin { .member-role-admin {
background: rgba(30, 144, 255, 0.16);
color: var(--primary-dark); color: var(--primary-dark);
} }
.member-role-member, .member-role-member,
.member-role-viewer { .member-role-viewer {
background: rgba(139, 92, 246, 0.12);
color: #6d28d9; color: #6d28d9;
} }
@ -483,7 +457,8 @@ body.dark-mode .member-card:hover {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
padding: 0.24rem 0.5rem; flex: 0 0 auto;
padding: 0.18rem 0.45rem;
border-radius: var(--border-radius-full); border-radius: var(--border-radius-full);
background: rgba(245, 158, 11, 0.16); background: rgba(245, 158, 11, 0.16);
color: #a16207; color: #a16207;
@ -497,7 +472,7 @@ body.dark-mode .member-card:hover {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
padding-top: 0.75rem; padding-top: 0.65rem;
border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent); border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent);
} }

View File

@ -93,6 +93,94 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.store-location-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.store-location-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card-bg);
}
.store-location-row > .store-zones-panel,
.store-location-row > .store-available-items-trigger {
grid-column: 1 / -1;
}
.add-location-panel,
.store-zone-create-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.add-location-panel input,
.add-store-panel input,
.store-zone-create-row input {
width: 100%;
box-sizing: border-box;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--input-bg);
color: var(--text-primary);
}
.store-zones-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.store-zones-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
}
.store-zone-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.store-zone-row {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.store-zone-order {
color: var(--text-secondary);
font-size: 0.85rem;
text-align: right;
}
.store-zone-name {
min-width: 0;
color: var(--text-primary);
}
.store-zone-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
/* Add Store Panel */ /* Add Store Panel */
.add-store-panel { .add-store-panel {
display: flex; display: flex;
@ -172,4 +260,23 @@
.available-store-card button { .available-store-card button {
width: 100%; width: 100%;
} }
.store-location-row,
.add-location-panel,
.store-zone-create-row,
.store-zone-row {
grid-template-columns: 1fr;
}
.store-zone-order {
text-align: left;
}
.store-zone-actions {
justify-content: stretch;
}
.store-zone-actions button {
flex: 1;
}
} }

View File

@ -14,13 +14,6 @@
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
/* Title */
.glist-title {
text-align: center;
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-sm);
}
.glist-section-title { .glist-section-title {
text-align: center; text-align: center;
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
@ -116,14 +109,14 @@
/* Classification Groups */ /* Classification Groups */
.glist-classification-group { .glist-classification-group {
margin-bottom: var(--spacing-xl); margin-bottom: 0;
} }
.glist-classification-header { .glist-classification-header {
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
color: var(--color-primary); color: var(--color-primary);
margin: var(--spacing-md) 0 var(--spacing-sm) 0; margin: var(--spacing-md) 0 0 0;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-primary-light); background: var(--color-primary-light);
border-left: var(--border-width-thick) solid var(--color-primary); border-left: var(--border-width-thick) solid var(--color-primary);
@ -239,6 +232,10 @@
margin-top: var(--spacing-md); margin-top: var(--spacing-md);
} }
.glist-classification-group .glist-ul {
margin-top: var(--spacing-xs);
}
.glist-li { .glist-li {
background: var(--color-bg-surface); background: var(--color-bg-surface);
border: var(--border-width-thin) solid var(--color-border-light); border: var(--border-width-thin) solid var(--color-border-light);
@ -254,6 +251,14 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
.glist-classification-group .glist-li {
margin-bottom: var(--spacing-xs);
}
.glist-classification-group .glist-li:last-child {
margin-bottom: 0;
}
.glist-item-layout { .glist-item-layout {
display: flex; display: flex;
gap: 1em; gap: 1em;
@ -359,10 +364,21 @@
font-size: 0.65em; font-size: 0.65em;
} }
/* Sorting dropdown */ /* List search */
.glist-sort { .glist-search {
width: 100%; width: 100%;
margin: var(--spacing-xs) 0; margin: var(--spacing-xs) 0 var(--spacing-sm);
}
.glist-search-row {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.glist-search-input {
width: 100%;
min-width: 0;
padding: var(--spacing-sm); padding: var(--spacing-sm);
font-size: var(--font-size-base); font-size: var(--font-size-base);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
@ -371,6 +387,48 @@
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.glist-search-input::placeholder {
color: var(--color-text-muted);
}
.glist-search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-light);
}
.glist-search-clear {
flex: 0 0 auto;
min-height: 40px;
padding: 0 var(--spacing-sm);
border: var(--border-width-thin) solid var(--color-border-medium);
border-radius: var(--border-radius-sm);
background: var(--color-bg-hover);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 700;
cursor: pointer;
}
.glist-search-clear:hover,
.glist-search-clear:focus-visible {
border-color: var(--color-primary);
color: var(--color-primary);
}
.glist-search-meta {
margin: 4px 0 0;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
}
.glist-empty-search {
margin: var(--spacing-md) 0;
color: var(--color-text-secondary);
text-align: center;
font-size: var(--font-size-sm);
}
/* Image upload */ /* Image upload */
.glist-image-upload { .glist-image-upload {
margin: 0.5em 0; margin: 0.5em 0;

View File

@ -33,8 +33,8 @@ test("selected household stays active after refreshing on settings and home page
]; ];
const storesByHousehold = { const storesByHousehold = {
1: [{ id: 101, name: "Costco", is_default: true }], 1: [{ id: 101, household_store_id: 1001, name: "Costco", is_default: true }],
2: [{ id: 201, name: "Trader Joe's", is_default: true }], 2: [{ id: 201, household_store_id: 2001, name: "Trader Joe's", is_default: true }],
}; };
await page.route("**/households", async (route) => { await page.route("**/households", async (route) => {
@ -45,8 +45,8 @@ test("selected household stays active after refreshing on settings and home page
}); });
}); });
await page.route("**/stores/household/*", async (route) => { await page.route("**/households/*/stores", async (route) => {
const householdId = Number(route.request().url().split("/").pop()); const householdId = Number(route.request().url().match(/households\/(\d+)\/stores/)?.[1]);
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -56,7 +56,15 @@ test("selected household stays active after refreshing on settings and home page
}); });
}); });
await page.route("**/households/*/stores/*/list/recent", async (route) => { await page.route("**/households/*/locations/*/zones", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ zones: [] }),
});
});
await page.route("**/households/*/locations/*/list/recent", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -64,7 +72,7 @@ test("selected household stays active after refreshing on settings and home page
}); });
}); });
await page.route("**/households/*/stores/*/list", async (route) => { await page.route("**/households/*/locations/*/list", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -84,8 +92,20 @@ test("selected household stays active after refreshing on settings and home page
await expect(page.getByRole("button", { name: "Alpha Home" })).toBeVisible(); await expect(page.getByRole("button", { name: "Alpha Home" })).toBeVisible();
await page.getByRole("button", { name: "Alpha Home" }).click(); const householdTrigger = page.locator(".household-switcher-toggle");
await page.getByRole("button", { name: "Bravo Home" }).click(); await expect(householdTrigger).toContainText("Alpha Home");
await householdTrigger.click();
const householdDropdown = page.locator(".household-switcher-dropdown");
await expect(householdDropdown).toBeVisible();
await expect(page.locator(".check-mark")).toHaveCount(0);
const triggerBox = await householdTrigger.boundingBox();
const dropdownBox = await householdDropdown.boundingBox();
expect(triggerBox).not.toBeNull();
expect(dropdownBox).not.toBeNull();
expect(Math.abs((dropdownBox?.x ?? 0) - (triggerBox?.x ?? 0))).toBeLessThan(1);
await page.getByRole("button", { name: "Bravo Home", exact: true }).click();
await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible(); await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible();
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2"); await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2");

View File

@ -4,6 +4,7 @@ import {
confirmSlide, confirmSlide,
expectNoFailedApiRequests, expectNoFailedApiRequests,
mockConfig, mockConfig,
mockHouseholdAndStoreShell,
seedAuthStorage, seedAuthStorage,
} from "./helpers/e2e"; } from "./helpers/e2e";
@ -179,6 +180,65 @@ test("household management shows pending invite approvals and can approve them",
await expect(page.getByText("Members (2)")).toBeVisible(); await expect(page.getByText("Members (2)")).toBeVisible();
}); });
test("household member removal opens slide confirmation instead of browser dialog", async ({ page }) => {
await seedAuthStorage(page, { role: "owner", username: "manager-user" });
await mockConfig(page);
await mockHouseholdAndStoreShell(page, {
household: { name: "Removal Home", role: "owner" },
});
let dialogCount = 0;
page.on("dialog", async (dialog) => {
dialogCount += 1;
await dialog.dismiss();
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, username: "manager-user", role: "owner" },
{ id: 2, username: "remove-me", role: "member" },
]),
});
});
await page.route("**/api/groups/join-policy", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ joinPolicy: "APPROVAL_REQUIRED" }),
});
});
await page.route("**/api/groups/invites", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ links: [] }),
});
});
await page.route("**/api/groups/join-requests", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ requests: [] }),
});
});
await page.goto("/manage?tab=household");
const memberCard = page.locator(".member-card").filter({ hasText: "remove-me" });
await memberCard.getByRole("button", { name: "Remove" }).click();
await expect(page.getByRole("heading", { name: "Remove remove-me?" })).toBeVisible();
await expect(page.getByText("Slide to confirm. They will lose access to this household.")).toBeVisible();
await expect(page.locator(".confirm-slide-label")).toHaveText("Remove Member");
expect(dialogCount).toBe(0);
});
test("household owner can transfer ownership from household settings", async ({ page }) => { test("household owner can transfer ownership from household settings", async ({ page }) => {
const failedApiRequests = collectFailedApiRequests(page); const failedApiRequests = collectFailedApiRequests(page);
await seedAuthStorage(page, { role: "owner", username: "manager-user" }); await seedAuthStorage(page, { role: "owner", username: "manager-user" });
@ -206,6 +266,14 @@ test("household owner can transfer ownership from household settings", async ({
}); });
}); });
await page.route("**/stores/household/1", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 10, name: "Costco", location: "Warehouse", is_default: true }]),
});
});
await page.route("**/households/1/members/*/role", async (route) => { await page.route("**/households/1/members/*/role", async (route) => {
const request = route.request(); const request = route.request();
if (request.method() !== "PATCH") { if (request.method() !== "PATCH") {

View File

@ -19,6 +19,11 @@
"db:migrate:new": "node scripts/db-migrate-new.js", "db:migrate:new": "node scripts/db-migrate-new.js",
"db:migrate:stale": "node scripts/db-stale-sql-tracker.js --write", "db:migrate:stale": "node scripts/db-stale-sql-tracker.js --write",
"db:migrate:stale:check": "node scripts/db-stale-sql-tracker.js --fail-on-stale", "db:migrate:stale:check": "node scripts/db-stale-sql-tracker.js --fail-on-stale",
"pr:auth": "node scripts/gitea-pr.js auth-check",
"pr:create": "node scripts/gitea-pr.js create",
"pr:view": "node scripts/gitea-pr.js view",
"pr:update": "node scripts/gitea-pr.js update",
"pr:merge": "node scripts/gitea-pr.js merge",
"test": "jest --runInBand", "test": "jest --runInBand",
"test:e2e": "npm --prefix frontend run test:e2e --", "test:e2e": "npm --prefix frontend run test:e2e --",
"test:e2e:headed": "npm --prefix frontend run test:e2e:headed --", "test:e2e:headed": "npm --prefix frontend run test:e2e:headed --",

View File

@ -0,0 +1,465 @@
BEGIN;
-- Household-owned store brands. The legacy public.stores table remains for
-- historical data and system-admin compatibility, but the household shopping
-- flow should use these records plus store_locations.
CREATE TABLE IF NOT EXISTS household_custom_stores (
id SERIAL PRIMARY KEY,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
name VARCHAR(120) NOT NULL,
normalized_name VARCHAR(120) NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(household_id, normalized_name)
);
CREATE INDEX IF NOT EXISTS idx_household_custom_stores_household
ON household_custom_stores(household_id);
CREATE TABLE IF NOT EXISTS store_locations (
id SERIAL PRIMARY KEY,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
household_store_id INTEGER NOT NULL REFERENCES household_custom_stores(id) ON DELETE CASCADE,
name VARCHAR(160) NOT NULL,
normalized_name VARCHAR(160) NOT NULL,
address TEXT,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
map_data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(household_store_id, normalized_name)
);
CREATE INDEX IF NOT EXISTS idx_store_locations_household
ON store_locations(household_id);
CREATE INDEX IF NOT EXISTS idx_store_locations_store
ON store_locations(household_store_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_store_locations_default_per_household
ON store_locations(household_id)
WHERE is_default;
CREATE TABLE IF NOT EXISTS store_location_zones (
id SERIAL PRIMARY KEY,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE,
name VARCHAR(120) NOT NULL,
normalized_name VARCHAR(120) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
color VARCHAR(32),
map_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(store_location_id, normalized_name)
);
CREATE INDEX IF NOT EXISTS idx_store_location_zones_location_order
ON store_location_zones(store_location_id, is_active, sort_order, name);
CREATE TABLE IF NOT EXISTS household_item_images (
id BIGSERIAL PRIMARY KEY,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
store_location_id INTEGER REFERENCES store_locations(id) ON DELETE CASCADE,
household_store_item_id INTEGER REFERENCES household_store_items(id) ON DELETE CASCADE,
household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE,
image_scope VARCHAR(20) NOT NULL CHECK (image_scope IN ('catalog', 'list')),
image BYTEA NOT NULL,
mime_type VARCHAR(50) NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_household_item_images_item
ON household_item_images(household_store_item_id, image_scope, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_household_item_images_location
ON household_item_images(household_id, store_location_id, created_at DESC);
CREATE TABLE IF NOT EXISTS household_item_events (
id BIGSERIAL PRIMARY KEY,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE,
household_store_item_id INTEGER REFERENCES household_store_items(id) ON DELETE SET NULL,
household_list_id INTEGER REFERENCES household_lists(id) ON DELETE SET NULL,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
event_type VARCHAR(50) NOT NULL CHECK (
event_type IN (
'ITEM_ADDED',
'ITEM_BOUGHT',
'ITEM_UNBOUGHT',
'ITEM_QUANTITY_CHANGED',
'ITEM_DELETED',
'ITEM_CLASSIFICATION_CHANGED',
'ITEM_ZONE_CHANGED'
)
),
quantity_delta INTEGER,
quantity_after INTEGER,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_household_item_events_location_time
ON household_item_events(household_id, store_location_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_household_item_events_item_time
ON household_item_events(household_store_item_id, created_at DESC);
-- Allow new location-scoped rows to be independent of the legacy global
-- stores/items catalog.
ALTER TABLE household_store_items
ALTER COLUMN store_id DROP NOT NULL;
ALTER TABLE household_lists
ADD COLUMN IF NOT EXISTS store_location_id INTEGER;
ALTER TABLE household_store_items
ADD COLUMN IF NOT EXISTS store_location_id INTEGER,
ADD COLUMN IF NOT EXISTS image_id BIGINT REFERENCES household_item_images(id) ON DELETE SET NULL;
ALTER TABLE household_item_classifications
ADD COLUMN IF NOT EXISTS store_location_id INTEGER,
ADD COLUMN IF NOT EXISTS zone_id INTEGER REFERENCES store_location_zones(id) ON DELETE SET NULL;
ALTER TABLE household_list_history
ADD COLUMN IF NOT EXISTS store_location_id INTEGER;
ALTER TABLE household_lists
ADD COLUMN IF NOT EXISTS image_id BIGINT REFERENCES household_item_images(id) ON DELETE SET NULL;
-- One owner per household, with defensive cleanup for older data.
WITH ranked_owners AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY household_id
ORDER BY joined_at ASC, id ASC
) AS owner_rank
FROM household_members
WHERE role = 'owner'
)
UPDATE household_members hm
SET role = 'admin'
FROM ranked_owners ro
WHERE hm.id = ro.id
AND ro.owner_rank > 1;
CREATE UNIQUE INDEX IF NOT EXISTS uq_household_members_one_owner
ON household_members(household_id)
WHERE role = 'owner';
-- Backfill custom stores from current household/global-store links.
INSERT INTO household_custom_stores (
household_id,
name,
normalized_name,
created_at,
updated_at
)
SELECT
hs.household_id,
s.name,
LOWER(TRIM(s.name)),
COALESCE(MIN(hs.added_at), NOW()),
NOW()
FROM household_stores hs
JOIN stores s ON s.id = hs.store_id
GROUP BY hs.household_id, s.name
ON CONFLICT (household_id, normalized_name) DO NOTHING;
WITH ranked_links AS (
SELECT
hs.household_id,
hcs.id AS household_store_id,
ROW_NUMBER() OVER (
PARTITION BY hs.household_id
ORDER BY hs.is_default DESC, hs.added_at ASC, hs.id ASC
) AS household_rank
FROM household_stores hs
JOIN stores s ON s.id = hs.store_id
JOIN household_custom_stores hcs
ON hcs.household_id = hs.household_id
AND hcs.normalized_name = LOWER(TRIM(s.name))
)
INSERT INTO store_locations (
household_id,
household_store_id,
name,
normalized_name,
is_default,
created_at,
updated_at
)
SELECT
household_id,
household_store_id,
'Default Location',
'default location',
household_rank = 1,
NOW(),
NOW()
FROM ranked_links
ON CONFLICT (household_store_id, normalized_name) DO NOTHING;
-- Backfill location ids onto existing records.
UPDATE household_store_items hsi
SET store_location_id = sl.id
FROM stores s
JOIN household_custom_stores hcs
ON hcs.normalized_name = LOWER(TRIM(s.name))
JOIN store_locations sl
ON sl.household_store_id = hcs.id
AND sl.normalized_name = 'default location'
WHERE hsi.store_id = s.id
AND hcs.household_id = hsi.household_id
AND hsi.store_location_id IS NULL;
UPDATE household_lists hl
SET store_location_id = hsi.store_location_id
FROM household_store_items hsi
WHERE hl.household_store_item_id = hsi.id
AND hl.store_location_id IS NULL;
UPDATE household_item_classifications hic
SET store_location_id = hsi.store_location_id
FROM household_store_items hsi
WHERE hic.household_store_item_id = hsi.id
AND hic.store_location_id IS NULL;
UPDATE household_list_history hlh
SET store_location_id = hl.store_location_id
FROM household_lists hl
WHERE hlh.household_list_id = hl.id
AND hlh.store_location_id IS NULL;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM household_store_items WHERE store_location_id IS NULL) THEN
RAISE EXCEPTION 'Failed to backfill household_store_items.store_location_id';
END IF;
IF EXISTS (SELECT 1 FROM household_lists WHERE store_location_id IS NULL) THEN
RAISE EXCEPTION 'Failed to backfill household_lists.store_location_id';
END IF;
IF EXISTS (SELECT 1 FROM household_item_classifications WHERE store_location_id IS NULL) THEN
RAISE EXCEPTION 'Failed to backfill household_item_classifications.store_location_id';
END IF;
END $$;
ALTER TABLE household_store_items
ALTER COLUMN store_location_id SET NOT NULL,
ADD CONSTRAINT household_store_items_store_location_id_fkey
FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE;
ALTER TABLE household_lists
ALTER COLUMN store_location_id SET NOT NULL,
ADD CONSTRAINT household_lists_store_location_id_fkey
FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE;
ALTER TABLE household_item_classifications
ALTER COLUMN store_location_id SET NOT NULL,
ADD CONSTRAINT household_item_classifications_store_location_id_fkey
FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE;
ALTER TABLE household_list_history
ADD CONSTRAINT household_list_history_store_location_id_fkey
FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE;
-- Seed v1 zones for every migrated/default location.
WITH default_zones(name, sort_order, color) AS (
VALUES
('Entrance & Seasonal', 10, '#64748b'),
('Produce & Fresh Vegetables', 20, '#16a34a'),
('Meat & Seafood Counter', 30, '#dc2626'),
('Deli & Prepared Foods', 40, '#ea580c'),
('Bakery', 50, '#ca8a04'),
('Dairy & Refrigerated', 60, '#0284c7'),
('Frozen Foods', 70, '#2563eb'),
('Center Aisles (Dry Goods)', 80, '#7c3aed'),
('Beverages & Water', 90, '#0891b2'),
('Snacks & Candy', 100, '#db2777'),
('Household & Cleaning', 110, '#475569'),
('Health & Beauty', 120, '#9333ea'),
('Checkout Area', 130, '#0f172a')
)
INSERT INTO store_location_zones (
household_id,
store_location_id,
name,
normalized_name,
sort_order,
color
)
SELECT
sl.household_id,
sl.id,
dz.name,
LOWER(TRIM(dz.name)),
dz.sort_order,
dz.color
FROM store_locations sl
CROSS JOIN default_zones dz
ON CONFLICT (store_location_id, normalized_name) DO NOTHING;
WITH existing_zone_names AS (
SELECT DISTINCT
hic.household_id,
hic.store_location_id,
TRIM(hic.zone) AS name
FROM household_item_classifications hic
WHERE hic.zone IS NOT NULL
AND TRIM(hic.zone) <> ''
),
ranked_existing_zones AS (
SELECT
ezn.*,
1000 + ROW_NUMBER() OVER (
PARTITION BY ezn.store_location_id
ORDER BY ezn.name
) AS sort_order
FROM existing_zone_names ezn
)
INSERT INTO store_location_zones (
household_id,
store_location_id,
name,
normalized_name,
sort_order
)
SELECT
household_id,
store_location_id,
name,
LOWER(TRIM(name)),
sort_order
FROM ranked_existing_zones
ON CONFLICT (store_location_id, normalized_name) DO NOTHING;
UPDATE household_item_classifications hic
SET zone_id = slz.id
FROM store_location_zones slz
WHERE slz.store_location_id = hic.store_location_id
AND slz.normalized_name = LOWER(TRIM(hic.zone))
AND hic.zone_id IS NULL
AND hic.zone IS NOT NULL
AND TRIM(hic.zone) <> '';
-- Backfill image references while retaining old BYTEA columns for rollback and
-- old-code compatibility.
INSERT INTO household_item_images (
household_id,
store_location_id,
household_store_item_id,
image_scope,
image,
mime_type,
created_at
)
SELECT
hsi.household_id,
hsi.store_location_id,
hsi.id,
'catalog',
hsi.custom_image,
COALESCE(hsi.custom_image_mime_type, 'image/jpeg'),
COALESCE(hsi.updated_at, NOW())
FROM household_store_items hsi
WHERE hsi.custom_image IS NOT NULL
AND hsi.image_id IS NULL;
UPDATE household_store_items hsi
SET image_id = img.id
FROM household_item_images img
WHERE img.household_store_item_id = hsi.id
AND img.image_scope = 'catalog'
AND hsi.image_id IS NULL;
INSERT INTO household_item_images (
household_id,
store_location_id,
household_store_item_id,
household_list_id,
image_scope,
image,
mime_type,
created_by,
created_at
)
SELECT
hl.household_id,
hl.store_location_id,
hl.household_store_item_id,
hl.id,
'list',
hl.custom_image,
COALESCE(hl.custom_image_mime_type, 'image/jpeg'),
hl.added_by,
COALESCE(hl.modified_on, NOW())
FROM household_lists hl
WHERE hl.custom_image IS NOT NULL
AND hl.image_id IS NULL;
UPDATE household_lists hl
SET image_id = img.id
FROM household_item_images img
WHERE img.household_list_id = hl.id
AND img.image_scope = 'list'
AND hl.image_id IS NULL;
-- Backfill known add history into the new event log.
INSERT INTO household_item_events (
household_id,
store_location_id,
household_store_item_id,
household_list_id,
actor_user_id,
event_type,
quantity_delta,
metadata,
created_at
)
SELECT
hl.household_id,
hl.store_location_id,
hlh.household_store_item_id,
hlh.household_list_id,
hlh.added_by,
'ITEM_ADDED',
hlh.quantity,
jsonb_build_object('source', 'household_list_history'),
hlh.added_on
FROM household_list_history hlh
JOIN household_lists hl ON hl.id = hlh.household_list_id
WHERE NOT EXISTS (
SELECT 1
FROM household_item_events hie
WHERE hie.household_list_id = hlh.household_list_id
AND hie.household_store_item_id = hlh.household_store_item_id
AND hie.event_type = 'ITEM_ADDED'
AND hie.created_at = hlh.added_on
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_household_store_items_location_name
ON household_store_items(household_id, store_location_id, normalized_name);
CREATE UNIQUE INDEX IF NOT EXISTS uq_household_lists_location_item
ON household_lists(household_id, store_location_id, household_store_item_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_household_item_classifications_location_item
ON household_item_classifications(household_id, store_location_id, household_store_item_id);
CREATE INDEX IF NOT EXISTS idx_household_lists_location_bought
ON household_lists(household_id, store_location_id, bought);
CREATE INDEX IF NOT EXISTS idx_household_classifications_location_zone
ON household_item_classifications(household_id, store_location_id, zone_id);
CREATE INDEX IF NOT EXISTS idx_household_list_history_location_item
ON household_list_history(store_location_id, household_store_item_id, added_on DESC);
COMMIT;

View File

@ -0,0 +1,9 @@
BEGIN;
ALTER TABLE household_members
ADD COLUMN IF NOT EXISTS household_sort_order INTEGER;
CREATE INDEX IF NOT EXISTS idx_household_members_user_sort_order
ON household_members(user_id, household_sort_order, joined_at DESC);
COMMIT;

379
scripts/gitea-pr.js Normal file
View File

@ -0,0 +1,379 @@
#!/usr/bin/env node
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
loadLocalEnv();
const DEFAULT_REMOTE = "origin";
const DEFAULT_SSH_HTTP_PORT = process.env.GITEA_PORT || "3000";
function loadLocalEnv() {
const envPath = ".codex-local.env";
if (!fs.existsSync(envPath)) {
return;
}
const lines = fs.readFileSync(envPath, "utf8").split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) {
continue;
}
const key = match[1].replace(/^\uFEFF/, "");
let value = match[2].trim();
const isQuoted =
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"));
if (isQuoted) {
value = value.slice(1, -1);
}
if (!process.env[key]) {
process.env[key] = value;
}
}
}
function usage() {
console.log(`Gitea PR helper
Environment:
GITEA_TOKEN Required for API calls. Never commit this value.
GITEA_BASE_URL Optional, e.g. http://192.168.7.78:3000.
GITEA_OWNER Optional repo owner override.
GITEA_REPO Optional repo name override.
Commands:
auth-check
Verify the token can authenticate with Gitea.
create --base <branch> [--head <branch>] --title <title> [--body-file <path> | --body <text>]
Create a PR, or return the existing open PR for the same base/head.
view --number <pr-number>
Print basic PR status.
update --number <pr-number> [--title <title>] [--body-file <path> | --body <text>]
Update a PR title/body. Prefer --body-file for multi-line PR bodies.
merge --number <pr-number> --yes [--method merge|squash|rebase] [--delete-branch]
Merge a PR. The --yes flag is required to avoid accidental merges.
Examples:
npm run pr:auth
npm run pr:create -- --base feature-custom-store-locations --title "Allow household switcher reordering" --body-file pr-body.md
npm run pr:update -- --number 12 --body-file pr-body.md
npm run pr:view -- --number 12
npm run pr:merge -- --number 12 --method merge --delete-branch --yes
`);
}
function fail(message) {
console.error(message);
process.exit(1);
}
function runGit(args) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
function parseFlags(argv) {
const flags = {};
const positionals = [];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith("--")) {
positionals.push(arg);
continue;
}
const key = arg.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
flags[key] = true;
continue;
}
flags[key] = next;
index += 1;
}
return { flags, positionals };
}
function requiredFlag(flags, key) {
const value = flags[key];
if (!value || value === true) {
fail(`Missing required --${key}`);
}
return value;
}
function currentBranch() {
return runGit(["branch", "--show-current"]);
}
function parseRemoteUrl(remoteUrl) {
const normalized = remoteUrl.replace(/\.git$/, "");
const httpMatch = normalized.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)$/);
if (httpMatch) {
const [, host, owner, repo] = httpMatch;
return { host, owner, repo, scheme: remoteUrl.startsWith("https://") ? "https" : "http" };
}
const sshMatch = normalized.match(/^(?:ssh:\/\/)?[^@]+@([^:/]+)(?::\d+)?[:/]([^/]+)\/([^/]+)$/);
if (sshMatch) {
const [, host, owner, repo] = sshMatch;
return { host, owner, repo, scheme: "ssh" };
}
fail(`Could not parse git remote URL: ${remoteUrl}`);
}
function repoConfig() {
const remoteUrl = runGit(["remote", "get-url", DEFAULT_REMOTE]);
const parsed = parseRemoteUrl(remoteUrl);
const baseUrl =
process.env.GITEA_BASE_URL ||
process.env.GITEA_URL ||
(parsed.scheme === "ssh"
? `http://${parsed.host}:${DEFAULT_SSH_HTTP_PORT}`
: `${parsed.scheme}://${parsed.host}`);
return {
baseUrl: baseUrl.replace(/\/$/, ""),
owner: process.env.GITEA_OWNER || parsed.owner,
repo: process.env.GITEA_REPO || parsed.repo,
};
}
function getToken() {
const token = process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN;
if (!token) {
fail(
"Missing GITEA_TOKEN. Create a Gitea access token with repository pull request permissions and set it in the shell."
);
}
return token;
}
async function apiRequest(method, route, body) {
const { baseUrl } = repoConfig();
const url = `${baseUrl}/api/v1${route}`;
const response = await fetch(url, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `token ${getToken()}`,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
const parsed = text ? safeJson(text) : null;
if (!response.ok) {
const message =
parsed?.message ||
parsed?.error ||
text ||
`${response.status} ${response.statusText}`;
fail(`Gitea API ${method} ${route} failed: ${response.status} ${message}`);
}
return parsed;
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return { message: text };
}
}
function repoRoute(pathname) {
const { owner, repo } = repoConfig();
return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${pathname}`;
}
async function listOpenPulls() {
const pulls = [];
let page = 1;
while (true) {
const batch = await apiRequest("GET", repoRoute(`/pulls?state=open&limit=50&page=${page}`));
pulls.push(...batch);
if (batch.length < 50) {
break;
}
page += 1;
}
return pulls;
}
async function findOpenPull(base, head) {
const pulls = await listOpenPulls();
return pulls.find((pull) => {
const headLabels = [pull.head?.ref, pull.head?.label].filter(Boolean);
return (
pull.base?.ref === base &&
headLabels.some((label) => label === head || label.endsWith(`:${head}`))
);
});
}
function readBody(flags) {
if (flags["body-file"]) {
return fs.readFileSync(flags["body-file"], "utf8");
}
return typeof flags.body === "string" ? flags.body : "";
}
async function authCheck() {
const user = await apiRequest("GET", "/user");
console.log(`Authenticated as ${user.login || user.username || user.full_name || "Gitea user"}`);
}
async function createPull(flags) {
const base = requiredFlag(flags, "base");
const head = flags.head && flags.head !== true ? flags.head : currentBranch();
const title = requiredFlag(flags, "title");
const body = readBody(flags);
const existing = await findOpenPull(base, head);
if (existing) {
console.log(`Existing PR #${existing.number}: ${existing.html_url || existing.url}`);
return;
}
const pull = await apiRequest("POST", repoRoute("/pulls"), {
base,
head,
title,
body,
});
console.log(`Created PR #${pull.number}: ${pull.html_url || pull.url}`);
}
async function viewPull(flags) {
const number = requiredFlag(flags, "number");
const pull = await apiRequest("GET", repoRoute(`/pulls/${encodeURIComponent(number)}`));
console.log(
[
`PR #${pull.number}: ${pull.title}`,
`URL: ${pull.html_url || pull.url}`,
`State: ${pull.state}${pull.merged ? " (merged)" : ""}`,
`Base: ${pull.base?.ref || "(unknown)"}`,
`Head: ${pull.head?.ref || "(unknown)"}`,
`Mergeable: ${String(pull.mergeable)}`,
].join("\n")
);
}
async function updatePull(flags) {
const number = requiredFlag(flags, "number");
const payload = {};
if (flags.title && flags.title !== true) {
payload.title = flags.title;
}
if (flags["body-file"] || typeof flags.body === "string") {
payload.body = readBody(flags);
}
if (Object.keys(payload).length === 0) {
fail("Nothing to update; pass --title, --body, or --body-file");
}
const pull = await apiRequest(
"PATCH",
repoRoute(`/pulls/${encodeURIComponent(number)}`),
payload
);
console.log(`Updated PR #${pull.number}: ${pull.html_url || pull.url}`);
}
async function mergePull(flags) {
const number = requiredFlag(flags, "number");
if (!flags.yes) {
fail("Refusing to merge without --yes");
}
const method = flags.method && flags.method !== true ? flags.method : "merge";
const allowedMethods = new Set(["merge", "squash", "rebase"]);
if (!allowedMethods.has(method)) {
fail("--method must be one of: merge, squash, rebase");
}
const pull = await apiRequest("GET", repoRoute(`/pulls/${encodeURIComponent(number)}`));
if (pull.state !== "open") {
fail(`Refusing to merge PR #${number}; state is ${pull.state}`);
}
if (pull.merged) {
fail(`PR #${number} is already merged`);
}
await apiRequest("POST", repoRoute(`/pulls/${encodeURIComponent(number)}/merge`), {
Do: method,
delete_branch_after_merge: Boolean(flags["delete-branch"]),
});
console.log(`Merged PR #${number} with method ${method}`);
}
async function main() {
const [command, ...rest] = process.argv.slice(2);
const { flags } = parseFlags(rest);
switch (command) {
case undefined:
case "help":
case "--help":
case "-h":
usage();
break;
case "auth-check":
await authCheck();
break;
case "create":
await createPull(flags);
break;
case "view":
await viewPull(flags);
break;
case "update":
await updatePull(flags);
break;
case "merge":
await mergePull(flags);
break;
default:
fail(`Unknown command: ${command}`);
}
}
main().catch((error) => {
fail(error.stack || error.message || String(error));
});