Merge pull request 'feature-custom-store-locations' (#4) from feature-custom-store-locations into main
Reviewed-on: #4
This commit is contained in:
commit
4c8c197e17
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
# Environment variables (DO NOT COMMIT)
|
||||
.env
|
||||
.codex-local.env
|
||||
|
||||
# Node dependencies
|
||||
node_modules/
|
||||
|
||||
@ -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/<short-description>`
|
||||
- `bugfix/<short-description>`
|
||||
- `refactor/<short-description>`
|
||||
- `chore/<short-description>`
|
||||
- `spike/<short-description>`
|
||||
- Do not include tracker numbers in branch names.
|
||||
- Use standalone branches from `main` for independent work.
|
||||
- Use stacked branches from the parent PR branch for follow-on work.
|
||||
- Target standalone PRs at `main`.
|
||||
- Target stacked PRs at the parent PR branch.
|
||||
- Never push directly to `main`.
|
||||
|
||||
### Commit discipline
|
||||
- Treat committing as a first-class part of the workflow: create frequent, verified checkpoint commits for completed work instead of accumulating large uncommitted changes.
|
||||
- Commit after each coherent logical unit of work.
|
||||
- Commit in small, logical slices (no broad mixed-purpose commits).
|
||||
- Before committing:
|
||||
1. Run `git diff --stat`.
|
||||
2. Run relevant tests or checks when practical.
|
||||
3. Stage only files that belong to the logical unit.
|
||||
4. Run `git diff --cached --stat`.
|
||||
5. Commit with an imperative, present-tense subject at or below 72 characters.
|
||||
- Each commit must:
|
||||
- follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
|
||||
- include only related files for that slice
|
||||
- exclude secrets, credentials, and generated noise
|
||||
- Do not stage unrelated user or collaborator changes.
|
||||
- Do not start a second unrelated task while the first has uncommitted work.
|
||||
- If existing local changes are external/user changes, leave them untouched unless explicitly told otherwise.
|
||||
- If asked to commit and external changes already exist, commit those separately on the proper branch before starting new work.
|
||||
- Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas).
|
||||
- Prefer frequent checkpoint commits during agentic work rather than one large end-state commit.
|
||||
- Before switching tasks or stopping after a completed change, check git status and either commit the finished slice or clearly document why it remains uncommitted.
|
||||
- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code).
|
||||
|
||||
### Push and PR coordination
|
||||
- Push the branch before opening a PR.
|
||||
- For this Gitea repo, use `docs/GITEA_PR_WORKFLOW.md` and `scripts/gitea-pr.js` for PR creation, lookup, and merge operations.
|
||||
- PR tooling must read auth from `GITEA_TOKEN`/`GITEA_BASE_URL` shell environment or ignored `.codex-local.env` only; never commit tokens or print token values.
|
||||
- Open a draft PR early for non-trivial, collision-prone, or multi-agent work once the first coherent commit exists.
|
||||
- Use the PR body as the coordination record:
|
||||
- `Owner:`
|
||||
- `Status: proposed / in-progress / blocked / review / done`
|
||||
- `Branch:`
|
||||
- `Branch relationship: standalone from main / stacked on parent branch / continuing existing branch`
|
||||
- `Likely modified areas:`
|
||||
- `Actual modified files:`
|
||||
- `Collision risk: low / medium / high`
|
||||
- `Last meaningful update:`
|
||||
- Collision risk levels:
|
||||
- Low: isolated docs, tests, or one leaf component.
|
||||
- Medium: shared stores/types, panels/components, handlers, helpers.
|
||||
- High: interface contracts, broad app flows, core registries, cross-cutting behavior.
|
||||
- If a branch already contains assigned feature work and has no current PR, stop before adding more feature commits. Push the branch and open a draft PR, or record the GitHub/auth blocker.
|
||||
- Before writing or updating the final PR body, inspect:
|
||||
- `git log <base>..HEAD`
|
||||
- `git diff --stat <base>..HEAD`
|
||||
- The PR should describe the cumulative branch diff against the target branch, not only the latest commit.
|
||||
- Include:
|
||||
- Summary of functional changes.
|
||||
- Tests run, or a clear reason tests were not run.
|
||||
- For broad branches, organize the summary by subsystem, workflow, or behavior area.
|
||||
- Do not use auto-closing keywords such as `Closes`, `Fixes`, or `Resolves`.
|
||||
- Merge PRs only after explicit operator approval, required checks, and a final `npm run pr:view -- --number <pr-number>` status check.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<Array>} 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<Object|null>} 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;
|
||||
};
|
||||
|
||||
@ -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,7 +94,6 @@ 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(
|
||||
@ -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
|
||||
WHERE household_id = $1 AND store_id = $2`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
return result.rowCount > 0;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
118
backend/tests/auth.middleware.test.js
Normal file
118
backend/tests/auth.middleware.test.js
Normal file
@ -0,0 +1,118 @@
|
||||
jest.mock("jsonwebtoken", () => ({
|
||||
verify: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../models/session.model", () => ({
|
||||
getActiveSessionWithUser: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/logger", () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
const jwt = require("jsonwebtoken");
|
||||
const Session = require("../models/session.model");
|
||||
const auth = require("../middleware/auth");
|
||||
|
||||
function createResponse() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("auth middleware", () => {
|
||||
const originalJwtSecret = process.env.JWT_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalJwtSecret === undefined) {
|
||||
delete process.env.JWT_SECRET;
|
||||
} else {
|
||||
process.env.JWT_SECRET = originalJwtSecret;
|
||||
}
|
||||
});
|
||||
|
||||
test("uses a valid bearer token without reading the session cookie", async () => {
|
||||
jwt.verify.mockReturnValue({ id: 5, role: "admin" });
|
||||
|
||||
const req = {
|
||||
headers: {
|
||||
authorization: "Bearer valid-token",
|
||||
cookie: "sid=session-id",
|
||||
},
|
||||
};
|
||||
const res = createResponse();
|
||||
const next = jest.fn();
|
||||
|
||||
await auth(req, res, next);
|
||||
|
||||
expect(jwt.verify).toHaveBeenCalledWith("valid-token", "test-secret");
|
||||
expect(Session.getActiveSessionWithUser).not.toHaveBeenCalled();
|
||||
expect(req.user).toEqual({ id: 5, role: "admin" });
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("falls back to a valid session cookie when the bearer token is stale", async () => {
|
||||
jwt.verify.mockImplementation(() => {
|
||||
throw new Error("stale token");
|
||||
});
|
||||
Session.getActiveSessionWithUser.mockResolvedValue({
|
||||
id: "session-id",
|
||||
user_id: 7,
|
||||
role: "user",
|
||||
username: "shopper",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {
|
||||
authorization: "Bearer stale-token",
|
||||
cookie: "sid=session-id",
|
||||
},
|
||||
};
|
||||
const res = createResponse();
|
||||
const next = jest.fn();
|
||||
|
||||
await auth(req, res, next);
|
||||
|
||||
expect(Session.getActiveSessionWithUser).toHaveBeenCalledWith("session-id");
|
||||
expect(req.user).toEqual({
|
||||
id: 7,
|
||||
role: "user",
|
||||
username: "shopper",
|
||||
});
|
||||
expect(req.session_id).toBe("session-id");
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects a stale bearer token when no session cookie is present", async () => {
|
||||
jwt.verify.mockImplementation(() => {
|
||||
throw new Error("stale token");
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {
|
||||
authorization: "Bearer stale-token",
|
||||
},
|
||||
};
|
||||
const res = createResponse();
|
||||
const next = jest.fn();
|
||||
|
||||
await auth(req, res, next);
|
||||
|
||||
expect(Session.getActiveSessionWithUser).not.toHaveBeenCalled();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: {
|
||||
code: "unauthorized",
|
||||
message: "Invalid or expired token",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
75
backend/tests/household.model.test.js
Normal file
75
backend/tests/household.model.test.js
Normal file
@ -0,0 +1,75 @@
|
||||
jest.mock("../db/pool", () => ({
|
||||
connect: jest.fn(),
|
||||
query: jest.fn(),
|
||||
}));
|
||||
|
||||
const pool = require("../db/pool");
|
||||
const Household = require("../models/household.model");
|
||||
|
||||
describe("household.model household ordering", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("loads households using the user's saved sort order", async () => {
|
||||
pool.query.mockResolvedValueOnce({
|
||||
rows: [{ id: 2, name: "Second", household_sort_order: 0 }],
|
||||
});
|
||||
|
||||
const households = await Household.getUserHouseholds(9);
|
||||
|
||||
expect(households).toEqual([{ id: 2, name: "Second", household_sort_order: 0 }]);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("ORDER BY hm.household_sort_order ASC NULLS LAST"),
|
||||
[9]
|
||||
);
|
||||
});
|
||||
|
||||
test("persists a full household order for the current user", async () => {
|
||||
const client = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ rows: [{ household_id: 1 }, { household_id: 2 }] })
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ rows: [{ id: 2 }, { id: 1 }] })
|
||||
.mockResolvedValueOnce({}),
|
||||
release: jest.fn(),
|
||||
};
|
||||
pool.connect.mockResolvedValueOnce(client);
|
||||
|
||||
const households = await Household.reorderUserHouseholds(9, [2, 1]);
|
||||
|
||||
expect(households).toEqual([{ id: 2 }, { id: 1 }]);
|
||||
expect(client.query).toHaveBeenNthCalledWith(1, "BEGIN");
|
||||
expect(client.query).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.stringContaining("SET household_sort_order = $1"),
|
||||
[0, 9, 2]
|
||||
);
|
||||
expect(client.query).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.stringContaining("SET household_sort_order = $1"),
|
||||
[1, 9, 1]
|
||||
);
|
||||
expect(client.query).toHaveBeenLastCalledWith("COMMIT");
|
||||
expect(client.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects an order that does not match the user's memberships", async () => {
|
||||
const client = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ rows: [{ household_id: 1 }] })
|
||||
.mockResolvedValueOnce({}),
|
||||
release: jest.fn(),
|
||||
};
|
||||
pool.connect.mockResolvedValueOnce(client);
|
||||
|
||||
const households = await Household.reorderUserHouseholds(9, [1, 2]);
|
||||
|
||||
expect(households).toBeNull();
|
||||
expect(client.query).toHaveBeenNthCalledWith(3, "ROLLBACK");
|
||||
expect(client.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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" },
|
||||
|
||||
164
backend/tests/store-locations.routes.test.js
Normal file
164
backend/tests/store-locations.routes.test.js
Normal file
@ -0,0 +1,164 @@
|
||||
jest.mock("../middleware/auth", () => (req, res, next) => {
|
||||
req.user = { id: 42, role: "user" };
|
||||
next();
|
||||
});
|
||||
|
||||
jest.mock("../middleware/household", () => ({
|
||||
householdAccess: (req, res, next) => {
|
||||
req.household = {
|
||||
id: Number.parseInt(req.params.householdId, 10),
|
||||
role: req.headers["x-household-role"] || "member",
|
||||
};
|
||||
next();
|
||||
},
|
||||
locationAccess: (req, res, next) => {
|
||||
req.storeLocation = { id: Number.parseInt(req.params.locationId, 10) };
|
||||
next();
|
||||
},
|
||||
requireHouseholdAdmin: (req, res, next) => {
|
||||
if (["owner", "admin"].includes(req.household?.role)) {
|
||||
return next();
|
||||
}
|
||||
return res.status(403).json({
|
||||
error: { code: "FORBIDDEN", message: "Admin role required" },
|
||||
request_id: req.request_id,
|
||||
});
|
||||
},
|
||||
storeAccess: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock("../middleware/image", () => ({
|
||||
upload: {
|
||||
single: () => (req, res, next) => next(),
|
||||
},
|
||||
processImage: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock("../controllers/households.controller", () => ({
|
||||
createHousehold: jest.fn(),
|
||||
deleteHousehold: jest.fn(),
|
||||
getHousehold: jest.fn(),
|
||||
getMembers: jest.fn(),
|
||||
getUserHouseholds: jest.fn(),
|
||||
joinHousehold: jest.fn(),
|
||||
refreshInviteCode: jest.fn(),
|
||||
removeMember: jest.fn(),
|
||||
reorderHouseholds: jest.fn((req, res) => res.json({ message: "ordered" })),
|
||||
updateHousehold: jest.fn(),
|
||||
updateMemberRole: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../controllers/lists.controller.v2", () => ({
|
||||
addItem: jest.fn(),
|
||||
deleteItem: jest.fn(),
|
||||
getClassification: jest.fn(),
|
||||
getItemByName: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
getRecentlyBought: jest.fn(),
|
||||
getSuggestions: jest.fn(),
|
||||
markBought: jest.fn(),
|
||||
setClassification: jest.fn(),
|
||||
updateItem: jest.fn(),
|
||||
updateItemImage: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../controllers/available-items.controller", () => ({
|
||||
createAvailableItem: jest.fn(),
|
||||
deleteAvailableItem: jest.fn(),
|
||||
getAvailableItems: jest.fn(),
|
||||
importCurrentItems: jest.fn(),
|
||||
updateAvailableItem: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../controllers/stores.controller", () => ({
|
||||
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
|
||||
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
|
||||
createZone: jest.fn((req, res) => res.status(201).json({ message: "zone" })),
|
||||
deleteHouseholdStore: jest.fn((req, res) => res.json({ message: "deleted store" })),
|
||||
deleteLocation: jest.fn((req, res) => res.json({ message: "deleted location" })),
|
||||
deleteZone: jest.fn((req, res) => res.json({ message: "deleted zone" })),
|
||||
getHouseholdStores: jest.fn((req, res) => res.json([{ id: 2, name: "Costco" }])),
|
||||
getLocationZones: jest.fn((req, res) => res.json({ zones: [] })),
|
||||
setDefaultLocation: jest.fn((req, res) => res.json({ message: "default" })),
|
||||
updateHouseholdStore: jest.fn((req, res) => res.json({ message: "updated store" })),
|
||||
updateLocation: jest.fn((req, res) => res.json({ message: "updated location" })),
|
||||
updateZone: jest.fn((req, res) => res.json({ message: "updated zone" })),
|
||||
}));
|
||||
|
||||
const express = require("express");
|
||||
const request = require("supertest");
|
||||
const router = require("../routes/households.routes");
|
||||
const householdsController = require("../controllers/households.controller");
|
||||
const storesController = require("../controllers/stores.controller");
|
||||
|
||||
describe("store location routes", () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/households", router);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("members can list household store locations", async () => {
|
||||
const response = await request(app).get("/households/1/stores");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(storesController.getHouseholdStores).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("users can reorder their household switcher list", async () => {
|
||||
const response = await request(app)
|
||||
.patch("/households/order")
|
||||
.send({ household_ids: [3, 1, 2] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(householdsController.reorderHouseholds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("members cannot create household stores", async () => {
|
||||
const response = await request(app)
|
||||
.post("/households/1/stores")
|
||||
.set("x-household-role", "member")
|
||||
.send({ name: "Costco" });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(storesController.createHouseholdStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("admins can create household stores", async () => {
|
||||
const response = await request(app)
|
||||
.post("/households/1/stores")
|
||||
.set("x-household-role", "admin")
|
||||
.send({ name: "Costco", location_name: "Fontana" });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(storesController.createHouseholdStore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("members can list zones but cannot create zones", async () => {
|
||||
const listResponse = await request(app)
|
||||
.get("/households/1/locations/2/zones")
|
||||
.set("x-household-role", "member");
|
||||
const createResponse = await request(app)
|
||||
.post("/households/1/locations/2/zones")
|
||||
.set("x-household-role", "member")
|
||||
.send({ name: "Produce", sort_order: 10 });
|
||||
|
||||
expect(listResponse.status).toBe(200);
|
||||
expect(createResponse.status).toBe(403);
|
||||
expect(storesController.getLocationZones).toHaveBeenCalled();
|
||||
expect(storesController.createZone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("admins can update zone order", async () => {
|
||||
const response = await request(app)
|
||||
.patch("/households/1/locations/2/zones/9")
|
||||
.set("x-household-role", "admin")
|
||||
.send({ sort_order: 20 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(storesController.updateZone).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
71
docs/GITEA_PR_WORKFLOW.md
Normal file
71
docs/GITEA_PR_WORKFLOW.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Gitea PR Workflow
|
||||
|
||||
Use this workflow when creating or merging PRs for this repo. It is designed for Codex and local operators to use the same commands without storing secrets in git.
|
||||
|
||||
## One-time token setup
|
||||
|
||||
Create a Gitea access token with repository pull request permissions, then set it only in the shell or user environment:
|
||||
|
||||
```powershell
|
||||
$env:GITEA_BASE_URL = "http://192.168.7.78:3000"
|
||||
$env:GITEA_TOKEN = "<token>"
|
||||
```
|
||||
|
||||
For Codex sandbox sessions, use the ignored local env file when inherited environment variables are not visible:
|
||||
|
||||
```powershell
|
||||
@"
|
||||
GITEA_BASE_URL=http://192.168.7.78:3000
|
||||
GITEA_TOKEN=<token>
|
||||
"@ | Set-Content .codex-local.env
|
||||
```
|
||||
|
||||
Do not commit tokens, paste them into docs, or print them in logs.
|
||||
|
||||
Check auth:
|
||||
|
||||
```powershell
|
||||
npm run pr:auth
|
||||
```
|
||||
|
||||
## Create a PR
|
||||
|
||||
1. Push the branch first.
|
||||
2. Inspect the cumulative branch diff against the PR target:
|
||||
|
||||
```powershell
|
||||
git log <base>..HEAD --oneline --decorate
|
||||
git diff --stat <base>..HEAD
|
||||
```
|
||||
|
||||
3. Write the PR body to a temporary local file. Include the coordination record required by `PROJECT_INSTRUCTIONS.md`.
|
||||
4. Create the PR:
|
||||
|
||||
```powershell
|
||||
npm run pr:create -- --base <base> --title "<title>" --body-file <body-file>
|
||||
```
|
||||
|
||||
The helper checks for an existing open PR with the same base/head and returns it instead of creating a duplicate.
|
||||
If the PR body needs to be changed after creation, update it from a body file:
|
||||
|
||||
```powershell
|
||||
npm run pr:update -- --number <pr-number> --body-file <body-file>
|
||||
```
|
||||
|
||||
For stacked work, pass the parent PR branch as `<base>`. For standalone work, pass `main`.
|
||||
|
||||
## View or merge a PR
|
||||
|
||||
View:
|
||||
|
||||
```powershell
|
||||
npm run pr:view -- --number <pr-number>
|
||||
```
|
||||
|
||||
Merge after explicit operator approval and required checks:
|
||||
|
||||
```powershell
|
||||
npm run pr:merge -- --number <pr-number> --method merge --delete-branch --yes
|
||||
```
|
||||
|
||||
The helper refuses to merge without `--yes`. Use `--method squash` or `--method rebase` only when that is the intended repo workflow.
|
||||
@ -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)
|
||||
|
||||
@ -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`);
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
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,6 +61,23 @@ api.interceptors.response.use(
|
||||
window.location.href = "/login";
|
||||
alert("Your session has expired. Please log in again.");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -4,13 +4,13 @@ import api from "./axios";
|
||||
* Get grocery list for household and store
|
||||
*/
|
||||
export const getList = (householdId, storeId) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list`);
|
||||
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`, {
|
||||
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",
|
||||
},
|
||||
@ -50,7 +50,7 @@ export const addItem = (
|
||||
* Get item classification
|
||||
*/
|
||||
export const getClassification = (householdId, storeId, itemName) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/classification`, {
|
||||
api.get(`/households/${householdId}/locations/${storeId}/list/classification`, {
|
||||
params: { item_name: itemName }
|
||||
});
|
||||
|
||||
@ -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
|
||||
});
|
||||
@ -104,7 +104,7 @@ 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`, {
|
||||
api.put(`/households/${householdId}/locations/${storeId}/list/item`, {
|
||||
item_name: itemName,
|
||||
quantity,
|
||||
notes
|
||||
@ -114,7 +114,7 @@ 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`, {
|
||||
api.patch(`/households/${householdId}/locations/${storeId}/list/item`, {
|
||||
item_name: itemName,
|
||||
bought,
|
||||
quantity_bought: quantityBought
|
||||
@ -124,7 +124,7 @@ 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`, {
|
||||
api.delete(`/households/${householdId}/locations/${storeId}/list/item`, {
|
||||
data: { item_name: itemName }
|
||||
});
|
||||
|
||||
@ -132,7 +132,7 @@ export const deleteItem = (householdId, storeId, itemName) =>
|
||||
* Get suggestions based on query
|
||||
*/
|
||||
export const getSuggestions = (householdId, storeId, query) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, {
|
||||
api.get(`/households/${householdId}/locations/${storeId}/list/suggestions`, {
|
||||
params: { query }
|
||||
});
|
||||
|
||||
@ -140,7 +140,7 @@ export const getSuggestions = (householdId, storeId, query) =>
|
||||
* Get recently bought items
|
||||
*/
|
||||
export const getRecentlyBought = (householdId, storeId) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/recent`);
|
||||
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",
|
||||
},
|
||||
|
||||
186
frontend/src/api/offlineCache.js
Normal file
186
frontend/src/api/offlineCache.js
Normal file
@ -0,0 +1,186 @@
|
||||
const CACHE_PREFIX = "fiddy:api-cache:v1:";
|
||||
const CACHE_INDEX_KEY = "fiddy:api-cache-index:v1";
|
||||
const MAX_CACHE_ENTRIES = 80;
|
||||
const TRANSIENT_STATUS_CODES = new Set([408, 429, 502, 503, 504]);
|
||||
const SENSITIVE_URL_PATTERN = /(receipt|download|update-image)/i;
|
||||
const BINARY_FIELD_PATTERN = /(image|receipt|bytes|buffer|blob|base64)/i;
|
||||
|
||||
function getStorage() {
|
||||
if (typeof window === "undefined" || !window.localStorage) return null;
|
||||
return window.localStorage;
|
||||
}
|
||||
|
||||
function getCurrentUserScope() {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
|
||||
try {
|
||||
return storage.getItem("userId") || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableStringify).join(",")}]`;
|
||||
}
|
||||
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
|
||||
function hashString(value) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
function requestFingerprint(config) {
|
||||
if (!config || String(config.method || "get").toLowerCase() !== "get") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.responseType && config.responseType !== "json") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = config.url || "";
|
||||
if (!url || SENSITIVE_URL_PATTERN.test(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return stableStringify({
|
||||
baseURL: config.baseURL || "",
|
||||
method: "get",
|
||||
params: config.params || null,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
function cacheKeyForConfig(config) {
|
||||
const scope = getCurrentUserScope();
|
||||
const fingerprint = requestFingerprint(config);
|
||||
if (!scope || !fingerprint) return null;
|
||||
|
||||
return `${CACHE_PREFIX}${scope}:${hashString(fingerprint)}`;
|
||||
}
|
||||
|
||||
function readIndex(storage) {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(CACHE_INDEX_KEY) || "[]");
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeIndex(storage, index) {
|
||||
try {
|
||||
storage.setItem(CACHE_INDEX_KEY, JSON.stringify(index.slice(-MAX_CACHE_ENTRIES)));
|
||||
} catch (_) {
|
||||
// Ignore cache index write failures; the live request already succeeded.
|
||||
}
|
||||
}
|
||||
|
||||
function rememberCacheKey(storage, key, scope) {
|
||||
const now = Date.now();
|
||||
const nextIndex = readIndex(storage)
|
||||
.filter((entry) => entry?.key !== key)
|
||||
.concat({ key, scope, touchedAt: now });
|
||||
|
||||
const trimmedIndex = nextIndex.slice(-MAX_CACHE_ENTRIES);
|
||||
for (const staleEntry of nextIndex.slice(0, -MAX_CACHE_ENTRIES)) {
|
||||
try {
|
||||
storage.removeItem(staleEntry.key);
|
||||
} catch (_) {
|
||||
// Best-effort cache pruning only.
|
||||
}
|
||||
}
|
||||
writeIndex(storage, trimmedIndex);
|
||||
}
|
||||
|
||||
function sanitizeForCache(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sanitizeForCache);
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entryValue]) => [
|
||||
key,
|
||||
BINARY_FIELD_PATTERN.test(key) ? null : sanitizeForCache(entryValue),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export function cacheApiResponse(config, data) {
|
||||
const storage = getStorage();
|
||||
const key = cacheKeyForConfig(config);
|
||||
const scope = getCurrentUserScope();
|
||||
if (!storage || !key || !scope) return;
|
||||
|
||||
try {
|
||||
storage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
cachedAt: new Date().toISOString(),
|
||||
data: sanitizeForCache(data),
|
||||
})
|
||||
);
|
||||
rememberCacheKey(storage, key, scope);
|
||||
} catch (_) {
|
||||
// Cache is an opportunistic fallback. Quota/private-mode failures are non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedApiResponse(config) {
|
||||
const storage = getStorage();
|
||||
const key = cacheKeyForConfig(config);
|
||||
if (!storage || !key) return null;
|
||||
|
||||
try {
|
||||
const cached = JSON.parse(storage.getItem(key) || "null");
|
||||
if (!cached || !Object.prototype.hasOwnProperty.call(cached, "data")) {
|
||||
return null;
|
||||
}
|
||||
return cached;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTransientApiError(error) {
|
||||
if (!error?.response) return true;
|
||||
return TRANSIENT_STATUS_CODES.has(error.response.status);
|
||||
}
|
||||
|
||||
export function clearApiCacheForCurrentUser() {
|
||||
const storage = getStorage();
|
||||
const scope = getCurrentUserScope();
|
||||
if (!storage || !scope) return;
|
||||
|
||||
const nextIndex = readIndex(storage).filter((entry) => {
|
||||
if (entry?.scope !== scope) return true;
|
||||
try {
|
||||
storage.removeItem(entry.key);
|
||||
} catch (_) {
|
||||
// Best effort only.
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
writeIndex(storage, nextIndex);
|
||||
}
|
||||
@ -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`);
|
||||
|
||||
35
frontend/src/components/common/ListSearchInput.jsx
Normal file
35
frontend/src/components/common/ListSearchInput.jsx
Normal file
@ -0,0 +1,35 @@
|
||||
export default function ListSearchInput({ value, onChange, resultCount, totalCount }) {
|
||||
const hasSearch = value.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="glist-search">
|
||||
<div className="glist-search-row">
|
||||
<input
|
||||
id="grocery-list-search"
|
||||
className="glist-search-input"
|
||||
type="search"
|
||||
aria-label="Search list"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="Search list"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{hasSearch && (
|
||||
<button
|
||||
className="glist-search-clear"
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
aria-label="Clear list search"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasSearch && (
|
||||
<p className="glist-search-meta">
|
||||
{resultCount} of {totalCount} item{totalCount === 1 ? "" : "s"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
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");
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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,42 +554,6 @@ 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);
|
||||
@ -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,7 +776,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 stores...
|
||||
</p>
|
||||
@ -809,7 +788,6 @@ export default function GroceryList() {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">{pageTitle}</h1>
|
||||
<div className="glist-empty-state">
|
||||
<h2 className="glist-empty-title">No stores found</h2>
|
||||
<p className="glist-empty-text">
|
||||
@ -837,7 +815,6 @@ export default function GroceryList() {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">{pageTitle}</h1>
|
||||
<StoreTabs />
|
||||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
||||
Loading stores...
|
||||
@ -851,7 +828,6 @@ export default function GroceryList() {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">{pageTitle}</h1>
|
||||
<StoreTabs />
|
||||
<p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p>
|
||||
</div>
|
||||
@ -863,8 +839,6 @@ export default function GroceryList() {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">{pageTitle}</h1>
|
||||
|
||||
<StoreTabs />
|
||||
|
||||
{canEditList && (
|
||||
@ -878,13 +852,24 @@ export default function GroceryList() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SortDropdown value={sortMode} onChange={setSortMode} />
|
||||
<ListSearchInput
|
||||
value={listSearchQuery}
|
||||
onChange={setListSearchQuery}
|
||||
resultCount={sortedItems.length}
|
||||
totalCount={items.length}
|
||||
/>
|
||||
|
||||
{sortMode === "zone" ? (
|
||||
{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,24 +908,6 @@ 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 && (
|
||||
@ -993,8 +960,8 @@ export default function GroceryList() {
|
||||
{showAddDetailsModal && pendingItem && (
|
||||
<AddItemWithDetailsModal
|
||||
itemName={pendingItem.itemName}
|
||||
zones={zones}
|
||||
onConfirm={handleAddWithDetails}
|
||||
onSkip={handleAddDetailsSkip}
|
||||
onCancel={handleAddDetailsCancel}
|
||||
/>
|
||||
)}
|
||||
@ -1012,6 +979,7 @@ export default function GroceryList() {
|
||||
{showEditModal && editingItem && (
|
||||
<EditItemModal
|
||||
item={editingItem}
|
||||
zones={zones}
|
||||
onSave={handleEditSave}
|
||||
onCancel={handleEditCancel}
|
||||
onImageUpdate={handleImageAdded}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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 --",
|
||||
|
||||
@ -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;
|
||||
@ -0,0 +1,9 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE household_members
|
||||
ADD COLUMN IF NOT EXISTS household_sort_order INTEGER;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_household_members_user_sort_order
|
||||
ON household_members(user_id, household_sort_order, joined_at DESC);
|
||||
|
||||
COMMIT;
|
||||
379
scripts/gitea-pr.js
Normal file
379
scripts/gitea-pr.js
Normal file
@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
|
||||
loadLocalEnv();
|
||||
|
||||
const DEFAULT_REMOTE = "origin";
|
||||
const DEFAULT_SSH_HTTP_PORT = process.env.GITEA_PORT || "3000";
|
||||
|
||||
function loadLocalEnv() {
|
||||
const envPath = ".codex-local.env";
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = fs.readFileSync(envPath, "utf8").split(/\r?\n/);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1].replace(/^\uFEFF/, "");
|
||||
let value = match[2].trim();
|
||||
const isQuoted =
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"));
|
||||
|
||||
if (isQuoted) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`Gitea PR helper
|
||||
|
||||
Environment:
|
||||
GITEA_TOKEN Required for API calls. Never commit this value.
|
||||
GITEA_BASE_URL Optional, e.g. http://192.168.7.78:3000.
|
||||
GITEA_OWNER Optional repo owner override.
|
||||
GITEA_REPO Optional repo name override.
|
||||
|
||||
Commands:
|
||||
auth-check
|
||||
Verify the token can authenticate with Gitea.
|
||||
|
||||
create --base <branch> [--head <branch>] --title <title> [--body-file <path> | --body <text>]
|
||||
Create a PR, or return the existing open PR for the same base/head.
|
||||
|
||||
view --number <pr-number>
|
||||
Print basic PR status.
|
||||
|
||||
update --number <pr-number> [--title <title>] [--body-file <path> | --body <text>]
|
||||
Update a PR title/body. Prefer --body-file for multi-line PR bodies.
|
||||
|
||||
merge --number <pr-number> --yes [--method merge|squash|rebase] [--delete-branch]
|
||||
Merge a PR. The --yes flag is required to avoid accidental merges.
|
||||
|
||||
Examples:
|
||||
npm run pr:auth
|
||||
npm run pr:create -- --base feature-custom-store-locations --title "Allow household switcher reordering" --body-file pr-body.md
|
||||
npm run pr:update -- --number 12 --body-file pr-body.md
|
||||
npm run pr:view -- --number 12
|
||||
npm run pr:merge -- --number 12 --method merge --delete-branch --yes
|
||||
`);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function runGit(args) {
|
||||
return execFileSync("git", args, { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function parseFlags(argv) {
|
||||
const flags = {};
|
||||
const positionals = [];
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (!arg.startsWith("--")) {
|
||||
positionals.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = arg.slice(2);
|
||||
const next = argv[index + 1];
|
||||
|
||||
if (!next || next.startsWith("--")) {
|
||||
flags[key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
flags[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return { flags, positionals };
|
||||
}
|
||||
|
||||
function requiredFlag(flags, key) {
|
||||
const value = flags[key];
|
||||
if (!value || value === true) {
|
||||
fail(`Missing required --${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentBranch() {
|
||||
return runGit(["branch", "--show-current"]);
|
||||
}
|
||||
|
||||
function parseRemoteUrl(remoteUrl) {
|
||||
const normalized = remoteUrl.replace(/\.git$/, "");
|
||||
|
||||
const httpMatch = normalized.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)$/);
|
||||
if (httpMatch) {
|
||||
const [, host, owner, repo] = httpMatch;
|
||||
return { host, owner, repo, scheme: remoteUrl.startsWith("https://") ? "https" : "http" };
|
||||
}
|
||||
|
||||
const sshMatch = normalized.match(/^(?:ssh:\/\/)?[^@]+@([^:/]+)(?::\d+)?[:/]([^/]+)\/([^/]+)$/);
|
||||
if (sshMatch) {
|
||||
const [, host, owner, repo] = sshMatch;
|
||||
return { host, owner, repo, scheme: "ssh" };
|
||||
}
|
||||
|
||||
fail(`Could not parse git remote URL: ${remoteUrl}`);
|
||||
}
|
||||
|
||||
function repoConfig() {
|
||||
const remoteUrl = runGit(["remote", "get-url", DEFAULT_REMOTE]);
|
||||
const parsed = parseRemoteUrl(remoteUrl);
|
||||
const baseUrl =
|
||||
process.env.GITEA_BASE_URL ||
|
||||
process.env.GITEA_URL ||
|
||||
(parsed.scheme === "ssh"
|
||||
? `http://${parsed.host}:${DEFAULT_SSH_HTTP_PORT}`
|
||||
: `${parsed.scheme}://${parsed.host}`);
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl.replace(/\/$/, ""),
|
||||
owner: process.env.GITEA_OWNER || parsed.owner,
|
||||
repo: process.env.GITEA_REPO || parsed.repo,
|
||||
};
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
const token = process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN;
|
||||
if (!token) {
|
||||
fail(
|
||||
"Missing GITEA_TOKEN. Create a Gitea access token with repository pull request permissions and set it in the shell."
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function apiRequest(method, route, body) {
|
||||
const { baseUrl } = repoConfig();
|
||||
const url = `${baseUrl}/api/v1${route}`;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `token ${getToken()}`,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const parsed = text ? safeJson(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
parsed?.message ||
|
||||
parsed?.error ||
|
||||
text ||
|
||||
`${response.status} ${response.statusText}`;
|
||||
fail(`Gitea API ${method} ${route} failed: ${response.status} ${message}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function safeJson(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { message: text };
|
||||
}
|
||||
}
|
||||
|
||||
function repoRoute(pathname) {
|
||||
const { owner, repo } = repoConfig();
|
||||
return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${pathname}`;
|
||||
}
|
||||
|
||||
async function listOpenPulls() {
|
||||
const pulls = [];
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const batch = await apiRequest("GET", repoRoute(`/pulls?state=open&limit=50&page=${page}`));
|
||||
pulls.push(...batch);
|
||||
|
||||
if (batch.length < 50) {
|
||||
break;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return pulls;
|
||||
}
|
||||
|
||||
async function findOpenPull(base, head) {
|
||||
const pulls = await listOpenPulls();
|
||||
return pulls.find((pull) => {
|
||||
const headLabels = [pull.head?.ref, pull.head?.label].filter(Boolean);
|
||||
return (
|
||||
pull.base?.ref === base &&
|
||||
headLabels.some((label) => label === head || label.endsWith(`:${head}`))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(flags) {
|
||||
if (flags["body-file"]) {
|
||||
return fs.readFileSync(flags["body-file"], "utf8");
|
||||
}
|
||||
return typeof flags.body === "string" ? flags.body : "";
|
||||
}
|
||||
|
||||
async function authCheck() {
|
||||
const user = await apiRequest("GET", "/user");
|
||||
console.log(`Authenticated as ${user.login || user.username || user.full_name || "Gitea user"}`);
|
||||
}
|
||||
|
||||
async function createPull(flags) {
|
||||
const base = requiredFlag(flags, "base");
|
||||
const head = flags.head && flags.head !== true ? flags.head : currentBranch();
|
||||
const title = requiredFlag(flags, "title");
|
||||
const body = readBody(flags);
|
||||
|
||||
const existing = await findOpenPull(base, head);
|
||||
if (existing) {
|
||||
console.log(`Existing PR #${existing.number}: ${existing.html_url || existing.url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pull = await apiRequest("POST", repoRoute("/pulls"), {
|
||||
base,
|
||||
head,
|
||||
title,
|
||||
body,
|
||||
});
|
||||
|
||||
console.log(`Created PR #${pull.number}: ${pull.html_url || pull.url}`);
|
||||
}
|
||||
|
||||
async function viewPull(flags) {
|
||||
const number = requiredFlag(flags, "number");
|
||||
const pull = await apiRequest("GET", repoRoute(`/pulls/${encodeURIComponent(number)}`));
|
||||
console.log(
|
||||
[
|
||||
`PR #${pull.number}: ${pull.title}`,
|
||||
`URL: ${pull.html_url || pull.url}`,
|
||||
`State: ${pull.state}${pull.merged ? " (merged)" : ""}`,
|
||||
`Base: ${pull.base?.ref || "(unknown)"}`,
|
||||
`Head: ${pull.head?.ref || "(unknown)"}`,
|
||||
`Mergeable: ${String(pull.mergeable)}`,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
async function updatePull(flags) {
|
||||
const number = requiredFlag(flags, "number");
|
||||
const payload = {};
|
||||
|
||||
if (flags.title && flags.title !== true) {
|
||||
payload.title = flags.title;
|
||||
}
|
||||
|
||||
if (flags["body-file"] || typeof flags.body === "string") {
|
||||
payload.body = readBody(flags);
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
fail("Nothing to update; pass --title, --body, or --body-file");
|
||||
}
|
||||
|
||||
const pull = await apiRequest(
|
||||
"PATCH",
|
||||
repoRoute(`/pulls/${encodeURIComponent(number)}`),
|
||||
payload
|
||||
);
|
||||
|
||||
console.log(`Updated PR #${pull.number}: ${pull.html_url || pull.url}`);
|
||||
}
|
||||
|
||||
async function mergePull(flags) {
|
||||
const number = requiredFlag(flags, "number");
|
||||
if (!flags.yes) {
|
||||
fail("Refusing to merge without --yes");
|
||||
}
|
||||
|
||||
const method = flags.method && flags.method !== true ? flags.method : "merge";
|
||||
const allowedMethods = new Set(["merge", "squash", "rebase"]);
|
||||
if (!allowedMethods.has(method)) {
|
||||
fail("--method must be one of: merge, squash, rebase");
|
||||
}
|
||||
|
||||
const pull = await apiRequest("GET", repoRoute(`/pulls/${encodeURIComponent(number)}`));
|
||||
if (pull.state !== "open") {
|
||||
fail(`Refusing to merge PR #${number}; state is ${pull.state}`);
|
||||
}
|
||||
if (pull.merged) {
|
||||
fail(`PR #${number} is already merged`);
|
||||
}
|
||||
|
||||
await apiRequest("POST", repoRoute(`/pulls/${encodeURIComponent(number)}/merge`), {
|
||||
Do: method,
|
||||
delete_branch_after_merge: Boolean(flags["delete-branch"]),
|
||||
});
|
||||
|
||||
console.log(`Merged PR #${number} with method ${method}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
const { flags } = parseFlags(rest);
|
||||
|
||||
switch (command) {
|
||||
case undefined:
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
usage();
|
||||
break;
|
||||
case "auth-check":
|
||||
await authCheck();
|
||||
break;
|
||||
case "create":
|
||||
await createPull(flags);
|
||||
break;
|
||||
case "view":
|
||||
await viewPull(flags);
|
||||
break;
|
||||
case "update":
|
||||
await updatePull(flags);
|
||||
break;
|
||||
case "merge":
|
||||
await mergePull(flags);
|
||||
break;
|
||||
default:
|
||||
fail(`Unknown command: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
fail(error.stack || error.message || String(error));
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user