diff --git a/.gitignore b/.gitignore index b5c8e5b..ce1835e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -# Environment variables (DO NOT COMMIT) -.env - -# Node dependencies -node_modules/ +# Environment variables (DO NOT COMMIT) +.env +.codex-local.env + +# Node dependencies +node_modules/ # Build output (if using a bundler or React later) dist/ diff --git a/PROJECT_INSTRUCTIONS.md b/PROJECT_INSTRUCTIONS.md index 4b75b70..c88b345 100644 --- a/PROJECT_INSTRUCTIONS.md +++ b/PROJECT_INSTRUCTIONS.md @@ -19,6 +19,15 @@ If anything conflicts, follow **this** doc. - Dev/Prod share schema via migrations in: `packages/db/migrations`. - Active migration runbook: `docs/DB_MIGRATION_WORKFLOW.md` (active set + status commands). +### Docker dev runtime +- After backend/API code changes while using `docker-compose.dev.yml`, rebuild and restart only the backend service: + - `docker compose -f docker-compose.dev.yml up -d --build backend` +- After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded: + - `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend` +- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include the exact browser origin, for example `http://localhost:3010` and `http://127.0.0.1:3010`. +- Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`. +- Do not print or commit real `.env` values while checking or updating local Docker env. + ### No background jobs - **No cron/worker jobs**. Any fix must work without background tasks. @@ -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/` + - `bugfix/` + - `refactor/` + - `chore/` + - `spike/` +- Do not include tracker numbers in branch names. +- Use standalone branches from `main` for independent work. +- Use stacked branches from the parent PR branch for follow-on work. +- Target standalone PRs at `main`. +- Target stacked PRs at the parent PR branch. +- Never push directly to `main`. + +### Commit discipline - Treat committing as a first-class part of the workflow: create frequent, verified checkpoint commits for completed work instead of accumulating large uncommitted changes. +- Commit after each coherent logical unit of work. - Commit in small, logical slices (no broad mixed-purpose commits). +- Before committing: + 1. Run `git diff --stat`. + 2. Run relevant tests or checks when practical. + 3. Stage only files that belong to the logical unit. + 4. Run `git diff --cached --stat`. + 5. Commit with an imperative, present-tense subject at or below 72 characters. - Each commit must: - follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`) - include only related files for that slice - exclude secrets, credentials, and generated noise +- Do not stage unrelated user or collaborator changes. +- Do not start a second unrelated task while the first has uncommitted work. +- If existing local changes are external/user changes, leave them untouched unless explicitly told otherwise. +- If asked to commit and external changes already exist, commit those separately on the proper branch before starting new work. - Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas). - Prefer frequent checkpoint commits during agentic work rather than one large end-state commit. - Before switching tasks or stopping after a completed change, check git status and either commit the finished slice or clearly document why it remains uncommitted. - If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code). + +### Push and PR coordination +- Push the branch before opening a PR. +- For this Gitea repo, use `docs/GITEA_PR_WORKFLOW.md` and `scripts/gitea-pr.js` for PR creation, lookup, and merge operations. +- PR tooling must read auth from `GITEA_TOKEN`/`GITEA_BASE_URL` shell environment or ignored `.codex-local.env` only; never commit tokens or print token values. +- Open a draft PR early for non-trivial, collision-prone, or multi-agent work once the first coherent commit exists. +- Use the PR body as the coordination record: + - `Owner:` + - `Status: proposed / in-progress / blocked / review / done` + - `Branch:` + - `Branch relationship: standalone from main / stacked on parent branch / continuing existing branch` + - `Likely modified areas:` + - `Actual modified files:` + - `Collision risk: low / medium / high` + - `Last meaningful update:` +- Collision risk levels: + - Low: isolated docs, tests, or one leaf component. + - Medium: shared stores/types, panels/components, handlers, helpers. + - High: interface contracts, broad app flows, core registries, cross-cutting behavior. +- If a branch already contains assigned feature work and has no current PR, stop before adding more feature commits. Push the branch and open a draft PR, or record the GitHub/auth blocker. +- Before writing or updating the final PR body, inspect: + - `git log ..HEAD` + - `git diff --stat ..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 ` status check. diff --git a/backend/controllers/available-items.controller.js b/backend/controllers/available-items.controller.js index 74617a1..bdb3d73 100644 --- a/backend/controllers/available-items.controller.js +++ b/backend/controllers/available-items.controller.js @@ -1,6 +1,6 @@ const AvailableItems = require("../models/available-item.model"); 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 { logError } = require("../utils/logger"); @@ -13,6 +13,10 @@ function parseBoolean(value) { return value === true || value === "true" || value === "1"; } +function getStoreLocationId(req) { + return req.params.locationId || req.params.storeId; +} + function isCatalogTableMissing(error) { return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || ""); } @@ -84,7 +88,7 @@ function normalizeClassificationPayload(classification) { return { item_type, item_group, zone }; } -function validateClassification(res, classification) { +async function validateClassification(res, householdId, storeLocationId, classification) { if (!classification) { return false; } @@ -106,9 +110,12 @@ function validateClassification(res, classification) { return true; } - if (zone && !isValidZone(zone)) { - sendError(res, 400, "Invalid zone"); - return true; + if (zone) { + const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone); + if (!zoneRecord) { + sendError(res, 400, "Invalid zone"); + return true; + } } return false; @@ -121,8 +128,13 @@ function parseItemId(value) { exports.getAvailableItems = async (req, res) => { try { - const { householdId, storeId } = req.params; - const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || ""); + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); + const items = await AvailableItems.listAvailableItems( + householdId, + storeLocationId, + req.query.query || "" + ); res.json({ items, catalog_ready: true }); } catch (error) { if (isCatalogTableMissing(error)) { @@ -139,7 +151,8 @@ exports.getAvailableItems = async (req, res) => { exports.createAvailableItem = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name } = req.body; if (!item_name || item_name.trim() === "") { @@ -152,7 +165,7 @@ exports.createAvailableItem = async (req, res) => { } const normalizedClassification = normalizeClassificationPayload(parsedClassification); - if (validateClassification(res, normalizedClassification)) { + if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) { return; } @@ -161,21 +174,37 @@ exports.createAvailableItem = async (req, res) => { const item = await AvailableItems.createAvailableItem( householdId, - storeId, + storeLocationId, item_name, imageBuffer, - mimeType + mimeType, + req.user.id ); if (normalizedClassification) { - await List.upsertClassification(householdId, storeId, item.item_id, { + await List.upsertClassification(householdId, storeLocationId, item.item_id, { ...normalizedClassification, confidence: 1.0, source: "user", }); + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: item.item_id, + actorUserId: req.user.id, + eventType: "ITEM_CLASSIFICATION_CHANGED", + metadata: { + item_name, + ...normalizedClassification, + }, + }); } - const refreshedItem = await AvailableItems.getAvailableItemById(householdId, storeId, item.item_id); + const refreshedItem = await AvailableItems.getAvailableItemById( + householdId, + storeLocationId, + item.item_id + ); res.status(201).json({ message: "Available item added", @@ -199,7 +228,8 @@ exports.createAvailableItem = async (req, res) => { exports.updateAvailableItem = async (req, res) => { try { - const { householdId, storeId, itemId: rawItemId } = req.params; + const { householdId, itemId: rawItemId } = req.params; + const storeLocationId = getStoreLocationId(req); const itemId = parseItemId(rawItemId); if (!itemId) { @@ -214,15 +244,19 @@ exports.updateAvailableItem = async (req, res) => { } const normalizedClassification = normalizeClassificationPayload(parsedClassification); - if (normalizedClassification && validateClassification(res, normalizedClassification)) { + if ( + normalizedClassification && + (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) + ) { return; } - const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeId, itemId, { + const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeLocationId, itemId, { itemName: req.body.item_name, imageBuffer: req.processedImage?.buffer || null, mimeType: req.processedImage?.mimeType || null, removeImage: parseBoolean(req.body.remove_image), + userId: req.user.id, }); if (!updatedItem) { @@ -231,19 +265,30 @@ exports.updateAvailableItem = async (req, res) => { if (hasClassificationField) { if (normalizedClassification) { - await List.upsertClassification(householdId, storeId, updatedItem.item_id, { + await List.upsertClassification(householdId, storeLocationId, updatedItem.item_id, { ...normalizedClassification, confidence: 1.0, source: "user", }); + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: updatedItem.item_id, + actorUserId: req.user.id, + eventType: "ITEM_CLASSIFICATION_CHANGED", + metadata: { + item_name: updatedItem.item_name, + ...normalizedClassification, + }, + }); } else { - await List.deleteClassification(householdId, storeId, updatedItem.item_id); + await List.deleteClassification(householdId, storeLocationId, updatedItem.item_id); } } const refreshedItem = await AvailableItems.getAvailableItemById( householdId, - storeId, + storeLocationId, updatedItem.item_id ); @@ -269,14 +314,20 @@ exports.updateAvailableItem = async (req, res) => { exports.deleteAvailableItem = async (req, res) => { try { - const { householdId, storeId, itemId: rawItemId } = req.params; + const { householdId, itemId: rawItemId } = req.params; + const storeLocationId = getStoreLocationId(req); const itemId = parseItemId(rawItemId); if (!itemId) { return sendError(res, 400, "Item ID must be a positive integer"); } - const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId); + const deleted = await AvailableItems.deleteAvailableItem( + householdId, + storeLocationId, + itemId, + req.user.id + ); if (!deleted) { return sendError(res, 404, "Store item not found"); @@ -298,8 +349,9 @@ exports.deleteAvailableItem = async (req, res) => { exports.importCurrentItems = async (req, res) => { try { - const { householdId, storeId } = req.params; - const importedCount = await AvailableItems.importCurrentListItems(householdId, storeId); + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); + const importedCount = await AvailableItems.importCurrentListItems(householdId, storeLocationId); res.json({ message: importedCount > 0 ? "Imported current list items" : "No current list items to import", diff --git a/backend/controllers/households.controller.js b/backend/controllers/households.controller.js index 49f0e53..03390b3 100644 --- a/backend/controllers/households.controller.js +++ b/backend/controllers/households.controller.js @@ -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 exports.getHousehold = async (req, res) => { try { diff --git a/backend/controllers/lists.controller.v2.js b/backend/controllers/lists.controller.v2.js index 8295fdb..d09eda9 100644 --- a/backend/controllers/lists.controller.v2.js +++ b/backend/controllers/lists.controller.v2.js @@ -1,6 +1,6 @@ const List = require("../models/list.model.v2"); 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 { logError } = require("../utils/logger"); @@ -9,6 +9,10 @@ const LEGACY_ITEM_TYPE_MAP = { snacks: "snack", }; +function getStoreLocationId(req) { + return req.params.locationId || req.params.storeId; +} + function normalizeClassificationPayload(classification) { if (typeof classification === "string") { const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification; @@ -43,14 +47,40 @@ function normalizeClassificationPayload(classification) { return { item_type, item_group, zone }; } -/** - * Get list items for household and store - * GET /households/:householdId/stores/:storeId/list - */ +async function validateClassification(res, householdId, storeLocationId, classification) { + const { item_type, item_group, zone } = classification; + + if (item_type && !isValidItemType(item_type)) { + sendError(res, 400, "Invalid item_type"); + return true; + } + + if (item_group && !item_type) { + sendError(res, 400, "Item type is required when item group is provided"); + return true; + } + + if (item_group && !isValidItemGroup(item_type, item_group)) { + sendError(res, 400, "Invalid item_group for selected item_type"); + return true; + } + + if (zone) { + const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone); + if (!zoneRecord) { + sendError(res, 400, "Invalid zone"); + return true; + } + } + + return false; +} + exports.getList = async (req, res) => { try { - const { householdId, storeId } = req.params; - const items = await List.getHouseholdStoreList(householdId, storeId); + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); + const items = await List.getHouseholdStoreList(householdId, storeLocationId); res.json({ items }); } catch (error) { logError(req, "listsV2.getList", error); @@ -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) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name } = req.query; if (!item_name) { return sendError(res, 400, "Item name is required"); } - const item = await List.getItemByName(householdId, storeId, item_name); + const item = await List.getItemByName(householdId, storeLocationId, item_name); if (!item) { 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) => { 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 userId = req.user.id; let historyUserId = userId; @@ -118,13 +142,12 @@ exports.addItem = async (req, res) => { historyUserId = parsedUserId; } - // Get processed image if uploaded const imageBuffer = req.processedImage?.buffer || null; const mimeType = req.processedImage?.mimeType || null; const result = await List.addOrUpdateItem( householdId, - storeId, + storeLocationId, item_name, quantity || "1", userId, @@ -133,17 +156,38 @@ exports.addItem = async (req, res) => { notes ); - // Add history record - await List.addHistoryRecord(result.listId, result.householdStoreItemId, quantity || "1", historyUserId); + await List.addHistoryRecord( + result.listId, + result.householdStoreItemId, + result.historyQuantity ?? quantity ?? "1", + historyUserId, + storeLocationId + ); + + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: result.householdStoreItemId, + householdListId: result.listId, + actorUserId: historyUserId, + eventType: "ITEM_ADDED", + quantityDelta: result.historyQuantity ?? Number.parseInt(quantity || "1", 10), + quantityAfter: result.quantity, + metadata: { + item_name: result.itemName, + is_new_list_item: result.isNew, + added_by_request_user_id: userId, + }, + }); res.json({ message: result.isNew ? "Item added" : "Item updated", item: { id: result.listId, item_name: result.itemName, - quantity: quantity || "1", - bought: false - } + quantity: result.quantity ?? quantity ?? "1", + bought: false, + }, }); } catch (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) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name, bought, quantity_bought } = req.body; if (!item_name) return sendError(res, 400, "Item name is required"); - const item = await List.getItemByName(householdId, storeId, item_name); + const item = await List.getItemByName(householdId, storeLocationId, item_name); if (!item) return sendError(res, 404, "Item not found"); + const eventDetails = await List.setBought(item.id, bought, quantity_bought); - // Update bought status (with optional partial purchase) - await List.setBought(item.id, bought, quantity_bought); + if (eventDetails) { + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: item.household_store_item_id, + householdListId: item.id, + actorUserId: req.user.id, + eventType: eventDetails.eventType, + quantityDelta: eventDetails.quantityDelta, + quantityAfter: eventDetails.quantityAfter, + metadata: { + item_name, + requested_quantity: quantity_bought || null, + }, + }); + } res.json({ message: bought ? "Item marked as bought" : "Item unmarked" }); } catch (error) { @@ -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) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name, quantity, notes } = req.body; if (!item_name) { return sendError(res, 400, "Item name is required"); } - // Get the list item - 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"); } - // Update item - await List.updateItem(item.id, item_name, quantity, notes); + const updateResult = await List.updateItem(item.id, item_name, quantity, notes); + if (!updateResult) { + return sendError(res, 404, "Item not found"); + } + + if (quantity !== undefined && Number(quantity) !== Number(updateResult.previous.quantity)) { + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: item.household_store_item_id, + householdListId: item.id, + actorUserId: req.user.id, + eventType: "ITEM_QUANTITY_CHANGED", + quantityDelta: Number(quantity) - Number(updateResult.previous.quantity), + quantityAfter: Number(quantity), + metadata: { + item_name, + previous_quantity: updateResult.previous.quantity, + }, + }); + } res.json({ message: "Item updated", @@ -204,8 +275,8 @@ exports.updateItem = async (req, res) => { id: item.id, item_name, quantity, - notes - } + notes, + }, }); } catch (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) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name } = req.body; if (!item_name) { return sendError(res, 400, "Item name is required"); } - // Get the list item - const item = await List.getItemByName(householdId, storeId, item_name); + const item = await List.getItemByName(householdId, storeLocationId, item_name); if (!item) { 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" }); } 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) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { query } = req.query; - const suggestions = await List.getSuggestions(query || "", householdId, storeId); + const suggestions = await List.getSuggestions(query || "", householdId, storeLocationId); res.json(suggestions); } catch (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) => { try { - const { householdId, storeId } = req.params; - const items = await List.getRecentlyBoughtItems(householdId, storeId); + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); + const items = await List.getRecentlyBoughtItems(householdId, storeLocationId); res.json(items); } catch (error) { logError(req, "listsV2.getRecentlyBought", error); @@ -273,26 +348,26 @@ exports.getRecentlyBought = async (req, res) => { } }; -/** - * Get item classification - * GET /households/:householdId/stores/:storeId/list/classification - */ exports.getClassification = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name } = req.query; if (!item_name) { return sendError(res, 400, "Item name is required"); } - // Get item ID from name - const item = await List.getItemByName(householdId, storeId, item_name); + const item = await List.getItemByName(householdId, storeLocationId, item_name); if (!item) { 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 }); } catch (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) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name, classification } = req.body; if (!item_name) { @@ -318,33 +390,17 @@ exports.setClassification = async (req, res) => { return sendError(res, 400, "Classification is required"); } - const { item_type, item_group, zone } = normalizedClassification; - - if (item_type && !isValidItemType(item_type)) { - return sendError(res, 400, "Invalid item_type"); + if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) { + return; } - if (item_group && !item_type) { - 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); + const item = await List.getItemByName(householdId, storeLocationId, item_name); let itemId; if (!item) { - // Item doesn't exist in list, need to get from items table or create const itemResult = await List.ensureHouseholdStoreItem( householdId, - storeId, + storeLocationId, item_name ); itemId = itemResult.id; @@ -352,14 +408,43 @@ exports.setClassification = async (req, res) => { itemId = item.item_id; } - await List.upsertClassification(householdId, storeId, itemId, { - item_type, - item_group, - zone, + const updated = await List.upsertClassification(householdId, storeLocationId, itemId, { + ...normalizedClassification, confidence: 1.0, source: "user", }); + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: itemId, + householdListId: item?.id || null, + actorUserId: req.user.id, + eventType: "ITEM_CLASSIFICATION_CHANGED", + metadata: { + item_name, + item_type: normalizedClassification.item_type, + item_group: normalizedClassification.item_group, + zone: normalizedClassification.zone, + }, + }); + + if (normalizedClassification.zone) { + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: itemId, + householdListId: item?.id || null, + actorUserId: req.user.id, + eventType: "ITEM_ZONE_CHANGED", + metadata: { + item_name, + zone: normalizedClassification.zone, + zone_id: updated.zone_id || null, + }, + }); + } + res.json({ message: "Classification set", classification: normalizedClassification }); } catch (error) { logError(req, "listsV2.setClassification", error); @@ -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) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name, quantity } = req.body; const userId = req.user.id; - // Get processed image const imageBuffer = req.processedImage?.buffer || null; const mimeType = req.processedImage?.mimeType || null; @@ -385,8 +466,15 @@ exports.updateItemImage = async (req, res) => { return sendError(res, 400, "No image provided"); } - // Update the item with new image - await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType); + await List.addOrUpdateItem( + householdId, + storeLocationId, + item_name, + quantity, + userId, + imageBuffer, + mimeType + ); res.json({ message: "Image updated successfully" }); } catch (error) { diff --git a/backend/controllers/stores.controller.js b/backend/controllers/stores.controller.js index 6e4adee..d4a086e 100644 --- a/backend/controllers/stores.controller.js +++ b/backend/controllers/stores.controller.js @@ -2,7 +2,20 @@ const storeModel = require("../models/store.model"); const { sendError } = require("../utils/http"); 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) => { try { 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) => { try { const { name, default_zones } = req.body; @@ -97,25 +38,24 @@ exports.createStore = async (req, res) => { res.status(201).json({ message: "Store created successfully", - store + store, }); } catch (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"); } sendError(res, 500, "Failed to create store"); } }; -// Update store (system admin only) exports.updateStore = async (req, res) => { try { const { name, default_zones } = req.body; const store = await storeModel.updateStore(req.params.storeId, { name: name?.trim(), - default_zones + default_zones, }); if (!store) { @@ -124,7 +64,7 @@ exports.updateStore = async (req, res) => { res.json({ message: "Store updated successfully", - store + store, }); } catch (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) => { try { await storeModel.deleteStore(req.params.storeId); res.json({ message: "Store deleted successfully" }); } catch (error) { logError(req, "stores.deleteStore", error); - if (error.message.includes('in use')) { + if (error.message.includes("in use")) { return sendError(res, 400, error.message); } sendError(res, 500, "Failed to delete store"); } }; + +// Household-owned store/location management. +exports.getHouseholdStores = async (req, res) => { + try { + const stores = await storeModel.getHouseholdStores(getHouseholdId(req)); + res.json(stores); + } catch (error) { + logError(req, "stores.getHouseholdStores", error); + sendError(res, 500, "Failed to fetch household stores"); + } +}; + +exports.createHouseholdStore = async (req, res) => { + try { + const householdId = getHouseholdId(req); + const { name, location_name, address } = req.body; + + if (!name || name.trim().length === 0) { + return sendError(res, 400, "Store name is required"); + } + + const store = await storeModel.createHouseholdStore( + householdId, + name, + location_name || "Default Location", + address || null, + req.user.id + ); + + res.status(201).json({ + message: "Store location created successfully", + store, + }); + } catch (error) { + logError(req, "stores.createHouseholdStore", error); + if (error.code === "23505") { + return sendError(res, 400, "Store or location already exists for this household"); + } + sendError(res, 500, "Failed to create store location"); + } +}; + +exports.updateHouseholdStore = async (req, res) => { + try { + const { name } = req.body; + const householdStoreId = parsePositiveInteger(req.params.householdStoreId); + + if (!householdStoreId) { + return sendError(res, 400, "Store ID must be a positive integer"); + } + + if (!name || name.trim().length === 0) { + return sendError(res, 400, "Store name is required"); + } + + const store = await storeModel.updateHouseholdStore(getHouseholdId(req), householdStoreId, { + name, + }); + + if (!store) { + return sendError(res, 404, "Store not found"); + } + + res.json({ message: "Store updated successfully", store }); + } catch (error) { + logError(req, "stores.updateHouseholdStore", error); + sendError(res, 500, "Failed to update store"); + } +}; + +exports.deleteHouseholdStore = async (req, res) => { + try { + const householdStoreId = parsePositiveInteger(req.params.householdStoreId); + if (!householdStoreId) { + return sendError(res, 400, "Store ID must be a positive integer"); + } + + const deleted = await storeModel.deleteHouseholdStore(getHouseholdId(req), householdStoreId); + if (!deleted) { + return sendError(res, 404, "Store not found"); + } + + res.json({ message: "Store deleted successfully" }); + } catch (error) { + logError(req, "stores.deleteHouseholdStore", error); + if (error.message.includes("last store location")) { + return sendError(res, 400, error.message); + } + sendError(res, 500, "Failed to delete store"); + } +}; + +exports.addLocationToStore = async (req, res) => { + try { + const householdStoreId = parsePositiveInteger(req.params.householdStoreId); + const { name, address } = req.body; + + if (!householdStoreId) { + return sendError(res, 400, "Store ID must be a positive integer"); + } + + if (!name || name.trim().length === 0) { + return sendError(res, 400, "Location name is required"); + } + + const location = await storeModel.addLocationToStore( + getHouseholdId(req), + householdStoreId, + name, + address || null, + req.user.id + ); + + if (!location) { + return sendError(res, 404, "Store not found"); + } + + res.status(201).json({ + message: "Location added successfully", + store: location, + }); + } catch (error) { + logError(req, "stores.addLocationToStore", error); + if (error.code === "23505") { + return sendError(res, 400, "Location already exists for this store"); + } + sendError(res, 500, "Failed to add location"); + } +}; + +exports.updateLocation = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + const { name, address, map_data } = req.body; + + if (!locationId) { + return sendError(res, 400, "Location ID must be a positive integer"); + } + + const location = await storeModel.updateLocation(getHouseholdId(req), locationId, { + name, + address, + map_data, + }); + + if (!location) { + return sendError(res, 404, "Location not found"); + } + + res.json({ message: "Location updated successfully", store: location }); + } catch (error) { + logError(req, "stores.updateLocation", error); + sendError(res, 500, "Failed to update location"); + } +}; + +exports.deleteLocation = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + if (!locationId) { + return sendError(res, 400, "Location ID must be a positive integer"); + } + + const deleted = await storeModel.deleteLocation(getHouseholdId(req), locationId); + if (!deleted) { + return sendError(res, 404, "Location not found"); + } + + res.json({ message: "Location removed successfully" }); + } catch (error) { + logError(req, "stores.deleteLocation", error); + if (error.message.includes("last store location")) { + return sendError(res, 400, error.message); + } + sendError(res, 500, "Failed to remove location"); + } +}; + +exports.setDefaultLocation = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + if (!locationId) { + return sendError(res, 400, "Location ID must be a positive integer"); + } + + await storeModel.setDefaultLocation(getHouseholdId(req), locationId); + res.json({ message: "Default location updated successfully" }); + } catch (error) { + logError(req, "stores.setDefaultLocation", error); + sendError(res, 500, "Failed to set default location"); + } +}; + +exports.getLocationZones = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + if (!locationId) { + return sendError(res, 400, "Location ID must be a positive integer"); + } + + const zones = await storeModel.listLocationZones(getHouseholdId(req), locationId); + res.json({ zones }); + } catch (error) { + logError(req, "stores.getLocationZones", error); + sendError(res, 500, "Failed to load zones"); + } +}; + +exports.createZone = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + const { name, sort_order, color, map_metadata } = req.body; + + if (!locationId) { + return sendError(res, 400, "Location ID must be a positive integer"); + } + + if (!name || name.trim().length === 0) { + return sendError(res, 400, "Zone name is required"); + } + + const zone = await storeModel.createZone(getHouseholdId(req), locationId, { + name, + sort_order: Number.isInteger(sort_order) ? sort_order : Number.parseInt(sort_order, 10), + color, + map_metadata, + }); + + res.status(201).json({ message: "Zone created successfully", zone }); + } catch (error) { + logError(req, "stores.createZone", error); + if (error.code === "23505") { + return sendError(res, 400, "Zone already exists for this location"); + } + sendError(res, 500, "Failed to create zone"); + } +}; + +exports.updateZone = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + const zoneId = parsePositiveInteger(req.params.zoneId); + + if (!locationId || !zoneId) { + return sendError(res, 400, "Location ID and zone ID must be positive integers"); + } + + const sortOrder = req.body.sort_order; + const zone = await storeModel.updateZone(getHouseholdId(req), locationId, zoneId, { + name: req.body.name, + sort_order: + sortOrder === undefined + ? undefined + : Number.isInteger(sortOrder) + ? sortOrder + : Number.parseInt(sortOrder, 10), + color: req.body.color, + map_metadata: req.body.map_metadata, + is_active: req.body.is_active, + }); + + if (!zone) { + return sendError(res, 404, "Zone not found"); + } + + res.json({ message: "Zone updated successfully", zone }); + } catch (error) { + logError(req, "stores.updateZone", error); + sendError(res, 500, "Failed to update zone"); + } +}; + +exports.deleteZone = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + const zoneId = parsePositiveInteger(req.params.zoneId); + + if (!locationId || !zoneId) { + return sendError(res, 400, "Location ID and zone ID must be positive integers"); + } + + const deleted = await storeModel.deleteZone(getHouseholdId(req), locationId, zoneId); + if (!deleted) { + return sendError(res, 404, "Zone not found"); + } + + res.json({ message: "Zone removed successfully" }); + } catch (error) { + logError(req, "stores.deleteZone", error); + sendError(res, 500, "Failed to remove zone"); + } +}; + +// Backward-compatible handlers for the old /stores/household routes. +exports.addStoreToHousehold = async (req, res) => { + try { + const { storeId } = req.body; + if (!storeId) { + return sendError(res, 400, "Store ID is required"); + } + + const legacyStore = await storeModel.getStoreById(storeId); + if (!legacyStore) { + return sendError(res, 404, "Store not found"); + } + + const store = await storeModel.createHouseholdStore( + getHouseholdId(req), + legacyStore.name, + "Default Location", + null, + req.user.id + ); + + res.status(201).json({ + message: "Store added to household successfully", + store, + }); + } catch (error) { + logError(req, "stores.addStoreToHousehold", error); + sendError(res, 500, "Failed to add store to household"); + } +}; + +exports.removeStoreFromHousehold = exports.deleteLocation; +exports.setDefaultStore = exports.setDefaultLocation; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 08e1ae6..c936b56 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -8,27 +8,30 @@ const { logError } = require("../utils/logger"); async function auth(req, res, next) { const header = req.headers.authorization || ""; const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null; + const cookies = parseCookieHeader(req.headers.cookie); + const sid = cookies[cookieName()]; if (token) { const jwtSecret = process.env.JWT_SECRET; - if (!jwtSecret) { + if (!jwtSecret && !sid) { logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured")); return sendError(res, 500, "Authentication is unavailable"); } - try { - const decoded = jwt.verify(token, jwtSecret); - req.user = decoded; // id + role - return next(); - } catch (err) { - return sendError(res, 401, "Invalid or expired token"); + if (jwtSecret) { + try { + const decoded = jwt.verify(token, jwtSecret); + req.user = decoded; // id + role + return next(); + } catch (err) { + if (!sid) { + return sendError(res, 401, "Invalid or expired token"); + } + } } } try { - const cookies = parseCookieHeader(req.headers.cookie); - const sid = cookies[cookieName()]; - if (!sid) { return sendError(res, 401, "Missing authentication"); } diff --git a/backend/middleware/household.js b/backend/middleware/household.js index 8a2ece0..4ce5fff 100644 --- a/backend/middleware/household.js +++ b/backend/middleware/household.js @@ -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 exports.requireSystemAdmin = (req, res, next) => { if (!req.user) { diff --git a/backend/middleware/optional-auth.js b/backend/middleware/optional-auth.js index ddbf932..fb0d93d 100644 --- a/backend/middleware/optional-auth.js +++ b/backend/middleware/optional-auth.js @@ -10,16 +10,14 @@ async function optionalAuth(req, res, next) { if (token) { const jwtSecret = process.env.JWT_SECRET; - if (!jwtSecret) { - return next(); - } - - try { - const decoded = jwt.verify(token, jwtSecret); - req.user = decoded; - return next(); - } catch (err) { - return next(); + if (jwtSecret) { + try { + const decoded = jwt.verify(token, jwtSecret); + req.user = decoded; + return next(); + } catch (err) { + // Continue to the session cookie fallback below. + } } } diff --git a/backend/models/available-item.model.js b/backend/models/available-item.model.js index 0d4c7d6..e6d6571 100644 --- a/backend/models/available-item.model.js +++ b/backend/models/available-item.model.js @@ -1,81 +1,97 @@ const pool = require("../db/pool"); +const List = require("./list.model.v2"); function normalizeItemName(itemName) { return String(itemName || "").trim().toLowerCase(); } -async function getHouseholdStoreItemRecord(householdId, storeId, itemId) { +async function getHouseholdStoreItemRecord(householdId, storeLocationId, itemId) { const result = await pool.query( `WITH latest_list_items AS ( SELECT DISTINCT ON (hl.household_store_item_id) hl.household_store_item_id, + hl.image_id, hl.custom_image, hl.custom_image_mime_type, hl.modified_on, hl.id FROM household_lists hl WHERE hl.household_id = $1 - AND hl.store_id = $2 + AND hl.store_location_id = $2 ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC ) SELECT hsi.id AS item_id, hsi.name AS item_name, - ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image, - COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type, + ENCODE( + COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image), + 'base64' + ) AS item_image, + COALESCE( + catalog_img.mime_type, + hsi.custom_image_mime_type, + list_img.mime_type, + lli.custom_image_mime_type + ) AS image_mime_type, hic.item_type, hic.item_group, - hic.zone + COALESCE(slz.name, hic.zone) AS zone, + slz.sort_order AS zone_sort_order FROM household_store_items hsi + LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id + LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id LEFT JOIN household_item_classifications hic ON hic.household_id = hsi.household_id - AND hic.store_id = hsi.store_id + AND hic.store_location_id = hsi.store_location_id AND hic.household_store_item_id = hsi.id + LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id WHERE hsi.household_id = $1 - AND hsi.store_id = $2 + AND hsi.store_location_id = $2 AND hsi.id = $3`, - [householdId, storeId, itemId] + [householdId, storeLocationId, itemId] ); return result.rows[0] || null; } -async function findOrCreateHouseholdStoreItem(householdId, storeId, itemName) { +async function findOrCreateHouseholdStoreItem(householdId, storeLocationId, itemName) { const normalizedName = normalizeItemName(itemName); const existing = await pool.query( `SELECT id, name FROM household_store_items WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 AND normalized_name = $3`, - [householdId, storeId, normalizedName] + [householdId, storeLocationId, normalizedName] ); if (existing.rowCount > 0) { return { itemId: existing.rows[0].id, itemName: existing.rows[0].name, + isNew: false, }; } const created = await pool.query( `INSERT INTO household_store_items - (household_id, store_id, name, normalized_name, updated_at) + (household_id, store_location_id, name, normalized_name, updated_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING id, name`, - [householdId, storeId, normalizedName, normalizedName] + [householdId, storeLocationId, normalizedName, normalizedName] ); return { itemId: created.rows[0].id, 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 values = [householdId, storeId]; + const values = [householdId, storeLocationId]; let filterClause = ""; if (trimmedQuery) { @@ -87,35 +103,49 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => { `WITH latest_list_items AS ( SELECT DISTINCT ON (hl.household_store_item_id) hl.household_store_item_id, + hl.image_id, hl.custom_image, hl.custom_image_mime_type, hl.modified_on, hl.id FROM household_lists hl WHERE hl.household_id = $1 - AND hl.store_id = $2 + AND hl.store_location_id = $2 ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC ) SELECT hsi.id AS item_id, hsi.name AS item_name, - ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image, - COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type, + ENCODE( + COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image), + 'base64' + ) AS item_image, + COALESCE( + catalog_img.mime_type, + hsi.custom_image_mime_type, + list_img.mime_type, + lli.custom_image_mime_type + ) AS image_mime_type, hic.item_type, hic.item_group, - 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 ) AS has_managed_settings FROM household_store_items hsi + LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id + LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id LEFT JOIN household_item_classifications hic ON hic.household_id = hsi.household_id - AND hic.store_id = hsi.store_id + AND hic.store_location_id = hsi.store_location_id AND hic.household_store_item_id = hsi.id + LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id WHERE hsi.household_id = $1 - AND hsi.store_id = $2 + AND hsi.store_location_id = $2 ${filterClause} ORDER BY hsi.name ASC LIMIT 100`, @@ -125,22 +155,23 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => { return result.rows; }; -exports.getAvailableItemById = async (householdId, storeId, itemId) => - getHouseholdStoreItemRecord(householdId, storeId, itemId); +exports.getAvailableItemById = async (householdId, storeLocationId, itemId) => + getHouseholdStoreItemRecord(householdId, storeLocationId, itemId); -exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => { +exports.getAvailableItemImageByName = async (householdId, storeLocationId, itemName) => { const normalizedName = normalizeItemName(itemName); const result = await pool.query( `SELECT - id AS item_id, - name AS item_name, - custom_image, - custom_image_mime_type - FROM household_store_items - WHERE household_id = $1 - AND store_id = $2 - AND normalized_name = $3`, - [householdId, storeId, normalizedName] + hsi.id AS item_id, + hsi.name AS item_name, + COALESCE(img.image, hsi.custom_image) AS custom_image, + COALESCE(img.mime_type, hsi.custom_image_mime_type) AS custom_image_mime_type + FROM household_store_items hsi + LEFT JOIN household_item_images img ON img.id = hsi.image_id + WHERE hsi.household_id = $1 + AND hsi.store_location_id = $2 + AND hsi.normalized_name = $3`, + [householdId, storeLocationId, normalizedName] ); return result.rows[0] || null; @@ -148,39 +179,54 @@ exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => exports.createAvailableItem = async ( householdId, - storeId, + storeLocationId, itemName, 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) { - await pool.query( - `UPDATE household_store_items - SET custom_image = $1, - custom_image_mime_type = $2, - updated_at = NOW() - WHERE id = $3 - AND household_id = $4 - AND store_id = $5`, - [imageBuffer, mimeType, itemId, householdId, storeId] + await List.setCatalogItemImage( + householdId, + storeLocationId, + itemId, + imageBuffer, + mimeType, + userId ); } - 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 { itemName, imageBuffer, mimeType, removeImage = false, + userId = null, } = updates; const assignments = ["updated_at = NOW()"]; - const values = [householdId, storeId, itemId]; + const values = [householdId, storeLocationId, itemId]; let parameterIndex = values.length; if (itemName !== undefined && String(itemName).trim() !== "") { @@ -195,22 +241,14 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) } if (removeImage) { - assignments.push("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); + assignments.push("image_id = NULL", "custom_image = NULL", "custom_image_mime_type = NULL"); } const result = await pool.query( `UPDATE household_store_items SET ${assignments.join(", ")} WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 AND id = $3 RETURNING id`, values @@ -220,53 +258,75 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) 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( `DELETE FROM household_store_items WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 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; }; -exports.importCurrentListItems = async (householdId, storeId) => { +exports.importCurrentListItems = async (householdId, storeLocationId) => { const result = await pool.query( `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) hl.household_id, - hl.store_id, + hl.store_location_id, hsi.name, hsi.normalized_name, - hsi.custom_image, - hsi.custom_image_mime_type, + hsi.image_id, NOW() FROM household_lists hl JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id WHERE hl.household_id = $1 - AND hl.store_id = $2 - ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING + AND hl.store_location_id = $2 + ON CONFLICT (household_id, store_location_id, normalized_name) DO NOTHING RETURNING id`, - [householdId, storeId] + [householdId, storeLocationId] ); return result.rowCount; }; -exports.hasAvailableItems = async (householdId, storeId) => { +exports.hasAvailableItems = async (householdId, storeLocationId) => { const result = await pool.query( `SELECT 1 FROM household_store_items WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 LIMIT 1`, - [householdId, storeId] + [householdId, storeLocationId] ); return result.rowCount > 0; diff --git a/backend/models/household.model.js b/backend/models/household.model.js index 528ce89..5c568d7 100644 --- a/backend/models/household.model.js +++ b/backend/models/household.model.js @@ -1,8 +1,7 @@ const pool = require("../db/pool"); -// Get all households a user belongs to -exports.getUserHouseholds = async (userId) => { - const result = await pool.query( +async function queryUserHouseholds(db, userId) { + const result = await db.query( `SELECT h.id, h.name, @@ -10,14 +9,65 @@ exports.getUserHouseholds = async (userId) => { h.created_at, hm.role, hm.joined_at, + hm.household_sort_order, (SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count FROM households h JOIN household_members hm ON h.id = hm.household_id WHERE hm.user_id = $1 - ORDER BY hm.joined_at DESC`, + ORDER BY hm.household_sort_order ASC NULLS LAST, hm.joined_at DESC`, [userId] ); return result.rows; +} + +// Get all households a user belongs to +exports.getUserHouseholds = async (userId) => { + return queryUserHouseholds(pool, userId); +}; + +exports.reorderUserHouseholds = async (userId, householdIds) => { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const membershipResult = await client.query( + `SELECT household_id + FROM household_members + WHERE user_id = $1`, + [userId] + ); + + const currentIds = membershipResult.rows.map((row) => Number(row.household_id)); + const currentIdSet = new Set(currentIds); + + if ( + householdIds.length !== currentIds.length || + householdIds.some((householdId) => !currentIdSet.has(householdId)) + ) { + await client.query("ROLLBACK"); + return null; + } + + for (const [index, householdId] of householdIds.entries()) { + await client.query( + `UPDATE household_members + SET household_sort_order = $1 + WHERE user_id = $2 + AND household_id = $3`, + [index, userId, householdId] + ); + } + + const households = await queryUserHouseholds(client, userId); + await client.query("COMMIT"); + return households; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } }; // Get household by ID (with member check) @@ -169,18 +219,6 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse try { 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( `UPDATE household_members SET role = 'admin' @@ -193,6 +231,18 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse throw new Error("CURRENT_OWNER_NOT_FOUND"); } + const promoteResult = await client.query( + `UPDATE household_members + SET role = 'owner' + WHERE household_id = $1 AND user_id = $2 + RETURNING user_id, role`, + [householdId, nextOwnerUserId] + ); + + if (promoteResult.rows.length === 0) { + throw new Error("TARGET_MEMBER_NOT_FOUND"); + } + await client.query("COMMIT"); return promoteResult.rows[0]; } catch (error) { diff --git a/backend/models/list.model.v2.js b/backend/models/list.model.v2.js index 7f513bb..9993104 100644 --- a/backend/models/list.model.v2.js +++ b/backend/models/list.model.v2.js @@ -4,22 +4,104 @@ function normalizeItemName(itemName) { 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( - `SELECT id, name, normalized_name, custom_image, custom_image_mime_type + `SELECT id, name, normalized_name, image_id FROM household_store_items WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 AND normalized_name = $3`, - [householdId, storeId, normalizedName] + [householdId, storeLocationId, normalizedName] ); 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); - let item = await getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName); + let item = await getHouseholdStoreItemByNormalizedName( + householdId, + storeLocationId, + normalizedName + ); if (item) { return item; @@ -27,23 +109,16 @@ exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => { const result = await pool.query( `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()) - RETURNING id, name, normalized_name, custom_image, custom_image_mime_type`, - [householdId, storeId, normalizedName, normalizedName] + RETURNING id, name, normalized_name, image_id`, + [householdId, storeLocationId, normalizedName, normalizedName] ); return result.rows[0]; }; -/** - * 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} List of items - */ -exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => { +exports.getHouseholdStoreList = async (householdId, storeLocationId, includeHistory = true) => { const result = await pool.query( `SELECT hl.id, @@ -52,130 +127,141 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr hsi.name AS item_name, hl.quantity, hl.bought, - ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, - COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, - ${includeHistory ? ` - ( - SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) - FROM ( - SELECT DISTINCT - COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label - FROM household_list_history hlh - JOIN users u ON hlh.added_by = u.id - WHERE hlh.household_list_id = hl.id - ) added_by_labels - ) AS added_by_users, - ` : "NULL AS added_by_users,"} + hl.notes, + ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image, + COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type, + ${includeHistory ? `${ACTIVE_ADDED_BY_USERS_SQL},` : "NULL AS added_by_users,"} hl.modified_on AS last_added_on, hic.item_type, hic.item_group, - hic.zone + COALESCE(slz.name, hic.zone) AS zone, + slz.sort_order AS zone_sort_order FROM household_lists hl JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id + LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id LEFT JOIN household_item_classifications hic ON hic.household_id = hl.household_id - AND hic.store_id = hl.store_id + AND hic.store_location_id = hl.store_location_id AND hic.household_store_item_id = hl.household_store_item_id + LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id WHERE hl.household_id = $1 - AND hl.store_id = $2 + AND hl.store_location_id = $2 AND hl.bought = FALSE - ORDER BY hl.id ASC`, - [householdId, storeId] + ORDER BY slz.sort_order ASC NULLS LAST, hsi.name ASC`, + [householdId, storeLocationId] ); return result.rows; }; -/** - * Get a specific item from household list by name - * @param {number} householdId - Household ID - * @param {number} storeId - Store ID - * @param {string} itemName - Item name to search for - * @returns {Promise} Item or null - */ -exports.getItemByName = async (householdId, storeId, itemName) => { +exports.getItemByName = async (householdId, storeLocationId, itemName) => { const normalizedName = normalizeItemName(itemName); const result = await pool.query( `SELECT hl.id, + hl.household_id, + hl.store_location_id, hl.household_store_item_id AS item_id, hl.household_store_item_id, hsi.name AS item_name, hl.quantity, hl.bought, - ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, - COALESCE(hl.custom_image_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) - 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.notes, + ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image, + COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type, + ${ACTIVE_ADDED_BY_USERS_SQL}, hl.modified_on AS last_added_on, hic.item_type, hic.item_group, - hic.zone + COALESCE(slz.name, hic.zone) AS zone, + slz.sort_order AS zone_sort_order FROM household_lists hl JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id + LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id LEFT JOIN household_item_classifications hic ON hic.household_id = hl.household_id - AND hic.store_id = hl.store_id + AND hic.store_location_id = hl.store_location_id AND hic.household_store_item_id = hl.household_store_item_id + LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id WHERE hl.household_id = $1 - AND hl.store_id = $2 + AND hl.store_location_id = $2 AND hsi.normalized_name = $3`, - [householdId, storeId, normalizedName] + [householdId, storeLocationId, normalizedName] ); 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 ( householdId, - storeId, + storeLocationId, itemName, quantity, userId, 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( - `SELECT id, bought + `SELECT id, bought, quantity FROM household_lists WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 AND household_store_item_id = $3`, - [householdId, storeId, householdStoreItem.id] + [householdId, storeLocationId, householdStoreItem.id] ); if (listResult.rowCount > 0) { const listId = listResult.rows[0].id; + const previousQuantity = toPositiveInteger(listResult.rows[0].quantity, 0); + const wasBought = Boolean(listResult.rows[0].bought); + const historyQuantity = + !wasBought && nextQuantity > previousQuantity + ? nextQuantity - previousQuantity + : nextQuantity; + + let imageId = null; if (imageBuffer && mimeType) { + imageId = await createItemImage({ + householdId, + storeLocationId, + householdStoreItemId: householdStoreItem.id, + householdListId: listId, + imageScope: "list", + imageBuffer, + mimeType, + userId, + }); + } + + if (imageId) { await pool.query( `UPDATE household_lists SET quantity = $1, bought = FALSE, - custom_image = $2, - custom_image_mime_type = $3, + image_id = $2, + custom_image = NULL, + custom_image_mime_type = NULL, + notes = COALESCE($3, notes), modified_on = NOW() WHERE id = $4`, - [quantity, imageBuffer, mimeType, listId] + [nextQuantity, imageId, notes, listId] ); } else { await pool.query( `UPDATE household_lists SET quantity = $1, bought = FALSE, + notes = COALESCE($2, notes), modified_on = NOW() - WHERE id = $2`, - [quantity, listId] + WHERE id = $3`, + [nextQuantity, notes, listId] ); } @@ -184,46 +270,87 @@ exports.addOrUpdateItem = async ( itemId: householdStoreItem.id, householdStoreItemId: householdStoreItem.id, itemName: householdStoreItem.name, + quantity: nextQuantity, + previousQuantity, + historyQuantity, + wasBought, isNew: false, }; } const insert = await pool.query( `INSERT INTO household_lists - (household_id, store_id, household_store_item_id, quantity, custom_image, custom_image_mime_type, added_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) + (household_id, store_location_id, household_store_item_id, quantity, added_by, notes) + VALUES ($1, $2, $3, $4, $5, $6) 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 { listId: insert.rows[0].id, itemId: householdStoreItem.id, householdStoreItemId: householdStoreItem.id, itemName: householdStoreItem.name, + quantity: nextQuantity, + previousQuantity: 0, + historyQuantity: nextQuantity, + wasBought: false, isNew: true, }; }; exports.setBought = async (listId, bought, quantityBought = null) => { + const item = await pool.query( + `SELECT id, household_id, store_location_id, household_store_item_id, quantity, bought + FROM household_lists + WHERE id = $1`, + [listId] + ); + + if (!item.rows[0]) return null; + + const current = item.rows[0]; + const currentQuantity = toPositiveInteger(current.quantity, 0); + if (bought === false) { await pool.query( "UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1", [listId] ); - return; + return { + ...current, + eventType: "ITEM_UNBOUGHT", + quantityDelta: null, + quantityAfter: currentQuantity, + }; } - if (quantityBought && quantityBought > 0) { - const item = await pool.query( - "SELECT quantity FROM household_lists WHERE id = $1", - [listId] - ); - - if (!item.rows[0]) return; - - const currentQuantity = item.rows[0].quantity; - const remainingQuantity = currentQuantity - quantityBought; + const requestedQuantity = toPositiveInteger(quantityBought, 0); + if (requestedQuantity > 0) { + const boughtQuantity = Math.min(requestedQuantity, currentQuantity); + const remainingQuantity = currentQuantity - boughtQuantity; if (remainingQuantity <= 0) { await pool.query( @@ -236,23 +363,90 @@ exports.setBought = async (listId, bought, quantityBought = null) => { [remainingQuantity, listId] ); } - } else { - await pool.query( - "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", - [listId] - ); + + return { + ...current, + eventType: "ITEM_BOUGHT", + quantityDelta: -boughtQuantity, + quantityAfter: Math.max(remainingQuantity, 0), + }; } + + await pool.query( + "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", + [listId] + ); + + return { + ...current, + eventType: "ITEM_BOUGHT", + quantityDelta: -currentQuantity, + quantityAfter: 0, + }; }; -exports.addHistoryRecord = async (listId, householdStoreItemId, quantity, userId) => { +exports.addHistoryRecord = async ( + listId, + householdStoreItemId, + quantity, + userId, + storeLocationId = null +) => { await pool.query( - `INSERT INTO household_list_history (household_list_id, household_store_item_id, quantity, added_by, added_on) - VALUES ($1, $2, $3, $4, NOW())`, - [listId, householdStoreItemId, quantity, userId] + `INSERT INTO household_list_history + (household_list_id, store_location_id, household_store_item_id, quantity, added_by, added_on) + VALUES ( + $1, + COALESCE($5, (SELECT store_location_id FROM household_lists WHERE id = $1)), + $2, + $3, + $4, + NOW() + )`, + [listId, householdStoreItemId, quantity, userId, storeLocationId] ); }; -exports.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( `SELECT DISTINCT hsi.name AS item_name, @@ -261,18 +455,18 @@ exports.getSuggestions = async (query, householdId, storeId) => { LEFT JOIN household_lists hl ON hl.household_store_item_id = hsi.id AND hl.household_id = $2 - AND hl.store_id = $3 + AND hl.store_location_id = $3 WHERE hsi.household_id = $2 - AND hsi.store_id = $3 + AND hsi.store_location_id = $3 AND hsi.name ILIKE $1 ORDER BY sort_order, hsi.name LIMIT 10`, - [`%${query}%`, householdId, storeId] + [`%${query}%`, householdId, storeLocationId] ); return result.rows; }; -exports.getRecentlyBoughtItems = async (householdId, storeId) => { +exports.getRecentlyBoughtItems = async (householdId, storeLocationId) => { const result = await pool.query( `SELECT hl.id, @@ -281,73 +475,121 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => { hsi.name AS item_name, hl.quantity, hl.bought, - ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, - COALESCE(hl.custom_image_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) - 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, + ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image, + COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type, + ${ACTIVE_ADDED_BY_USERS_SQL}, hl.modified_on AS last_added_on FROM household_lists hl JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id + LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id WHERE hl.household_id = $1 - AND hl.store_id = $2 + AND hl.store_location_id = $2 AND hl.bought = TRUE AND hl.modified_on >= NOW() - INTERVAL '24 hours' ORDER BY hl.modified_on DESC`, - [householdId, storeId] + [householdId, storeLocationId] ); return result.rows; }; -exports.getClassification = async (householdId, storeId, itemId) => { +exports.getZoneByName = async (householdId, storeLocationId, zoneName) => { const result = await pool.query( - `SELECT item_type, item_group, zone, confidence, source - FROM household_item_classifications - WHERE household_id = $1 AND store_id = $2 AND household_store_item_id = $3`, - [householdId, storeId, itemId] + `SELECT id, name, sort_order + FROM store_location_zones + WHERE household_id = $1 + AND store_location_id = $2 + AND normalized_name = $3 + AND is_active = TRUE`, + [householdId, storeLocationId, normalizeItemName(zoneName)] ); return result.rows[0] || null; }; -exports.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 zoneRecord = zone ? await exports.getZoneByName(householdId, storeLocationId, zone) : null; const result = await pool.query( `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) - ON CONFLICT (household_id, store_id, household_store_item_id) + ( + household_id, + store_location_id, + household_store_item_id, + item_type, + item_group, + zone, + zone_id, + confidence, + source + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (household_id, store_location_id, household_store_item_id) DO UPDATE SET item_type = EXCLUDED.item_type, item_group = EXCLUDED.item_group, zone = EXCLUDED.zone, + zone_id = EXCLUDED.zone_id, confidence = EXCLUDED.confidence, - source = EXCLUDED.source + source = EXCLUDED.source, + updated_at = NOW() 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]; }; -exports.deleteClassification = async (householdId, storeId, itemId) => { +exports.deleteClassification = async (householdId, storeLocationId, itemId) => { const result = await pool.query( `DELETE FROM household_item_classifications WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 AND household_store_item_id = $3`, - [householdId, storeId, itemId] + [householdId, storeLocationId, itemId] ); return result.rowCount > 0; }; exports.updateItem = async (listId, itemName, quantity, notes) => { + const existing = await pool.query( + `SELECT id, household_id, store_location_id, household_store_item_id, quantity, notes + FROM household_lists + WHERE id = $1`, + [listId] + ); + + if (existing.rowCount === 0) { + return null; + } + const updates = []; const values = [listId]; let paramCount = 1; @@ -366,22 +608,56 @@ exports.updateItem = async (listId, itemName, quantity, notes) => { 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( `UPDATE household_lists SET ${updates.join(", ")} WHERE id = $1 RETURNING *`, values ); - return result.rows[0]; + return { + previous: existing.rows[0], + updated: result.rows[0], + }; }; 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; }; diff --git a/backend/models/store.model.js b/backend/models/store.model.js index f838d10..64bdb68 100644 --- a/backend/models/store.model.js +++ b/backend/models/store.model.js @@ -1,6 +1,70 @@ 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 () => { const result = await pool.query( `SELECT id, name, default_zones, created_at @@ -10,7 +74,6 @@ exports.getAllStores = async () => { return result.rows; }; -// Get store by ID exports.getStoreById = async (storeId) => { const result = await pool.query( `SELECT id, name, default_zones, created_at @@ -21,77 +84,6 @@ exports.getStoreById = async (storeId) => { return result.rows[0]; }; -// Get stores for a specific household -exports.getHouseholdStores = async (householdId) => { - const result = await pool.query( - `SELECT - s.id, - s.name, - s.default_zones, - hs.is_default, - hs.added_at - FROM stores s - JOIN household_stores hs ON s.id = hs.store_id - WHERE hs.household_id = $1 - ORDER BY hs.is_default DESC, s.name ASC`, - [householdId] - ); - return result.rows; -}; - -// Add store to household -exports.addStoreToHousehold = async (householdId, storeId, isDefault = false) => { - // If setting as default, unset other defaults - if (isDefault) { - await pool.query( - `UPDATE household_stores - SET is_default = FALSE - WHERE household_id = $1`, - [householdId] - ); - } - - const result = await pool.query( - `INSERT INTO household_stores (household_id, store_id, is_default) - VALUES ($1, $2, $3) - ON CONFLICT (household_id, store_id) - DO UPDATE SET is_default = $3 - RETURNING household_id, store_id, is_default`, - [householdId, storeId, isDefault] - ); - - return result.rows[0]; -}; - -// Remove store from household -exports.removeStoreFromHousehold = async (householdId, storeId) => { - await pool.query( - `DELETE FROM household_stores - WHERE household_id = $1 AND store_id = $2`, - [householdId, storeId] - ); -}; - -// Set default store for household -exports.setDefaultStore = async (householdId, storeId) => { - // Unset all defaults - await pool.query( - `UPDATE household_stores - SET is_default = FALSE - WHERE household_id = $1`, - [householdId] - ); - - // Set new default - await pool.query( - `UPDATE household_stores - SET is_default = TRUE - WHERE household_id = $1 AND store_id = $2`, - [householdId, storeId] - ); -}; - -// Create new store (system admin only) exports.createStore = async (name, defaultZones) => { const result = await pool.query( `INSERT INTO stores (name, default_zones) @@ -102,12 +94,11 @@ exports.createStore = async (name, defaultZones) => { return result.rows[0]; }; -// Update store (system admin only) exports.updateStore = async (storeId, updates) => { const { name, default_zones } = updates; const result = await pool.query( - `UPDATE stores - SET + `UPDATE stores + SET name = COALESCE($1, name), default_zones = COALESCE($2, default_zones) WHERE id = $3 @@ -117,27 +108,452 @@ exports.updateStore = async (storeId, updates) => { return result.rows[0]; }; -// Delete store (system admin only, only if not in use) exports.deleteStore = async (storeId) => { - // Check if store is in use const usage = await pool.query( `SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`, [storeId] ); - if (parseInt(usage.rows[0].count) > 0) { - throw new Error('Cannot delete store that is in use by households'); + if (parseInt(usage.rows[0].count, 10) > 0) { + throw new Error("Cannot delete store that is in use by households"); } - await pool.query('DELETE FROM stores WHERE id = $1', [storeId]); + 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) => { const result = await pool.query( - `SELECT 1 FROM household_stores + `SELECT 1 FROM household_stores WHERE household_id = $1 AND store_id = $2`, [householdId, storeId] ); - return result.rows.length > 0; + return result.rowCount > 0; }; diff --git a/backend/routes/households.routes.js b/backend/routes/households.routes.js index 20aef5a..970d647 100644 --- a/backend/routes/households.routes.js +++ b/backend/routes/households.routes.js @@ -3,9 +3,11 @@ const router = express.Router(); const controller = require("../controllers/households.controller"); const listsController = require("../controllers/lists.controller.v2"); const availableItemsController = require("../controllers/available-items.controller"); +const storesController = require("../controllers/stores.controller"); const auth = require("../middleware/auth"); const { householdAccess, + locationAccess, requireHouseholdAdmin, storeAccess, } = require("../middleware/household"); @@ -14,6 +16,7 @@ const { upload, processImage } = require("../middleware/image"); // Public routes (authenticated only) router.get("/", auth, controller.getUserHouseholds); router.post("/", auth, controller.createHousehold); +router.patch("/order", auth, controller.reorderHouseholds); router.post("/join/:inviteCode", auth, controller.joinHousehold); // Household-scoped routes (member access required) @@ -40,6 +43,139 @@ router.post( controller.refreshInviteCode ); +// Household-owned stores and locations +router.get( + "/:householdId/stores", + auth, + householdAccess, + storesController.getHouseholdStores +); +router.post( + "/:householdId/stores", + auth, + householdAccess, + requireHouseholdAdmin, + storesController.createHouseholdStore +); +router.patch( + "/:householdId/stores/:householdStoreId", + auth, + householdAccess, + requireHouseholdAdmin, + storesController.updateHouseholdStore +); +router.delete( + "/:householdId/stores/:householdStoreId", + auth, + householdAccess, + requireHouseholdAdmin, + storesController.deleteHouseholdStore +); +router.post( + "/:householdId/stores/:householdStoreId/locations", + auth, + householdAccess, + requireHouseholdAdmin, + storesController.addLocationToStore +); +router.patch( + "/:householdId/locations/:locationId", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.updateLocation +); +router.delete( + "/:householdId/locations/:locationId", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.deleteLocation +); +router.patch( + "/:householdId/locations/:locationId/default", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.setDefaultLocation +); +router.get( + "/:householdId/locations/:locationId/zones", + auth, + householdAccess, + locationAccess, + storesController.getLocationZones +); +router.post( + "/:householdId/locations/:locationId/zones", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.createZone +); +router.patch( + "/:householdId/locations/:locationId/zones/:zoneId", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.updateZone +); +router.delete( + "/:householdId/locations/:locationId/zones/:zoneId", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.deleteZone +); + +router.get( + "/:householdId/locations/:locationId/available-items", + auth, + householdAccess, + locationAccess, + availableItemsController.getAvailableItems +); +router.post( + "/:householdId/locations/:locationId/available-items", + auth, + householdAccess, + locationAccess, + upload.single("image"), + processImage, + availableItemsController.createAvailableItem +); +router.patch( + "/:householdId/locations/:locationId/available-items/:itemId", + auth, + householdAccess, + locationAccess, + upload.single("image"), + processImage, + availableItemsController.updateAvailableItem +); +router.delete( + "/:householdId/locations/:locationId/available-items/:itemId", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + availableItemsController.deleteAvailableItem +); +router.post( + "/:householdId/locations/:locationId/available-items/import-current", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + availableItemsController.importCurrentItems +); + router.get( "/:householdId/stores/:storeId/available-items", auth, @@ -109,6 +245,88 @@ router.delete( // All list routes require household access AND store access // Get grocery list +router.get( + "/:householdId/locations/:locationId/list", + auth, + householdAccess, + locationAccess, + listsController.getList +); +router.get( + "/:householdId/locations/:locationId/list/item", + auth, + householdAccess, + locationAccess, + listsController.getItemByName +); +router.post( + "/:householdId/locations/:locationId/list/add", + auth, + householdAccess, + locationAccess, + upload.single("image"), + processImage, + listsController.addItem +); +router.patch( + "/:householdId/locations/:locationId/list/item", + auth, + householdAccess, + locationAccess, + listsController.markBought +); +router.put( + "/:householdId/locations/:locationId/list/item", + auth, + householdAccess, + locationAccess, + listsController.updateItem +); +router.delete( + "/:householdId/locations/:locationId/list/item", + auth, + householdAccess, + locationAccess, + listsController.deleteItem +); +router.get( + "/:householdId/locations/:locationId/list/suggestions", + auth, + householdAccess, + locationAccess, + listsController.getSuggestions +); +router.get( + "/:householdId/locations/:locationId/list/recent", + auth, + householdAccess, + locationAccess, + listsController.getRecentlyBought +); +router.get( + "/:householdId/locations/:locationId/list/classification", + auth, + householdAccess, + locationAccess, + listsController.getClassification +); +router.post( + "/:householdId/locations/:locationId/list/classification", + auth, + householdAccess, + locationAccess, + listsController.setClassification +); +router.post( + "/:householdId/locations/:locationId/list/update-image", + auth, + householdAccess, + locationAccess, + upload.single("image"), + processImage, + listsController.updateItemImage +); + router.get( "/:householdId/stores/:storeId/list", auth, diff --git a/backend/tests/auth.middleware.test.js b/backend/tests/auth.middleware.test.js new file mode 100644 index 0000000..2ec74df --- /dev/null +++ b/backend/tests/auth.middleware.test.js @@ -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", + }, + }); + }); +}); diff --git a/backend/tests/available-item.model.test.js b/backend/tests/available-item.model.test.js index 50d0923..3cbb865 100644 --- a/backend/tests/available-item.model.test.js +++ b/backend/tests/available-item.model.test.js @@ -2,12 +2,20 @@ jest.mock("../db/pool", () => ({ query: jest.fn(), })); +jest.mock("../models/list.model.v2", () => ({ + recordItemEvent: jest.fn(), + setCatalogItemImage: jest.fn(), +})); + const pool = require("../db/pool"); +const List = require("../models/list.model.v2"); const AvailableItems = require("../models/available-item.model"); describe("available-item.model", () => { beforeEach(() => { pool.query.mockReset(); + List.recordItemEvent.mockReset(); + List.setCatalogItemImage.mockReset(); }); test("lists household store items", async () => { @@ -58,6 +66,14 @@ describe("available-item.model", () => { expect.stringContaining("INSERT INTO household_store_items"), [1, 2, "granola", "granola"] ); + expect(List.recordItemEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "ITEM_ADDED", + householdId: 1, + storeLocationId: 2, + householdStoreItemId: 77, + }) + ); }); test("updates household store item images and returns refreshed data", async () => { @@ -78,19 +94,37 @@ describe("available-item.model", () => { expect(pool.query).toHaveBeenNthCalledWith( 1, 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 () => { - 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); expect(deleted).toBe(true); - expect(pool.query).toHaveBeenCalledWith( + expect(pool.query).toHaveBeenLastCalledWith( expect.stringContaining("DELETE FROM household_store_items"), [1, 2, 55] ); + expect(List.recordItemEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "ITEM_DELETED", + householdId: 1, + storeLocationId: 2, + householdStoreItemId: 55, + }) + ); }); }); diff --git a/backend/tests/available-items.controller.test.js b/backend/tests/available-items.controller.test.js index 7d7ce38..26e6c9e 100644 --- a/backend/tests/available-items.controller.test.js +++ b/backend/tests/available-items.controller.test.js @@ -9,6 +9,8 @@ jest.mock("../models/available-item.model", () => ({ jest.mock("../models/list.model.v2", () => ({ deleteClassification: jest.fn(), + getZoneByName: jest.fn(), + recordItemEvent: jest.fn(), upsertClassification: jest.fn(), })); @@ -42,7 +44,9 @@ describe("available-items.controller", () => { AvailableItems.deleteAvailableItem.mockResolvedValue(true); AvailableItems.importCurrentListItems.mockResolvedValue(2); 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); }); @@ -58,12 +62,13 @@ describe("available-items.controller", () => { }), }, processedImage: null, + user: { id: 7 }, }; const res = createResponse(); 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( "1", "2", @@ -87,6 +92,7 @@ describe("available-items.controller", () => { item_group: "Bread", }), }, + user: { id: 7 }, }; const res = createResponse(); @@ -110,6 +116,7 @@ describe("available-items.controller", () => { classification: "null", }, processedImage: null, + user: { id: 7 }, }; const res = createResponse(); @@ -122,6 +129,7 @@ describe("available-items.controller", () => { test("imports current list items and reports the import count", async () => { const req = { params: { householdId: "1", storeId: "2" }, + user: { id: 7 }, }; const res = createResponse(); @@ -138,12 +146,13 @@ describe("available-items.controller", () => { test("deletes a store item", async () => { const req = { params: { householdId: "1", storeId: "2", itemId: "99" }, + user: { id: 7 }, }; const res = createResponse(); await controller.deleteAvailableItem(req, res); - expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99); + expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99, 7); expect(List.deleteClassification).not.toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" }); }); @@ -152,6 +161,7 @@ describe("available-items.controller", () => { const req = { params: { householdId: "1", storeId: "2" }, query: {}, + user: { id: 7 }, }; const res = createResponse(); @@ -177,6 +187,7 @@ describe("available-items.controller", () => { item_name: "milk", }, processedImage: null, + user: { id: 7 }, }; const res = createResponse(); diff --git a/backend/tests/available-items.routes.test.js b/backend/tests/available-items.routes.test.js index 05d5784..0e50ae9 100644 --- a/backend/tests/available-items.routes.test.js +++ b/backend/tests/available-items.routes.test.js @@ -11,6 +11,10 @@ jest.mock("../middleware/household", () => ({ }; 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(); @@ -39,6 +43,7 @@ jest.mock("../controllers/households.controller", () => ({ joinHousehold: jest.fn(), refreshInviteCode: jest.fn(), removeMember: jest.fn(), + reorderHouseholds: jest.fn(), updateHousehold: jest.fn(), updateMemberRole: jest.fn(), })); @@ -65,6 +70,21 @@ jest.mock("../controllers/available-items.controller", () => ({ updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })), })); +jest.mock("../controllers/stores.controller", () => ({ + addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })), + createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })), + createZone: jest.fn((req, res) => res.status(201).json({ message: "zone" })), + deleteHouseholdStore: jest.fn((req, res) => res.json({ message: "deleted store" })), + deleteLocation: jest.fn((req, res) => res.json({ message: "deleted location" })), + deleteZone: jest.fn((req, res) => res.json({ message: "deleted zone" })), + getHouseholdStores: jest.fn((req, res) => res.json([])), + getLocationZones: jest.fn((req, res) => res.json({ zones: [] })), + setDefaultLocation: jest.fn((req, res) => res.json({ message: "default" })), + updateHouseholdStore: jest.fn((req, res) => res.json({ message: "updated store" })), + updateLocation: jest.fn((req, res) => res.json({ message: "updated location" })), + updateZone: jest.fn((req, res) => res.json({ message: "updated zone" })), +})); + const express = require("express"); const request = require("supertest"); const router = require("../routes/households.routes"); @@ -106,4 +126,23 @@ describe("available-items routes", () => { expect(response.status).toBe(201); expect(availableItemsController.createAvailableItem).toHaveBeenCalled(); }); + + test("members can create available items on location-scoped routes", async () => { + const response = await request(app) + .post("/households/1/locations/2/available-items") + .set("x-household-role", "member") + .send({ item_name: "milk" }); + + expect(response.status).toBe(201); + expect(availableItemsController.createAvailableItem).toHaveBeenCalled(); + }); + + test("members cannot delete available items on location-scoped routes", async () => { + const response = await request(app) + .delete("/households/1/locations/2/available-items/3") + .set("x-household-role", "member"); + + expect(response.status).toBe(403); + expect(availableItemsController.deleteAvailableItem).not.toHaveBeenCalled(); + }); }); diff --git a/backend/tests/household.model.test.js b/backend/tests/household.model.test.js new file mode 100644 index 0000000..835fbfe --- /dev/null +++ b/backend/tests/household.model.test.js @@ -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(); + }); +}); diff --git a/backend/tests/households.controller.test.js b/backend/tests/households.controller.test.js index 6ff47cb..2e0b645 100644 --- a/backend/tests/households.controller.test.js +++ b/backend/tests/households.controller.test.js @@ -1,5 +1,6 @@ jest.mock("../models/household.model", () => ({ getUserRole: jest.fn(), + reorderUserHouseholds: jest.fn(), transferOwnership: 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", + }), + }) + ); + }); +}); diff --git a/backend/tests/list.model.v2.test.js b/backend/tests/list.model.v2.test.js index 59e4469..a928d70 100644 --- a/backend/tests/list.model.v2.test.js +++ b/backend/tests/list.model.v2.test.js @@ -24,6 +24,10 @@ describe("list.model.v2 addOrUpdateItem", () => { itemId: 55, householdStoreItemId: 55, itemName: "milk", + quantity: 3, + previousQuantity: 0, + historyQuantity: 3, + wasBought: false, isNew: true, }); 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 () => { pool.query .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: [] }); const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7); @@ -51,14 +55,48 @@ describe("list.model.v2 addOrUpdateItem", () => { itemId: 55, householdStoreItemId: 55, itemName: "milk", + quantity: 4, + previousQuantity: 2, + historyQuantity: 2, + wasBought: false, isNew: false, }); expect(pool.query).toHaveBeenNthCalledWith( 3, expect.stringContaining("UPDATE household_lists"), - [4, 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", () => { @@ -66,7 +104,7 @@ describe("list.model.v2 classification helpers", () => { 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({ rowCount: 1, rows: [ @@ -95,17 +133,23 @@ describe("list.model.v2 classification helpers", () => { ); }); - test("upserts classification using household-store item conflict target", async () => { - pool.query.mockResolvedValueOnce({ + test("upserts classification using household-location item conflict target", async () => { + pool.query + .mockResolvedValueOnce({ + rowCount: 1, + rows: [{ id: 12, name: "Dairy & Refrigerated", sort_order: 60 }], + }) + .mockResolvedValueOnce({ rowCount: 1, rows: [ { household_id: 1, - store_id: 2, + store_location_id: 2, household_store_item_id: 55, item_type: "dairy", item_group: "Milk", zone: "Dairy & Refrigerated", + zone_id: 12, confidence: 1, source: "user", }, @@ -123,14 +167,14 @@ describe("list.model.v2 classification helpers", () => { expect(result).toEqual( expect.objectContaining({ household_id: 1, - store_id: 2, + store_location_id: 2, household_store_item_id: 55, item_type: "dairy", }) ); - expect(pool.query).toHaveBeenCalledWith( - expect.stringContaining("ON CONFLICT (household_id, store_id, household_store_item_id)"), - [1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"] + expect(pool.query).toHaveBeenLastCalledWith( + expect.stringContaining("ON CONFLICT (household_id, store_location_id, household_store_item_id)"), + [1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 12, 1, "user"] ); }); }); diff --git a/backend/tests/lists.controller.v2.test.js b/backend/tests/lists.controller.v2.test.js index 0fcdb03..b84ee05 100644 --- a/backend/tests/lists.controller.v2.test.js +++ b/backend/tests/lists.controller.v2.test.js @@ -3,6 +3,8 @@ jest.mock("../models/list.model.v2", () => ({ addOrUpdateItem: jest.fn(), ensureHouseholdStoreItem: jest.fn(), getItemByName: jest.fn(), + getZoneByName: jest.fn(), + recordItemEvent: jest.fn(), upsertClassification: jest.fn(), })); @@ -37,7 +39,9 @@ describe("lists.controller.v2 addItem", () => { }); List.addHistoryRecord.mockResolvedValue(undefined); 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); }); @@ -54,7 +58,15 @@ describe("lists.controller.v2 addItem", () => { expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9); 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); }); @@ -71,10 +83,42 @@ describe("lists.controller.v2 addItem", () => { expect(householdModel.isHouseholdMember).not.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); }); + test("records duplicate-add history with the added quantity instead of the new total", async () => { + List.addOrUpdateItem.mockResolvedValueOnce({ + listId: 42, + itemId: 99, + householdStoreItemId: 99, + itemName: "milk", + quantity: 3, + previousQuantity: 1, + historyQuantity: 2, + isNew: false, + }); + + const req = { + params: { householdId: "1", storeId: "2" }, + body: { item_name: "milk", quantity: "3" }, + user: { id: 7 }, + processedImage: null, + }; + const res = createResponse(); + + await controller.addItem(req, res); + + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, 2, 7, "2"); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + item: expect.objectContaining({ + quantity: 3, + }), + }) + ); + }); + test("records history using request user when added_for_user_id is blank", async () => { const req = { params: { householdId: "1", storeId: "2" }, @@ -88,7 +132,7 @@ describe("lists.controller.v2 addItem", () => { expect(householdModel.isHouseholdMember).not.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); }); @@ -169,7 +213,9 @@ describe("lists.controller.v2 setClassification", () => { beforeEach(() => { jest.clearAllMocks(); 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" }); }); @@ -216,6 +262,7 @@ describe("lists.controller.v2 setClassification", () => { }); test("accepts zone-only classification updates", async () => { + List.getZoneByName.mockResolvedValueOnce({ id: 6, name: "Checkout Area" }); const req = { params: { householdId: "1", storeId: "2" }, body: { @@ -297,6 +344,7 @@ describe("lists.controller.v2 setClassification", () => { }); test("rejects invalid zone", async () => { + List.getZoneByName.mockResolvedValueOnce(null); const req = { params: { householdId: "1", storeId: "2" }, 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 () => { List.getItemByName.mockResolvedValueOnce(null); + List.getZoneByName.mockResolvedValueOnce({ id: 7, name: "Snacks & Candy" }); const req = { params: { householdId: "1", storeId: "2" }, diff --git a/backend/tests/store-locations.routes.test.js b/backend/tests/store-locations.routes.test.js new file mode 100644 index 0000000..b331eae --- /dev/null +++ b/backend/tests/store-locations.routes.test.js @@ -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(); + }); +}); diff --git a/docs/GITEA_PR_WORKFLOW.md b/docs/GITEA_PR_WORKFLOW.md new file mode 100644 index 0000000..93322fc --- /dev/null +++ b/docs/GITEA_PR_WORKFLOW.md @@ -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 = "" +``` + +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= +"@ | 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 ..HEAD --oneline --decorate +git diff --stat ..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 --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. diff --git a/docs/guides/api-documentation.md b/docs/guides/api-documentation.md index 2c53c4b..99c14fb 100644 --- a/docs/guides/api-documentation.md +++ b/docs/guides/api-documentation.md @@ -4,6 +4,7 @@ Base URL: `http://localhost:5000/api` ## Table of Contents - [Authentication](#authentication) +- [Current Household Location Scope](#current-household-location-scope) - [Grocery List Management](#grocery-list-management) - [User Management](#user-management) - [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 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) - `item_image` - Base64 encoded 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 - `item_type` - Classification type (nullable) - `item_group` - Classification group (nullable) diff --git a/frontend/src/api/availableItems.js b/frontend/src/api/availableItems.js index bac18d9..6eb63bd 100644 --- a/frontend/src/api/availableItems.js +++ b/frontend/src/api/availableItems.js @@ -9,7 +9,7 @@ function appendClassification(formData, classification) { } 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, }); @@ -21,7 +21,7 @@ export const createAvailableItem = (householdId, storeId, payload) => { 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: { "Content-Type": "multipart/form-data", }, @@ -41,7 +41,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => { 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: { "Content-Type": "multipart/form-data", }, @@ -49,7 +49,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => { }; 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) => - api.post(`/households/${householdId}/stores/${storeId}/available-items/import-current`); + api.post(`/households/${householdId}/locations/${storeId}/available-items/import-current`); diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js index f10c1c4..9fc14a9 100644 --- a/frontend/src/api/axios.js +++ b/frontend/src/api/axios.js @@ -1,5 +1,10 @@ -import axios from "axios"; -import { API_BASE_URL } from "../config"; +import axios from "axios"; +import { API_BASE_URL } from "../config"; +import { + cacheApiResponse, + getCachedApiResponse, + isTransientApiError, +} from "./offlineCache"; const api = axios.create({ baseURL: API_BASE_URL, @@ -31,6 +36,7 @@ api.interceptors.response.use( response.request_id = payload.request_id; response.data = payload.data; } + cacheApiResponse(response.config, response.data); return response; }, error => { @@ -55,8 +61,25 @@ api.interceptors.response.use( window.location.href = "/login"; 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; diff --git a/frontend/src/api/households.js b/frontend/src/api/households.js index 9ac78e4..e0d04f9 100644 --- a/frontend/src/api/households.js +++ b/frontend/src/api/households.js @@ -5,6 +5,12 @@ import api from "./axios"; */ 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 */ diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js index 259d402..66f79c7 100644 --- a/frontend/src/api/list.js +++ b/frontend/src/api/list.js @@ -3,14 +3,14 @@ import api from "./axios"; /** * Get grocery list for household and store */ -export const getList = (householdId, storeId) => - api.get(`/households/${householdId}/stores/${storeId}/list`); +export const getList = (householdId, storeId) => + api.get(`/households/${householdId}/locations/${storeId}/list`); /** * Get specific item by name */ -export const getItemByName = (householdId, storeId, itemName) => - api.get(`/households/${householdId}/stores/${storeId}/list/item`, { +export const getItemByName = (householdId, storeId, itemName) => + api.get(`/households/${householdId}/locations/${storeId}/list/item`, { params: { item_name: itemName } }); @@ -39,7 +39,7 @@ export const addItem = ( 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: { "Content-Type": "multipart/form-data", }, @@ -49,8 +49,8 @@ export const addItem = ( /** * Get item classification */ -export const getClassification = (householdId, storeId, itemName) => - api.get(`/households/${householdId}/stores/${storeId}/list/classification`, { +export const getClassification = (householdId, storeId, itemName) => + api.get(`/households/${householdId}/locations/${storeId}/list/classification`, { params: { item_name: itemName } }); @@ -58,7 +58,7 @@ export const getClassification = (householdId, storeId, itemName) => * Set item 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, classification }); @@ -103,8 +103,8 @@ export const updateItemWithClassification = (householdId, storeId, itemName, qua /** * Update item details (quantity, notes) */ -export const updateItem = (householdId, storeId, itemName, quantity, notes) => - api.put(`/households/${householdId}/stores/${storeId}/list/item`, { +export const updateItem = (householdId, storeId, itemName, quantity, notes) => + api.put(`/households/${householdId}/locations/${storeId}/list/item`, { item_name: itemName, quantity, notes @@ -113,8 +113,8 @@ export const updateItem = (householdId, storeId, itemName, quantity, notes) => /** * Mark item as bought or unbought */ -export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) => - api.patch(`/households/${householdId}/stores/${storeId}/list/item`, { +export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) => + api.patch(`/households/${householdId}/locations/${storeId}/list/item`, { item_name: itemName, bought, quantity_bought: quantityBought @@ -123,24 +123,24 @@ export const markBought = (householdId, storeId, itemName, quantityBought = null /** * Delete item from list */ -export const deleteItem = (householdId, storeId, itemName) => - api.delete(`/households/${householdId}/stores/${storeId}/list/item`, { +export const deleteItem = (householdId, storeId, itemName) => + api.delete(`/households/${householdId}/locations/${storeId}/list/item`, { data: { item_name: itemName } }); /** * Get suggestions based on query */ -export const getSuggestions = (householdId, storeId, query) => - api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, { +export const getSuggestions = (householdId, storeId, query) => + api.get(`/households/${householdId}/locations/${storeId}/list/suggestions`, { params: { query } }); /** * Get recently bought items */ -export const getRecentlyBought = (householdId, storeId) => - api.get(`/households/${householdId}/stores/${storeId}/list/recent`); +export const getRecentlyBought = (householdId, storeId) => + api.get(`/households/${householdId}/locations/${storeId}/list/recent`); /** * Update item image @@ -158,7 +158,7 @@ export const updateItemImage = ( formData.append("quantity", quantity); 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: { "Content-Type": "multipart/form-data", }, diff --git a/frontend/src/api/offlineCache.js b/frontend/src/api/offlineCache.js new file mode 100644 index 0000000..0df476f --- /dev/null +++ b/frontend/src/api/offlineCache.js @@ -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); +} diff --git a/frontend/src/api/stores.js b/frontend/src/api/stores.js index 2b92f18..61da518 100644 --- a/frontend/src/api/stores.js +++ b/frontend/src/api/stores.js @@ -1,48 +1,55 @@ import api from "./axios"; -/** - * Get all stores in the system - */ +// Legacy global store catalog for the system-admin page. export const getAllStores = () => api.get("/stores"); - -/** - * Get stores linked to a household - */ -export const getHouseholdStores = (householdId) => - api.get(`/stores/household/${householdId}`); - -/** - * Add a store to a household - */ -export const addStoreToHousehold = (householdId, storeId, isDefault = false) => - api.post(`/stores/household/${householdId}`, { storeId: storeId, isDefault: isDefault }); - -/** - * Remove a store from a household - */ -export const removeStoreFromHousehold = (householdId, storeId) => - api.delete(`/stores/household/${householdId}/${storeId}`); - -/** - * Set a store as default for a household - */ -export const setDefaultStore = (householdId, storeId) => - api.patch(`/stores/household/${householdId}/${storeId}/default`); - -/** - * Create a new store (system admin only) - */ -export const createStore = (name, location) => - api.post("/stores", { name, location }); - -/** - * Update store details (system admin only) - */ -export const updateStore = (storeId, name, location) => - api.patch(`/stores/${storeId}`, { name, location }); - -/** - * Delete a store (system admin only) - */ +export const createStore = (name, default_zones) => + api.post("/stores", { name, default_zones }); +export const updateStore = (storeId, name, default_zones) => + api.patch(`/stores/${storeId}`, { name, default_zones }); export const deleteStore = (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`); diff --git a/frontend/src/components/common/ListSearchInput.jsx b/frontend/src/components/common/ListSearchInput.jsx new file mode 100644 index 0000000..8a4518d --- /dev/null +++ b/frontend/src/components/common/ListSearchInput.jsx @@ -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> + ); +} diff --git a/frontend/src/components/common/SortDropdown.jsx b/frontend/src/components/common/SortDropdown.jsx deleted file mode 100644 index 21e6ad4..0000000 --- a/frontend/src/components/common/SortDropdown.jsx +++ /dev/null @@ -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> - ); -} diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js index d553900..2b0bcd0 100644 --- a/frontend/src/components/common/index.js +++ b/frontend/src/components/common/index.js @@ -2,7 +2,7 @@ export { default as ErrorMessage } from './ErrorMessage.jsx'; export { default as FloatingActionButton } from './FloatingActionButton.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 UserRoleCard } from './UserRoleCard.jsx'; diff --git a/frontend/src/components/forms/ClassificationSection.jsx b/frontend/src/components/forms/ClassificationSection.jsx index f701dd2..9396650 100644 --- a/frontend/src/components/forms/ClassificationSection.jsx +++ b/frontend/src/components/forms/ClassificationSection.jsx @@ -21,11 +21,15 @@ export default function ClassificationSection({ onItemTypeChange, onItemGroupChange, onZoneChange, + zones = null, title = "Item Classification (Optional)", fieldClass = "classification-field", selectClass = "classification-select" }) { 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 newType = e.target.value; @@ -35,7 +39,7 @@ export default function ClassificationSection({ return ( <div className="classification-section"> - <h3 className="classification-title">{title}</h3> + {title && <h3 className="classification-title">{title}</h3>} <div className={fieldClass}> <label>Item Type</label> @@ -79,7 +83,7 @@ export default function ClassificationSection({ className={selectClass} > <option value="">-- Select Zone --</option> - {getZoneValues().map((z) => ( + {zoneOptions.map((z) => ( <option key={z} value={z}> {z} </option> diff --git a/frontend/src/components/forms/ImageUploadSection.jsx b/frontend/src/components/forms/ImageUploadSection.jsx index c83bae4..7598864 100644 --- a/frontend/src/components/forms/ImageUploadSection.jsx +++ b/frontend/src/components/forms/ImageUploadSection.jsx @@ -9,12 +9,16 @@ import "../../styles/components/ImageUploadSection.css"; * @param {Function} props.onImageChange - Callback when image is selected (file) * @param {Function} props.onImageRemove - Callback to remove image * @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({ imagePreview, onImageChange, onImageRemove, - title = "Item Image (Optional)" + title = "Item Image (Optional)", + cameraLabel = "Use Camera", + galleryLabel = "Choose from Gallery" }) { const cameraInputRef = useRef(null); const galleryInputRef = useRef(null); @@ -51,7 +55,7 @@ export default function ImageUploadSection({ return ( <div className="image-upload-section"> - <h3 className="image-upload-title">{title}</h3> + {title && <h3 className="image-upload-title">{title}</h3>} {sizeError && ( <div className="image-upload-error"> {sizeError} @@ -60,10 +64,20 @@ export default function ImageUploadSection({ <div className="image-upload-content"> {!imagePreview ? ( <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 </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 </button> </div> diff --git a/frontend/src/components/household/HouseholdSwitcher.jsx b/frontend/src/components/household/HouseholdSwitcher.jsx index 8a01b27..d429937 100644 --- a/frontend/src/components/household/HouseholdSwitcher.jsx +++ b/frontend/src/components/household/HouseholdSwitcher.jsx @@ -1,5 +1,7 @@ import { useContext, useState } from "react"; import { HouseholdContext } from "../../context/HouseholdContext"; +import useActionToast from "../../hooks/useActionToast"; +import getApiErrorMessage from "../../lib/getApiErrorMessage"; import "../../styles/components/HouseholdSwitcher.css"; import CreateJoinHousehold from "../manage/CreateJoinHousehold"; @@ -8,10 +10,13 @@ export default function HouseholdSwitcher() { households, activeHousehold, setActiveHousehold, + reorderHouseholds, loading, hasLoaded, } = useContext(HouseholdContext); + const toast = useActionToast(); const [isOpen, setIsOpen] = useState(false); + const [isReordering, setIsReordering] = useState(false); const [showCreateJoin, setShowCreateJoin] = useState(false); if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) { @@ -49,6 +54,28 @@ export default function HouseholdSwitcher() { 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 ( <div className="household-switcher"> <button @@ -65,18 +92,48 @@ export default function HouseholdSwitcher() { <> <div className="household-switcher-overlay" onClick={() => setIsOpen(false)} /> <div className="household-switcher-dropdown"> - {households.map((household) => ( - <button + {households.map((household, index) => ( + <div key={household.id} - className={`household-option ${household.id === activeHousehold.id ? "active" : ""}`} - type="button" - onClick={() => handleSelect(household)} + className={ + `household-option-row ${household.id === activeHousehold.id ? "active" : ""}` + } > - {household.name} - {household.id === activeHousehold.id && ( - <span className="check-mark">✓</span> + <button + className="household-option" + 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`} + > + ▲ + </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`} + > + ▼ + </button> + </div> )} - </button> + </div> ))} <div className="household-divider"></div> <button diff --git a/frontend/src/components/manage/ManageHousehold.jsx b/frontend/src/components/manage/ManageHousehold.jsx index 8971d3a..e75ed0e 100644 --- a/frontend/src/components/manage/ManageHousehold.jsx +++ b/frontend/src/components/manage/ManageHousehold.jsx @@ -70,6 +70,7 @@ export default function ManageHousehold() { const [pendingDecisionId, setPendingDecisionId] = useState(null); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [pendingRoleChange, setPendingRoleChange] = useState(null); + const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null); const isManager = ["owner", "admin"].includes(activeHousehold?.role); const isOwner = activeHousehold?.role === "owner"; @@ -307,12 +308,19 @@ export default function ManageHousehold() { setPendingRoleChange({ memberId, nextRole, memberName }); }; - const handleRemoveMember = async (memberId, username) => { - if (!confirm(`Remove ${username} from this household?`)) return; + const handleRemoveMember = (memberId, username) => { + setPendingMemberRemoval({ memberId, username }); + }; + + const handleConfirmRemoveMember = async () => { + if (!pendingMemberRemoval) return; + + const { memberId, username } = pendingMemberRemoval; try { await removeMember(activeHousehold.id, memberId); await loadMembers(); + setPendingMemberRemoval(null); toast.success("Removed member", `Removed member ${username}`); } catch (error) { const message = getApiErrorMessage(error, "Failed to remove member"); @@ -360,9 +368,6 @@ export default function ManageHousehold() { <div> <p className="manage-section-eyebrow">Household</p> <h2>Identity</h2> - <p className="section-description"> - Keep the household name crisp and easy to recognize across invites and shared lists. - </p> </div> </div> {editingName ? ( @@ -408,9 +413,6 @@ export default function ManageHousehold() { <div> <p className="manage-section-eyebrow">Entry Rules</p> <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> {inviteError && <p className="section-error">{inviteError}</p>} @@ -547,9 +549,6 @@ export default function ManageHousehold() { <div> <p className="manage-section-eyebrow">People</p> <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> {loading ? ( @@ -563,16 +562,12 @@ export default function ManageHousehold() { return ( <div key={member.id} className="member-card"> <div className="member-main"> - <div className="member-avatar" aria-hidden="true">{roleMeta.icon}</div> <div className="member-info"> - <div className="member-topline"> <span className={`member-role member-role-${member.role}`}> {roleMeta.icon} {roleMeta.label} </span> - {isSelf && <span className="member-self-pill">✨ You</span>} - </div> <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> {isManager && !isSelf && member.role !== "owner" && ( @@ -616,11 +611,6 @@ export default function ManageHousehold() { <div> <p className="manage-section-eyebrow">Final Actions</p> <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> {isMemberOnly ? ( <button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger"> @@ -664,6 +654,15 @@ export default function ManageHousehold() { onClose={() => setPendingRoleChange(null)} 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> ); } diff --git a/frontend/src/components/manage/ManageStores.jsx b/frontend/src/components/manage/ManageStores.jsx index 1f73f23..1da5467 100644 --- a/frontend/src/components/manage/ManageStores.jsx +++ b/frontend/src/components/manage/ManageStores.jsx @@ -1,9 +1,13 @@ -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { - addStoreToHousehold, - getAllStores, - removeStoreFromHousehold, - setDefaultStore + addLocationToStore, + createHouseholdStore, + createLocationZone, + deleteLocationZone, + getLocationZones, + removeLocation, + setDefaultLocation, + updateLocationZone, } from "../../api/stores"; import StoreAvailableItemsManager from "./StoreAvailableItemsManager"; import { HouseholdContext } from "../../context/HouseholdContext"; @@ -13,172 +17,427 @@ import getApiErrorMessage from "../../lib/getApiErrorMessage"; import "../../styles/components/manage/ManageStores.css"; import "../../styles/components/manage/StoreAvailableItemsManager.css"; -export default function ManageStores() { - const { activeHousehold } = useContext(HouseholdContext); - const { stores: householdStores, refreshStores } = useContext(StoreContext); +function groupLocationsByStore(locations) { + const grouped = new Map(); + + 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 [allStores, setAllStores] = useState([]); - const [loading, setLoading] = useState(true); - const [showAddStore, setShowAddStore] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [zones, setZones] = useState([]); + const [loading, setLoading] = useState(false); + const [newZoneName, setNewZoneName] = useState(""); - const isAdmin = ["owner", "admin"].includes(activeHousehold?.role); - - useEffect(() => { - loadAllStores(); - }, []); - - const loadAllStores = async () => { + const loadZones = async () => { + if (!householdId || !location?.id) return; setLoading(true); try { - const response = await getAllStores(); - setAllStores(response.data); + const response = await getLocationZones(householdId, location.id); + setZones(response.data?.zones || []); } 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 { setLoading(false); } }; - const handleAddStore = async (storeId) => { - const storeName = allStores.find((store) => store.id === storeId)?.name || `store #${storeId}`; + useEffect(() => { + if (isOpen) { + loadZones(); + } + }, [isOpen, householdId, location?.id]); + + const handleCreateZone = async () => { + const name = newZoneName.trim(); + if (!name) return; + try { - console.log("Adding store with ID:", storeId); - await addStoreToHousehold(activeHousehold.id, storeId, false); - await refreshStores(); - toast.success("Added store", `Added store ${storeName}`); - setShowAddStore(false); + const nextSortOrder = + zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10; + await createLocationZone(householdId, location.id, { + name, + sort_order: nextSortOrder, + }); + setNewZoneName(""); + await loadZones(); + await refreshActiveZones(); + toast.success("Added zone", `Added zone ${name}`); } catch (error) { - console.error("Failed to add store:", error); - const message = getApiErrorMessage(error, "Failed to add store"); - toast.error("Add store failed", `Add store failed: ${message}`); + const message = getApiErrorMessage(error, "Failed to add zone"); + toast.error("Add zone failed", `Add zone failed: ${message}`); } }; - const handleRemoveStore = async (storeId, storeName) => { - if (!confirm(`Remove ${storeName} from this household?`)) return; + const handleMoveZone = async (zone, direction) => { + 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 { - await removeStoreFromHousehold(activeHousehold.id, storeId); - await refreshStores(); - toast.success("Removed store", `Removed store ${storeName}`); + await Promise.all([ + updateLocationZone(householdId, location.id, zone.id, { + sort_order: other.sort_order, + }), + updateLocationZone(householdId, location.id, other.id, { + sort_order: zone.sort_order, + }), + ]); + await loadZones(); + await refreshActiveZones(); } catch (error) { - console.error("Failed to remove store:", error); - const message = getApiErrorMessage(error, "Failed to remove store"); - toast.error("Remove store failed", `Remove store failed: ${message}`); + const message = getApiErrorMessage(error, "Failed to reorder zones"); + toast.error("Reorder zones failed", `Reorder zones failed: ${message}`); } }; - const handleSetDefault = async (storeId) => { - const storeName = - householdStores.find((store) => store.id === storeId)?.name || `store #${storeId}`; + const handleDeleteZone = async (zone) => { + if (!confirm(`Remove zone "${zone.name}" from ${location.display_name || location.name}?`)) { + return; + } + try { - await setDefaultStore(activeHousehold.id, storeId); - await refreshStores(); - toast.success("Updated default store", `Default store set to ${storeName}`); + await deleteLocationZone(householdId, location.id, zone.id); + await loadZones(); + await refreshActiveZones(); + toast.success("Removed zone", `Removed zone ${zone.name}`); } catch (error) { - console.error("Failed to set default store:", error); - const message = getApiErrorMessage(error, "Failed to set default store"); - toast.error("Set default store failed", `Set default store failed: ${message}`); + const message = getApiErrorMessage(error, "Failed to remove zone"); + toast.error("Remove zone failed", `Remove zone failed: ${message}`); } }; - const availableStores = allStores.filter( - store => !householdStores.some(hs => hs.id === store.id) + return ( + <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 ( <div className="manage-stores"> - {/* Current Stores Section */} <section className="manage-section"> - <h2>Your Stores ({householdStores.length})</h2> + <h2>Store Locations ({householdStores.length})</h2> <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> - {!isAdmin && ( - <p className="manage-stores-note"> - Only household owners and admins can manage store item catalogs. - </p> - )} {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"> - {householdStores.map((store) => ( - <div key={store.id} className="store-card"> + {groupedStores.map((storeGroup) => ( + <div key={storeGroup.household_store_id} className="store-card"> <div className="store-info"> - <h3>{store.name}</h3> - {store.location && <p className="store-location">{store.location}</p>} + <h3>{storeGroup.name}</h3> </div> - {isAdmin && ( - <div className="store-actions"> - {!store.is_default && ( - <button - onClick={() => handleSetDefault(store.id)} - className="btn-secondary btn-small" - > - Set as Default - </button> - )} + + <div className="store-location-list"> + {storeGroup.locations.map((location) => ( + <div key={location.id} className="store-location-row"> + <div className="store-info"> + <strong>{location.display_name || location.name}</strong> + {location.address ? ( + <p className="store-location">{location.address}</p> + ) : 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 - onClick={() => handleRemoveStore(store.id, store.name)} - className="btn-danger btn-small" - disabled={householdStores.length === 1} - title={householdStores.length === 1 ? "Cannot remove last store" : ""} + type="button" + className="btn-primary btn-small" + onClick={() => handleAddLocation(storeGroup.household_store_id, storeGroup.name)} > - Remove + Add Location </button> </div> - )} - <StoreAvailableItemsManager - householdId={activeHousehold.id} - store={store} - isAdmin={isAdmin} - /> + ) : null} </div> ))} </div> )} </section> - {/* Add Store Section */} - {isAdmin && ( + {isAdmin ? ( <section className="manage-section"> <h2>Add Store</h2> - {!showAddStore ? ( - <button onClick={() => setShowAddStore(true)} className="btn-primary"> - + Add Store + <form className="add-store-panel" onSubmit={handleCreateStore}> + <input + 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> - ) : ( - <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> - )} + </form> </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> ); } diff --git a/frontend/src/components/manage/StoreAvailableItemsManager.jsx b/frontend/src/components/manage/StoreAvailableItemsManager.jsx index a886fed..7651bd6 100644 --- a/frontend/src/components/manage/StoreAvailableItemsManager.jsx +++ b/frontend/src/components/manage/StoreAvailableItemsManager.jsx @@ -1,9 +1,11 @@ import { useCallback, useEffect, useState } from "react"; import { + createAvailableItem, deleteAvailableItem, getAvailableItems, updateAvailableItem, } from "../../api/availableItems"; +import { getLocationZones } from "../../api/stores"; import useActionToast from "../../hooks/useActionToast"; import getApiErrorMessage from "../../lib/getApiErrorMessage"; import AvailableItemEditorModal from "../modals/AvailableItemEditorModal"; @@ -22,6 +24,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin const toast = useActionToast(); const [isOpen, setIsOpen] = useState(false); const [items, setItems] = useState([]); + const [zones, setZones] = useState([]); const [catalogReady, setCatalogReady] = useState(true); const [catalogMessage, setCatalogMessage] = useState(""); const [query, setQuery] = useState(""); @@ -53,13 +56,29 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin } }, [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(() => { if (!isOpen) { return; } loadItems(query); - }, [isOpen, query, loadItems]); + loadZones(); + }, [isOpen, query, loadItems, loadZones]); const closeManager = () => { setIsOpen(false); @@ -76,8 +95,16 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin } try { - await updateAvailableItem(householdId, store.id, editorItem.item_id, payload); - toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.name}`); + if (editorItem?.item_id) { + 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); setEditorItem(null); await loadItems(query); @@ -95,7 +122,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin try { 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); await loadItems(query); } catch (error) { @@ -104,10 +131,6 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin } }; - if (!isAdmin) { - return null; - } - return ( <> <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-header"> <div> - <h3>{store.name} Items</h3> - <p>Manage the household/store items used for suggestions and store defaults.</p> + <h3>{store.display_name || store.name} Items</h3> + <p>Manage location-specific items used for suggestions and defaults.</p> </div> <button type="button" @@ -150,6 +173,17 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin placeholder="Search household/store items" disabled={!catalogReady} /> + <button + type="button" + className="btn-primary btn-small" + disabled={!catalogReady} + onClick={() => { + setEditorItem(null); + setShowEditor(true); + }} + > + Add Item + </button> </div> <div className="store-items-modal-body"> @@ -209,13 +243,15 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin > Edit Settings </button> - <button - type="button" - className="btn-danger btn-small" - onClick={() => setPendingDeleteItem(item)} - > - Delete Item - </button> + {isAdmin ? ( + <button + type="button" + className="btn-danger btn-small" + onClick={() => setPendingDeleteItem(item)} + > + Delete Item + </button> + ) : null} </div> </div> </div> @@ -232,6 +268,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin <AvailableItemEditorModal isOpen={showEditor} item={editorItem} + zones={zones} onCancel={() => { setShowEditor(false); setEditorItem(null); @@ -244,7 +281,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"} description={ 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" diff --git a/frontend/src/components/modals/AddItemWithDetailsModal.jsx b/frontend/src/components/modals/AddItemWithDetailsModal.jsx index fab0ab9..899b99c 100644 --- a/frontend/src/components/modals/AddItemWithDetailsModal.jsx +++ b/frontend/src/components/modals/AddItemWithDetailsModal.jsx @@ -4,7 +4,7 @@ import ClassificationSection from "../forms/ClassificationSection"; import useActionToast from "../../hooks/useActionToast"; 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 [selectedImage, setSelectedImage] = useState(null); const [imagePreview, setImagePreview] = useState(null); @@ -47,15 +47,12 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o onConfirm(selectedImage, classification); }; - const handleSkip = () => { - onSkip(); - }; - return ( <div className="add-item-details-overlay" onClick={onCancel}> <div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}> - <h2 className="add-item-details-title">Add Details for "{itemName}"</h2> - <p className="add-item-details-subtitle">Add an image and classification to help organize your list</p> + <div className="add-item-details-item-name"> + {itemName} + </div> {/* Image Section */} <div className="add-item-details-section"> @@ -63,6 +60,9 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o imagePreview={imagePreview} onImageChange={handleImageChange} onImageRemove={handleImageRemove} + title={null} + cameraLabel="Use Image" + galleryLabel="Choose Photo" /> </div> @@ -75,6 +75,8 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o onItemTypeChange={handleItemTypeChange} onItemGroupChange={setItemGroup} onZoneChange={setZone} + zones={zones} + title={null} fieldClass="add-item-details-field" 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"> Cancel </button> - <button onClick={handleSkip} className="add-item-details-btn skip"> - Skip All - </button> <button onClick={handleConfirm} className="add-item-details-btn confirm"> Add Item </button> diff --git a/frontend/src/components/modals/AvailableItemEditorModal.jsx b/frontend/src/components/modals/AvailableItemEditorModal.jsx index 582a0c6..c682b93 100644 --- a/frontend/src/components/modals/AvailableItemEditorModal.jsx +++ b/frontend/src/components/modals/AvailableItemEditorModal.jsx @@ -13,7 +13,7 @@ function buildPreview(item) { 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 [itemName, setItemName] = useState(""); const [itemType, setItemType] = useState(""); @@ -136,6 +136,7 @@ export default function AvailableItemEditorModal({ isOpen, item = null, onCancel onItemTypeChange={handleItemTypeChange} onItemGroupChange={setItemGroup} onZoneChange={setZone} + zones={zones} fieldClass="available-item-editor-field" selectClass="available-item-editor-select" title="Store Classification (Optional)" diff --git a/frontend/src/components/modals/EditItemModal.jsx b/frontend/src/components/modals/EditItemModal.jsx index b56b236..a2c6339 100644 --- a/frontend/src/components/modals/EditItemModal.jsx +++ b/frontend/src/components/modals/EditItemModal.jsx @@ -4,7 +4,7 @@ import useActionToast from "../../hooks/useActionToast"; import "../../styles/components/EditItemModal.css"; 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 [itemName, setItemName] = useState(item.item_name || ""); 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 zoneOptions = Array.isArray(zones) && zones.length > 0 + ? zones.map((candidateZone) => candidateZone.name || candidateZone).filter(Boolean) + : getZoneValues(); return ( <div className="edit-modal-overlay" onClick={onCancel}> @@ -172,7 +175,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) className="edit-modal-select" > <option value="">-- Select Zone --</option> - {getZoneValues().map((candidateZone) => ( + {zoneOptions.map((candidateZone) => ( <option key={candidateZone} value={candidateZone}> {candidateZone} </option> diff --git a/frontend/src/components/store/StoreTabs.jsx b/frontend/src/components/store/StoreTabs.jsx index 4da17e3..59c6691 100644 --- a/frontend/src/components/store/StoreTabs.jsx +++ b/frontend/src/components/store/StoreTabs.jsx @@ -25,7 +25,7 @@ export default function StoreTabs() { onClick={() => setActiveStore(store)} disabled={loading} > - <span className="store-name">{store.name}</span> + <span className="store-name">{store.display_name || store.name}</span> </button> ))} </div> diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index ca3627c..6835930 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,4 +1,5 @@ -import { createContext, useState } from 'react'; +import { createContext, useState } from 'react'; +import { clearApiCacheForCurrentUser } from '../api/offlineCache'; export const AuthContext = createContext({ token: null, @@ -16,6 +17,7 @@ export const AuthProvider = ({ children }) => { const [username, setUsername] = useState(localStorage.getItem('username') || null); const clearAuthStorage = () => { + clearApiCacheForCurrentUser(); localStorage.removeItem("token"); localStorage.removeItem("userId"); localStorage.removeItem("role"); diff --git a/frontend/src/context/HouseholdContext.jsx b/frontend/src/context/HouseholdContext.jsx index cedda4f..644433b 100644 --- a/frontend/src/context/HouseholdContext.jsx +++ b/frontend/src/context/HouseholdContext.jsx @@ -1,5 +1,10 @@ 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'; const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId'; @@ -13,6 +18,7 @@ export const HouseholdContext = createContext({ setActiveHousehold: () => { }, refreshHouseholds: () => { }, createHousehold: () => { }, + reorderHouseholds: () => { }, }); export const HouseholdProvider = ({ children }) => { @@ -43,9 +49,15 @@ export const HouseholdProvider = ({ children }) => { } } catch (err) { console.error('[HouseholdContext] Failed to load households:', err); - setError(err.response?.data?.message || 'Failed to load households'); - setHouseholds([]); - clearActiveHousehold(); + setError( + err.response?.data?.error?.message || + err.response?.data?.message || + 'Failed to load households' + ); + if (!isTransientApiError(err)) { + setHouseholds([]); + clearActiveHousehold(); + } } finally { setLoading(false); 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 = { households, activeHousehold, @@ -123,6 +186,7 @@ export const HouseholdProvider = ({ children }) => { setActiveHousehold, refreshHouseholds: loadHouseholds, createHousehold, + reorderHouseholds, }; return ( diff --git a/frontend/src/context/SettingsContext.jsx b/frontend/src/context/SettingsContext.jsx index 59dcf90..d3ebf40 100644 --- a/frontend/src/context/SettingsContext.jsx +++ b/frontend/src/context/SettingsContext.jsx @@ -8,7 +8,6 @@ const DEFAULT_SETTINGS = { compactView: false, // List Display - defaultSortMode: "zone", showRecentlyBought: true, recentlyBoughtCount: 10, recentlyBoughtCollapsed: false, @@ -22,6 +21,17 @@ const DEFAULT_SETTINGS = { 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({ settings: DEFAULT_SETTINGS, @@ -48,7 +58,9 @@ export const SettingsProvider = ({ children }) => { if (savedSettings) { try { const parsed = JSON.parse(savedSettings); - setSettings({ ...DEFAULT_SETTINGS, ...parsed }); + const normalized = normalizeSettings(parsed); + setSettings(normalized); + localStorage.setItem(storageKey, JSON.stringify(normalized)); } catch (error) { console.error("Failed to parse settings:", error); setSettings(DEFAULT_SETTINGS); @@ -88,7 +100,7 @@ export const SettingsProvider = ({ children }) => { const updateSettings = (newSettings) => { if (!username) return; - const updated = { ...settings, ...newSettings }; + const updated = normalizeSettings({ ...settings, ...newSettings }); setSettings(updated); const storageKey = `user_preferences_${username}`; diff --git a/frontend/src/context/StoreContext.jsx b/frontend/src/context/StoreContext.jsx index 5a97a03..26664e7 100644 --- a/frontend/src/context/StoreContext.jsx +++ b/frontend/src/context/StoreContext.jsx @@ -1,15 +1,19 @@ 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 { HouseholdContext } from './HouseholdContext'; export const StoreContext = createContext({ stores: [], activeStore: null, + zones: [], loading: false, + zonesLoading: false, error: null, setActiveStore: () => { }, refreshStores: () => { }, + refreshZones: () => { }, }); export const StoreProvider = ({ children }) => { @@ -17,7 +21,9 @@ export const StoreProvider = ({ children }) => { const { activeHousehold } = useContext(HouseholdContext); const [stores, setStores] = useState([]); const [activeStore, setActiveStoreState] = useState(null); + const [zones, setZones] = useState([]); const [loading, setLoading] = useState(false); + const [zonesLoading, setZonesLoading] = useState(false); const [error, setError] = useState(null); // Load stores when household changes @@ -28,6 +34,7 @@ export const StoreProvider = ({ children }) => { // Clear state when logged out or no household setStores([]); setActiveStoreState(null); + setZones([]); } }, [token, activeHousehold?.id]); @@ -40,7 +47,7 @@ export const StoreProvider = ({ children }) => { const savedStoreId = localStorage.getItem(storageKey); if (savedStoreId) { - const store = stores.find(s => s.id === parseInt(savedStoreId)); + const store = stores.find(s => String(s.id) === String(savedStoreId)); if (store) { console.log('[StoreContext] Found saved store:', store); setActiveStoreState(store); @@ -55,6 +62,14 @@ export const StoreProvider = ({ children }) => { localStorage.setItem(storageKey, defaultStore.id); }, [stores, activeHousehold]); + useEffect(() => { + if (token && activeHousehold?.id && activeStore?.id) { + loadZones(); + } else { + setZones([]); + } + }, [token, activeHousehold?.id, activeStore?.id]); + const loadStores = async () => { if (!token || !activeHousehold) return; @@ -67,8 +82,15 @@ export const StoreProvider = ({ children }) => { setStores(response.data); } catch (err) { console.error('[StoreContext] Failed to load stores:', err); - setError(err.response?.data?.message || 'Failed to load stores'); - setStores([]); + setError( + err.response?.data?.error?.message || + err.response?.data?.message || + 'Failed to load stores' + ); + if (!isTransientApiError(err)) { + setStores([]); + setActiveStoreState(null); + } } finally { setLoading(false); } @@ -78,17 +100,37 @@ export const StoreProvider = ({ children }) => { setActiveStoreState(store); if (store && activeHousehold) { 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 = { stores, activeStore, + zones, loading, + zonesLoading, error, setActiveStore, refreshStores: loadStores, + refreshZones: loadZones, }; return ( diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index 20cad76..c0316fd 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -11,7 +11,7 @@ import { updateItemWithClassification } from "../api/list"; import { getHouseholdMembers } from "../api/households"; -import SortDropdown from "../components/common/SortDropdown"; +import ListSearchInput from "../components/common/ListSearchInput"; import AddItemForm from "../components/forms/AddItemForm"; import NoHouseholdState from "../components/household/NoHouseholdState"; import GroceryListItem from "../components/items/GroceryListItem"; @@ -33,40 +33,54 @@ import getApiErrorMessage from "../lib/getApiErrorMessage"; import "../styles/pages/GroceryList.css"; import { findSimilarItems } from "../utils/stringSimilarity"; -function sortItemsForMode(items, sortMode) { +function sortItemsByZone(items) { const sorted = [...items]; - if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name)); - if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name)); - if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity); - if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity); - 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); + 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 bZoneIndex = ZONE_FLOW.indexOf(b.zone); - const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex; - const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex; + const aZoneIndex = Number.isInteger(a.zone_sort_order) ? a.zone_sort_order : ZONE_FLOW.indexOf(a.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 bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex; - const zoneCompare = aIndex - bIndex; - if (zoneCompare !== 0) return zoneCompare; + const zoneCompare = aIndex - bIndex; + if (zoneCompare !== 0) return zoneCompare; - const typeCompare = (a.item_type || "").localeCompare(b.item_type || ""); - if (typeCompare !== 0) return typeCompare; + const typeCompare = (a.item_type || "").localeCompare(b.item_type || ""); + if (typeCompare !== 0) return typeCompare; - const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); - if (groupCompare !== 0) return groupCompare; + const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); + if (groupCompare !== 0) return groupCompare; - return a.item_name.localeCompare(b.item_name); - }); - } + return a.item_name.localeCompare(b.item_name); + }); 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) { const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId); @@ -79,7 +93,6 @@ function getNextModalItem(sortedItems, currentIndex, excludedItemId) { export default function GroceryList() { - const pageTitle = "Grocery List"; const { userId } = useContext(AuthContext); const { activeHousehold, @@ -87,7 +100,7 @@ export default function GroceryList() { loading: householdLoading, hasLoaded: householdsLoaded } = useContext(HouseholdContext); - const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); + const { activeStore, stores, zones, loading: storeLoading } = useContext(StoreContext); const { settings } = useContext(SettingsContext); const toast = useActionToast(); const { enqueueImageUpload } = useUploadQueue(); @@ -103,7 +116,7 @@ export default function GroceryList() { const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [householdMembers, setHouseholdMembers] = useState([]); const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount); - const [sortMode, setSortMode] = useState(settings.defaultSortMode); + const [listSearchQuery, setListSearchQuery] = useState(""); const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(true); 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(() => { - return sortItemsForMode(items, sortMode); - }, [items, sortMode]); + return sortItemsByZone(filterItemsForSearch(items, normalizedListSearchQuery)); + }, [items, normalizedListSearchQuery]); const visibleRecentlyBoughtItems = useMemo( () => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount), @@ -538,45 +554,9 @@ export default function GroceryList() { } }, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]); - const handleAddDetailsSkip = useCallback(async () => { - if (!pendingItem) return; - if (!activeHousehold?.id || !activeStore?.id) return; - - try { - await addItem( - activeHousehold.id, - activeStore.id, - pendingItem.itemName, - pendingItem.quantity, - null, - 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); + const handleAddDetailsCancel = useCallback(() => { + setShowAddDetailsModal(false); + setPendingItem(null); setSuggestions([]); setButtonText("Add Item"); }, []); @@ -614,7 +594,9 @@ export default function GroceryList() { setItems(nextItems); - const nextSortedItems = sortItemsForMode(nextItems, sortMode); + const nextSortedItems = sortItemsByZone( + filterItemsForSearch(nextItems, normalizedListSearchQuery) + ); const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id); setBuyModalState( @@ -633,7 +615,7 @@ export default function GroceryList() { const message = getApiErrorMessage(error, "Failed to mark item as bought"); 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) => { setBuyModalState({ @@ -772,7 +754,6 @@ export default function GroceryList() { return ( <div className="glist-body"> <div className="glist-container"> - <h1 className="glist-title">{pageTitle}</h1> <p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}> Loading households... </p> @@ -785,7 +766,6 @@ export default function GroceryList() { return ( <div className="glist-body"> <div className="glist-container"> - <h1 className="glist-title">{pageTitle}</h1> <NoHouseholdState /> </div> </div> @@ -796,8 +776,7 @@ export default function GroceryList() { return ( <div className="glist-body"> <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... </p> </div> @@ -809,8 +788,7 @@ export default function GroceryList() { return ( <div className="glist-body"> <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> <p className="glist-empty-text"> This household doesn’t have any stores yet. @@ -837,8 +815,7 @@ export default function GroceryList() { return ( <div className="glist-body"> <div className="glist-container"> - <h1 className="glist-title">{pageTitle}</h1> - <StoreTabs /> + <StoreTabs /> <p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}> Loading stores... </p> @@ -851,8 +828,7 @@ export default function GroceryList() { return ( <div className="glist-body"> <div className="glist-container"> - <h1 className="glist-title">{pageTitle}</h1> - <StoreTabs /> + <StoreTabs /> <p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p> </div> </div> @@ -863,9 +839,7 @@ export default function GroceryList() { return ( <div className="glist-body"> <div className="glist-container"> - <h1 className="glist-title">{pageTitle}</h1> - - <StoreTabs /> + <StoreTabs /> {canEditList && ( <AddItemForm @@ -878,13 +852,24 @@ export default function GroceryList() { /> )} - <SortDropdown value={sortMode} onChange={setSortMode} /> - - {sortMode === "zone" ? ( + <ListSearchInput + value={listSearchQuery} + 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); return Object.keys(grouped).map(zone => { - const isCollapsed = collapsedZones[zone]; + const isCollapsed = isListSearchActive ? false : collapsedZones[zone]; const itemCount = grouped[zone].length; return ( <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 && ( <> @@ -992,11 +959,11 @@ export default function GroceryList() { {showAddDetailsModal && pendingItem && ( <AddItemWithDetailsModal - itemName={pendingItem.itemName} - onConfirm={handleAddWithDetails} - onSkip={handleAddDetailsSkip} - onCancel={handleAddDetailsCancel} - /> + itemName={pendingItem.itemName} + zones={zones} + onConfirm={handleAddWithDetails} + onCancel={handleAddDetailsCancel} + /> )} {showSimilarModal && similarItemSuggestion && ( @@ -1012,8 +979,9 @@ export default function GroceryList() { {showEditModal && editingItem && ( <EditItemModal item={editingItem} - onSave={handleEditSave} - onCancel={handleEditCancel} + zones={zones} + onSave={handleEditSave} + onCancel={handleEditCancel} onImageUpdate={handleImageAdded} /> )} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index d4fa7cc..4f28963 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -137,12 +137,6 @@ export default function Settings() { updateSettings({ [key]: parseInt(value, 10) }); }; - - const handleSelectChange = (key, value) => { - updateSettings({ [key]: value }); - }; - - const handleReset = () => { if (window.confirm("Reset all settings to defaults?")) { resetSettings(); @@ -252,24 +246,6 @@ export default function Settings() { <div className="settings-section"> <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"> <label className="settings-label"> <input diff --git a/frontend/src/styles/components/AddItemWithDetailsModal.css b/frontend/src/styles/components/AddItemWithDetailsModal.css index 500981c..9bd4565 100644 --- a/frontend/src/styles/components/AddItemWithDetailsModal.css +++ b/frontend/src/styles/components/AddItemWithDetailsModal.css @@ -15,14 +15,28 @@ .add-item-details-modal { background: var(--modal-bg); border-radius: var(--border-radius-xl); - padding: var(--spacing-xl); - max-width: 500px; + padding: var(--spacing-lg); + max-width: 520px; width: 100%; max-height: 90vh; overflow-y: auto; 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 { font-size: var(--font-size-xl); margin: 0 0 var(--spacing-xs) 0; @@ -38,13 +52,15 @@ } .add-item-details-section { - margin-bottom: var(--spacing-xl); - padding-bottom: var(--spacing-xl); + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-md); border-bottom: var(--border-width-thin) solid var(--color-border-light); } .add-item-details-section:last-of-type { border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; } .add-item-details-section-title { @@ -55,6 +71,68 @@ } /* 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 { min-height: 120px; } @@ -119,22 +197,33 @@ } /* Classification Section */ +.add-item-details-modal .classification-section { + margin: 0; +} + .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 { display: block; - margin-bottom: var(--spacing-sm); + margin: 0; font-weight: var(--font-weight-semibold); color: var(--color-text-primary); font-size: var(--font-size-sm); + line-height: var(--line-height-tight); + white-space: nowrap; } .add-item-details-select { width: 100%; - padding: var(--input-padding-y) var(--input-padding-x); - font-size: var(--font-size-base); + min-height: 2.5rem; + padding: 0.55rem 0.75rem; + font-size: var(--font-size-sm); border: var(--border-width-thin) solid var(--input-border-color); border-radius: var(--input-border-radius); box-sizing: border-box; @@ -153,7 +242,7 @@ .add-item-details-actions { display: flex; gap: var(--spacing-sm); - margin-top: var(--spacing-lg); + margin-top: var(--spacing-md); padding-top: var(--spacing-md); border-top: var(--border-width-thin) solid var(--color-border-light); } @@ -162,38 +251,36 @@ flex: 1; padding: var(--button-padding-y) var(--button-padding-x); font-size: var(--font-size-base); - border: none; + border: var(--border-width-thin) solid transparent; border-radius: var(--button-border-radius); cursor: pointer; font-weight: var(--button-font-weight); transition: var(--transition-base); + min-height: 44px; } .add-item-details-btn.cancel { - background: var(--color-secondary); - color: var(--color-text-inverse); + background: var(--button-secondary-bg); + border-color: var(--button-secondary-border); + color: var(--button-secondary-text); } .add-item-details-btn.cancel:hover { - background: var(--color-secondary-hover); -} - -.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); + background: var(--button-secondary-hover-bg); + border-color: var(--button-secondary-border-hover); + color: var(--button-secondary-text); } .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); } .add-item-details-btn.confirm:hover { background: var(--color-primary-hover); + border-color: var(--color-primary-hover); + color: var(--color-text-inverse); } /* Mobile responsiveness */ @@ -225,6 +312,10 @@ min-height: 44px; } + .add-item-details-field { + grid-template-columns: 6rem minmax(0, 1fr); + } + .add-item-details-actions { flex-direction: column; gap: 0.5rem; diff --git a/frontend/src/styles/components/HouseholdSwitcher.css b/frontend/src/styles/components/HouseholdSwitcher.css index 468b9fd..4766fb2 100644 --- a/frontend/src/styles/components/HouseholdSwitcher.css +++ b/frontend/src/styles/components/HouseholdSwitcher.css @@ -70,8 +70,10 @@ position: absolute; top: calc(100% + 0.5rem); left: 0; - right: 0; + right: auto; width: 100%; + min-width: 100%; + max-width: min(320px, calc(100vw - 2rem)); overflow: hidden; background: var(--card-bg); border: 2px solid var(--border); @@ -80,15 +82,32 @@ 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 { display: flex; align-items: center; justify-content: space-between; + flex: 1; + min-width: 0; width: 100%; - padding: 0.875rem 1rem; - background: var(--card-bg); + padding: 0.875rem 0.625rem 0.875rem 1rem; + background: transparent; border: none; - border-bottom: 1px solid var(--border); color: var(--text-primary); font-size: 1rem; text-align: left; @@ -96,25 +115,59 @@ transition: all 0.2s ease; } -.household-option:last-child { - border-bottom: none; +.household-option-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .household-option:hover { - background: var(--button-secondary-bg); - border-color: var(--primary); + color: var(--primary); } -.household-option.active { - background: color-mix(in srgb, var(--primary) 15%, transparent); +.household-option-row.active .household-option { color: var(--primary); 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); - font-size: 1.1rem; - font-weight: bold; + outline: none; +} + +.household-reorder-button:disabled { + opacity: 0.35; + cursor: not-allowed; } .household-divider { @@ -124,6 +177,7 @@ } .create-household-btn { + border-bottom: none; color: var(--primary); font-weight: 600; } diff --git a/frontend/src/styles/components/manage/ManageHousehold.css b/frontend/src/styles/components/manage/ManageHousehold.css index d88d423..07840f2 100644 --- a/frontend/src/styles/components/manage/ManageHousehold.css +++ b/frontend/src/styles/components/manage/ManageHousehold.css @@ -383,8 +383,8 @@ body.dark-mode .invite-status-badge.is-used { .member-card { display: flex; flex-direction: column; - gap: 0.9rem; - padding: 0.95rem 1rem; + gap: 0.7rem; + padding: 0.85rem 1rem; background: rgba(255, 255, 255, 1); border: 1px solid var(--border); border-radius: var(--border-radius-lg); @@ -408,74 +408,48 @@ body.dark-mode .member-card:hover { 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 { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 0.85rem; - align-items: flex-start; + min-width: 0; } .member-info { - display: flex; - flex-direction: column; - gap: 0.35rem; - min-width: 0; -} - -.member-topline { display: flex; align-items: center; gap: 0.45rem; - flex-wrap: wrap; + min-width: 0; + max-width: 100%; + white-space: nowrap; } .member-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; font-weight: 700; color: var(--text-primary); font-size: 1rem; } -.member-meta { - color: var(--text-secondary); - font-size: 0.82rem; -} - .member-role { display: inline-flex; align-items: center; gap: 0.35rem; - font-size: 0.78rem; - padding: 0.24rem 0.55rem; - border-radius: var(--border-radius-full); - width: fit-content; + flex: 0 0 auto; + font-size: 0.88rem; text-transform: capitalize; font-weight: 700; } .member-role-owner { - background: rgba(245, 158, 11, 0.18); color: #b45309; } .member-role-admin { - background: rgba(30, 144, 255, 0.16); color: var(--primary-dark); } .member-role-member, .member-role-viewer { - background: rgba(139, 92, 246, 0.12); color: #6d28d9; } @@ -483,7 +457,8 @@ body.dark-mode .member-card:hover { display: inline-flex; align-items: center; gap: 0.25rem; - padding: 0.24rem 0.5rem; + flex: 0 0 auto; + padding: 0.18rem 0.45rem; border-radius: var(--border-radius-full); background: rgba(245, 158, 11, 0.16); color: #a16207; @@ -497,7 +472,7 @@ body.dark-mode .member-card:hover { flex-wrap: wrap; justify-content: flex-start; 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); } diff --git a/frontend/src/styles/components/manage/ManageStores.css b/frontend/src/styles/components/manage/ManageStores.css index 32275f1..55b5683 100644 --- a/frontend/src/styles/components/manage/ManageStores.css +++ b/frontend/src/styles/components/manage/ManageStores.css @@ -93,6 +93,94 @@ 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 { display: flex; @@ -172,4 +260,23 @@ .available-store-card button { 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; + } } diff --git a/frontend/src/styles/pages/GroceryList.css b/frontend/src/styles/pages/GroceryList.css index cd0a15b..7dffa2a 100644 --- a/frontend/src/styles/pages/GroceryList.css +++ b/frontend/src/styles/pages/GroceryList.css @@ -14,13 +14,6 @@ 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 { text-align: center; font-size: var(--font-size-xl); @@ -116,14 +109,14 @@ /* Classification Groups */ .glist-classification-group { - margin-bottom: var(--spacing-xl); + margin-bottom: 0; } .glist-classification-header { font-size: var(--font-size-lg); font-weight: var(--font-weight-semibold); 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); background: var(--color-primary-light); border-left: var(--border-width-thick) solid var(--color-primary); @@ -239,6 +232,10 @@ margin-top: var(--spacing-md); } +.glist-classification-group .glist-ul { + margin-top: var(--spacing-xs); +} + .glist-li { background: var(--color-bg-surface); border: var(--border-width-thin) solid var(--color-border-light); @@ -254,6 +251,14 @@ 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 { display: flex; gap: 1em; @@ -359,10 +364,21 @@ font-size: 0.65em; } -/* Sorting dropdown */ -.glist-sort { +/* List search */ +.glist-search { 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); font-size: var(--font-size-base); border-radius: var(--border-radius-sm); @@ -371,6 +387,48 @@ 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 */ .glist-image-upload { margin: 0.5em 0; diff --git a/frontend/tests/household-selection-persistence.spec.ts b/frontend/tests/household-selection-persistence.spec.ts index 04de148..88f949c 100644 --- a/frontend/tests/household-selection-persistence.spec.ts +++ b/frontend/tests/household-selection-persistence.spec.ts @@ -33,8 +33,8 @@ test("selected household stays active after refreshing on settings and home page ]; const storesByHousehold = { - 1: [{ id: 101, name: "Costco", is_default: true }], - 2: [{ id: 201, name: "Trader Joe's", is_default: true }], + 1: [{ id: 101, household_store_id: 1001, name: "Costco", is_default: true }], + 2: [{ id: 201, household_store_id: 2001, name: "Trader Joe's", is_default: true }], }; 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) => { - const householdId = Number(route.request().url().split("/").pop()); + await page.route("**/households/*/stores", async (route) => { + const householdId = Number(route.request().url().match(/households\/(\d+)\/stores/)?.[1]); await route.fulfill({ status: 200, 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({ status: 200, 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({ status: 200, 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 page.getByRole("button", { name: "Alpha Home" }).click(); - await page.getByRole("button", { name: "Bravo Home" }).click(); + const householdTrigger = page.locator(".household-switcher-toggle"); + 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.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2"); diff --git a/frontend/tests/invite-link-management.spec.ts b/frontend/tests/invite-link-management.spec.ts index 70f0678..441cc49 100644 --- a/frontend/tests/invite-link-management.spec.ts +++ b/frontend/tests/invite-link-management.spec.ts @@ -4,6 +4,7 @@ import { confirmSlide, expectNoFailedApiRequests, mockConfig, + mockHouseholdAndStoreShell, seedAuthStorage, } 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(); }); +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 }) => { const failedApiRequests = collectFailedApiRequests(page); 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) => { const request = route.request(); if (request.method() !== "PATCH") { diff --git a/package.json b/package.json index 44595c3..4ed9852 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,11 @@ "db:migrate:new": "node scripts/db-migrate-new.js", "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", + "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:e2e": "npm --prefix frontend run test:e2e --", "test:e2e:headed": "npm --prefix frontend run test:e2e:headed --", diff --git a/packages/db/migrations/20260526_010000_custom_store_locations.sql b/packages/db/migrations/20260526_010000_custom_store_locations.sql new file mode 100644 index 0000000..ad0d725 --- /dev/null +++ b/packages/db/migrations/20260526_010000_custom_store_locations.sql @@ -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; diff --git a/packages/db/migrations/20260526_020000_add_household_member_sort_order.sql b/packages/db/migrations/20260526_020000_add_household_member_sort_order.sql new file mode 100644 index 0000000..1a9f7ac --- /dev/null +++ b/packages/db/migrations/20260526_020000_add_household_member_sort_order.sql @@ -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; diff --git a/scripts/gitea-pr.js b/scripts/gitea-pr.js new file mode 100644 index 0000000..53bb83d --- /dev/null +++ b/scripts/gitea-pr.js @@ -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)); +});