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)
|
# Environment variables (DO NOT COMMIT)
|
||||||
.env
|
.env
|
||||||
|
.codex-local.env
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@ -19,6 +19,15 @@ If anything conflicts, follow **this** doc.
|
|||||||
- Dev/Prod share schema via migrations in: `packages/db/migrations`.
|
- Dev/Prod share schema via migrations in: `packages/db/migrations`.
|
||||||
- Active migration runbook: `docs/DB_MIGRATION_WORKFLOW.md` (active set + status commands).
|
- Active migration runbook: `docs/DB_MIGRATION_WORKFLOW.md` (active set + status commands).
|
||||||
|
|
||||||
|
### Docker dev runtime
|
||||||
|
- After backend/API code changes while using `docker-compose.dev.yml`, rebuild and restart only the backend service:
|
||||||
|
- `docker compose -f docker-compose.dev.yml up -d --build backend`
|
||||||
|
- After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded:
|
||||||
|
- `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend`
|
||||||
|
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include the exact browser origin, for example `http://localhost:3010` and `http://127.0.0.1:3010`.
|
||||||
|
- Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`.
|
||||||
|
- Do not print or commit real `.env` values while checking or updating local Docker env.
|
||||||
|
|
||||||
### No background jobs
|
### No background jobs
|
||||||
- **No cron/worker jobs**. Any fix must work without background tasks.
|
- **No cron/worker jobs**. Any fix must work without background tasks.
|
||||||
|
|
||||||
@ -192,14 +201,90 @@ Usage rules:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12) Commit Discipline (required)
|
## 12) Git Intake, Branching, Commit, and PR Discipline (required)
|
||||||
|
|
||||||
|
### Read-only intake before editing
|
||||||
|
Before editing, run this read-only intake:
|
||||||
|
- `git status --short --branch`
|
||||||
|
- `git branch -vv`
|
||||||
|
- `git log --oneline --decorate -8`
|
||||||
|
- `git ls-files --others --exclude-standard`
|
||||||
|
- Check current PR status when GitHub CLI is available.
|
||||||
|
- Check open PRs for overlapping work before editing shared or collision-prone areas.
|
||||||
|
|
||||||
|
### Branch suitability gate before editing
|
||||||
|
- Continue on the current branch only when the requested work belongs to that branch or PR.
|
||||||
|
- Start independent work from `main` after pulling latest.
|
||||||
|
- Start stacked work from the current PR branch when the work intentionally builds on that PR.
|
||||||
|
- If the current branch purpose does not match the request, stop before editing and switch or create the correct branch.
|
||||||
|
- If either `main` or the current branch could be valid, ask whether the work is independent side work or required follow-on work.
|
||||||
|
- Do not layer unrelated work on top of a dirty worktree.
|
||||||
|
- If unrelated local changes exist, pause and ask how to separate them.
|
||||||
|
|
||||||
|
### Branch creation and naming
|
||||||
|
- Create a descriptive branch before writing code.
|
||||||
|
- Preferred branch prefixes:
|
||||||
|
- `feature/<short-description>`
|
||||||
|
- `bugfix/<short-description>`
|
||||||
|
- `refactor/<short-description>`
|
||||||
|
- `chore/<short-description>`
|
||||||
|
- `spike/<short-description>`
|
||||||
|
- Do not include tracker numbers in branch names.
|
||||||
|
- Use standalone branches from `main` for independent work.
|
||||||
|
- Use stacked branches from the parent PR branch for follow-on work.
|
||||||
|
- Target standalone PRs at `main`.
|
||||||
|
- Target stacked PRs at the parent PR branch.
|
||||||
|
- Never push directly to `main`.
|
||||||
|
|
||||||
|
### Commit discipline
|
||||||
- Treat committing as a first-class part of the workflow: create frequent, verified checkpoint commits for completed work instead of accumulating large uncommitted changes.
|
- Treat committing as a first-class part of the workflow: create frequent, verified checkpoint commits for completed work instead of accumulating large uncommitted changes.
|
||||||
|
- Commit after each coherent logical unit of work.
|
||||||
- Commit in small, logical slices (no broad mixed-purpose commits).
|
- Commit in small, logical slices (no broad mixed-purpose commits).
|
||||||
|
- Before committing:
|
||||||
|
1. Run `git diff --stat`.
|
||||||
|
2. Run relevant tests or checks when practical.
|
||||||
|
3. Stage only files that belong to the logical unit.
|
||||||
|
4. Run `git diff --cached --stat`.
|
||||||
|
5. Commit with an imperative, present-tense subject at or below 72 characters.
|
||||||
- Each commit must:
|
- Each commit must:
|
||||||
- follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
|
- follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
|
||||||
- include only related files for that slice
|
- include only related files for that slice
|
||||||
- exclude secrets, credentials, and generated noise
|
- exclude secrets, credentials, and generated noise
|
||||||
|
- Do not stage unrelated user or collaborator changes.
|
||||||
|
- Do not start a second unrelated task while the first has uncommitted work.
|
||||||
|
- If existing local changes are external/user changes, leave them untouched unless explicitly told otherwise.
|
||||||
|
- If asked to commit and external changes already exist, commit those separately on the proper branch before starting new work.
|
||||||
- Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas).
|
- Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas).
|
||||||
- Prefer frequent checkpoint commits during agentic work rather than one large end-state commit.
|
- Prefer frequent checkpoint commits during agentic work rather than one large end-state commit.
|
||||||
- Before switching tasks or stopping after a completed change, check git status and either commit the finished slice or clearly document why it remains uncommitted.
|
- Before switching tasks or stopping after a completed change, check git status and either commit the finished slice or clearly document why it remains uncommitted.
|
||||||
- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code).
|
- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code).
|
||||||
|
|
||||||
|
### Push and PR coordination
|
||||||
|
- Push the branch before opening a PR.
|
||||||
|
- For this Gitea repo, use `docs/GITEA_PR_WORKFLOW.md` and `scripts/gitea-pr.js` for PR creation, lookup, and merge operations.
|
||||||
|
- PR tooling must read auth from `GITEA_TOKEN`/`GITEA_BASE_URL` shell environment or ignored `.codex-local.env` only; never commit tokens or print token values.
|
||||||
|
- Open a draft PR early for non-trivial, collision-prone, or multi-agent work once the first coherent commit exists.
|
||||||
|
- Use the PR body as the coordination record:
|
||||||
|
- `Owner:`
|
||||||
|
- `Status: proposed / in-progress / blocked / review / done`
|
||||||
|
- `Branch:`
|
||||||
|
- `Branch relationship: standalone from main / stacked on parent branch / continuing existing branch`
|
||||||
|
- `Likely modified areas:`
|
||||||
|
- `Actual modified files:`
|
||||||
|
- `Collision risk: low / medium / high`
|
||||||
|
- `Last meaningful update:`
|
||||||
|
- Collision risk levels:
|
||||||
|
- Low: isolated docs, tests, or one leaf component.
|
||||||
|
- Medium: shared stores/types, panels/components, handlers, helpers.
|
||||||
|
- High: interface contracts, broad app flows, core registries, cross-cutting behavior.
|
||||||
|
- If a branch already contains assigned feature work and has no current PR, stop before adding more feature commits. Push the branch and open a draft PR, or record the GitHub/auth blocker.
|
||||||
|
- Before writing or updating the final PR body, inspect:
|
||||||
|
- `git log <base>..HEAD`
|
||||||
|
- `git diff --stat <base>..HEAD`
|
||||||
|
- The PR should describe the cumulative branch diff against the target branch, not only the latest commit.
|
||||||
|
- Include:
|
||||||
|
- Summary of functional changes.
|
||||||
|
- Tests run, or a clear reason tests were not run.
|
||||||
|
- For broad branches, organize the summary by subsystem, workflow, or behavior area.
|
||||||
|
- Do not use auto-closing keywords such as `Closes`, `Fixes`, or `Resolves`.
|
||||||
|
- Merge PRs only after explicit operator approval, required checks, and a final `npm run pr:view -- --number <pr-number>` status check.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
const AvailableItems = require("../models/available-item.model");
|
const AvailableItems = require("../models/available-item.model");
|
||||||
const List = require("../models/list.model.v2");
|
const List = require("../models/list.model.v2");
|
||||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
const { isValidItemType, isValidItemGroup } = require("../constants/classifications");
|
||||||
const { sendError } = require("../utils/http");
|
const { sendError } = require("../utils/http");
|
||||||
const { logError } = require("../utils/logger");
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
@ -13,6 +13,10 @@ function parseBoolean(value) {
|
|||||||
return value === true || value === "true" || value === "1";
|
return value === true || value === "true" || value === "1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoreLocationId(req) {
|
||||||
|
return req.params.locationId || req.params.storeId;
|
||||||
|
}
|
||||||
|
|
||||||
function isCatalogTableMissing(error) {
|
function isCatalogTableMissing(error) {
|
||||||
return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || "");
|
return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || "");
|
||||||
}
|
}
|
||||||
@ -84,7 +88,7 @@ function normalizeClassificationPayload(classification) {
|
|||||||
return { item_type, item_group, zone };
|
return { item_type, item_group, zone };
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateClassification(res, classification) {
|
async function validateClassification(res, householdId, storeLocationId, classification) {
|
||||||
if (!classification) {
|
if (!classification) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -106,10 +110,13 @@ function validateClassification(res, classification) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (zone && !isValidZone(zone)) {
|
if (zone) {
|
||||||
|
const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone);
|
||||||
|
if (!zoneRecord) {
|
||||||
sendError(res, 400, "Invalid zone");
|
sendError(res, 400, "Invalid zone");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -121,8 +128,13 @@ function parseItemId(value) {
|
|||||||
|
|
||||||
exports.getAvailableItems = async (req, res) => {
|
exports.getAvailableItems = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || "");
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const items = await AvailableItems.listAvailableItems(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
req.query.query || ""
|
||||||
|
);
|
||||||
res.json({ items, catalog_ready: true });
|
res.json({ items, catalog_ready: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isCatalogTableMissing(error)) {
|
if (isCatalogTableMissing(error)) {
|
||||||
@ -139,7 +151,8 @@ exports.getAvailableItems = async (req, res) => {
|
|||||||
|
|
||||||
exports.createAvailableItem = async (req, res) => {
|
exports.createAvailableItem = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name } = req.body;
|
const { item_name } = req.body;
|
||||||
|
|
||||||
if (!item_name || item_name.trim() === "") {
|
if (!item_name || item_name.trim() === "") {
|
||||||
@ -152,7 +165,7 @@ exports.createAvailableItem = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
||||||
if (validateClassification(res, normalizedClassification)) {
|
if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,21 +174,37 @@ exports.createAvailableItem = async (req, res) => {
|
|||||||
|
|
||||||
const item = await AvailableItems.createAvailableItem(
|
const item = await AvailableItems.createAvailableItem(
|
||||||
householdId,
|
householdId,
|
||||||
storeId,
|
storeLocationId,
|
||||||
item_name,
|
item_name,
|
||||||
imageBuffer,
|
imageBuffer,
|
||||||
mimeType
|
mimeType,
|
||||||
|
req.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (normalizedClassification) {
|
if (normalizedClassification) {
|
||||||
await List.upsertClassification(householdId, storeId, item.item_id, {
|
await List.upsertClassification(householdId, storeLocationId, item.item_id, {
|
||||||
...normalizedClassification,
|
...normalizedClassification,
|
||||||
confidence: 1.0,
|
confidence: 1.0,
|
||||||
source: "user",
|
source: "user",
|
||||||
});
|
});
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: item.item_id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_CLASSIFICATION_CHANGED",
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
...normalizedClassification,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshedItem = await AvailableItems.getAvailableItemById(householdId, storeId, item.item_id);
|
const refreshedItem = await AvailableItems.getAvailableItemById(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
item.item_id
|
||||||
|
);
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: "Available item added",
|
message: "Available item added",
|
||||||
@ -199,7 +228,8 @@ exports.createAvailableItem = async (req, res) => {
|
|||||||
|
|
||||||
exports.updateAvailableItem = async (req, res) => {
|
exports.updateAvailableItem = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId, itemId: rawItemId } = req.params;
|
const { householdId, itemId: rawItemId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const itemId = parseItemId(rawItemId);
|
const itemId = parseItemId(rawItemId);
|
||||||
|
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
@ -214,15 +244,19 @@ exports.updateAvailableItem = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
||||||
if (normalizedClassification && validateClassification(res, normalizedClassification)) {
|
if (
|
||||||
|
normalizedClassification &&
|
||||||
|
(await validateClassification(res, householdId, storeLocationId, normalizedClassification))
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeId, itemId, {
|
const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeLocationId, itemId, {
|
||||||
itemName: req.body.item_name,
|
itemName: req.body.item_name,
|
||||||
imageBuffer: req.processedImage?.buffer || null,
|
imageBuffer: req.processedImage?.buffer || null,
|
||||||
mimeType: req.processedImage?.mimeType || null,
|
mimeType: req.processedImage?.mimeType || null,
|
||||||
removeImage: parseBoolean(req.body.remove_image),
|
removeImage: parseBoolean(req.body.remove_image),
|
||||||
|
userId: req.user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updatedItem) {
|
if (!updatedItem) {
|
||||||
@ -231,19 +265,30 @@ exports.updateAvailableItem = async (req, res) => {
|
|||||||
|
|
||||||
if (hasClassificationField) {
|
if (hasClassificationField) {
|
||||||
if (normalizedClassification) {
|
if (normalizedClassification) {
|
||||||
await List.upsertClassification(householdId, storeId, updatedItem.item_id, {
|
await List.upsertClassification(householdId, storeLocationId, updatedItem.item_id, {
|
||||||
...normalizedClassification,
|
...normalizedClassification,
|
||||||
confidence: 1.0,
|
confidence: 1.0,
|
||||||
source: "user",
|
source: "user",
|
||||||
});
|
});
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: updatedItem.item_id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_CLASSIFICATION_CHANGED",
|
||||||
|
metadata: {
|
||||||
|
item_name: updatedItem.item_name,
|
||||||
|
...normalizedClassification,
|
||||||
|
},
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await List.deleteClassification(householdId, storeId, updatedItem.item_id);
|
await List.deleteClassification(householdId, storeLocationId, updatedItem.item_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshedItem = await AvailableItems.getAvailableItemById(
|
const refreshedItem = await AvailableItems.getAvailableItemById(
|
||||||
householdId,
|
householdId,
|
||||||
storeId,
|
storeLocationId,
|
||||||
updatedItem.item_id
|
updatedItem.item_id
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -269,14 +314,20 @@ exports.updateAvailableItem = async (req, res) => {
|
|||||||
|
|
||||||
exports.deleteAvailableItem = async (req, res) => {
|
exports.deleteAvailableItem = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId, itemId: rawItemId } = req.params;
|
const { householdId, itemId: rawItemId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const itemId = parseItemId(rawItemId);
|
const itemId = parseItemId(rawItemId);
|
||||||
|
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
return sendError(res, 400, "Item ID must be a positive integer");
|
return sendError(res, 400, "Item ID must be a positive integer");
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId);
|
const deleted = await AvailableItems.deleteAvailableItem(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
itemId,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
return sendError(res, 404, "Store item not found");
|
return sendError(res, 404, "Store item not found");
|
||||||
@ -298,8 +349,9 @@ exports.deleteAvailableItem = async (req, res) => {
|
|||||||
|
|
||||||
exports.importCurrentItems = async (req, res) => {
|
exports.importCurrentItems = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeId);
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeLocationId);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: importedCount > 0 ? "Imported current list items" : "No current list items to import",
|
message: importedCount > 0 ? "Imported current list items" : "No current list items to import",
|
||||||
|
|||||||
@ -14,6 +14,42 @@ exports.getUserHouseholds = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.reorderHouseholds = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rawHouseholdIds = req.body.household_ids || req.body.householdIds;
|
||||||
|
|
||||||
|
if (!Array.isArray(rawHouseholdIds)) {
|
||||||
|
return sendError(res, 400, "household_ids must be an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const householdIds = rawHouseholdIds.map((householdId) =>
|
||||||
|
Number.parseInt(householdId, 10)
|
||||||
|
);
|
||||||
|
const hasInvalidId = householdIds.some(
|
||||||
|
(householdId) => !Number.isInteger(householdId) || householdId <= 0
|
||||||
|
);
|
||||||
|
const hasDuplicates = new Set(householdIds).size !== householdIds.length;
|
||||||
|
|
||||||
|
if (hasInvalidId || hasDuplicates) {
|
||||||
|
return sendError(res, 400, "household_ids must contain unique positive household IDs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const households = await householdModel.reorderUserHouseholds(req.user.id, householdIds);
|
||||||
|
|
||||||
|
if (!households) {
|
||||||
|
return sendError(res, 400, "Household order must include every household you belong to");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "Household order updated successfully",
|
||||||
|
households,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "households.reorderHouseholds", error);
|
||||||
|
sendError(res, 500, "Failed to update household order");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Get household details
|
// Get household details
|
||||||
exports.getHousehold = async (req, res) => {
|
exports.getHousehold = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
const List = require("../models/list.model.v2");
|
const List = require("../models/list.model.v2");
|
||||||
const householdModel = require("../models/household.model");
|
const householdModel = require("../models/household.model");
|
||||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
const { isValidItemType, isValidItemGroup } = require("../constants/classifications");
|
||||||
const { sendError } = require("../utils/http");
|
const { sendError } = require("../utils/http");
|
||||||
const { logError } = require("../utils/logger");
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
@ -9,6 +9,10 @@ const LEGACY_ITEM_TYPE_MAP = {
|
|||||||
snacks: "snack",
|
snacks: "snack",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getStoreLocationId(req) {
|
||||||
|
return req.params.locationId || req.params.storeId;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeClassificationPayload(classification) {
|
function normalizeClassificationPayload(classification) {
|
||||||
if (typeof classification === "string") {
|
if (typeof classification === "string") {
|
||||||
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
||||||
@ -43,14 +47,40 @@ function normalizeClassificationPayload(classification) {
|
|||||||
return { item_type, item_group, zone };
|
return { item_type, item_group, zone };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function validateClassification(res, householdId, storeLocationId, classification) {
|
||||||
* Get list items for household and store
|
const { item_type, item_group, zone } = classification;
|
||||||
* GET /households/:householdId/stores/:storeId/list
|
|
||||||
*/
|
if (item_type && !isValidItemType(item_type)) {
|
||||||
|
sendError(res, 400, "Invalid item_type");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !item_type) {
|
||||||
|
sendError(res, 400, "Item type is required when item group is provided");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
||||||
|
sendError(res, 400, "Invalid item_group for selected item_type");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zone) {
|
||||||
|
const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone);
|
||||||
|
if (!zoneRecord) {
|
||||||
|
sendError(res, 400, "Invalid zone");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
exports.getList = async (req, res) => {
|
exports.getList = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
const items = await List.getHouseholdStoreList(householdId, storeId);
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const items = await List.getHouseholdStoreList(householdId, storeLocationId);
|
||||||
res.json({ items });
|
res.json({ items });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.getList", error);
|
logError(req, "listsV2.getList", error);
|
||||||
@ -58,20 +88,17 @@ exports.getList = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get specific item by name
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.getItemByName = async (req, res) => {
|
exports.getItemByName = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name } = req.query;
|
const { item_name } = req.query;
|
||||||
|
|
||||||
if (!item_name) {
|
if (!item_name) {
|
||||||
return sendError(res, 400, "Item name is required");
|
return sendError(res, 400, "Item name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return sendError(res, 404, "Item not found");
|
return sendError(res, 404, "Item not found");
|
||||||
}
|
}
|
||||||
@ -83,13 +110,10 @@ exports.getItemByName = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Add or update item in household store list
|
|
||||||
* POST /households/:householdId/stores/:storeId/list/add
|
|
||||||
*/
|
|
||||||
exports.addItem = async (req, res) => {
|
exports.addItem = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name, quantity, notes, added_for_user_id } = req.body;
|
const { item_name, quantity, notes, added_for_user_id } = req.body;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
let historyUserId = userId;
|
let historyUserId = userId;
|
||||||
@ -118,13 +142,12 @@ exports.addItem = async (req, res) => {
|
|||||||
historyUserId = parsedUserId;
|
historyUserId = parsedUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get processed image if uploaded
|
|
||||||
const imageBuffer = req.processedImage?.buffer || null;
|
const imageBuffer = req.processedImage?.buffer || null;
|
||||||
const mimeType = req.processedImage?.mimeType || null;
|
const mimeType = req.processedImage?.mimeType || null;
|
||||||
|
|
||||||
const result = await List.addOrUpdateItem(
|
const result = await List.addOrUpdateItem(
|
||||||
householdId,
|
householdId,
|
||||||
storeId,
|
storeLocationId,
|
||||||
item_name,
|
item_name,
|
||||||
quantity || "1",
|
quantity || "1",
|
||||||
userId,
|
userId,
|
||||||
@ -133,17 +156,38 @@ exports.addItem = async (req, res) => {
|
|||||||
notes
|
notes
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add history record
|
await List.addHistoryRecord(
|
||||||
await List.addHistoryRecord(result.listId, result.householdStoreItemId, quantity || "1", historyUserId);
|
result.listId,
|
||||||
|
result.householdStoreItemId,
|
||||||
|
result.historyQuantity ?? quantity ?? "1",
|
||||||
|
historyUserId,
|
||||||
|
storeLocationId
|
||||||
|
);
|
||||||
|
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: result.householdStoreItemId,
|
||||||
|
householdListId: result.listId,
|
||||||
|
actorUserId: historyUserId,
|
||||||
|
eventType: "ITEM_ADDED",
|
||||||
|
quantityDelta: result.historyQuantity ?? Number.parseInt(quantity || "1", 10),
|
||||||
|
quantityAfter: result.quantity,
|
||||||
|
metadata: {
|
||||||
|
item_name: result.itemName,
|
||||||
|
is_new_list_item: result.isNew,
|
||||||
|
added_by_request_user_id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: result.isNew ? "Item added" : "Item updated",
|
message: result.isNew ? "Item added" : "Item updated",
|
||||||
item: {
|
item: {
|
||||||
id: result.listId,
|
id: result.listId,
|
||||||
item_name: result.itemName,
|
item_name: result.itemName,
|
||||||
quantity: quantity || "1",
|
quantity: result.quantity ?? quantity ?? "1",
|
||||||
bought: false
|
bought: false,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.addItem", error);
|
logError(req, "listsV2.addItem", error);
|
||||||
@ -151,23 +195,35 @@ exports.addItem = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark item as bought or unbought
|
|
||||||
* PATCH /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.markBought = async (req, res) => {
|
exports.markBought = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name, bought, quantity_bought } = req.body;
|
const { item_name, bought, quantity_bought } = req.body;
|
||||||
|
|
||||||
if (!item_name) return sendError(res, 400, "Item name is required");
|
if (!item_name) return sendError(res, 400, "Item name is required");
|
||||||
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
if (!item) return sendError(res, 404, "Item not found");
|
if (!item) return sendError(res, 404, "Item not found");
|
||||||
|
|
||||||
|
const eventDetails = await List.setBought(item.id, bought, quantity_bought);
|
||||||
|
|
||||||
// Update bought status (with optional partial purchase)
|
if (eventDetails) {
|
||||||
await List.setBought(item.id, bought, quantity_bought);
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: item.household_store_item_id,
|
||||||
|
householdListId: item.id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: eventDetails.eventType,
|
||||||
|
quantityDelta: eventDetails.quantityDelta,
|
||||||
|
quantityAfter: eventDetails.quantityAfter,
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
requested_quantity: quantity_bought || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
|
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -176,27 +232,42 @@ exports.markBought = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update item details (quantity, notes)
|
|
||||||
* PUT /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.updateItem = async (req, res) => {
|
exports.updateItem = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name, quantity, notes } = req.body;
|
const { item_name, quantity, notes } = req.body;
|
||||||
|
|
||||||
if (!item_name) {
|
if (!item_name) {
|
||||||
return sendError(res, 400, "Item name is required");
|
return sendError(res, 400, "Item name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the list item
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return sendError(res, 404, "Item not found");
|
return sendError(res, 404, "Item not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update item
|
const updateResult = await List.updateItem(item.id, item_name, quantity, notes);
|
||||||
await List.updateItem(item.id, item_name, quantity, notes);
|
if (!updateResult) {
|
||||||
|
return sendError(res, 404, "Item not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quantity !== undefined && Number(quantity) !== Number(updateResult.previous.quantity)) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: item.household_store_item_id,
|
||||||
|
householdListId: item.id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_QUANTITY_CHANGED",
|
||||||
|
quantityDelta: Number(quantity) - Number(updateResult.previous.quantity),
|
||||||
|
quantityAfter: Number(quantity),
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
previous_quantity: updateResult.previous.quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Item updated",
|
message: "Item updated",
|
||||||
@ -204,8 +275,8 @@ exports.updateItem = async (req, res) => {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
item_name,
|
item_name,
|
||||||
quantity,
|
quantity,
|
||||||
notes
|
notes,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.updateItem", error);
|
logError(req, "listsV2.updateItem", error);
|
||||||
@ -213,26 +284,36 @@ exports.updateItem = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete item from list
|
|
||||||
* DELETE /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.deleteItem = async (req, res) => {
|
exports.deleteItem = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name } = req.body;
|
const { item_name } = req.body;
|
||||||
|
|
||||||
if (!item_name) {
|
if (!item_name) {
|
||||||
return sendError(res, 400, "Item name is required");
|
return sendError(res, 400, "Item name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the list item
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return sendError(res, 404, "Item not found");
|
return sendError(res, 404, "Item not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await List.deleteItem(item.id);
|
const deleted = await List.deleteItem(item.id);
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: item.household_store_item_id,
|
||||||
|
householdListId: item.id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_DELETED",
|
||||||
|
quantityDelta: -Number(item.quantity || 0),
|
||||||
|
quantityAfter: 0,
|
||||||
|
metadata: { item_name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ message: "Item deleted" });
|
res.json({ message: "Item deleted" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -241,16 +322,13 @@ exports.deleteItem = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get item suggestions based on query
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/suggestions
|
|
||||||
*/
|
|
||||||
exports.getSuggestions = async (req, res) => {
|
exports.getSuggestions = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { query } = req.query;
|
const { query } = req.query;
|
||||||
|
|
||||||
const suggestions = await List.getSuggestions(query || "", householdId, storeId);
|
const suggestions = await List.getSuggestions(query || "", householdId, storeLocationId);
|
||||||
res.json(suggestions);
|
res.json(suggestions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.getSuggestions", error);
|
logError(req, "listsV2.getSuggestions", error);
|
||||||
@ -258,14 +336,11 @@ exports.getSuggestions = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recently bought items
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/recent
|
|
||||||
*/
|
|
||||||
exports.getRecentlyBought = async (req, res) => {
|
exports.getRecentlyBought = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
const items = await List.getRecentlyBoughtItems(householdId, storeId);
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const items = await List.getRecentlyBoughtItems(householdId, storeLocationId);
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.getRecentlyBought", error);
|
logError(req, "listsV2.getRecentlyBought", error);
|
||||||
@ -273,26 +348,26 @@ exports.getRecentlyBought = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get item classification
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/classification
|
|
||||||
*/
|
|
||||||
exports.getClassification = async (req, res) => {
|
exports.getClassification = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name } = req.query;
|
const { item_name } = req.query;
|
||||||
|
|
||||||
if (!item_name) {
|
if (!item_name) {
|
||||||
return sendError(res, 400, "Item name is required");
|
return sendError(res, 400, "Item name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get item ID from name
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return res.json({ classification: null });
|
return res.json({ classification: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
const classification = await List.getClassification(householdId, storeId, item.item_id);
|
const classification = await List.getClassification(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
item.item_id
|
||||||
|
);
|
||||||
res.json({ classification });
|
res.json({ classification });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.getClassification", error);
|
logError(req, "listsV2.getClassification", error);
|
||||||
@ -300,13 +375,10 @@ exports.getClassification = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Set/update item classification
|
|
||||||
* POST /households/:householdId/stores/:storeId/list/classification
|
|
||||||
*/
|
|
||||||
exports.setClassification = async (req, res) => {
|
exports.setClassification = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name, classification } = req.body;
|
const { item_name, classification } = req.body;
|
||||||
|
|
||||||
if (!item_name) {
|
if (!item_name) {
|
||||||
@ -318,33 +390,17 @@ exports.setClassification = async (req, res) => {
|
|||||||
return sendError(res, 400, "Classification is required");
|
return sendError(res, 400, "Classification is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { item_type, item_group, zone } = normalizedClassification;
|
if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) {
|
||||||
|
return;
|
||||||
if (item_type && !isValidItemType(item_type)) {
|
|
||||||
return sendError(res, 400, "Invalid item_type");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item_group && !item_type) {
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
return sendError(res, 400, "Item type is required when item group is provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
|
||||||
return sendError(res, 400, "Invalid item_group for selected item_type");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zone && !isValidZone(zone)) {
|
|
||||||
return sendError(res, 400, "Invalid zone");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get item - add to master items if not exists
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
let itemId;
|
let itemId;
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
// Item doesn't exist in list, need to get from items table or create
|
|
||||||
const itemResult = await List.ensureHouseholdStoreItem(
|
const itemResult = await List.ensureHouseholdStoreItem(
|
||||||
householdId,
|
householdId,
|
||||||
storeId,
|
storeLocationId,
|
||||||
item_name
|
item_name
|
||||||
);
|
);
|
||||||
itemId = itemResult.id;
|
itemId = itemResult.id;
|
||||||
@ -352,14 +408,43 @@ exports.setClassification = async (req, res) => {
|
|||||||
itemId = item.item_id;
|
itemId = item.item_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await List.upsertClassification(householdId, storeId, itemId, {
|
const updated = await List.upsertClassification(householdId, storeLocationId, itemId, {
|
||||||
item_type,
|
...normalizedClassification,
|
||||||
item_group,
|
|
||||||
zone,
|
|
||||||
confidence: 1.0,
|
confidence: 1.0,
|
||||||
source: "user",
|
source: "user",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: itemId,
|
||||||
|
householdListId: item?.id || null,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_CLASSIFICATION_CHANGED",
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
item_type: normalizedClassification.item_type,
|
||||||
|
item_group: normalizedClassification.item_group,
|
||||||
|
zone: normalizedClassification.zone,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (normalizedClassification.zone) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: itemId,
|
||||||
|
householdListId: item?.id || null,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_ZONE_CHANGED",
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
zone: normalizedClassification.zone,
|
||||||
|
zone_id: updated.zone_id || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ message: "Classification set", classification: normalizedClassification });
|
res.json({ message: "Classification set", classification: normalizedClassification });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.setClassification", error);
|
logError(req, "listsV2.setClassification", error);
|
||||||
@ -367,17 +452,13 @@ exports.setClassification = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update item image
|
|
||||||
* POST /households/:householdId/stores/:storeId/list/update-image
|
|
||||||
*/
|
|
||||||
exports.updateItemImage = async (req, res) => {
|
exports.updateItemImage = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name, quantity } = req.body;
|
const { item_name, quantity } = req.body;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
// Get processed image
|
|
||||||
const imageBuffer = req.processedImage?.buffer || null;
|
const imageBuffer = req.processedImage?.buffer || null;
|
||||||
const mimeType = req.processedImage?.mimeType || null;
|
const mimeType = req.processedImage?.mimeType || null;
|
||||||
|
|
||||||
@ -385,8 +466,15 @@ exports.updateItemImage = async (req, res) => {
|
|||||||
return sendError(res, 400, "No image provided");
|
return sendError(res, 400, "No image provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the item with new image
|
await List.addOrUpdateItem(
|
||||||
await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType);
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
item_name,
|
||||||
|
quantity,
|
||||||
|
userId,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType
|
||||||
|
);
|
||||||
|
|
||||||
res.json({ message: "Image updated successfully" });
|
res.json({ message: "Image updated successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -2,7 +2,20 @@ const storeModel = require("../models/store.model");
|
|||||||
const { sendError } = require("../utils/http");
|
const { sendError } = require("../utils/http");
|
||||||
const { logError } = require("../utils/logger");
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
// Get all available stores
|
function parsePositiveInteger(value) {
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHouseholdId(req) {
|
||||||
|
return req.params.householdId || req.household?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocationId(req) {
|
||||||
|
return req.params.locationId || req.params.storeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy global store catalog. Kept for system-admin compatibility.
|
||||||
exports.getAllStores = async (req, res) => {
|
exports.getAllStores = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const stores = await storeModel.getAllStores();
|
const stores = await storeModel.getAllStores();
|
||||||
@ -13,78 +26,6 @@ exports.getAllStores = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get stores for household
|
|
||||||
exports.getHouseholdStores = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const stores = await storeModel.getHouseholdStores(req.params.householdId);
|
|
||||||
res.json(stores);
|
|
||||||
} catch (error) {
|
|
||||||
logError(req, "stores.getHouseholdStores", error);
|
|
||||||
sendError(res, 500, "Failed to fetch household stores");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add store to household
|
|
||||||
exports.addStoreToHousehold = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { storeId, isDefault } = req.body;
|
|
||||||
// console.log("Adding store to household:", { householdId: req.params.householdId, storeId, isDefault });
|
|
||||||
if (!storeId) {
|
|
||||||
return sendError(res, 400, "Store ID is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = await storeModel.getStoreById(storeId);
|
|
||||||
if (!store) return sendError(res, 404, "Store not found");
|
|
||||||
const foundStores = await storeModel.getHouseholdStores(req.params.householdId);
|
|
||||||
// if (foundStores.length == 0) isDefault = 'true';
|
|
||||||
|
|
||||||
await storeModel.addStoreToHousehold(
|
|
||||||
req.params.householdId,
|
|
||||||
storeId,
|
|
||||||
foundStores.length == 0 ? true : isDefault || false
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
message: "Store added to household successfully",
|
|
||||||
store
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(req, "stores.addStoreToHousehold", error);
|
|
||||||
sendError(res, 500, "Failed to add store to household");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove store from household
|
|
||||||
exports.removeStoreFromHousehold = async (req, res) => {
|
|
||||||
try {
|
|
||||||
await storeModel.removeStoreFromHousehold(
|
|
||||||
req.params.householdId,
|
|
||||||
req.params.storeId
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ message: "Store removed from household successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
logError(req, "stores.removeStoreFromHousehold", error);
|
|
||||||
sendError(res, 500, "Failed to remove store from household");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set default store
|
|
||||||
exports.setDefaultStore = async (req, res) => {
|
|
||||||
try {
|
|
||||||
await storeModel.setDefaultStore(
|
|
||||||
req.params.householdId,
|
|
||||||
req.params.storeId
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ message: "Default store updated successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
logError(req, "stores.setDefaultStore", error);
|
|
||||||
sendError(res, 500, "Failed to set default store");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create store (system admin only)
|
|
||||||
exports.createStore = async (req, res) => {
|
exports.createStore = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, default_zones } = req.body;
|
const { name, default_zones } = req.body;
|
||||||
@ -97,25 +38,24 @@ exports.createStore = async (req, res) => {
|
|||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: "Store created successfully",
|
message: "Store created successfully",
|
||||||
store
|
store,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "stores.createStore", error);
|
logError(req, "stores.createStore", error);
|
||||||
if (error.code === '23505') { // Unique violation
|
if (error.code === "23505") {
|
||||||
return sendError(res, 400, "Store with this name already exists");
|
return sendError(res, 400, "Store with this name already exists");
|
||||||
}
|
}
|
||||||
sendError(res, 500, "Failed to create store");
|
sendError(res, 500, "Failed to create store");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update store (system admin only)
|
|
||||||
exports.updateStore = async (req, res) => {
|
exports.updateStore = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, default_zones } = req.body;
|
const { name, default_zones } = req.body;
|
||||||
|
|
||||||
const store = await storeModel.updateStore(req.params.storeId, {
|
const store = await storeModel.updateStore(req.params.storeId, {
|
||||||
name: name?.trim(),
|
name: name?.trim(),
|
||||||
default_zones
|
default_zones,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!store) {
|
if (!store) {
|
||||||
@ -124,7 +64,7 @@ exports.updateStore = async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Store updated successfully",
|
message: "Store updated successfully",
|
||||||
store
|
store,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "stores.updateStore", error);
|
logError(req, "stores.updateStore", error);
|
||||||
@ -132,16 +72,341 @@ exports.updateStore = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete store (system admin only)
|
|
||||||
exports.deleteStore = async (req, res) => {
|
exports.deleteStore = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await storeModel.deleteStore(req.params.storeId);
|
await storeModel.deleteStore(req.params.storeId);
|
||||||
res.json({ message: "Store deleted successfully" });
|
res.json({ message: "Store deleted successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "stores.deleteStore", error);
|
logError(req, "stores.deleteStore", error);
|
||||||
if (error.message.includes('in use')) {
|
if (error.message.includes("in use")) {
|
||||||
return sendError(res, 400, error.message);
|
return sendError(res, 400, error.message);
|
||||||
}
|
}
|
||||||
sendError(res, 500, "Failed to delete store");
|
sendError(res, 500, "Failed to delete store");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Household-owned store/location management.
|
||||||
|
exports.getHouseholdStores = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stores = await storeModel.getHouseholdStores(getHouseholdId(req));
|
||||||
|
res.json(stores);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.getHouseholdStores", error);
|
||||||
|
sendError(res, 500, "Failed to fetch household stores");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createHouseholdStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const householdId = getHouseholdId(req);
|
||||||
|
const { name, location_name, address } = req.body;
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Store name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await storeModel.createHouseholdStore(
|
||||||
|
householdId,
|
||||||
|
name,
|
||||||
|
location_name || "Default Location",
|
||||||
|
address || null,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Store location created successfully",
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.createHouseholdStore", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Store or location already exists for this household");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to create store location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateHouseholdStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body;
|
||||||
|
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||||
|
|
||||||
|
if (!householdStoreId) {
|
||||||
|
return sendError(res, 400, "Store ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Store name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await storeModel.updateHouseholdStore(getHouseholdId(req), householdStoreId, {
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Store updated successfully", store });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.updateHouseholdStore", error);
|
||||||
|
sendError(res, 500, "Failed to update store");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteHouseholdStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||||
|
if (!householdStoreId) {
|
||||||
|
return sendError(res, 400, "Store ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await storeModel.deleteHouseholdStore(getHouseholdId(req), householdStoreId);
|
||||||
|
if (!deleted) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Store deleted successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.deleteHouseholdStore", error);
|
||||||
|
if (error.message.includes("last store location")) {
|
||||||
|
return sendError(res, 400, error.message);
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to delete store");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.addLocationToStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||||
|
const { name, address } = req.body;
|
||||||
|
|
||||||
|
if (!householdStoreId) {
|
||||||
|
return sendError(res, 400, "Store ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Location name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await storeModel.addLocationToStore(
|
||||||
|
getHouseholdId(req),
|
||||||
|
householdStoreId,
|
||||||
|
name,
|
||||||
|
address || null,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Location added successfully",
|
||||||
|
store: location,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.addLocationToStore", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Location already exists for this store");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to add location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateLocation = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const { name, address, map_data } = req.body;
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await storeModel.updateLocation(getHouseholdId(req), locationId, {
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
map_data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
return sendError(res, 404, "Location not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Location updated successfully", store: location });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.updateLocation", error);
|
||||||
|
sendError(res, 500, "Failed to update location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteLocation = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await storeModel.deleteLocation(getHouseholdId(req), locationId);
|
||||||
|
if (!deleted) {
|
||||||
|
return sendError(res, 404, "Location not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Location removed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.deleteLocation", error);
|
||||||
|
if (error.message.includes("last store location")) {
|
||||||
|
return sendError(res, 400, error.message);
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to remove location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setDefaultLocation = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeModel.setDefaultLocation(getHouseholdId(req), locationId);
|
||||||
|
res.json({ message: "Default location updated successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.setDefaultLocation", error);
|
||||||
|
sendError(res, 500, "Failed to set default location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getLocationZones = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const zones = await storeModel.listLocationZones(getHouseholdId(req), locationId);
|
||||||
|
res.json({ zones });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.getLocationZones", error);
|
||||||
|
sendError(res, 500, "Failed to load zones");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createZone = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const { name, sort_order, color, map_metadata } = req.body;
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Zone name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const zone = await storeModel.createZone(getHouseholdId(req), locationId, {
|
||||||
|
name,
|
||||||
|
sort_order: Number.isInteger(sort_order) ? sort_order : Number.parseInt(sort_order, 10),
|
||||||
|
color,
|
||||||
|
map_metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ message: "Zone created successfully", zone });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.createZone", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Zone already exists for this location");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to create zone");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateZone = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const zoneId = parsePositiveInteger(req.params.zoneId);
|
||||||
|
|
||||||
|
if (!locationId || !zoneId) {
|
||||||
|
return sendError(res, 400, "Location ID and zone ID must be positive integers");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrder = req.body.sort_order;
|
||||||
|
const zone = await storeModel.updateZone(getHouseholdId(req), locationId, zoneId, {
|
||||||
|
name: req.body.name,
|
||||||
|
sort_order:
|
||||||
|
sortOrder === undefined
|
||||||
|
? undefined
|
||||||
|
: Number.isInteger(sortOrder)
|
||||||
|
? sortOrder
|
||||||
|
: Number.parseInt(sortOrder, 10),
|
||||||
|
color: req.body.color,
|
||||||
|
map_metadata: req.body.map_metadata,
|
||||||
|
is_active: req.body.is_active,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!zone) {
|
||||||
|
return sendError(res, 404, "Zone not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Zone updated successfully", zone });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.updateZone", error);
|
||||||
|
sendError(res, 500, "Failed to update zone");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteZone = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const zoneId = parsePositiveInteger(req.params.zoneId);
|
||||||
|
|
||||||
|
if (!locationId || !zoneId) {
|
||||||
|
return sendError(res, 400, "Location ID and zone ID must be positive integers");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await storeModel.deleteZone(getHouseholdId(req), locationId, zoneId);
|
||||||
|
if (!deleted) {
|
||||||
|
return sendError(res, 404, "Zone not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Zone removed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.deleteZone", error);
|
||||||
|
sendError(res, 500, "Failed to remove zone");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backward-compatible handlers for the old /stores/household routes.
|
||||||
|
exports.addStoreToHousehold = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { storeId } = req.body;
|
||||||
|
if (!storeId) {
|
||||||
|
return sendError(res, 400, "Store ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyStore = await storeModel.getStoreById(storeId);
|
||||||
|
if (!legacyStore) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await storeModel.createHouseholdStore(
|
||||||
|
getHouseholdId(req),
|
||||||
|
legacyStore.name,
|
||||||
|
"Default Location",
|
||||||
|
null,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Store added to household successfully",
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.addStoreToHousehold", error);
|
||||||
|
sendError(res, 500, "Failed to add store to household");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.removeStoreFromHousehold = exports.deleteLocation;
|
||||||
|
exports.setDefaultStore = exports.setDefaultLocation;
|
||||||
|
|||||||
@ -8,27 +8,30 @@ const { logError } = require("../utils/logger");
|
|||||||
async function auth(req, res, next) {
|
async function auth(req, res, next) {
|
||||||
const header = req.headers.authorization || "";
|
const header = req.headers.authorization || "";
|
||||||
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null;
|
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null;
|
||||||
|
const cookies = parseCookieHeader(req.headers.cookie);
|
||||||
|
const sid = cookies[cookieName()];
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const jwtSecret = process.env.JWT_SECRET;
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
if (!jwtSecret) {
|
if (!jwtSecret && !sid) {
|
||||||
logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured"));
|
logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured"));
|
||||||
return sendError(res, 500, "Authentication is unavailable");
|
return sendError(res, 500, "Authentication is unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (jwtSecret) {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, jwtSecret);
|
const decoded = jwt.verify(token, jwtSecret);
|
||||||
req.user = decoded; // id + role
|
req.user = decoded; // id + role
|
||||||
return next();
|
return next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!sid) {
|
||||||
return sendError(res, 401, "Invalid or expired token");
|
return sendError(res, 401, "Invalid or expired token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cookies = parseCookieHeader(req.headers.cookie);
|
|
||||||
const sid = cookies[cookieName()];
|
|
||||||
|
|
||||||
if (!sid) {
|
if (!sid) {
|
||||||
return sendError(res, 401, "Missing authentication");
|
return sendError(res, 401, "Missing authentication");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,6 +90,43 @@ exports.storeAccess = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Middleware to check location access (household must own the store location)
|
||||||
|
exports.locationAccess = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const locationId = parseInt(req.params.locationId || req.params.storeId);
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.household) {
|
||||||
|
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeModel = require("../models/store.model");
|
||||||
|
const hasLocation = await storeModel.householdHasLocation(req.household.id, locationId);
|
||||||
|
|
||||||
|
if (!hasLocation) {
|
||||||
|
return sendError(res, 403, "This household does not have access to this store location.");
|
||||||
|
}
|
||||||
|
|
||||||
|
req.storeLocation = {
|
||||||
|
id: locationId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep req.store populated so older controller code and tests can continue
|
||||||
|
// to refer to the active shopping scope as a store.
|
||||||
|
req.store = {
|
||||||
|
id: locationId
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "middleware.locationAccess", error);
|
||||||
|
sendError(res, 500, "Server error checking location access");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Middleware to require system admin role
|
// Middleware to require system admin role
|
||||||
exports.requireSystemAdmin = (req, res, next) => {
|
exports.requireSystemAdmin = (req, res, next) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
|
|||||||
@ -10,16 +10,14 @@ async function optionalAuth(req, res, next) {
|
|||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const jwtSecret = process.env.JWT_SECRET;
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
if (!jwtSecret) {
|
if (jwtSecret) {
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, jwtSecret);
|
const decoded = jwt.verify(token, jwtSecret);
|
||||||
req.user = decoded;
|
req.user = decoded;
|
||||||
return next();
|
return next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return next();
|
// Continue to the session cookie fallback below.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,81 +1,97 @@
|
|||||||
const pool = require("../db/pool");
|
const pool = require("../db/pool");
|
||||||
|
const List = require("./list.model.v2");
|
||||||
|
|
||||||
function normalizeItemName(itemName) {
|
function normalizeItemName(itemName) {
|
||||||
return String(itemName || "").trim().toLowerCase();
|
return String(itemName || "").trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getHouseholdStoreItemRecord(householdId, storeId, itemId) {
|
async function getHouseholdStoreItemRecord(householdId, storeLocationId, itemId) {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`WITH latest_list_items AS (
|
`WITH latest_list_items AS (
|
||||||
SELECT DISTINCT ON (hl.household_store_item_id)
|
SELECT DISTINCT ON (hl.household_store_item_id)
|
||||||
hl.household_store_item_id,
|
hl.household_store_item_id,
|
||||||
|
hl.image_id,
|
||||||
hl.custom_image,
|
hl.custom_image,
|
||||||
hl.custom_image_mime_type,
|
hl.custom_image_mime_type,
|
||||||
hl.modified_on,
|
hl.modified_on,
|
||||||
hl.id
|
hl.id
|
||||||
FROM household_lists hl
|
FROM household_lists hl
|
||||||
WHERE hl.household_id = $1
|
WHERE hl.household_id = $1
|
||||||
AND hl.store_id = $2
|
AND hl.store_location_id = $2
|
||||||
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
|
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
hsi.id AS item_id,
|
hsi.id AS item_id,
|
||||||
hsi.name AS item_name,
|
hsi.name AS item_name,
|
||||||
ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image,
|
ENCODE(
|
||||||
COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
|
COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image),
|
||||||
|
'base64'
|
||||||
|
) AS item_image,
|
||||||
|
COALESCE(
|
||||||
|
catalog_img.mime_type,
|
||||||
|
hsi.custom_image_mime_type,
|
||||||
|
list_img.mime_type,
|
||||||
|
lli.custom_image_mime_type
|
||||||
|
) AS image_mime_type,
|
||||||
hic.item_type,
|
hic.item_type,
|
||||||
hic.item_group,
|
hic.item_group,
|
||||||
hic.zone
|
COALESCE(slz.name, hic.zone) AS zone,
|
||||||
|
slz.sort_order AS zone_sort_order
|
||||||
FROM household_store_items hsi
|
FROM household_store_items hsi
|
||||||
|
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||||
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
|
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
|
||||||
|
LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id
|
||||||
LEFT JOIN household_item_classifications hic
|
LEFT JOIN household_item_classifications hic
|
||||||
ON hic.household_id = hsi.household_id
|
ON hic.household_id = hsi.household_id
|
||||||
AND hic.store_id = hsi.store_id
|
AND hic.store_location_id = hsi.store_location_id
|
||||||
AND hic.household_store_item_id = hsi.id
|
AND hic.household_store_item_id = hsi.id
|
||||||
|
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||||
WHERE hsi.household_id = $1
|
WHERE hsi.household_id = $1
|
||||||
AND hsi.store_id = $2
|
AND hsi.store_location_id = $2
|
||||||
AND hsi.id = $3`,
|
AND hsi.id = $3`,
|
||||||
[householdId, storeId, itemId]
|
[householdId, storeLocationId, itemId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findOrCreateHouseholdStoreItem(householdId, storeId, itemName) {
|
async function findOrCreateHouseholdStoreItem(householdId, storeLocationId, itemName) {
|
||||||
const normalizedName = normalizeItemName(itemName);
|
const normalizedName = normalizeItemName(itemName);
|
||||||
const existing = await pool.query(
|
const existing = await pool.query(
|
||||||
`SELECT id, name
|
`SELECT id, name
|
||||||
FROM household_store_items
|
FROM household_store_items
|
||||||
WHERE household_id = $1
|
WHERE household_id = $1
|
||||||
AND store_id = $2
|
AND store_location_id = $2
|
||||||
AND normalized_name = $3`,
|
AND normalized_name = $3`,
|
||||||
[householdId, storeId, normalizedName]
|
[householdId, storeLocationId, normalizedName]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existing.rowCount > 0) {
|
if (existing.rowCount > 0) {
|
||||||
return {
|
return {
|
||||||
itemId: existing.rows[0].id,
|
itemId: existing.rows[0].id,
|
||||||
itemName: existing.rows[0].name,
|
itemName: existing.rows[0].name,
|
||||||
|
isNew: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await pool.query(
|
const created = await pool.query(
|
||||||
`INSERT INTO household_store_items
|
`INSERT INTO household_store_items
|
||||||
(household_id, store_id, name, normalized_name, updated_at)
|
(household_id, store_location_id, name, normalized_name, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, NOW())
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
RETURNING id, name`,
|
RETURNING id, name`,
|
||||||
[householdId, storeId, normalizedName, normalizedName]
|
[householdId, storeLocationId, normalizedName, normalizedName]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
itemId: created.rows[0].id,
|
itemId: created.rows[0].id,
|
||||||
itemName: created.rows[0].name,
|
itemName: created.rows[0].name,
|
||||||
|
isNew: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.listAvailableItems = async (householdId, storeId, query = "") => {
|
exports.listAvailableItems = async (householdId, storeLocationId, query = "") => {
|
||||||
const trimmedQuery = String(query || "").trim();
|
const trimmedQuery = String(query || "").trim();
|
||||||
const values = [householdId, storeId];
|
const values = [householdId, storeLocationId];
|
||||||
let filterClause = "";
|
let filterClause = "";
|
||||||
|
|
||||||
if (trimmedQuery) {
|
if (trimmedQuery) {
|
||||||
@ -87,35 +103,49 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => {
|
|||||||
`WITH latest_list_items AS (
|
`WITH latest_list_items AS (
|
||||||
SELECT DISTINCT ON (hl.household_store_item_id)
|
SELECT DISTINCT ON (hl.household_store_item_id)
|
||||||
hl.household_store_item_id,
|
hl.household_store_item_id,
|
||||||
|
hl.image_id,
|
||||||
hl.custom_image,
|
hl.custom_image,
|
||||||
hl.custom_image_mime_type,
|
hl.custom_image_mime_type,
|
||||||
hl.modified_on,
|
hl.modified_on,
|
||||||
hl.id
|
hl.id
|
||||||
FROM household_lists hl
|
FROM household_lists hl
|
||||||
WHERE hl.household_id = $1
|
WHERE hl.household_id = $1
|
||||||
AND hl.store_id = $2
|
AND hl.store_location_id = $2
|
||||||
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
|
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
hsi.id AS item_id,
|
hsi.id AS item_id,
|
||||||
hsi.name AS item_name,
|
hsi.name AS item_name,
|
||||||
ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image,
|
ENCODE(
|
||||||
COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
|
COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image),
|
||||||
|
'base64'
|
||||||
|
) AS item_image,
|
||||||
|
COALESCE(
|
||||||
|
catalog_img.mime_type,
|
||||||
|
hsi.custom_image_mime_type,
|
||||||
|
list_img.mime_type,
|
||||||
|
lli.custom_image_mime_type
|
||||||
|
) AS image_mime_type,
|
||||||
hic.item_type,
|
hic.item_type,
|
||||||
hic.item_group,
|
hic.item_group,
|
||||||
hic.zone,
|
COALESCE(slz.name, hic.zone) AS zone,
|
||||||
|
slz.sort_order AS zone_sort_order,
|
||||||
(
|
(
|
||||||
hsi.custom_image IS NOT NULL
|
hsi.image_id IS NOT NULL
|
||||||
|
OR hsi.custom_image IS NOT NULL
|
||||||
OR hic.household_store_item_id IS NOT NULL
|
OR hic.household_store_item_id IS NOT NULL
|
||||||
) AS has_managed_settings
|
) AS has_managed_settings
|
||||||
FROM household_store_items hsi
|
FROM household_store_items hsi
|
||||||
|
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||||
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
|
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
|
||||||
|
LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id
|
||||||
LEFT JOIN household_item_classifications hic
|
LEFT JOIN household_item_classifications hic
|
||||||
ON hic.household_id = hsi.household_id
|
ON hic.household_id = hsi.household_id
|
||||||
AND hic.store_id = hsi.store_id
|
AND hic.store_location_id = hsi.store_location_id
|
||||||
AND hic.household_store_item_id = hsi.id
|
AND hic.household_store_item_id = hsi.id
|
||||||
|
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||||
WHERE hsi.household_id = $1
|
WHERE hsi.household_id = $1
|
||||||
AND hsi.store_id = $2
|
AND hsi.store_location_id = $2
|
||||||
${filterClause}
|
${filterClause}
|
||||||
ORDER BY hsi.name ASC
|
ORDER BY hsi.name ASC
|
||||||
LIMIT 100`,
|
LIMIT 100`,
|
||||||
@ -125,22 +155,23 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getAvailableItemById = async (householdId, storeId, itemId) =>
|
exports.getAvailableItemById = async (householdId, storeLocationId, itemId) =>
|
||||||
getHouseholdStoreItemRecord(householdId, storeId, itemId);
|
getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
|
||||||
|
|
||||||
exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => {
|
exports.getAvailableItemImageByName = async (householdId, storeLocationId, itemName) => {
|
||||||
const normalizedName = normalizeItemName(itemName);
|
const normalizedName = normalizeItemName(itemName);
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
id AS item_id,
|
hsi.id AS item_id,
|
||||||
name AS item_name,
|
hsi.name AS item_name,
|
||||||
custom_image,
|
COALESCE(img.image, hsi.custom_image) AS custom_image,
|
||||||
custom_image_mime_type
|
COALESCE(img.mime_type, hsi.custom_image_mime_type) AS custom_image_mime_type
|
||||||
FROM household_store_items
|
FROM household_store_items hsi
|
||||||
WHERE household_id = $1
|
LEFT JOIN household_item_images img ON img.id = hsi.image_id
|
||||||
AND store_id = $2
|
WHERE hsi.household_id = $1
|
||||||
AND normalized_name = $3`,
|
AND hsi.store_location_id = $2
|
||||||
[householdId, storeId, normalizedName]
|
AND hsi.normalized_name = $3`,
|
||||||
|
[householdId, storeLocationId, normalizedName]
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
@ -148,39 +179,54 @@ exports.getAvailableItemImageByName = async (householdId, storeId, itemName) =>
|
|||||||
|
|
||||||
exports.createAvailableItem = async (
|
exports.createAvailableItem = async (
|
||||||
householdId,
|
householdId,
|
||||||
storeId,
|
storeLocationId,
|
||||||
itemName,
|
itemName,
|
||||||
imageBuffer = null,
|
imageBuffer = null,
|
||||||
mimeType = null
|
mimeType = null,
|
||||||
|
userId = null
|
||||||
) => {
|
) => {
|
||||||
const { itemId } = await findOrCreateHouseholdStoreItem(householdId, storeId, itemName);
|
const { itemId, isNew } = await findOrCreateHouseholdStoreItem(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
itemName
|
||||||
|
);
|
||||||
|
|
||||||
if (imageBuffer && mimeType) {
|
if (imageBuffer && mimeType) {
|
||||||
await pool.query(
|
await List.setCatalogItemImage(
|
||||||
`UPDATE household_store_items
|
householdId,
|
||||||
SET custom_image = $1,
|
storeLocationId,
|
||||||
custom_image_mime_type = $2,
|
itemId,
|
||||||
updated_at = NOW()
|
imageBuffer,
|
||||||
WHERE id = $3
|
mimeType,
|
||||||
AND household_id = $4
|
userId
|
||||||
AND store_id = $5`,
|
|
||||||
[imageBuffer, mimeType, itemId, householdId, storeId]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getHouseholdStoreItemRecord(householdId, storeId, itemId);
|
if (isNew) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: itemId,
|
||||||
|
actorUserId: userId,
|
||||||
|
eventType: "ITEM_ADDED",
|
||||||
|
metadata: { source: "catalog" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => {
|
exports.updateAvailableItem = async (householdId, storeLocationId, itemId, updates = {}) => {
|
||||||
const {
|
const {
|
||||||
itemName,
|
itemName,
|
||||||
imageBuffer,
|
imageBuffer,
|
||||||
mimeType,
|
mimeType,
|
||||||
removeImage = false,
|
removeImage = false,
|
||||||
|
userId = null,
|
||||||
} = updates;
|
} = updates;
|
||||||
|
|
||||||
const assignments = ["updated_at = NOW()"];
|
const assignments = ["updated_at = NOW()"];
|
||||||
const values = [householdId, storeId, itemId];
|
const values = [householdId, storeLocationId, itemId];
|
||||||
let parameterIndex = values.length;
|
let parameterIndex = values.length;
|
||||||
|
|
||||||
if (itemName !== undefined && String(itemName).trim() !== "") {
|
if (itemName !== undefined && String(itemName).trim() !== "") {
|
||||||
@ -195,22 +241,14 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {})
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (removeImage) {
|
if (removeImage) {
|
||||||
assignments.push("custom_image = NULL", "custom_image_mime_type = NULL");
|
assignments.push("image_id = NULL", "custom_image = NULL", "custom_image_mime_type = NULL");
|
||||||
} else if (imageBuffer && mimeType) {
|
|
||||||
parameterIndex += 1;
|
|
||||||
assignments.push(`custom_image = $${parameterIndex}`);
|
|
||||||
values.push(imageBuffer);
|
|
||||||
|
|
||||||
parameterIndex += 1;
|
|
||||||
assignments.push(`custom_image_mime_type = $${parameterIndex}`);
|
|
||||||
values.push(mimeType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE household_store_items
|
`UPDATE household_store_items
|
||||||
SET ${assignments.join(", ")}
|
SET ${assignments.join(", ")}
|
||||||
WHERE household_id = $1
|
WHERE household_id = $1
|
||||||
AND store_id = $2
|
AND store_location_id = $2
|
||||||
AND id = $3
|
AND id = $3
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
values
|
values
|
||||||
@ -220,53 +258,75 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {})
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getHouseholdStoreItemRecord(householdId, storeId, result.rows[0].id);
|
if (!removeImage && imageBuffer && mimeType) {
|
||||||
|
await List.setCatalogItemImage(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
result.rows[0].id,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getHouseholdStoreItemRecord(householdId, storeLocationId, result.rows[0].id);
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.deleteAvailableItem = async (householdId, storeId, itemId) => {
|
exports.deleteAvailableItem = async (householdId, storeLocationId, itemId, userId = null) => {
|
||||||
|
const item = await getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`DELETE FROM household_store_items
|
`DELETE FROM household_store_items
|
||||||
WHERE household_id = $1
|
WHERE household_id = $1
|
||||||
AND store_id = $2
|
AND store_location_id = $2
|
||||||
AND id = $3`,
|
AND id = $3`,
|
||||||
[householdId, storeId, itemId]
|
[householdId, storeLocationId, itemId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (result.rowCount > 0) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: itemId,
|
||||||
|
actorUserId: userId,
|
||||||
|
eventType: "ITEM_DELETED",
|
||||||
|
metadata: { item_name: item?.item_name || null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return result.rowCount > 0;
|
return result.rowCount > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.importCurrentListItems = async (householdId, storeId) => {
|
exports.importCurrentListItems = async (householdId, storeLocationId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO household_store_items
|
`INSERT INTO household_store_items
|
||||||
(household_id, store_id, name, normalized_name, custom_image, custom_image_mime_type, updated_at)
|
(household_id, store_location_id, name, normalized_name, image_id, updated_at)
|
||||||
SELECT DISTINCT ON (hl.household_store_item_id)
|
SELECT DISTINCT ON (hl.household_store_item_id)
|
||||||
hl.household_id,
|
hl.household_id,
|
||||||
hl.store_id,
|
hl.store_location_id,
|
||||||
hsi.name,
|
hsi.name,
|
||||||
hsi.normalized_name,
|
hsi.normalized_name,
|
||||||
hsi.custom_image,
|
hsi.image_id,
|
||||||
hsi.custom_image_mime_type,
|
|
||||||
NOW()
|
NOW()
|
||||||
FROM household_lists hl
|
FROM household_lists hl
|
||||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||||
WHERE hl.household_id = $1
|
WHERE hl.household_id = $1
|
||||||
AND hl.store_id = $2
|
AND hl.store_location_id = $2
|
||||||
ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING
|
ON CONFLICT (household_id, store_location_id, normalized_name) DO NOTHING
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[householdId, storeId]
|
[householdId, storeLocationId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rowCount;
|
return result.rowCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.hasAvailableItems = async (householdId, storeId) => {
|
exports.hasAvailableItems = async (householdId, storeLocationId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT 1
|
`SELECT 1
|
||||||
FROM household_store_items
|
FROM household_store_items
|
||||||
WHERE household_id = $1
|
WHERE household_id = $1
|
||||||
AND store_id = $2
|
AND store_location_id = $2
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[householdId, storeId]
|
[householdId, storeLocationId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rowCount > 0;
|
return result.rowCount > 0;
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
const pool = require("../db/pool");
|
const pool = require("../db/pool");
|
||||||
|
|
||||||
// Get all households a user belongs to
|
async function queryUserHouseholds(db, userId) {
|
||||||
exports.getUserHouseholds = async (userId) => {
|
const result = await db.query(
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
`SELECT
|
||||||
h.id,
|
h.id,
|
||||||
h.name,
|
h.name,
|
||||||
@ -10,14 +9,65 @@ exports.getUserHouseholds = async (userId) => {
|
|||||||
h.created_at,
|
h.created_at,
|
||||||
hm.role,
|
hm.role,
|
||||||
hm.joined_at,
|
hm.joined_at,
|
||||||
|
hm.household_sort_order,
|
||||||
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
|
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
|
||||||
FROM households h
|
FROM households h
|
||||||
JOIN household_members hm ON h.id = hm.household_id
|
JOIN household_members hm ON h.id = hm.household_id
|
||||||
WHERE hm.user_id = $1
|
WHERE hm.user_id = $1
|
||||||
ORDER BY hm.joined_at DESC`,
|
ORDER BY hm.household_sort_order ASC NULLS LAST, hm.joined_at DESC`,
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all households a user belongs to
|
||||||
|
exports.getUserHouseholds = async (userId) => {
|
||||||
|
return queryUserHouseholds(pool, userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.reorderUserHouseholds = async (userId, householdIds) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const membershipResult = await client.query(
|
||||||
|
`SELECT household_id
|
||||||
|
FROM household_members
|
||||||
|
WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentIds = membershipResult.rows.map((row) => Number(row.household_id));
|
||||||
|
const currentIdSet = new Set(currentIds);
|
||||||
|
|
||||||
|
if (
|
||||||
|
householdIds.length !== currentIds.length ||
|
||||||
|
householdIds.some((householdId) => !currentIdSet.has(householdId))
|
||||||
|
) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, householdId] of householdIds.entries()) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE household_members
|
||||||
|
SET household_sort_order = $1
|
||||||
|
WHERE user_id = $2
|
||||||
|
AND household_id = $3`,
|
||||||
|
[index, userId, householdId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const households = await queryUserHouseholds(client, userId);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return households;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get household by ID (with member check)
|
// Get household by ID (with member check)
|
||||||
@ -169,18 +219,6 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse
|
|||||||
try {
|
try {
|
||||||
await client.query("BEGIN");
|
await client.query("BEGIN");
|
||||||
|
|
||||||
const promoteResult = await client.query(
|
|
||||||
`UPDATE household_members
|
|
||||||
SET role = 'owner'
|
|
||||||
WHERE household_id = $1 AND user_id = $2
|
|
||||||
RETURNING user_id, role`,
|
|
||||||
[householdId, nextOwnerUserId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (promoteResult.rows.length === 0) {
|
|
||||||
throw new Error("TARGET_MEMBER_NOT_FOUND");
|
|
||||||
}
|
|
||||||
|
|
||||||
const demoteResult = await client.query(
|
const demoteResult = await client.query(
|
||||||
`UPDATE household_members
|
`UPDATE household_members
|
||||||
SET role = 'admin'
|
SET role = 'admin'
|
||||||
@ -193,6 +231,18 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse
|
|||||||
throw new Error("CURRENT_OWNER_NOT_FOUND");
|
throw new Error("CURRENT_OWNER_NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const promoteResult = await client.query(
|
||||||
|
`UPDATE household_members
|
||||||
|
SET role = 'owner'
|
||||||
|
WHERE household_id = $1 AND user_id = $2
|
||||||
|
RETURNING user_id, role`,
|
||||||
|
[householdId, nextOwnerUserId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (promoteResult.rows.length === 0) {
|
||||||
|
throw new Error("TARGET_MEMBER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
await client.query("COMMIT");
|
await client.query("COMMIT");
|
||||||
return promoteResult.rows[0];
|
return promoteResult.rows[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -4,22 +4,104 @@ function normalizeItemName(itemName) {
|
|||||||
return String(itemName || "").trim().toLowerCase();
|
return String(itemName || "").trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName) {
|
function toPositiveInteger(value, fallback = 1) {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_ADDED_BY_USERS_SQL = `
|
||||||
|
(
|
||||||
|
SELECT ARRAY_AGG(
|
||||||
|
active_added_by_users.user_label
|
||||||
|
ORDER BY active_added_by_users.last_added_on DESC, active_added_by_users.user_label
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label,
|
||||||
|
MAX(active_history.added_on) AS last_added_on
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
hlh.*,
|
||||||
|
COALESCE(
|
||||||
|
SUM(hlh.quantity) OVER (
|
||||||
|
PARTITION BY hlh.household_list_id
|
||||||
|
ORDER BY hlh.added_on DESC, hlh.id DESC
|
||||||
|
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) AS newer_quantity
|
||||||
|
FROM household_list_history hlh
|
||||||
|
WHERE hlh.household_list_id = hl.id
|
||||||
|
) active_history
|
||||||
|
JOIN users u ON active_history.added_by = u.id
|
||||||
|
WHERE active_history.newer_quantity < GREATEST(hl.quantity, 0)
|
||||||
|
GROUP BY user_label
|
||||||
|
) active_added_by_users
|
||||||
|
) AS added_by_users`;
|
||||||
|
|
||||||
|
async function getHouseholdStoreItemByNormalizedName(householdId, storeLocationId, normalizedName) {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT id, name, normalized_name, custom_image, custom_image_mime_type
|
`SELECT id, name, normalized_name, image_id
|
||||||
FROM household_store_items
|
FROM household_store_items
|
||||||
WHERE household_id = $1
|
WHERE household_id = $1
|
||||||
AND store_id = $2
|
AND store_location_id = $2
|
||||||
AND normalized_name = $3`,
|
AND normalized_name = $3`,
|
||||||
[householdId, storeId, normalizedName]
|
[householdId, storeLocationId, normalizedName]
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => {
|
async function createItemImage({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
householdListId = null,
|
||||||
|
imageScope,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId = null,
|
||||||
|
}) {
|
||||||
|
if (!imageBuffer || !mimeType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO household_item_images
|
||||||
|
(
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
household_store_item_id,
|
||||||
|
household_list_id,
|
||||||
|
image_scope,
|
||||||
|
image,
|
||||||
|
mime_type,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
householdListId,
|
||||||
|
imageScope,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.ensureHouseholdStoreItem = async (householdId, storeLocationId, itemName) => {
|
||||||
const normalizedName = normalizeItemName(itemName);
|
const normalizedName = normalizeItemName(itemName);
|
||||||
let item = await getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName);
|
let item = await getHouseholdStoreItemByNormalizedName(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
normalizedName
|
||||||
|
);
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
return item;
|
return item;
|
||||||
@ -27,23 +109,16 @@ exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => {
|
|||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO household_store_items
|
`INSERT INTO household_store_items
|
||||||
(household_id, store_id, name, normalized_name, updated_at)
|
(household_id, store_location_id, name, normalized_name, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, NOW())
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
RETURNING id, name, normalized_name, custom_image, custom_image_mime_type`,
|
RETURNING id, name, normalized_name, image_id`,
|
||||||
[householdId, storeId, normalizedName, normalizedName]
|
[householdId, storeLocationId, normalizedName, normalizedName]
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
exports.getHouseholdStoreList = async (householdId, storeLocationId, includeHistory = true) => {
|
||||||
* Get list items for a specific household and store
|
|
||||||
* @param {number} householdId - Household ID
|
|
||||||
* @param {number} storeId - Store ID
|
|
||||||
* @param {boolean} includeHistory - Include purchase history
|
|
||||||
* @returns {Promise<Array>} List of items
|
|
||||||
*/
|
|
||||||
exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => {
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
hl.id,
|
hl.id,
|
||||||
@ -52,130 +127,141 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
|
|||||||
hsi.name AS item_name,
|
hsi.name AS item_name,
|
||||||
hl.quantity,
|
hl.quantity,
|
||||||
hl.bought,
|
hl.bought,
|
||||||
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
|
hl.notes,
|
||||||
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
|
||||||
${includeHistory ? `
|
COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||||
(
|
${includeHistory ? `${ACTIVE_ADDED_BY_USERS_SQL},` : "NULL AS added_by_users,"}
|
||||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT
|
|
||||||
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
|
|
||||||
FROM household_list_history hlh
|
|
||||||
JOIN users u ON hlh.added_by = u.id
|
|
||||||
WHERE hlh.household_list_id = hl.id
|
|
||||||
) added_by_labels
|
|
||||||
) AS added_by_users,
|
|
||||||
` : "NULL AS added_by_users,"}
|
|
||||||
hl.modified_on AS last_added_on,
|
hl.modified_on AS last_added_on,
|
||||||
hic.item_type,
|
hic.item_type,
|
||||||
hic.item_group,
|
hic.item_group,
|
||||||
hic.zone
|
COALESCE(slz.name, hic.zone) AS zone,
|
||||||
|
slz.sort_order AS zone_sort_order
|
||||||
FROM household_lists hl
|
FROM household_lists hl
|
||||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||||
|
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
|
||||||
|
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||||
LEFT JOIN household_item_classifications hic
|
LEFT JOIN household_item_classifications hic
|
||||||
ON hic.household_id = hl.household_id
|
ON hic.household_id = hl.household_id
|
||||||
AND hic.store_id = hl.store_id
|
AND hic.store_location_id = hl.store_location_id
|
||||||
AND hic.household_store_item_id = hl.household_store_item_id
|
AND hic.household_store_item_id = hl.household_store_item_id
|
||||||
|
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||||
WHERE hl.household_id = $1
|
WHERE hl.household_id = $1
|
||||||
AND hl.store_id = $2
|
AND hl.store_location_id = $2
|
||||||
AND hl.bought = FALSE
|
AND hl.bought = FALSE
|
||||||
ORDER BY hl.id ASC`,
|
ORDER BY slz.sort_order ASC NULLS LAST, hsi.name ASC`,
|
||||||
[householdId, storeId]
|
[householdId, storeLocationId]
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
exports.getItemByName = async (householdId, storeLocationId, itemName) => {
|
||||||
* Get a specific item from household list by name
|
|
||||||
* @param {number} householdId - Household ID
|
|
||||||
* @param {number} storeId - Store ID
|
|
||||||
* @param {string} itemName - Item name to search for
|
|
||||||
* @returns {Promise<Object|null>} Item or null
|
|
||||||
*/
|
|
||||||
exports.getItemByName = async (householdId, storeId, itemName) => {
|
|
||||||
const normalizedName = normalizeItemName(itemName);
|
const normalizedName = normalizeItemName(itemName);
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
hl.id,
|
hl.id,
|
||||||
|
hl.household_id,
|
||||||
|
hl.store_location_id,
|
||||||
hl.household_store_item_id AS item_id,
|
hl.household_store_item_id AS item_id,
|
||||||
hl.household_store_item_id,
|
hl.household_store_item_id,
|
||||||
hsi.name AS item_name,
|
hsi.name AS item_name,
|
||||||
hl.quantity,
|
hl.quantity,
|
||||||
hl.bought,
|
hl.bought,
|
||||||
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
|
hl.notes,
|
||||||
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
|
||||||
(
|
COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
${ACTIVE_ADDED_BY_USERS_SQL},
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT
|
|
||||||
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
|
|
||||||
FROM household_list_history hlh
|
|
||||||
JOIN users u ON hlh.added_by = u.id
|
|
||||||
WHERE hlh.household_list_id = hl.id
|
|
||||||
) added_by_labels
|
|
||||||
) AS added_by_users,
|
|
||||||
hl.modified_on AS last_added_on,
|
hl.modified_on AS last_added_on,
|
||||||
hic.item_type,
|
hic.item_type,
|
||||||
hic.item_group,
|
hic.item_group,
|
||||||
hic.zone
|
COALESCE(slz.name, hic.zone) AS zone,
|
||||||
|
slz.sort_order AS zone_sort_order
|
||||||
FROM household_lists hl
|
FROM household_lists hl
|
||||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||||
|
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
|
||||||
|
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||||
LEFT JOIN household_item_classifications hic
|
LEFT JOIN household_item_classifications hic
|
||||||
ON hic.household_id = hl.household_id
|
ON hic.household_id = hl.household_id
|
||||||
AND hic.store_id = hl.store_id
|
AND hic.store_location_id = hl.store_location_id
|
||||||
AND hic.household_store_item_id = hl.household_store_item_id
|
AND hic.household_store_item_id = hl.household_store_item_id
|
||||||
|
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||||
WHERE hl.household_id = $1
|
WHERE hl.household_id = $1
|
||||||
AND hl.store_id = $2
|
AND hl.store_location_id = $2
|
||||||
AND hsi.normalized_name = $3`,
|
AND hsi.normalized_name = $3`,
|
||||||
[householdId, storeId, normalizedName]
|
[householdId, storeLocationId, normalizedName]
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Add or update an item in household list
|
|
||||||
* @returns {Promise<{listId:number,itemId:number,householdStoreItemId:number,itemName:string,isNew:boolean}>}
|
|
||||||
*/
|
|
||||||
exports.addOrUpdateItem = async (
|
exports.addOrUpdateItem = async (
|
||||||
householdId,
|
householdId,
|
||||||
storeId,
|
storeLocationId,
|
||||||
itemName,
|
itemName,
|
||||||
quantity,
|
quantity,
|
||||||
userId,
|
userId,
|
||||||
imageBuffer = null,
|
imageBuffer = null,
|
||||||
mimeType = null
|
mimeType = null,
|
||||||
|
notes = undefined
|
||||||
) => {
|
) => {
|
||||||
const householdStoreItem = await exports.ensureHouseholdStoreItem(householdId, storeId, itemName);
|
const nextQuantity = toPositiveInteger(quantity);
|
||||||
|
const householdStoreItem = await exports.ensureHouseholdStoreItem(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
itemName
|
||||||
|
);
|
||||||
const listResult = await pool.query(
|
const listResult = await pool.query(
|
||||||
`SELECT id, bought
|
`SELECT id, bought, quantity
|
||||||
FROM household_lists
|
FROM household_lists
|
||||||
WHERE household_id = $1
|
WHERE household_id = $1
|
||||||
AND store_id = $2
|
AND store_location_id = $2
|
||||||
AND household_store_item_id = $3`,
|
AND household_store_item_id = $3`,
|
||||||
[householdId, storeId, householdStoreItem.id]
|
[householdId, storeLocationId, householdStoreItem.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (listResult.rowCount > 0) {
|
if (listResult.rowCount > 0) {
|
||||||
const listId = listResult.rows[0].id;
|
const listId = listResult.rows[0].id;
|
||||||
|
const previousQuantity = toPositiveInteger(listResult.rows[0].quantity, 0);
|
||||||
|
const wasBought = Boolean(listResult.rows[0].bought);
|
||||||
|
const historyQuantity =
|
||||||
|
!wasBought && nextQuantity > previousQuantity
|
||||||
|
? nextQuantity - previousQuantity
|
||||||
|
: nextQuantity;
|
||||||
|
|
||||||
|
let imageId = null;
|
||||||
if (imageBuffer && mimeType) {
|
if (imageBuffer && mimeType) {
|
||||||
|
imageId = await createItemImage({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: householdStoreItem.id,
|
||||||
|
householdListId: listId,
|
||||||
|
imageScope: "list",
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageId) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE household_lists
|
`UPDATE household_lists
|
||||||
SET quantity = $1,
|
SET quantity = $1,
|
||||||
bought = FALSE,
|
bought = FALSE,
|
||||||
custom_image = $2,
|
image_id = $2,
|
||||||
custom_image_mime_type = $3,
|
custom_image = NULL,
|
||||||
|
custom_image_mime_type = NULL,
|
||||||
|
notes = COALESCE($3, notes),
|
||||||
modified_on = NOW()
|
modified_on = NOW()
|
||||||
WHERE id = $4`,
|
WHERE id = $4`,
|
||||||
[quantity, imageBuffer, mimeType, listId]
|
[nextQuantity, imageId, notes, listId]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE household_lists
|
`UPDATE household_lists
|
||||||
SET quantity = $1,
|
SET quantity = $1,
|
||||||
bought = FALSE,
|
bought = FALSE,
|
||||||
|
notes = COALESCE($2, notes),
|
||||||
modified_on = NOW()
|
modified_on = NOW()
|
||||||
WHERE id = $2`,
|
WHERE id = $3`,
|
||||||
[quantity, listId]
|
[nextQuantity, notes, listId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,46 +270,87 @@ exports.addOrUpdateItem = async (
|
|||||||
itemId: householdStoreItem.id,
|
itemId: householdStoreItem.id,
|
||||||
householdStoreItemId: householdStoreItem.id,
|
householdStoreItemId: householdStoreItem.id,
|
||||||
itemName: householdStoreItem.name,
|
itemName: householdStoreItem.name,
|
||||||
|
quantity: nextQuantity,
|
||||||
|
previousQuantity,
|
||||||
|
historyQuantity,
|
||||||
|
wasBought,
|
||||||
isNew: false,
|
isNew: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const insert = await pool.query(
|
const insert = await pool.query(
|
||||||
`INSERT INTO household_lists
|
`INSERT INTO household_lists
|
||||||
(household_id, store_id, household_store_item_id, quantity, custom_image, custom_image_mime_type, added_by)
|
(household_id, store_location_id, household_store_item_id, quantity, added_by, notes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[householdId, storeId, householdStoreItem.id, quantity, imageBuffer, mimeType, userId]
|
[householdId, storeLocationId, householdStoreItem.id, nextQuantity, userId, notes || null]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (imageBuffer && mimeType) {
|
||||||
|
const imageId = await createItemImage({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: householdStoreItem.id,
|
||||||
|
householdListId: insert.rows[0].id,
|
||||||
|
imageScope: "list",
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE household_lists
|
||||||
|
SET image_id = $1,
|
||||||
|
custom_image = NULL,
|
||||||
|
custom_image_mime_type = NULL
|
||||||
|
WHERE id = $2`,
|
||||||
|
[imageId, insert.rows[0].id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listId: insert.rows[0].id,
|
listId: insert.rows[0].id,
|
||||||
itemId: householdStoreItem.id,
|
itemId: householdStoreItem.id,
|
||||||
householdStoreItemId: householdStoreItem.id,
|
householdStoreItemId: householdStoreItem.id,
|
||||||
itemName: householdStoreItem.name,
|
itemName: householdStoreItem.name,
|
||||||
|
quantity: nextQuantity,
|
||||||
|
previousQuantity: 0,
|
||||||
|
historyQuantity: nextQuantity,
|
||||||
|
wasBought: false,
|
||||||
isNew: true,
|
isNew: true,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.setBought = async (listId, bought, quantityBought = null) => {
|
exports.setBought = async (listId, bought, quantityBought = null) => {
|
||||||
|
const item = await pool.query(
|
||||||
|
`SELECT id, household_id, store_location_id, household_store_item_id, quantity, bought
|
||||||
|
FROM household_lists
|
||||||
|
WHERE id = $1`,
|
||||||
|
[listId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!item.rows[0]) return null;
|
||||||
|
|
||||||
|
const current = item.rows[0];
|
||||||
|
const currentQuantity = toPositiveInteger(current.quantity, 0);
|
||||||
|
|
||||||
if (bought === false) {
|
if (bought === false) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
|
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
|
||||||
[listId]
|
[listId]
|
||||||
);
|
);
|
||||||
return;
|
return {
|
||||||
|
...current,
|
||||||
|
eventType: "ITEM_UNBOUGHT",
|
||||||
|
quantityDelta: null,
|
||||||
|
quantityAfter: currentQuantity,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quantityBought && quantityBought > 0) {
|
const requestedQuantity = toPositiveInteger(quantityBought, 0);
|
||||||
const item = await pool.query(
|
if (requestedQuantity > 0) {
|
||||||
"SELECT quantity FROM household_lists WHERE id = $1",
|
const boughtQuantity = Math.min(requestedQuantity, currentQuantity);
|
||||||
[listId]
|
const remainingQuantity = currentQuantity - boughtQuantity;
|
||||||
);
|
|
||||||
|
|
||||||
if (!item.rows[0]) return;
|
|
||||||
|
|
||||||
const currentQuantity = item.rows[0].quantity;
|
|
||||||
const remainingQuantity = currentQuantity - quantityBought;
|
|
||||||
|
|
||||||
if (remainingQuantity <= 0) {
|
if (remainingQuantity <= 0) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
@ -236,23 +363,90 @@ exports.setBought = async (listId, bought, quantityBought = null) => {
|
|||||||
[remainingQuantity, listId]
|
[remainingQuantity, listId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
eventType: "ITEM_BOUGHT",
|
||||||
|
quantityDelta: -boughtQuantity,
|
||||||
|
quantityAfter: Math.max(remainingQuantity, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||||
[listId]
|
[listId]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
eventType: "ITEM_BOUGHT",
|
||||||
|
quantityDelta: -currentQuantity,
|
||||||
|
quantityAfter: 0,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.addHistoryRecord = async (listId, householdStoreItemId, quantity, userId) => {
|
exports.addHistoryRecord = async (
|
||||||
|
listId,
|
||||||
|
householdStoreItemId,
|
||||||
|
quantity,
|
||||||
|
userId,
|
||||||
|
storeLocationId = null
|
||||||
|
) => {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO household_list_history (household_list_id, household_store_item_id, quantity, added_by, added_on)
|
`INSERT INTO household_list_history
|
||||||
VALUES ($1, $2, $3, $4, NOW())`,
|
(household_list_id, store_location_id, household_store_item_id, quantity, added_by, added_on)
|
||||||
[listId, householdStoreItemId, quantity, userId]
|
VALUES (
|
||||||
|
$1,
|
||||||
|
COALESCE($5, (SELECT store_location_id FROM household_lists WHERE id = $1)),
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
NOW()
|
||||||
|
)`,
|
||||||
|
[listId, householdStoreItemId, quantity, userId, storeLocationId]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getSuggestions = async (query, householdId, storeId) => {
|
exports.recordItemEvent = async ({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
householdListId = null,
|
||||||
|
actorUserId = null,
|
||||||
|
eventType,
|
||||||
|
quantityDelta = null,
|
||||||
|
quantityAfter = null,
|
||||||
|
metadata = {},
|
||||||
|
}) => {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO household_item_events
|
||||||
|
(
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
household_store_item_id,
|
||||||
|
household_list_id,
|
||||||
|
actor_user_id,
|
||||||
|
event_type,
|
||||||
|
quantity_delta,
|
||||||
|
quantity_after,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
householdListId,
|
||||||
|
actorUserId,
|
||||||
|
eventType,
|
||||||
|
quantityDelta,
|
||||||
|
quantityAfter,
|
||||||
|
JSON.stringify(metadata || {}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getSuggestions = async (query, householdId, storeLocationId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT DISTINCT
|
`SELECT DISTINCT
|
||||||
hsi.name AS item_name,
|
hsi.name AS item_name,
|
||||||
@ -261,18 +455,18 @@ exports.getSuggestions = async (query, householdId, storeId) => {
|
|||||||
LEFT JOIN household_lists hl
|
LEFT JOIN household_lists hl
|
||||||
ON hl.household_store_item_id = hsi.id
|
ON hl.household_store_item_id = hsi.id
|
||||||
AND hl.household_id = $2
|
AND hl.household_id = $2
|
||||||
AND hl.store_id = $3
|
AND hl.store_location_id = $3
|
||||||
WHERE hsi.household_id = $2
|
WHERE hsi.household_id = $2
|
||||||
AND hsi.store_id = $3
|
AND hsi.store_location_id = $3
|
||||||
AND hsi.name ILIKE $1
|
AND hsi.name ILIKE $1
|
||||||
ORDER BY sort_order, hsi.name
|
ORDER BY sort_order, hsi.name
|
||||||
LIMIT 10`,
|
LIMIT 10`,
|
||||||
[`%${query}%`, householdId, storeId]
|
[`%${query}%`, householdId, storeLocationId]
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
exports.getRecentlyBoughtItems = async (householdId, storeLocationId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
hl.id,
|
hl.id,
|
||||||
@ -281,73 +475,121 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
|||||||
hsi.name AS item_name,
|
hsi.name AS item_name,
|
||||||
hl.quantity,
|
hl.quantity,
|
||||||
hl.bought,
|
hl.bought,
|
||||||
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
|
ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
|
||||||
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||||
(
|
${ACTIVE_ADDED_BY_USERS_SQL},
|
||||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT
|
|
||||||
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
|
|
||||||
FROM household_list_history hlh
|
|
||||||
JOIN users u ON hlh.added_by = u.id
|
|
||||||
WHERE hlh.household_list_id = hl.id
|
|
||||||
) added_by_labels
|
|
||||||
) AS added_by_users,
|
|
||||||
hl.modified_on AS last_added_on
|
hl.modified_on AS last_added_on
|
||||||
FROM household_lists hl
|
FROM household_lists hl
|
||||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||||
|
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
|
||||||
|
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||||
WHERE hl.household_id = $1
|
WHERE hl.household_id = $1
|
||||||
AND hl.store_id = $2
|
AND hl.store_location_id = $2
|
||||||
AND hl.bought = TRUE
|
AND hl.bought = TRUE
|
||||||
AND hl.modified_on >= NOW() - INTERVAL '24 hours'
|
AND hl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||||
ORDER BY hl.modified_on DESC`,
|
ORDER BY hl.modified_on DESC`,
|
||||||
[householdId, storeId]
|
[householdId, storeLocationId]
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getClassification = async (householdId, storeId, itemId) => {
|
exports.getZoneByName = async (householdId, storeLocationId, zoneName) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT item_type, item_group, zone, confidence, source
|
`SELECT id, name, sort_order
|
||||||
FROM household_item_classifications
|
FROM store_location_zones
|
||||||
WHERE household_id = $1 AND store_id = $2 AND household_store_item_id = $3`,
|
WHERE household_id = $1
|
||||||
[householdId, storeId, itemId]
|
AND store_location_id = $2
|
||||||
|
AND normalized_name = $3
|
||||||
|
AND is_active = TRUE`,
|
||||||
|
[householdId, storeLocationId, normalizeItemName(zoneName)]
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.upsertClassification = async (householdId, storeId, itemId, classification) => {
|
exports.getClassification = async (householdId, storeLocationId, itemId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
hic.item_type,
|
||||||
|
hic.item_group,
|
||||||
|
COALESCE(slz.name, hic.zone) AS zone,
|
||||||
|
hic.confidence,
|
||||||
|
hic.source
|
||||||
|
FROM household_item_classifications hic
|
||||||
|
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||||
|
WHERE hic.household_id = $1
|
||||||
|
AND hic.store_location_id = $2
|
||||||
|
AND hic.household_store_item_id = $3`,
|
||||||
|
[householdId, storeLocationId, itemId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.upsertClassification = async (householdId, storeLocationId, itemId, classification) => {
|
||||||
const { item_type, item_group, zone, confidence, source } = classification;
|
const { item_type, item_group, zone, confidence, source } = classification;
|
||||||
|
const zoneRecord = zone ? await exports.getZoneByName(householdId, storeLocationId, zone) : null;
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO household_item_classifications
|
`INSERT INTO household_item_classifications
|
||||||
(household_id, store_id, household_store_item_id, item_type, item_group, zone, confidence, source)
|
(
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
household_id,
|
||||||
ON CONFLICT (household_id, store_id, household_store_item_id)
|
store_location_id,
|
||||||
|
household_store_item_id,
|
||||||
|
item_type,
|
||||||
|
item_group,
|
||||||
|
zone,
|
||||||
|
zone_id,
|
||||||
|
confidence,
|
||||||
|
source
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (household_id, store_location_id, household_store_item_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
item_type = EXCLUDED.item_type,
|
item_type = EXCLUDED.item_type,
|
||||||
item_group = EXCLUDED.item_group,
|
item_group = EXCLUDED.item_group,
|
||||||
zone = EXCLUDED.zone,
|
zone = EXCLUDED.zone,
|
||||||
|
zone_id = EXCLUDED.zone_id,
|
||||||
confidence = EXCLUDED.confidence,
|
confidence = EXCLUDED.confidence,
|
||||||
source = EXCLUDED.source
|
source = EXCLUDED.source,
|
||||||
|
updated_at = NOW()
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[householdId, storeId, itemId, item_type, item_group, zone, confidence, source]
|
[
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
itemId,
|
||||||
|
item_type,
|
||||||
|
item_group,
|
||||||
|
zone,
|
||||||
|
zoneRecord?.id || null,
|
||||||
|
confidence,
|
||||||
|
source,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.deleteClassification = async (householdId, storeId, itemId) => {
|
exports.deleteClassification = async (householdId, storeLocationId, itemId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`DELETE FROM household_item_classifications
|
`DELETE FROM household_item_classifications
|
||||||
WHERE household_id = $1
|
WHERE household_id = $1
|
||||||
AND store_id = $2
|
AND store_location_id = $2
|
||||||
AND household_store_item_id = $3`,
|
AND household_store_item_id = $3`,
|
||||||
[householdId, storeId, itemId]
|
[householdId, storeLocationId, itemId]
|
||||||
);
|
);
|
||||||
return result.rowCount > 0;
|
return result.rowCount > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.updateItem = async (listId, itemName, quantity, notes) => {
|
exports.updateItem = async (listId, itemName, quantity, notes) => {
|
||||||
|
const existing = await pool.query(
|
||||||
|
`SELECT id, household_id, store_location_id, household_store_item_id, quantity, notes
|
||||||
|
FROM household_lists
|
||||||
|
WHERE id = $1`,
|
||||||
|
[listId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rowCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const values = [listId];
|
const values = [listId];
|
||||||
let paramCount = 1;
|
let paramCount = 1;
|
||||||
@ -366,22 +608,56 @@ exports.updateItem = async (listId, itemName, quantity, notes) => {
|
|||||||
|
|
||||||
updates.push("modified_on = NOW()");
|
updates.push("modified_on = NOW()");
|
||||||
|
|
||||||
if (updates.length === 1) {
|
|
||||||
const result = await pool.query(
|
|
||||||
"UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *",
|
|
||||||
[listId]
|
|
||||||
);
|
|
||||||
return result.rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE household_lists SET ${updates.join(", ")} WHERE id = $1 RETURNING *`,
|
`UPDATE household_lists SET ${updates.join(", ")} WHERE id = $1 RETURNING *`,
|
||||||
values
|
values
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0];
|
return {
|
||||||
|
previous: existing.rows[0],
|
||||||
|
updated: result.rows[0],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.deleteItem = async (listId) => {
|
exports.deleteItem = async (listId) => {
|
||||||
await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]);
|
const result = await pool.query(
|
||||||
|
`DELETE FROM household_lists
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, household_id, store_location_id, household_store_item_id, quantity`,
|
||||||
|
[listId]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setCatalogItemImage = async (
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId = null
|
||||||
|
) => {
|
||||||
|
const imageId = await createItemImage({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId,
|
||||||
|
imageScope: "catalog",
|
||||||
|
imageBuffer,
|
||||||
|
mimeType,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE household_store_items
|
||||||
|
SET image_id = $1,
|
||||||
|
custom_image = NULL,
|
||||||
|
custom_image_mime_type = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $2
|
||||||
|
AND store_location_id = $3
|
||||||
|
AND id = $4`,
|
||||||
|
[imageId, householdId, storeLocationId, householdStoreItemId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return imageId;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,70 @@
|
|||||||
const pool = require("../db/pool");
|
const pool = require("../db/pool");
|
||||||
|
const { ZONE_FLOW } = require("../constants/classifications");
|
||||||
|
|
||||||
// Get all available stores
|
const DEFAULT_LOCATION_NAME = "Default Location";
|
||||||
|
|
||||||
|
function normalizeName(value) {
|
||||||
|
return String(value || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayLocationName(storeName, locationName) {
|
||||||
|
if (!locationName || locationName === DEFAULT_LOCATION_NAME) {
|
||||||
|
return storeName;
|
||||||
|
}
|
||||||
|
return `${storeName} - ${locationName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLocationRow(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
id: row.location_id,
|
||||||
|
display_name: row.display_name || displayLocationName(row.name, row.location_name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryLocationById(db, householdId, locationId) {
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT
|
||||||
|
sl.id AS location_id,
|
||||||
|
sl.id,
|
||||||
|
sl.household_id,
|
||||||
|
sl.household_store_id,
|
||||||
|
hcs.name,
|
||||||
|
sl.name AS location_name,
|
||||||
|
sl.address,
|
||||||
|
sl.is_default,
|
||||||
|
sl.map_data,
|
||||||
|
sl.created_at,
|
||||||
|
sl.updated_at,
|
||||||
|
CASE
|
||||||
|
WHEN sl.name = $3 THEN hcs.name
|
||||||
|
ELSE hcs.name || ' - ' || sl.name
|
||||||
|
END AS display_name
|
||||||
|
FROM store_locations sl
|
||||||
|
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
|
||||||
|
WHERE sl.household_id = $1
|
||||||
|
AND sl.id = $2`,
|
||||||
|
[householdId, locationId, DEFAULT_LOCATION_NAME]
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapLocationRow(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDefaultZones(db, householdId, locationId) {
|
||||||
|
for (let index = 0; index < ZONE_FLOW.length; index += 1) {
|
||||||
|
const zoneName = ZONE_FLOW[index];
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO store_location_zones
|
||||||
|
(household_id, store_location_id, name, normalized_name, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (store_location_id, normalized_name) DO NOTHING`,
|
||||||
|
[householdId, locationId, zoneName, normalizeName(zoneName), (index + 1) * 10]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy global store catalog. Kept for system-admin compatibility only.
|
||||||
exports.getAllStores = async () => {
|
exports.getAllStores = async () => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT id, name, default_zones, created_at
|
`SELECT id, name, default_zones, created_at
|
||||||
@ -10,7 +74,6 @@ exports.getAllStores = async () => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get store by ID
|
|
||||||
exports.getStoreById = async (storeId) => {
|
exports.getStoreById = async (storeId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT id, name, default_zones, created_at
|
`SELECT id, name, default_zones, created_at
|
||||||
@ -21,77 +84,6 @@ exports.getStoreById = async (storeId) => {
|
|||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get stores for a specific household
|
|
||||||
exports.getHouseholdStores = async (householdId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
s.id,
|
|
||||||
s.name,
|
|
||||||
s.default_zones,
|
|
||||||
hs.is_default,
|
|
||||||
hs.added_at
|
|
||||||
FROM stores s
|
|
||||||
JOIN household_stores hs ON s.id = hs.store_id
|
|
||||||
WHERE hs.household_id = $1
|
|
||||||
ORDER BY hs.is_default DESC, s.name ASC`,
|
|
||||||
[householdId]
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add store to household
|
|
||||||
exports.addStoreToHousehold = async (householdId, storeId, isDefault = false) => {
|
|
||||||
// If setting as default, unset other defaults
|
|
||||||
if (isDefault) {
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_stores
|
|
||||||
SET is_default = FALSE
|
|
||||||
WHERE household_id = $1`,
|
|
||||||
[householdId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`INSERT INTO household_stores (household_id, store_id, is_default)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (household_id, store_id)
|
|
||||||
DO UPDATE SET is_default = $3
|
|
||||||
RETURNING household_id, store_id, is_default`,
|
|
||||||
[householdId, storeId, isDefault]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove store from household
|
|
||||||
exports.removeStoreFromHousehold = async (householdId, storeId) => {
|
|
||||||
await pool.query(
|
|
||||||
`DELETE FROM household_stores
|
|
||||||
WHERE household_id = $1 AND store_id = $2`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set default store for household
|
|
||||||
exports.setDefaultStore = async (householdId, storeId) => {
|
|
||||||
// Unset all defaults
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_stores
|
|
||||||
SET is_default = FALSE
|
|
||||||
WHERE household_id = $1`,
|
|
||||||
[householdId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set new default
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_stores
|
|
||||||
SET is_default = TRUE
|
|
||||||
WHERE household_id = $1 AND store_id = $2`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create new store (system admin only)
|
|
||||||
exports.createStore = async (name, defaultZones) => {
|
exports.createStore = async (name, defaultZones) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO stores (name, default_zones)
|
`INSERT INTO stores (name, default_zones)
|
||||||
@ -102,7 +94,6 @@ exports.createStore = async (name, defaultZones) => {
|
|||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update store (system admin only)
|
|
||||||
exports.updateStore = async (storeId, updates) => {
|
exports.updateStore = async (storeId, updates) => {
|
||||||
const { name, default_zones } = updates;
|
const { name, default_zones } = updates;
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@ -117,27 +108,452 @@ exports.updateStore = async (storeId, updates) => {
|
|||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete store (system admin only, only if not in use)
|
|
||||||
exports.deleteStore = async (storeId) => {
|
exports.deleteStore = async (storeId) => {
|
||||||
// Check if store is in use
|
|
||||||
const usage = await pool.query(
|
const usage = await pool.query(
|
||||||
`SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`,
|
`SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`,
|
||||||
[storeId]
|
[storeId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (parseInt(usage.rows[0].count) > 0) {
|
if (parseInt(usage.rows[0].count, 10) > 0) {
|
||||||
throw new Error('Cannot delete store that is in use by households');
|
throw new Error("Cannot delete store that is in use by households");
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query('DELETE FROM stores WHERE id = $1', [storeId]);
|
await pool.query("DELETE FROM stores WHERE id = $1", [storeId]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if household has store
|
// Household-owned store locations.
|
||||||
|
exports.getHouseholdStores = async (householdId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
sl.id AS location_id,
|
||||||
|
sl.id,
|
||||||
|
sl.household_id,
|
||||||
|
sl.household_store_id,
|
||||||
|
hcs.name,
|
||||||
|
sl.name AS location_name,
|
||||||
|
sl.address,
|
||||||
|
sl.is_default,
|
||||||
|
sl.map_data,
|
||||||
|
sl.created_at,
|
||||||
|
sl.updated_at,
|
||||||
|
CASE
|
||||||
|
WHEN sl.name = $2 THEN hcs.name
|
||||||
|
ELSE hcs.name || ' - ' || sl.name
|
||||||
|
END AS display_name
|
||||||
|
FROM store_locations sl
|
||||||
|
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
|
||||||
|
WHERE sl.household_id = $1
|
||||||
|
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
|
||||||
|
[householdId, DEFAULT_LOCATION_NAME]
|
||||||
|
);
|
||||||
|
return result.rows.map(mapLocationRow);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createHouseholdStore = async (
|
||||||
|
householdId,
|
||||||
|
name,
|
||||||
|
locationName = DEFAULT_LOCATION_NAME,
|
||||||
|
address = null,
|
||||||
|
createdBy = null
|
||||||
|
) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
const normalizedStoreName = normalizeName(name);
|
||||||
|
const normalizedLocationName = normalizeName(locationName || DEFAULT_LOCATION_NAME);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const storeResult = await client.query(
|
||||||
|
`INSERT INTO household_custom_stores
|
||||||
|
(household_id, name, normalized_name, created_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT (household_id, normalized_name)
|
||||||
|
DO UPDATE SET name = EXCLUDED.name, updated_at = NOW()
|
||||||
|
RETURNING id, name`,
|
||||||
|
[householdId, name.trim(), normalizedStoreName, createdBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasDefault = await client.query(
|
||||||
|
`SELECT 1 FROM store_locations
|
||||||
|
WHERE household_id = $1 AND is_default = TRUE
|
||||||
|
LIMIT 1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationResult = await client.query(
|
||||||
|
`INSERT INTO store_locations
|
||||||
|
(household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
|
ON CONFLICT (household_store_id, normalized_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
address = COALESCE(EXCLUDED.address, store_locations.address),
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
storeResult.rows[0].id,
|
||||||
|
(locationName || DEFAULT_LOCATION_NAME).trim(),
|
||||||
|
normalizedLocationName,
|
||||||
|
address || null,
|
||||||
|
hasDefault.rowCount === 0,
|
||||||
|
createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedDefaultZones(client, householdId, locationResult.rows[0].id);
|
||||||
|
|
||||||
|
const location = await queryLocationById(client, householdId, locationResult.rows[0].id);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return location;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateHouseholdStore = async (householdId, householdStoreId, updates = {}) => {
|
||||||
|
const { name } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE household_custom_stores
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
normalized_name = COALESCE($2, normalized_name),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $3
|
||||||
|
AND id = $4
|
||||||
|
RETURNING id, household_id, name, created_at, updated_at`,
|
||||||
|
[
|
||||||
|
name?.trim() || null,
|
||||||
|
name ? normalizeName(name) : null,
|
||||||
|
householdId,
|
||||||
|
householdStoreId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteHouseholdStore = async (householdId, householdStoreId) => {
|
||||||
|
const countResult = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS count
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const storeLocationCount = countResult.rows[0]?.count || 0;
|
||||||
|
const targetLocations = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS count
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND household_store_id = $2`,
|
||||||
|
[householdId, householdStoreId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (storeLocationCount <= targetLocations.rows[0]?.count) {
|
||||||
|
throw new Error("Cannot remove the last store location for a household");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM household_custom_stores
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2`,
|
||||||
|
[householdId, householdStoreId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.addLocationToStore = async (
|
||||||
|
householdId,
|
||||||
|
householdStoreId,
|
||||||
|
name,
|
||||||
|
address = null,
|
||||||
|
createdBy = null
|
||||||
|
) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const storeResult = await client.query(
|
||||||
|
`SELECT id FROM household_custom_stores
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2`,
|
||||||
|
[householdId, householdStoreId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (storeResult.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDefault = await client.query(
|
||||||
|
`SELECT 1 FROM store_locations
|
||||||
|
WHERE household_id = $1 AND is_default = TRUE
|
||||||
|
LIMIT 1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationResult = await client.query(
|
||||||
|
`INSERT INTO store_locations
|
||||||
|
(household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
householdStoreId,
|
||||||
|
name.trim(),
|
||||||
|
normalizeName(name),
|
||||||
|
address || null,
|
||||||
|
hasDefault.rowCount === 0,
|
||||||
|
createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedDefaultZones(client, householdId, locationResult.rows[0].id);
|
||||||
|
const location = await queryLocationById(client, householdId, locationResult.rows[0].id);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return location;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateLocation = async (householdId, locationId, updates = {}) => {
|
||||||
|
const { name, address, map_data } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
normalized_name = COALESCE($2, normalized_name),
|
||||||
|
address = COALESCE($3, address),
|
||||||
|
map_data = COALESCE($4::jsonb, map_data),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $5
|
||||||
|
AND id = $6
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
name?.trim() || null,
|
||||||
|
name ? normalizeName(name) : null,
|
||||||
|
address === undefined ? null : address,
|
||||||
|
map_data ? JSON.stringify(map_data) : null,
|
||||||
|
householdId,
|
||||||
|
locationId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) return null;
|
||||||
|
return queryLocationById(pool, householdId, locationId);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteLocation = async (householdId, locationId) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const countResult = await client.query(
|
||||||
|
`SELECT COUNT(*)::int AS count
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((countResult.rows[0]?.count || 0) <= 1) {
|
||||||
|
throw new Error("Cannot remove the last store location for a household");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await client.query(
|
||||||
|
`DELETE FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2
|
||||||
|
RETURNING is_default`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deleted.rowCount === 0) {
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted.rows[0].is_default) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET is_default = TRUE, updated_at = NOW()
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
)`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setDefaultLocation = async (householdId, locationId) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET is_default = FALSE, updated_at = NOW()
|
||||||
|
WHERE household_id = $1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET is_default = TRUE, updated_at = NOW()
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2
|
||||||
|
RETURNING id`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error("Location not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.householdHasLocation = async (householdId, locationId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getLocationById = async (householdId, locationId) =>
|
||||||
|
queryLocationById(pool, householdId, locationId);
|
||||||
|
|
||||||
|
exports.listLocationZones = async (householdId, locationId, includeInactive = false) => {
|
||||||
|
const values = [householdId, locationId];
|
||||||
|
const inactiveClause = includeInactive ? "" : "AND is_active = TRUE";
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name, sort_order, color, map_metadata, is_active, created_at, updated_at
|
||||||
|
FROM store_location_zones
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
${inactiveClause}
|
||||||
|
ORDER BY sort_order ASC, name ASC`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getZoneByName = async (householdId, locationId, zoneName) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name, sort_order, color, is_active
|
||||||
|
FROM store_location_zones
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND normalized_name = $3
|
||||||
|
AND is_active = TRUE`,
|
||||||
|
[householdId, locationId, normalizeName(zoneName)]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createZone = async (householdId, locationId, zone) => {
|
||||||
|
const { name, sort_order, color, map_metadata } = zone;
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO store_location_zones
|
||||||
|
(household_id, store_location_id, name, normalized_name, sort_order, color, map_metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7::jsonb, '{}'::jsonb))
|
||||||
|
ON CONFLICT (store_location_id, normalized_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
color = EXCLUDED.color,
|
||||||
|
map_metadata = EXCLUDED.map_metadata,
|
||||||
|
is_active = TRUE,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, name, sort_order, color, map_metadata, is_active`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
locationId,
|
||||||
|
name.trim(),
|
||||||
|
normalizeName(name),
|
||||||
|
Number.isInteger(sort_order) ? sort_order : 0,
|
||||||
|
color || null,
|
||||||
|
map_metadata ? JSON.stringify(map_metadata) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateZone = async (householdId, locationId, zoneId, updates = {}) => {
|
||||||
|
const { name, sort_order, color, map_metadata, is_active } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE store_location_zones
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
normalized_name = COALESCE($2, normalized_name),
|
||||||
|
sort_order = COALESCE($3, sort_order),
|
||||||
|
color = COALESCE($4, color),
|
||||||
|
map_metadata = COALESCE($5::jsonb, map_metadata),
|
||||||
|
is_active = COALESCE($6, is_active),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $7
|
||||||
|
AND store_location_id = $8
|
||||||
|
AND id = $9
|
||||||
|
RETURNING id, name, sort_order, color, map_metadata, is_active`,
|
||||||
|
[
|
||||||
|
name?.trim() || null,
|
||||||
|
name ? normalizeName(name) : null,
|
||||||
|
Number.isInteger(sort_order) ? sort_order : null,
|
||||||
|
color === undefined ? null : color,
|
||||||
|
map_metadata ? JSON.stringify(map_metadata) : null,
|
||||||
|
typeof is_active === "boolean" ? is_active : null,
|
||||||
|
householdId,
|
||||||
|
locationId,
|
||||||
|
zoneId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteZone = async (householdId, locationId, zoneId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE store_location_zones
|
||||||
|
SET is_active = FALSE, updated_at = NOW()
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND id = $3`,
|
||||||
|
[householdId, locationId, zoneId]
|
||||||
|
);
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backward-compatible check for legacy routes. Prefer householdHasLocation.
|
||||||
exports.householdHasStore = async (householdId, storeId) => {
|
exports.householdHasStore = async (householdId, storeId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT 1 FROM household_stores
|
`SELECT 1 FROM household_stores
|
||||||
WHERE household_id = $1 AND store_id = $2`,
|
WHERE household_id = $1 AND store_id = $2`,
|
||||||
[householdId, storeId]
|
[householdId, storeId]
|
||||||
);
|
);
|
||||||
return result.rows.length > 0;
|
return result.rowCount > 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,9 +3,11 @@ const router = express.Router();
|
|||||||
const controller = require("../controllers/households.controller");
|
const controller = require("../controllers/households.controller");
|
||||||
const listsController = require("../controllers/lists.controller.v2");
|
const listsController = require("../controllers/lists.controller.v2");
|
||||||
const availableItemsController = require("../controllers/available-items.controller");
|
const availableItemsController = require("../controllers/available-items.controller");
|
||||||
|
const storesController = require("../controllers/stores.controller");
|
||||||
const auth = require("../middleware/auth");
|
const auth = require("../middleware/auth");
|
||||||
const {
|
const {
|
||||||
householdAccess,
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
requireHouseholdAdmin,
|
requireHouseholdAdmin,
|
||||||
storeAccess,
|
storeAccess,
|
||||||
} = require("../middleware/household");
|
} = require("../middleware/household");
|
||||||
@ -14,6 +16,7 @@ const { upload, processImage } = require("../middleware/image");
|
|||||||
// Public routes (authenticated only)
|
// Public routes (authenticated only)
|
||||||
router.get("/", auth, controller.getUserHouseholds);
|
router.get("/", auth, controller.getUserHouseholds);
|
||||||
router.post("/", auth, controller.createHousehold);
|
router.post("/", auth, controller.createHousehold);
|
||||||
|
router.patch("/order", auth, controller.reorderHouseholds);
|
||||||
router.post("/join/:inviteCode", auth, controller.joinHousehold);
|
router.post("/join/:inviteCode", auth, controller.joinHousehold);
|
||||||
|
|
||||||
// Household-scoped routes (member access required)
|
// Household-scoped routes (member access required)
|
||||||
@ -40,6 +43,139 @@ router.post(
|
|||||||
controller.refreshInviteCode
|
controller.refreshInviteCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Household-owned stores and locations
|
||||||
|
router.get(
|
||||||
|
"/:householdId/stores",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
storesController.getHouseholdStores
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/stores",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.createHouseholdStore
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/stores/:householdStoreId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.updateHouseholdStore
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/stores/:householdStoreId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.deleteHouseholdStore
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/stores/:householdStoreId/locations",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.addLocationToStore
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/locations/:locationId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.updateLocation
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/locations/:locationId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.deleteLocation
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/locations/:locationId/default",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.setDefaultLocation
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/zones",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
storesController.getLocationZones
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/zones",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.createZone
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/locations/:locationId/zones/:zoneId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.updateZone
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/locations/:locationId/zones/:zoneId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
storesController.deleteZone
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/available-items",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
availableItemsController.getAvailableItems
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/available-items",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
availableItemsController.createAvailableItem
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/locations/:locationId/available-items/:itemId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
availableItemsController.updateAvailableItem
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/locations/:locationId/available-items/:itemId",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
availableItemsController.deleteAvailableItem
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/available-items/import-current",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
availableItemsController.importCurrentItems
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/:householdId/stores/:storeId/available-items",
|
"/:householdId/stores/:storeId/available-items",
|
||||||
auth,
|
auth,
|
||||||
@ -109,6 +245,88 @@ router.delete(
|
|||||||
// All list routes require household access AND store access
|
// All list routes require household access AND store access
|
||||||
|
|
||||||
// Get grocery list
|
// Get grocery list
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/list",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.getList
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/list/item",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.getItemByName
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/list/add",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
listsController.addItem
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
"/:householdId/locations/:locationId/list/item",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.markBought
|
||||||
|
);
|
||||||
|
router.put(
|
||||||
|
"/:householdId/locations/:locationId/list/item",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.updateItem
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:householdId/locations/:locationId/list/item",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.deleteItem
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/list/suggestions",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.getSuggestions
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/list/recent",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.getRecentlyBought
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/list/classification",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.getClassification
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/list/classification",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
listsController.setClassification
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/list/update-image",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
upload.single("image"),
|
||||||
|
processImage,
|
||||||
|
listsController.updateItemImage
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/:householdId/stores/:storeId/list",
|
"/:householdId/stores/:storeId/list",
|
||||||
auth,
|
auth,
|
||||||
|
|||||||
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(),
|
query: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("../models/list.model.v2", () => ({
|
||||||
|
recordItemEvent: jest.fn(),
|
||||||
|
setCatalogItemImage: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const pool = require("../db/pool");
|
const pool = require("../db/pool");
|
||||||
|
const List = require("../models/list.model.v2");
|
||||||
const AvailableItems = require("../models/available-item.model");
|
const AvailableItems = require("../models/available-item.model");
|
||||||
|
|
||||||
describe("available-item.model", () => {
|
describe("available-item.model", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pool.query.mockReset();
|
pool.query.mockReset();
|
||||||
|
List.recordItemEvent.mockReset();
|
||||||
|
List.setCatalogItemImage.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists household store items", async () => {
|
test("lists household store items", async () => {
|
||||||
@ -58,6 +66,14 @@ describe("available-item.model", () => {
|
|||||||
expect.stringContaining("INSERT INTO household_store_items"),
|
expect.stringContaining("INSERT INTO household_store_items"),
|
||||||
[1, 2, "granola", "granola"]
|
[1, 2, "granola", "granola"]
|
||||||
);
|
);
|
||||||
|
expect(List.recordItemEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: "ITEM_ADDED",
|
||||||
|
householdId: 1,
|
||||||
|
storeLocationId: 2,
|
||||||
|
householdStoreItemId: 77,
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("updates household store item images and returns refreshed data", async () => {
|
test("updates household store item images and returns refreshed data", async () => {
|
||||||
@ -78,19 +94,37 @@ describe("available-item.model", () => {
|
|||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
expect.stringContaining("UPDATE household_store_items"),
|
expect.stringContaining("UPDATE household_store_items"),
|
||||||
[1, 2, 55, imageBuffer, "image/jpeg"]
|
[1, 2, 55]
|
||||||
|
);
|
||||||
|
expect(List.setCatalogItemImage).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
55,
|
||||||
|
imageBuffer,
|
||||||
|
"image/jpeg",
|
||||||
|
null
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("deletes the household store item", async () => {
|
test("deletes the household store item", async () => {
|
||||||
pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ item_id: 55, item_name: "milk" }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55);
|
const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55);
|
||||||
|
|
||||||
expect(deleted).toBe(true);
|
expect(deleted).toBe(true);
|
||||||
expect(pool.query).toHaveBeenCalledWith(
|
expect(pool.query).toHaveBeenLastCalledWith(
|
||||||
expect.stringContaining("DELETE FROM household_store_items"),
|
expect.stringContaining("DELETE FROM household_store_items"),
|
||||||
[1, 2, 55]
|
[1, 2, 55]
|
||||||
);
|
);
|
||||||
|
expect(List.recordItemEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: "ITEM_DELETED",
|
||||||
|
householdId: 1,
|
||||||
|
storeLocationId: 2,
|
||||||
|
householdStoreItemId: 55,
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,6 +9,8 @@ jest.mock("../models/available-item.model", () => ({
|
|||||||
|
|
||||||
jest.mock("../models/list.model.v2", () => ({
|
jest.mock("../models/list.model.v2", () => ({
|
||||||
deleteClassification: jest.fn(),
|
deleteClassification: jest.fn(),
|
||||||
|
getZoneByName: jest.fn(),
|
||||||
|
recordItemEvent: jest.fn(),
|
||||||
upsertClassification: jest.fn(),
|
upsertClassification: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -42,7 +44,9 @@ describe("available-items.controller", () => {
|
|||||||
AvailableItems.deleteAvailableItem.mockResolvedValue(true);
|
AvailableItems.deleteAvailableItem.mockResolvedValue(true);
|
||||||
AvailableItems.importCurrentListItems.mockResolvedValue(2);
|
AvailableItems.importCurrentListItems.mockResolvedValue(2);
|
||||||
AvailableItems.listAvailableItems.mockResolvedValue([]);
|
AvailableItems.listAvailableItems.mockResolvedValue([]);
|
||||||
List.upsertClassification.mockResolvedValue(undefined);
|
List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" });
|
||||||
|
List.recordItemEvent.mockResolvedValue(undefined);
|
||||||
|
List.upsertClassification.mockResolvedValue({ zone_id: 5 });
|
||||||
List.deleteClassification.mockResolvedValue(false);
|
List.deleteClassification.mockResolvedValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -58,12 +62,13 @@ describe("available-items.controller", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
await controller.createAvailableItem(req, res);
|
await controller.createAvailableItem(req, res);
|
||||||
|
|
||||||
expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null);
|
expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null, 7);
|
||||||
expect(List.upsertClassification).toHaveBeenCalledWith(
|
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||||
"1",
|
"1",
|
||||||
"2",
|
"2",
|
||||||
@ -87,6 +92,7 @@ describe("available-items.controller", () => {
|
|||||||
item_group: "Bread",
|
item_group: "Bread",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
@ -110,6 +116,7 @@ describe("available-items.controller", () => {
|
|||||||
classification: "null",
|
classification: "null",
|
||||||
},
|
},
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
@ -122,6 +129,7 @@ describe("available-items.controller", () => {
|
|||||||
test("imports current list items and reports the import count", async () => {
|
test("imports current list items and reports the import count", async () => {
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
@ -138,12 +146,13 @@ describe("available-items.controller", () => {
|
|||||||
test("deletes a store item", async () => {
|
test("deletes a store item", async () => {
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2", itemId: "99" },
|
params: { householdId: "1", storeId: "2", itemId: "99" },
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
await controller.deleteAvailableItem(req, res);
|
await controller.deleteAvailableItem(req, res);
|
||||||
|
|
||||||
expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99);
|
expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99, 7);
|
||||||
expect(List.deleteClassification).not.toHaveBeenCalled();
|
expect(List.deleteClassification).not.toHaveBeenCalled();
|
||||||
expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" });
|
expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" });
|
||||||
});
|
});
|
||||||
@ -152,6 +161,7 @@ describe("available-items.controller", () => {
|
|||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
query: {},
|
query: {},
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
@ -177,6 +187,7 @@ describe("available-items.controller", () => {
|
|||||||
item_name: "milk",
|
item_name: "milk",
|
||||||
},
|
},
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
|
user: { id: 7 },
|
||||||
};
|
};
|
||||||
const res = createResponse();
|
const res = createResponse();
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,10 @@ jest.mock("../middleware/household", () => ({
|
|||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
},
|
},
|
||||||
|
locationAccess: (req, res, next) => {
|
||||||
|
req.storeLocation = { id: Number.parseInt(req.params.locationId, 10) };
|
||||||
|
next();
|
||||||
|
},
|
||||||
requireHouseholdAdmin: (req, res, next) => {
|
requireHouseholdAdmin: (req, res, next) => {
|
||||||
if (["owner", "admin"].includes(req.household?.role)) {
|
if (["owner", "admin"].includes(req.household?.role)) {
|
||||||
return next();
|
return next();
|
||||||
@ -39,6 +43,7 @@ jest.mock("../controllers/households.controller", () => ({
|
|||||||
joinHousehold: jest.fn(),
|
joinHousehold: jest.fn(),
|
||||||
refreshInviteCode: jest.fn(),
|
refreshInviteCode: jest.fn(),
|
||||||
removeMember: jest.fn(),
|
removeMember: jest.fn(),
|
||||||
|
reorderHouseholds: jest.fn(),
|
||||||
updateHousehold: jest.fn(),
|
updateHousehold: jest.fn(),
|
||||||
updateMemberRole: jest.fn(),
|
updateMemberRole: jest.fn(),
|
||||||
}));
|
}));
|
||||||
@ -65,6 +70,21 @@ jest.mock("../controllers/available-items.controller", () => ({
|
|||||||
updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })),
|
updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("../controllers/stores.controller", () => ({
|
||||||
|
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
|
||||||
|
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
|
||||||
|
createZone: jest.fn((req, res) => res.status(201).json({ message: "zone" })),
|
||||||
|
deleteHouseholdStore: jest.fn((req, res) => res.json({ message: "deleted store" })),
|
||||||
|
deleteLocation: jest.fn((req, res) => res.json({ message: "deleted location" })),
|
||||||
|
deleteZone: jest.fn((req, res) => res.json({ message: "deleted zone" })),
|
||||||
|
getHouseholdStores: jest.fn((req, res) => res.json([])),
|
||||||
|
getLocationZones: jest.fn((req, res) => res.json({ zones: [] })),
|
||||||
|
setDefaultLocation: jest.fn((req, res) => res.json({ message: "default" })),
|
||||||
|
updateHouseholdStore: jest.fn((req, res) => res.json({ message: "updated store" })),
|
||||||
|
updateLocation: jest.fn((req, res) => res.json({ message: "updated location" })),
|
||||||
|
updateZone: jest.fn((req, res) => res.json({ message: "updated zone" })),
|
||||||
|
}));
|
||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const request = require("supertest");
|
const request = require("supertest");
|
||||||
const router = require("../routes/households.routes");
|
const router = require("../routes/households.routes");
|
||||||
@ -106,4 +126,23 @@ describe("available-items routes", () => {
|
|||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
|
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("members can create available items on location-scoped routes", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/households/1/locations/2/available-items")
|
||||||
|
.set("x-household-role", "member")
|
||||||
|
.send({ item_name: "milk" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members cannot delete available items on location-scoped routes", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.delete("/households/1/locations/2/available-items/3")
|
||||||
|
.set("x-household-role", "member");
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(availableItemsController.deleteAvailableItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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", () => ({
|
jest.mock("../models/household.model", () => ({
|
||||||
getUserRole: jest.fn(),
|
getUserRole: jest.fn(),
|
||||||
|
reorderUserHouseholds: jest.fn(),
|
||||||
transferOwnership: jest.fn(),
|
transferOwnership: jest.fn(),
|
||||||
updateMemberRole: jest.fn(),
|
updateMemberRole: jest.fn(),
|
||||||
}));
|
}));
|
||||||
@ -86,3 +87,72 @@ describe("households.controller updateMemberRole", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("households.controller reorderHouseholds", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
householdModel.reorderUserHouseholds.mockResolvedValue([
|
||||||
|
{ id: 3, name: "Third" },
|
||||||
|
{ id: 1, name: "First" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates the current user's household order", async () => {
|
||||||
|
const req = {
|
||||||
|
body: { household_ids: [3, 1] },
|
||||||
|
user: { id: 9 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.reorderHouseholds(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.reorderUserHouseholds).toHaveBeenCalledWith(9, [3, 1]);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
message: "Household order updated successfully",
|
||||||
|
households: [
|
||||||
|
{ id: 3, name: "Third" },
|
||||||
|
{ id: 1, name: "First" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects duplicate household IDs", async () => {
|
||||||
|
const req = {
|
||||||
|
body: { household_ids: [3, 3] },
|
||||||
|
user: { id: 9 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.reorderHouseholds(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.reorderUserHouseholds).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "household_ids must contain unique positive household IDs",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects orders that do not match current memberships", async () => {
|
||||||
|
householdModel.reorderUserHouseholds.mockResolvedValue(null);
|
||||||
|
const req = {
|
||||||
|
body: { household_ids: [999] },
|
||||||
|
user: { id: 9 },
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.reorderHouseholds(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Household order must include every household you belong to",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -24,6 +24,10 @@ describe("list.model.v2 addOrUpdateItem", () => {
|
|||||||
itemId: 55,
|
itemId: 55,
|
||||||
householdStoreItemId: 55,
|
householdStoreItemId: 55,
|
||||||
itemName: "milk",
|
itemName: "milk",
|
||||||
|
quantity: 3,
|
||||||
|
previousQuantity: 0,
|
||||||
|
historyQuantity: 3,
|
||||||
|
wasBought: false,
|
||||||
isNew: true,
|
isNew: true,
|
||||||
});
|
});
|
||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
@ -41,7 +45,7 @@ describe("list.model.v2 addOrUpdateItem", () => {
|
|||||||
test("returns household store item metadata when updating an existing list item", async () => {
|
test("returns household store item metadata when updating an existing list item", async () => {
|
||||||
pool.query
|
pool.query
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false }] })
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false, quantity: 2 }] })
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
|
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
|
||||||
@ -51,14 +55,48 @@ describe("list.model.v2 addOrUpdateItem", () => {
|
|||||||
itemId: 55,
|
itemId: 55,
|
||||||
householdStoreItemId: 55,
|
householdStoreItemId: 55,
|
||||||
itemName: "milk",
|
itemName: "milk",
|
||||||
|
quantity: 4,
|
||||||
|
previousQuantity: 2,
|
||||||
|
historyQuantity: 2,
|
||||||
|
wasBought: false,
|
||||||
isNew: false,
|
isNew: false,
|
||||||
});
|
});
|
||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
expect(pool.query).toHaveBeenNthCalledWith(
|
||||||
3,
|
3,
|
||||||
expect.stringContaining("UPDATE household_lists"),
|
expect.stringContaining("UPDATE household_lists"),
|
||||||
[4, 88]
|
[4, undefined, 88]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses the full requested quantity when reopening a bought list item", async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: true, quantity: 2 }] })
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
|
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
listId: 88,
|
||||||
|
quantity: 4,
|
||||||
|
previousQuantity: 2,
|
||||||
|
historyQuantity: 4,
|
||||||
|
wasBought: true,
|
||||||
|
isNew: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("limits added_by_users to history entries that account for current quantity", async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({ rowCount: 0, rows: [] });
|
||||||
|
|
||||||
|
await List.getHouseholdStoreList(1, 2);
|
||||||
|
|
||||||
|
const sql = pool.query.mock.calls[0][0];
|
||||||
|
expect(sql).toContain("ORDER BY hlh.added_on DESC, hlh.id DESC");
|
||||||
|
expect(sql).toContain("active_history.newer_quantity < GREATEST(hl.quantity, 0)");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("list.model.v2 classification helpers", () => {
|
describe("list.model.v2 classification helpers", () => {
|
||||||
@ -66,7 +104,7 @@ describe("list.model.v2 classification helpers", () => {
|
|||||||
pool.query.mockReset();
|
pool.query.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("gets classification using household, store, and household-store item ids", async () => {
|
test("gets classification using household, location, and household-store item ids", async () => {
|
||||||
pool.query.mockResolvedValueOnce({
|
pool.query.mockResolvedValueOnce({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
rows: [
|
rows: [
|
||||||
@ -95,17 +133,23 @@ describe("list.model.v2 classification helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("upserts classification using household-store item conflict target", async () => {
|
test("upserts classification using household-location item conflict target", async () => {
|
||||||
pool.query.mockResolvedValueOnce({
|
pool.query
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ id: 12, name: "Dairy & Refrigerated", sort_order: 60 }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
rows: [
|
rows: [
|
||||||
{
|
{
|
||||||
household_id: 1,
|
household_id: 1,
|
||||||
store_id: 2,
|
store_location_id: 2,
|
||||||
household_store_item_id: 55,
|
household_store_item_id: 55,
|
||||||
item_type: "dairy",
|
item_type: "dairy",
|
||||||
item_group: "Milk",
|
item_group: "Milk",
|
||||||
zone: "Dairy & Refrigerated",
|
zone: "Dairy & Refrigerated",
|
||||||
|
zone_id: 12,
|
||||||
confidence: 1,
|
confidence: 1,
|
||||||
source: "user",
|
source: "user",
|
||||||
},
|
},
|
||||||
@ -123,14 +167,14 @@ describe("list.model.v2 classification helpers", () => {
|
|||||||
expect(result).toEqual(
|
expect(result).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
household_id: 1,
|
household_id: 1,
|
||||||
store_id: 2,
|
store_location_id: 2,
|
||||||
household_store_item_id: 55,
|
household_store_item_id: 55,
|
||||||
item_type: "dairy",
|
item_type: "dairy",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(pool.query).toHaveBeenCalledWith(
|
expect(pool.query).toHaveBeenLastCalledWith(
|
||||||
expect.stringContaining("ON CONFLICT (household_id, store_id, household_store_item_id)"),
|
expect.stringContaining("ON CONFLICT (household_id, store_location_id, household_store_item_id)"),
|
||||||
[1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"]
|
[1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 12, 1, "user"]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,8 @@ jest.mock("../models/list.model.v2", () => ({
|
|||||||
addOrUpdateItem: jest.fn(),
|
addOrUpdateItem: jest.fn(),
|
||||||
ensureHouseholdStoreItem: jest.fn(),
|
ensureHouseholdStoreItem: jest.fn(),
|
||||||
getItemByName: jest.fn(),
|
getItemByName: jest.fn(),
|
||||||
|
getZoneByName: jest.fn(),
|
||||||
|
recordItemEvent: jest.fn(),
|
||||||
upsertClassification: jest.fn(),
|
upsertClassification: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -37,7 +39,9 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
});
|
});
|
||||||
List.addHistoryRecord.mockResolvedValue(undefined);
|
List.addHistoryRecord.mockResolvedValue(undefined);
|
||||||
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
||||||
List.upsertClassification.mockResolvedValue(undefined);
|
List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" });
|
||||||
|
List.recordItemEvent.mockResolvedValue(undefined);
|
||||||
|
List.upsertClassification.mockResolvedValue({ zone_id: 5 });
|
||||||
householdModel.isHouseholdMember.mockResolvedValue(true);
|
householdModel.isHouseholdMember.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -54,7 +58,15 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
|
|
||||||
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
|
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
|
||||||
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||||
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 9);
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 9, "2");
|
||||||
|
expect(List.recordItemEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: "ITEM_ADDED",
|
||||||
|
householdId: "1",
|
||||||
|
storeLocationId: "2",
|
||||||
|
householdStoreItemId: 99,
|
||||||
|
})
|
||||||
|
);
|
||||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -71,10 +83,42 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
|
|
||||||
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||||
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||||
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7);
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7, "2");
|
||||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("records duplicate-add history with the added quantity instead of the new total", async () => {
|
||||||
|
List.addOrUpdateItem.mockResolvedValueOnce({
|
||||||
|
listId: 42,
|
||||||
|
itemId: 99,
|
||||||
|
householdStoreItemId: 99,
|
||||||
|
itemName: "milk",
|
||||||
|
quantity: 3,
|
||||||
|
previousQuantity: 1,
|
||||||
|
historyQuantity: 2,
|
||||||
|
isNew: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: { item_name: "milk", quantity: "3" },
|
||||||
|
user: { id: 7 },
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.addItem(req, res);
|
||||||
|
|
||||||
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, 2, 7, "2");
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
item: expect.objectContaining({
|
||||||
|
quantity: 3,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("records history using request user when added_for_user_id is blank", async () => {
|
test("records history using request user when added_for_user_id is blank", async () => {
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
@ -88,7 +132,7 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
|
|
||||||
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||||
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||||
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7);
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7, "2");
|
||||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -169,7 +213,9 @@ describe("lists.controller.v2 setClassification", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
||||||
List.upsertClassification.mockResolvedValue(undefined);
|
List.upsertClassification.mockResolvedValue({ zone_id: 5 });
|
||||||
|
List.recordItemEvent.mockResolvedValue(undefined);
|
||||||
|
List.getZoneByName.mockResolvedValue({ id: 5, name: "Dairy & Refrigerated" });
|
||||||
List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" });
|
List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -216,6 +262,7 @@ describe("lists.controller.v2 setClassification", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("accepts zone-only classification updates", async () => {
|
test("accepts zone-only classification updates", async () => {
|
||||||
|
List.getZoneByName.mockResolvedValueOnce({ id: 6, name: "Checkout Area" });
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
body: {
|
body: {
|
||||||
@ -297,6 +344,7 @@ describe("lists.controller.v2 setClassification", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("rejects invalid zone", async () => {
|
test("rejects invalid zone", async () => {
|
||||||
|
List.getZoneByName.mockResolvedValueOnce(null);
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
body: {
|
body: {
|
||||||
@ -350,6 +398,7 @@ describe("lists.controller.v2 setClassification", () => {
|
|||||||
|
|
||||||
test("creates a household store item when classification target is not yet on the list", async () => {
|
test("creates a household store item when classification target is not yet on the list", async () => {
|
||||||
List.getItemByName.mockResolvedValueOnce(null);
|
List.getItemByName.mockResolvedValueOnce(null);
|
||||||
|
List.getZoneByName.mockResolvedValueOnce({ id: 7, name: "Snacks & Candy" });
|
||||||
|
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
|||||||
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
|
## Table of Contents
|
||||||
- [Authentication](#authentication)
|
- [Authentication](#authentication)
|
||||||
|
- [Current Household Location Scope](#current-household-location-scope)
|
||||||
- [Grocery List Management](#grocery-list-management)
|
- [Grocery List Management](#grocery-list-management)
|
||||||
- [User Management](#user-management)
|
- [User Management](#user-management)
|
||||||
- [Admin Operations](#admin-operations)
|
- [Admin Operations](#admin-operations)
|
||||||
@ -12,6 +13,38 @@ Base URL: `http://localhost:5000/api`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Current Household Location Scope
|
||||||
|
|
||||||
|
The active grocery flow is scoped by household-owned store locations, not the legacy global store catalog.
|
||||||
|
|
||||||
|
- List household store locations: `GET /households/:householdId/stores`
|
||||||
|
- Create a household-owned store with an initial location: `POST /households/:householdId/stores`
|
||||||
|
- Add a location to a household store: `POST /households/:householdId/stores/:householdStoreId/locations`
|
||||||
|
- Set the default shopping location: `PATCH /households/:householdId/locations/:locationId/default`
|
||||||
|
- Manage ordered zones for a location: `GET|POST /households/:householdId/locations/:locationId/zones`
|
||||||
|
- Update/remove a zone: `PATCH|DELETE /households/:householdId/locations/:locationId/zones/:zoneId`
|
||||||
|
- Location-scoped list APIs: `/households/:householdId/locations/:locationId/list...`
|
||||||
|
- Location-scoped item catalog APIs: `/households/:householdId/locations/:locationId/available-items...`
|
||||||
|
|
||||||
|
Owners/admins manage stores, locations, zones, and catalog deletion. Members can add/update list items and catalog item details.
|
||||||
|
|
||||||
|
### Household Switcher Order
|
||||||
|
|
||||||
|
The current user can persist their own household switcher order:
|
||||||
|
|
||||||
|
- Update household order: `PATCH /households/order`
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"household_ids": [3, 1, 2]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The list must contain each household the current user belongs to exactly once. The response returns the reordered household list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
All authenticated endpoints require a JWT token in the `Authorization` header:
|
All authenticated endpoints require a JWT token in the `Authorization` header:
|
||||||
@ -129,7 +162,7 @@ Retrieve all unbought grocery items.
|
|||||||
- `bought` - Purchase status (always false for this endpoint)
|
- `bought` - Purchase status (always false for this endpoint)
|
||||||
- `item_image` - Base64 encoded image (nullable)
|
- `item_image` - Base64 encoded image (nullable)
|
||||||
- `image_mime_type` - MIME type of image (nullable)
|
- `image_mime_type` - MIME type of image (nullable)
|
||||||
- `added_by_users` - Array of user names who added/modified this item
|
- `added_by_users` - Array of user names whose additions account for the current listed quantity
|
||||||
- `modified_on` - Last modification timestamp
|
- `modified_on` - Last modification timestamp
|
||||||
- `item_type` - Classification type (nullable)
|
- `item_type` - Classification type (nullable)
|
||||||
- `item_group` - Classification group (nullable)
|
- `item_group` - Classification group (nullable)
|
||||||
|
|||||||
@ -9,7 +9,7 @@ function appendClassification(formData, classification) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getAvailableItems = (householdId, storeId, query = "") =>
|
export const getAvailableItems = (householdId, storeId, query = "") =>
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/available-items`, {
|
api.get(`/households/${householdId}/locations/${storeId}/available-items`, {
|
||||||
params: query ? { query } : undefined,
|
params: query ? { query } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ export const createAvailableItem = (householdId, storeId, payload) => {
|
|||||||
formData.append("image", payload.imageFile);
|
formData.append("image", payload.imageFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.post(`/households/${householdId}/stores/${storeId}/available-items`, formData, {
|
return api.post(`/households/${householdId}/locations/${storeId}/available-items`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
@ -41,7 +41,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => {
|
|||||||
formData.append("image", payload.imageFile);
|
formData.append("image", payload.imageFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.patch(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`, formData, {
|
return api.patch(`/households/${householdId}/locations/${storeId}/available-items/${itemId}`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
@ -49,7 +49,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const deleteAvailableItem = (householdId, storeId, itemId) =>
|
export const deleteAvailableItem = (householdId, storeId, itemId) =>
|
||||||
api.delete(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`);
|
api.delete(`/households/${householdId}/locations/${storeId}/available-items/${itemId}`);
|
||||||
|
|
||||||
export const importCurrentAvailableItems = (householdId, storeId) =>
|
export const importCurrentAvailableItems = (householdId, storeId) =>
|
||||||
api.post(`/households/${householdId}/stores/${storeId}/available-items/import-current`);
|
api.post(`/households/${householdId}/locations/${storeId}/available-items/import-current`);
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { API_BASE_URL } from "../config";
|
import { API_BASE_URL } from "../config";
|
||||||
|
import {
|
||||||
|
cacheApiResponse,
|
||||||
|
getCachedApiResponse,
|
||||||
|
isTransientApiError,
|
||||||
|
} from "./offlineCache";
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
@ -31,6 +36,7 @@ api.interceptors.response.use(
|
|||||||
response.request_id = payload.request_id;
|
response.request_id = payload.request_id;
|
||||||
response.data = payload.data;
|
response.data = payload.data;
|
||||||
}
|
}
|
||||||
|
cacheApiResponse(response.config, response.data);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
error => {
|
error => {
|
||||||
@ -55,6 +61,23 @@ api.interceptors.response.use(
|
|||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
alert("Your session has expired. Please log in again.");
|
alert("Your session has expired. Please log in again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,12 @@ import api from "./axios";
|
|||||||
*/
|
*/
|
||||||
export const getUserHouseholds = () => api.get("/households");
|
export const getUserHouseholds = () => api.get("/households");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current user's household switcher order
|
||||||
|
*/
|
||||||
|
export const reorderHouseholds = (householdIds) =>
|
||||||
|
api.patch("/households/order", { household_ids: householdIds });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get details of a specific household
|
* Get details of a specific household
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -4,13 +4,13 @@ import api from "./axios";
|
|||||||
* Get grocery list for household and store
|
* Get grocery list for household and store
|
||||||
*/
|
*/
|
||||||
export const getList = (householdId, storeId) =>
|
export const getList = (householdId, storeId) =>
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/list`);
|
api.get(`/households/${householdId}/locations/${storeId}/list`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get specific item by name
|
* Get specific item by name
|
||||||
*/
|
*/
|
||||||
export const getItemByName = (householdId, storeId, itemName) =>
|
export const getItemByName = (householdId, storeId, itemName) =>
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/list/item`, {
|
api.get(`/households/${householdId}/locations/${storeId}/list/item`, {
|
||||||
params: { item_name: itemName }
|
params: { item_name: itemName }
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ export const addItem = (
|
|||||||
formData.append("image", imageFile);
|
formData.append("image", imageFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, {
|
return api.post(`/households/${householdId}/locations/${storeId}/list/add`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
@ -50,7 +50,7 @@ export const addItem = (
|
|||||||
* Get item classification
|
* Get item classification
|
||||||
*/
|
*/
|
||||||
export const getClassification = (householdId, storeId, itemName) =>
|
export const getClassification = (householdId, storeId, itemName) =>
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/list/classification`, {
|
api.get(`/households/${householdId}/locations/${storeId}/list/classification`, {
|
||||||
params: { item_name: itemName }
|
params: { item_name: itemName }
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -58,7 +58,7 @@ export const getClassification = (householdId, storeId, itemName) =>
|
|||||||
* Set item classification
|
* Set item classification
|
||||||
*/
|
*/
|
||||||
export const setClassification = (householdId, storeId, itemName, classification) =>
|
export const setClassification = (householdId, storeId, itemName, classification) =>
|
||||||
api.post(`/households/${householdId}/stores/${storeId}/list/classification`, {
|
api.post(`/households/${householdId}/locations/${storeId}/list/classification`, {
|
||||||
item_name: itemName,
|
item_name: itemName,
|
||||||
classification
|
classification
|
||||||
});
|
});
|
||||||
@ -104,7 +104,7 @@ export const updateItemWithClassification = (householdId, storeId, itemName, qua
|
|||||||
* Update item details (quantity, notes)
|
* Update item details (quantity, notes)
|
||||||
*/
|
*/
|
||||||
export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
|
export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
|
||||||
api.put(`/households/${householdId}/stores/${storeId}/list/item`, {
|
api.put(`/households/${householdId}/locations/${storeId}/list/item`, {
|
||||||
item_name: itemName,
|
item_name: itemName,
|
||||||
quantity,
|
quantity,
|
||||||
notes
|
notes
|
||||||
@ -114,7 +114,7 @@ export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
|
|||||||
* Mark item as bought or unbought
|
* Mark item as bought or unbought
|
||||||
*/
|
*/
|
||||||
export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) =>
|
export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) =>
|
||||||
api.patch(`/households/${householdId}/stores/${storeId}/list/item`, {
|
api.patch(`/households/${householdId}/locations/${storeId}/list/item`, {
|
||||||
item_name: itemName,
|
item_name: itemName,
|
||||||
bought,
|
bought,
|
||||||
quantity_bought: quantityBought
|
quantity_bought: quantityBought
|
||||||
@ -124,7 +124,7 @@ export const markBought = (householdId, storeId, itemName, quantityBought = null
|
|||||||
* Delete item from list
|
* Delete item from list
|
||||||
*/
|
*/
|
||||||
export const deleteItem = (householdId, storeId, itemName) =>
|
export const deleteItem = (householdId, storeId, itemName) =>
|
||||||
api.delete(`/households/${householdId}/stores/${storeId}/list/item`, {
|
api.delete(`/households/${householdId}/locations/${storeId}/list/item`, {
|
||||||
data: { item_name: itemName }
|
data: { item_name: itemName }
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -132,7 +132,7 @@ export const deleteItem = (householdId, storeId, itemName) =>
|
|||||||
* Get suggestions based on query
|
* Get suggestions based on query
|
||||||
*/
|
*/
|
||||||
export const getSuggestions = (householdId, storeId, query) =>
|
export const getSuggestions = (householdId, storeId, query) =>
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, {
|
api.get(`/households/${householdId}/locations/${storeId}/list/suggestions`, {
|
||||||
params: { query }
|
params: { query }
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -140,7 +140,7 @@ export const getSuggestions = (householdId, storeId, query) =>
|
|||||||
* Get recently bought items
|
* Get recently bought items
|
||||||
*/
|
*/
|
||||||
export const getRecentlyBought = (householdId, storeId) =>
|
export const getRecentlyBought = (householdId, storeId) =>
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/list/recent`);
|
api.get(`/households/${householdId}/locations/${storeId}/list/recent`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update item image
|
* Update item image
|
||||||
@ -158,7 +158,7 @@ export const updateItemImage = (
|
|||||||
formData.append("quantity", quantity);
|
formData.append("quantity", quantity);
|
||||||
formData.append("image", imageFile);
|
formData.append("image", imageFile);
|
||||||
|
|
||||||
return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, {
|
return api.post(`/households/${householdId}/locations/${storeId}/list/update-image`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
|
|||||||
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";
|
import api from "./axios";
|
||||||
|
|
||||||
/**
|
// Legacy global store catalog for the system-admin page.
|
||||||
* Get all stores in the system
|
|
||||||
*/
|
|
||||||
export const getAllStores = () => api.get("/stores");
|
export const getAllStores = () => api.get("/stores");
|
||||||
|
export const createStore = (name, default_zones) =>
|
||||||
/**
|
api.post("/stores", { name, default_zones });
|
||||||
* Get stores linked to a household
|
export const updateStore = (storeId, name, default_zones) =>
|
||||||
*/
|
api.patch(`/stores/${storeId}`, { name, default_zones });
|
||||||
export const getHouseholdStores = (householdId) =>
|
|
||||||
api.get(`/stores/household/${householdId}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a store to a household
|
|
||||||
*/
|
|
||||||
export const addStoreToHousehold = (householdId, storeId, isDefault = false) =>
|
|
||||||
api.post(`/stores/household/${householdId}`, { storeId: storeId, isDefault: isDefault });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a store from a household
|
|
||||||
*/
|
|
||||||
export const removeStoreFromHousehold = (householdId, storeId) =>
|
|
||||||
api.delete(`/stores/household/${householdId}/${storeId}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a store as default for a household
|
|
||||||
*/
|
|
||||||
export const setDefaultStore = (householdId, storeId) =>
|
|
||||||
api.patch(`/stores/household/${householdId}/${storeId}/default`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new store (system admin only)
|
|
||||||
*/
|
|
||||||
export const createStore = (name, location) =>
|
|
||||||
api.post("/stores", { name, location });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update store details (system admin only)
|
|
||||||
*/
|
|
||||||
export const updateStore = (storeId, name, location) =>
|
|
||||||
api.patch(`/stores/${storeId}`, { name, location });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a store (system admin only)
|
|
||||||
*/
|
|
||||||
export const deleteStore = (storeId) =>
|
export const deleteStore = (storeId) =>
|
||||||
api.delete(`/stores/${storeId}`);
|
api.delete(`/stores/${storeId}`);
|
||||||
|
|
||||||
|
// Household-owned store locations used by the grocery flow.
|
||||||
|
export const getHouseholdStores = (householdId) =>
|
||||||
|
api.get(`/households/${householdId}/stores`);
|
||||||
|
|
||||||
|
export const createHouseholdStore = (householdId, payload) =>
|
||||||
|
api.post(`/households/${householdId}/stores`, payload);
|
||||||
|
|
||||||
|
export const updateHouseholdStore = (householdId, householdStoreId, payload) =>
|
||||||
|
api.patch(`/households/${householdId}/stores/${householdStoreId}`, payload);
|
||||||
|
|
||||||
|
export const deleteHouseholdStore = (householdId, householdStoreId) =>
|
||||||
|
api.delete(`/households/${householdId}/stores/${householdStoreId}`);
|
||||||
|
|
||||||
|
export const addLocationToStore = (householdId, householdStoreId, payload) =>
|
||||||
|
api.post(`/households/${householdId}/stores/${householdStoreId}/locations`, payload);
|
||||||
|
|
||||||
|
export const updateLocation = (householdId, locationId, payload) =>
|
||||||
|
api.patch(`/households/${householdId}/locations/${locationId}`, payload);
|
||||||
|
|
||||||
|
export const removeLocation = (householdId, locationId) =>
|
||||||
|
api.delete(`/households/${householdId}/locations/${locationId}`);
|
||||||
|
|
||||||
|
export const setDefaultLocation = (householdId, locationId) =>
|
||||||
|
api.patch(`/households/${householdId}/locations/${locationId}/default`);
|
||||||
|
|
||||||
|
export const getLocationZones = (householdId, locationId) =>
|
||||||
|
api.get(`/households/${householdId}/locations/${locationId}/zones`);
|
||||||
|
|
||||||
|
export const createLocationZone = (householdId, locationId, payload) =>
|
||||||
|
api.post(`/households/${householdId}/locations/${locationId}/zones`, payload);
|
||||||
|
|
||||||
|
export const updateLocationZone = (householdId, locationId, zoneId, payload) =>
|
||||||
|
api.patch(`/households/${householdId}/locations/${locationId}/zones/${zoneId}`, payload);
|
||||||
|
|
||||||
|
export const deleteLocationZone = (householdId, locationId, zoneId) =>
|
||||||
|
api.delete(`/households/${householdId}/locations/${locationId}/zones/${zoneId}`);
|
||||||
|
|
||||||
|
// Compatibility aliases for older callers.
|
||||||
|
export const addStoreToHousehold = (householdId, storeId, isDefault = false) =>
|
||||||
|
api.post(`/stores/household/${householdId}`, { storeId, isDefault });
|
||||||
|
export const removeStoreFromHousehold = (householdId, storeId) =>
|
||||||
|
api.delete(`/stores/household/${householdId}/${storeId}`);
|
||||||
|
export const setDefaultStore = (householdId, storeId) =>
|
||||||
|
api.patch(`/stores/household/${householdId}/${storeId}/default`);
|
||||||
|
|||||||
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 ErrorMessage } from './ErrorMessage.jsx';
|
||||||
export { default as FloatingActionButton } from './FloatingActionButton.jsx';
|
export { default as FloatingActionButton } from './FloatingActionButton.jsx';
|
||||||
export { default as FormInput } from './FormInput.jsx';
|
export { default as FormInput } from './FormInput.jsx';
|
||||||
export { default as SortDropdown } from './SortDropdown.jsx';
|
export { default as ListSearchInput } from './ListSearchInput.jsx';
|
||||||
export { default as ToggleButtonGroup } from './ToggleButtonGroup.jsx';
|
export { default as ToggleButtonGroup } from './ToggleButtonGroup.jsx';
|
||||||
export { default as UserRoleCard } from './UserRoleCard.jsx';
|
export { default as UserRoleCard } from './UserRoleCard.jsx';
|
||||||
|
|
||||||
|
|||||||
@ -21,11 +21,15 @@ export default function ClassificationSection({
|
|||||||
onItemTypeChange,
|
onItemTypeChange,
|
||||||
onItemGroupChange,
|
onItemGroupChange,
|
||||||
onZoneChange,
|
onZoneChange,
|
||||||
|
zones = null,
|
||||||
title = "Item Classification (Optional)",
|
title = "Item Classification (Optional)",
|
||||||
fieldClass = "classification-field",
|
fieldClass = "classification-field",
|
||||||
selectClass = "classification-select"
|
selectClass = "classification-select"
|
||||||
}) {
|
}) {
|
||||||
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
|
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
|
||||||
|
const zoneOptions = Array.isArray(zones) && zones.length > 0
|
||||||
|
? zones.map((candidate) => candidate.name || candidate).filter(Boolean)
|
||||||
|
: getZoneValues();
|
||||||
|
|
||||||
const handleTypeChange = (e) => {
|
const handleTypeChange = (e) => {
|
||||||
const newType = e.target.value;
|
const newType = e.target.value;
|
||||||
@ -35,7 +39,7 @@ export default function ClassificationSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="classification-section">
|
<div className="classification-section">
|
||||||
<h3 className="classification-title">{title}</h3>
|
{title && <h3 className="classification-title">{title}</h3>}
|
||||||
|
|
||||||
<div className={fieldClass}>
|
<div className={fieldClass}>
|
||||||
<label>Item Type</label>
|
<label>Item Type</label>
|
||||||
@ -79,7 +83,7 @@ export default function ClassificationSection({
|
|||||||
className={selectClass}
|
className={selectClass}
|
||||||
>
|
>
|
||||||
<option value="">-- Select Zone --</option>
|
<option value="">-- Select Zone --</option>
|
||||||
{getZoneValues().map((z) => (
|
{zoneOptions.map((z) => (
|
||||||
<option key={z} value={z}>
|
<option key={z} value={z}>
|
||||||
{z}
|
{z}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@ -9,12 +9,16 @@ import "../../styles/components/ImageUploadSection.css";
|
|||||||
* @param {Function} props.onImageChange - Callback when image is selected (file)
|
* @param {Function} props.onImageChange - Callback when image is selected (file)
|
||||||
* @param {Function} props.onImageRemove - Callback to remove image
|
* @param {Function} props.onImageRemove - Callback to remove image
|
||||||
* @param {string} props.title - Section title (optional)
|
* @param {string} props.title - Section title (optional)
|
||||||
|
* @param {string} props.cameraLabel - Camera button label (optional)
|
||||||
|
* @param {string} props.galleryLabel - Gallery button label (optional)
|
||||||
*/
|
*/
|
||||||
export default function ImageUploadSection({
|
export default function ImageUploadSection({
|
||||||
imagePreview,
|
imagePreview,
|
||||||
onImageChange,
|
onImageChange,
|
||||||
onImageRemove,
|
onImageRemove,
|
||||||
title = "Item Image (Optional)"
|
title = "Item Image (Optional)",
|
||||||
|
cameraLabel = "Use Camera",
|
||||||
|
galleryLabel = "Choose from Gallery"
|
||||||
}) {
|
}) {
|
||||||
const cameraInputRef = useRef(null);
|
const cameraInputRef = useRef(null);
|
||||||
const galleryInputRef = useRef(null);
|
const galleryInputRef = useRef(null);
|
||||||
@ -51,7 +55,7 @@ export default function ImageUploadSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="image-upload-section">
|
<div className="image-upload-section">
|
||||||
<h3 className="image-upload-title">{title}</h3>
|
{title && <h3 className="image-upload-title">{title}</h3>}
|
||||||
{sizeError && (
|
{sizeError && (
|
||||||
<div className="image-upload-error">
|
<div className="image-upload-error">
|
||||||
{sizeError}
|
{sizeError}
|
||||||
@ -60,10 +64,20 @@ export default function ImageUploadSection({
|
|||||||
<div className="image-upload-content">
|
<div className="image-upload-content">
|
||||||
{!imagePreview ? (
|
{!imagePreview ? (
|
||||||
<div className="image-upload-options">
|
<div className="image-upload-options">
|
||||||
<button onClick={handleCameraClick} className="image-upload-btn camera" type="button">
|
<button
|
||||||
|
onClick={handleCameraClick}
|
||||||
|
className="image-upload-btn camera"
|
||||||
|
type="button"
|
||||||
|
aria-label={cameraLabel}
|
||||||
|
>
|
||||||
📷 Use Camera
|
📷 Use Camera
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleGalleryClick} className="image-upload-btn gallery" type="button">
|
<button
|
||||||
|
onClick={handleGalleryClick}
|
||||||
|
className="image-upload-btn gallery"
|
||||||
|
type="button"
|
||||||
|
aria-label={galleryLabel}
|
||||||
|
>
|
||||||
🖼️ Choose from Gallery
|
🖼️ Choose from Gallery
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||||
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||||
import "../../styles/components/HouseholdSwitcher.css";
|
import "../../styles/components/HouseholdSwitcher.css";
|
||||||
import CreateJoinHousehold from "../manage/CreateJoinHousehold";
|
import CreateJoinHousehold from "../manage/CreateJoinHousehold";
|
||||||
|
|
||||||
@ -8,10 +10,13 @@ export default function HouseholdSwitcher() {
|
|||||||
households,
|
households,
|
||||||
activeHousehold,
|
activeHousehold,
|
||||||
setActiveHousehold,
|
setActiveHousehold,
|
||||||
|
reorderHouseholds,
|
||||||
loading,
|
loading,
|
||||||
hasLoaded,
|
hasLoaded,
|
||||||
} = useContext(HouseholdContext);
|
} = useContext(HouseholdContext);
|
||||||
|
const toast = useActionToast();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isReordering, setIsReordering] = useState(false);
|
||||||
const [showCreateJoin, setShowCreateJoin] = useState(false);
|
const [showCreateJoin, setShowCreateJoin] = useState(false);
|
||||||
|
|
||||||
if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) {
|
if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) {
|
||||||
@ -49,6 +54,28 @@ export default function HouseholdSwitcher() {
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMove = async (household, currentIndex, direction) => {
|
||||||
|
const nextIndex = currentIndex + direction;
|
||||||
|
if (nextIndex < 0 || nextIndex >= households.length || isReordering) return;
|
||||||
|
|
||||||
|
const nextHouseholds = [...households];
|
||||||
|
[nextHouseholds[currentIndex], nextHouseholds[nextIndex]] = [
|
||||||
|
nextHouseholds[nextIndex],
|
||||||
|
nextHouseholds[currentIndex],
|
||||||
|
];
|
||||||
|
|
||||||
|
setIsReordering(true);
|
||||||
|
try {
|
||||||
|
await reorderHouseholds(nextHouseholds.map((nextHousehold) => nextHousehold.id));
|
||||||
|
toast.success("Updated household order", `Moved ${household.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to update household order");
|
||||||
|
toast.error("Household order failed", `Household order failed: ${message}`);
|
||||||
|
} finally {
|
||||||
|
setIsReordering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="household-switcher">
|
<div className="household-switcher">
|
||||||
<button
|
<button
|
||||||
@ -65,18 +92,48 @@ export default function HouseholdSwitcher() {
|
|||||||
<>
|
<>
|
||||||
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
|
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
|
||||||
<div className="household-switcher-dropdown">
|
<div className="household-switcher-dropdown">
|
||||||
{households.map((household) => (
|
{households.map((household, index) => (
|
||||||
<button
|
<div
|
||||||
key={household.id}
|
key={household.id}
|
||||||
className={`household-option ${household.id === activeHousehold.id ? "active" : ""}`}
|
className={
|
||||||
|
`household-option-row ${household.id === activeHousehold.id ? "active" : ""}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="household-option"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelect(household)}
|
onClick={() => handleSelect(household)}
|
||||||
>
|
>
|
||||||
{household.name}
|
<span className="household-option-name">{household.name}</span>
|
||||||
{household.id === activeHousehold.id && (
|
|
||||||
<span className="check-mark">✓</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="household-divider"></div>
|
<div className="household-divider"></div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -70,6 +70,7 @@ export default function ManageHousehold() {
|
|||||||
const [pendingDecisionId, setPendingDecisionId] = useState(null);
|
const [pendingDecisionId, setPendingDecisionId] = useState(null);
|
||||||
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
|
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
|
||||||
const [pendingRoleChange, setPendingRoleChange] = useState(null);
|
const [pendingRoleChange, setPendingRoleChange] = useState(null);
|
||||||
|
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
|
||||||
|
|
||||||
const isManager = ["owner", "admin"].includes(activeHousehold?.role);
|
const isManager = ["owner", "admin"].includes(activeHousehold?.role);
|
||||||
const isOwner = activeHousehold?.role === "owner";
|
const isOwner = activeHousehold?.role === "owner";
|
||||||
@ -307,12 +308,19 @@ export default function ManageHousehold() {
|
|||||||
setPendingRoleChange({ memberId, nextRole, memberName });
|
setPendingRoleChange({ memberId, nextRole, memberName });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveMember = async (memberId, username) => {
|
const handleRemoveMember = (memberId, username) => {
|
||||||
if (!confirm(`Remove ${username} from this household?`)) return;
|
setPendingMemberRemoval({ memberId, username });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmRemoveMember = async () => {
|
||||||
|
if (!pendingMemberRemoval) return;
|
||||||
|
|
||||||
|
const { memberId, username } = pendingMemberRemoval;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await removeMember(activeHousehold.id, memberId);
|
await removeMember(activeHousehold.id, memberId);
|
||||||
await loadMembers();
|
await loadMembers();
|
||||||
|
setPendingMemberRemoval(null);
|
||||||
toast.success("Removed member", `Removed member ${username}`);
|
toast.success("Removed member", `Removed member ${username}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, "Failed to remove member");
|
const message = getApiErrorMessage(error, "Failed to remove member");
|
||||||
@ -360,9 +368,6 @@ export default function ManageHousehold() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="manage-section-eyebrow">Household</p>
|
<p className="manage-section-eyebrow">Household</p>
|
||||||
<h2>Identity</h2>
|
<h2>Identity</h2>
|
||||||
<p className="section-description">
|
|
||||||
Keep the household name crisp and easy to recognize across invites and shared lists.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{editingName ? (
|
{editingName ? (
|
||||||
@ -408,9 +413,6 @@ export default function ManageHousehold() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="manage-section-eyebrow">Entry Rules</p>
|
<p className="manage-section-eyebrow">Entry Rules</p>
|
||||||
<h2>Invite Links</h2>
|
<h2>Invite Links</h2>
|
||||||
<p className="section-description">
|
|
||||||
Decide how new people can enter, review manual approvals, then create invite links for the flow you want.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{inviteError && <p className="section-error">{inviteError}</p>}
|
{inviteError && <p className="section-error">{inviteError}</p>}
|
||||||
@ -547,9 +549,6 @@ export default function ManageHousehold() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="manage-section-eyebrow">People</p>
|
<p className="manage-section-eyebrow">People</p>
|
||||||
<h2>Members ({members.length})</h2>
|
<h2>Members ({members.length})</h2>
|
||||||
<p className="section-description">
|
|
||||||
Role badges and compact actions make it easier to see who runs the household and who just shops.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@ -563,16 +562,12 @@ export default function ManageHousehold() {
|
|||||||
return (
|
return (
|
||||||
<div key={member.id} className="member-card">
|
<div key={member.id} className="member-card">
|
||||||
<div className="member-main">
|
<div className="member-main">
|
||||||
<div className="member-avatar" aria-hidden="true">{roleMeta.icon}</div>
|
|
||||||
<div className="member-info">
|
<div className="member-info">
|
||||||
<div className="member-topline">
|
|
||||||
<span className={`member-role member-role-${member.role}`}>
|
<span className={`member-role member-role-${member.role}`}>
|
||||||
{roleMeta.icon} {roleMeta.label}
|
{roleMeta.icon} {roleMeta.label}
|
||||||
</span>
|
</span>
|
||||||
{isSelf && <span className="member-self-pill">✨ You</span>}
|
|
||||||
</div>
|
|
||||||
<span className="member-name">{member.username}</span>
|
<span className="member-name">{member.username}</span>
|
||||||
<span className="member-meta">ID #{member.id}</span>
|
{isSelf && <span className="member-self-pill">You</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isManager && !isSelf && member.role !== "owner" && (
|
{isManager && !isSelf && member.role !== "owner" && (
|
||||||
@ -616,11 +611,6 @@ export default function ManageHousehold() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="manage-section-eyebrow">Final Actions</p>
|
<p className="manage-section-eyebrow">Final Actions</p>
|
||||||
<h2>Danger Zone</h2>
|
<h2>Danger Zone</h2>
|
||||||
<p className="section-description">
|
|
||||||
{isMemberOnly
|
|
||||||
? "Leaving removes your access to this household."
|
|
||||||
: "Deleting a household is permanent and will delete all lists, items, and history."}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{isMemberOnly ? (
|
{isMemberOnly ? (
|
||||||
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
|
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
|
||||||
@ -664,6 +654,15 @@ export default function ManageHousehold() {
|
|||||||
onClose={() => setPendingRoleChange(null)}
|
onClose={() => setPendingRoleChange(null)}
|
||||||
onConfirm={handleConfirmRoleChange}
|
onConfirm={handleConfirmRoleChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmSlideModal
|
||||||
|
isOpen={Boolean(pendingMemberRemoval)}
|
||||||
|
title={`Remove ${pendingMemberRemoval?.username || "this member"}?`}
|
||||||
|
description="Slide to confirm. They will lose access to this household."
|
||||||
|
confirmLabel="Remove Member"
|
||||||
|
onClose={() => setPendingMemberRemoval(null)}
|
||||||
|
onConfirm={handleConfirmRemoveMember}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useContext, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
addStoreToHousehold,
|
addLocationToStore,
|
||||||
getAllStores,
|
createHouseholdStore,
|
||||||
removeStoreFromHousehold,
|
createLocationZone,
|
||||||
setDefaultStore
|
deleteLocationZone,
|
||||||
|
getLocationZones,
|
||||||
|
removeLocation,
|
||||||
|
setDefaultLocation,
|
||||||
|
updateLocationZone,
|
||||||
} from "../../api/stores";
|
} from "../../api/stores";
|
||||||
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
|
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
|
||||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||||
@ -13,172 +17,427 @@ import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
|||||||
import "../../styles/components/manage/ManageStores.css";
|
import "../../styles/components/manage/ManageStores.css";
|
||||||
import "../../styles/components/manage/StoreAvailableItemsManager.css";
|
import "../../styles/components/manage/StoreAvailableItemsManager.css";
|
||||||
|
|
||||||
export default function ManageStores() {
|
function groupLocationsByStore(locations) {
|
||||||
const { activeHousehold } = useContext(HouseholdContext);
|
const grouped = new Map();
|
||||||
const { stores: householdStores, refreshStores } = useContext(StoreContext);
|
|
||||||
|
for (const location of locations) {
|
||||||
|
const key = location.household_store_id;
|
||||||
|
if (!grouped.has(key)) {
|
||||||
|
grouped.set(key, {
|
||||||
|
household_store_id: location.household_store_id,
|
||||||
|
name: location.name,
|
||||||
|
locations: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped.get(key).locations.push(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZoneManager({ householdId, location, canManage, refreshActiveZones }) {
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const [allStores, setAllStores] = useState([]);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [zones, setZones] = useState([]);
|
||||||
const [showAddStore, setShowAddStore] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [newZoneName, setNewZoneName] = useState("");
|
||||||
|
|
||||||
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
|
const loadZones = async () => {
|
||||||
|
if (!householdId || !location?.id) return;
|
||||||
useEffect(() => {
|
|
||||||
loadAllStores();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadAllStores = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await getAllStores();
|
const response = await getLocationZones(householdId, location.id);
|
||||||
setAllStores(response.data);
|
setZones(response.data?.zones || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load stores:", error);
|
const message = getApiErrorMessage(error, "Failed to load zones");
|
||||||
|
toast.error("Load zones failed", `Load zones failed: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddStore = async (storeId) => {
|
useEffect(() => {
|
||||||
const storeName = allStores.find((store) => store.id === storeId)?.name || `store #${storeId}`;
|
if (isOpen) {
|
||||||
|
loadZones();
|
||||||
|
}
|
||||||
|
}, [isOpen, householdId, location?.id]);
|
||||||
|
|
||||||
|
const handleCreateZone = async () => {
|
||||||
|
const name = newZoneName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Adding store with ID:", storeId);
|
const nextSortOrder =
|
||||||
await addStoreToHousehold(activeHousehold.id, storeId, false);
|
zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10;
|
||||||
await refreshStores();
|
await createLocationZone(householdId, location.id, {
|
||||||
toast.success("Added store", `Added store ${storeName}`);
|
name,
|
||||||
setShowAddStore(false);
|
sort_order: nextSortOrder,
|
||||||
|
});
|
||||||
|
setNewZoneName("");
|
||||||
|
await loadZones();
|
||||||
|
await refreshActiveZones();
|
||||||
|
toast.success("Added zone", `Added zone ${name}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add store:", error);
|
const message = getApiErrorMessage(error, "Failed to add zone");
|
||||||
const message = getApiErrorMessage(error, "Failed to add store");
|
toast.error("Add zone failed", `Add zone failed: ${message}`);
|
||||||
toast.error("Add store failed", `Add store failed: ${message}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveStore = async (storeId, storeName) => {
|
const handleMoveZone = async (zone, direction) => {
|
||||||
if (!confirm(`Remove ${storeName} from this household?`)) return;
|
const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
|
||||||
|
const swapIndex = currentIndex + direction;
|
||||||
|
if (currentIndex < 0 || swapIndex < 0 || swapIndex >= zones.length) return;
|
||||||
|
|
||||||
|
const other = zones[swapIndex];
|
||||||
try {
|
try {
|
||||||
await removeStoreFromHousehold(activeHousehold.id, storeId);
|
await Promise.all([
|
||||||
await refreshStores();
|
updateLocationZone(householdId, location.id, zone.id, {
|
||||||
toast.success("Removed store", `Removed store ${storeName}`);
|
sort_order: other.sort_order,
|
||||||
|
}),
|
||||||
|
updateLocationZone(householdId, location.id, other.id, {
|
||||||
|
sort_order: zone.sort_order,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await loadZones();
|
||||||
|
await refreshActiveZones();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to remove store:", error);
|
const message = getApiErrorMessage(error, "Failed to reorder zones");
|
||||||
const message = getApiErrorMessage(error, "Failed to remove store");
|
toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
|
||||||
toast.error("Remove store failed", `Remove store failed: ${message}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetDefault = async (storeId) => {
|
const handleDeleteZone = async (zone) => {
|
||||||
const storeName =
|
if (!confirm(`Remove zone "${zone.name}" from ${location.display_name || location.name}?`)) {
|
||||||
householdStores.find((store) => store.id === storeId)?.name || `store #${storeId}`;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDefaultStore(activeHousehold.id, storeId);
|
await deleteLocationZone(householdId, location.id, zone.id);
|
||||||
await refreshStores();
|
await loadZones();
|
||||||
toast.success("Updated default store", `Default store set to ${storeName}`);
|
await refreshActiveZones();
|
||||||
|
toast.success("Removed zone", `Removed zone ${zone.name}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to set default store:", error);
|
const message = getApiErrorMessage(error, "Failed to remove zone");
|
||||||
const message = getApiErrorMessage(error, "Failed to set default store");
|
toast.error("Remove zone failed", `Remove zone failed: ${message}`);
|
||||||
toast.error("Set default store failed", `Set default store failed: ${message}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableStores = allStores.filter(
|
|
||||||
store => !householdStores.some(hs => hs.id === store.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="manage-stores">
|
<div className="store-zones-panel">
|
||||||
{/* Current Stores Section */}
|
|
||||||
<section className="manage-section">
|
|
||||||
<h2>Your Stores ({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.
|
|
||||||
</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>
|
|
||||||
) : (
|
|
||||||
<div className="stores-list">
|
|
||||||
{householdStores.map((store) => (
|
|
||||||
<div key={store.id} className="store-card">
|
|
||||||
<div className="store-info">
|
|
||||||
<h3>{store.name}</h3>
|
|
||||||
{store.location && <p className="store-location">{store.location}</p>}
|
|
||||||
</div>
|
|
||||||
{isAdmin && (
|
|
||||||
<div className="store-actions">
|
|
||||||
{!store.is_default && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSetDefault(store.id)}
|
type="button"
|
||||||
className="btn-secondary btn-small"
|
className="btn-secondary btn-small"
|
||||||
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
>
|
>
|
||||||
Set as Default
|
{isOpen ? "Hide Zones" : "Manage Zones"}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => handleRemoveStore(store.id, store.name)}
|
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"
|
className="btn-danger btn-small"
|
||||||
disabled={householdStores.length === 1}
|
onClick={() => handleDeleteZone(zone)}
|
||||||
title={householdStores.length === 1 ? "Cannot remove last store" : ""}
|
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
||||||
|
<section className="manage-section">
|
||||||
|
<h2>Store Locations ({householdStores.length})</h2>
|
||||||
|
<p className="manage-stores-help">
|
||||||
|
Stores and locations are private to this household. Each location has its own zones,
|
||||||
|
item defaults, and shopping order.
|
||||||
|
</p>
|
||||||
|
{householdStores.length === 0 ? (
|
||||||
|
<p className="empty-message">No store locations added yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="stores-list">
|
||||||
|
{groupedStores.map((storeGroup) => (
|
||||||
|
<div key={storeGroup.household_store_id} className="store-card">
|
||||||
|
<div className="store-info">
|
||||||
|
<h3>{storeGroup.name}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
<StoreAvailableItemsManager
|
||||||
householdId={activeHousehold.id}
|
householdId={activeHousehold.id}
|
||||||
store={store}
|
store={location}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Add Store Section */}
|
{isAdmin ? (
|
||||||
{isAdmin && (
|
<div className="add-location-panel">
|
||||||
<section className="manage-section">
|
<input
|
||||||
<h2>Add Store</h2>
|
value={locationDrafts[storeGroup.household_store_id]?.name || ""}
|
||||||
{!showAddStore ? (
|
onChange={(event) =>
|
||||||
<button onClick={() => setShowAddStore(true)} className="btn-primary">
|
setLocationDrafts((current) => ({
|
||||||
+ Add Store
|
...current,
|
||||||
</button>
|
[storeGroup.household_store_id]: {
|
||||||
) : (
|
...(current[storeGroup.household_store_id] || {}),
|
||||||
<div className="add-store-panel">
|
name: event.target.value,
|
||||||
<button onClick={() => setShowAddStore(false)} className="btn-secondary">
|
},
|
||||||
Cancel
|
}))
|
||||||
</button>
|
}
|
||||||
{loading ? (
|
placeholder="Location name"
|
||||||
<p>Loading stores...</p>
|
/>
|
||||||
) : availableStores.length === 0 ? (
|
<input
|
||||||
<p className="empty-message">All available stores have been added.</p>
|
value={locationDrafts[storeGroup.household_store_id]?.address || ""}
|
||||||
) : (
|
onChange={(event) =>
|
||||||
<div className="available-stores">
|
setLocationDrafts((current) => ({
|
||||||
{availableStores.map((store) => (
|
...current,
|
||||||
<div key={store.id} className="available-store-card">
|
[storeGroup.household_store_id]: {
|
||||||
<div className="store-info">
|
...(current[storeGroup.household_store_id] || {}),
|
||||||
<h3>{store.name}</h3>
|
address: event.target.value,
|
||||||
{store.location && <p className="store-location">{store.location}</p>}
|
},
|
||||||
</div>
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Address or notes"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAddStore(store.id)}
|
type="button"
|
||||||
className="btn-primary btn-small"
|
className="btn-primary btn-small"
|
||||||
|
onClick={() => handleAddLocation(storeGroup.household_store_id, storeGroup.name)}
|
||||||
>
|
>
|
||||||
Add
|
Add Location
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
|
{isAdmin ? (
|
||||||
|
<section className="manage-section">
|
||||||
|
<h2>Add Store</h2>
|
||||||
|
<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>
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
createAvailableItem,
|
||||||
deleteAvailableItem,
|
deleteAvailableItem,
|
||||||
getAvailableItems,
|
getAvailableItems,
|
||||||
updateAvailableItem,
|
updateAvailableItem,
|
||||||
} from "../../api/availableItems";
|
} from "../../api/availableItems";
|
||||||
|
import { getLocationZones } from "../../api/stores";
|
||||||
import useActionToast from "../../hooks/useActionToast";
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||||
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
|
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
|
||||||
@ -22,6 +24,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
|
const [zones, setZones] = useState([]);
|
||||||
const [catalogReady, setCatalogReady] = useState(true);
|
const [catalogReady, setCatalogReady] = useState(true);
|
||||||
const [catalogMessage, setCatalogMessage] = useState("");
|
const [catalogMessage, setCatalogMessage] = useState("");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@ -53,13 +56,29 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
}
|
}
|
||||||
}, [householdId, query, store?.id, toast]);
|
}, [householdId, query, store?.id, toast]);
|
||||||
|
|
||||||
|
const loadZones = useCallback(async () => {
|
||||||
|
if (!householdId || !store?.id) {
|
||||||
|
setZones([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getLocationZones(householdId, store.id);
|
||||||
|
setZones(response.data?.zones || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load location zones:", error);
|
||||||
|
setZones([]);
|
||||||
|
}
|
||||||
|
}, [householdId, store?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadItems(query);
|
loadItems(query);
|
||||||
}, [isOpen, query, loadItems]);
|
loadZones();
|
||||||
|
}, [isOpen, query, loadItems, loadZones]);
|
||||||
|
|
||||||
const closeManager = () => {
|
const closeManager = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@ -76,8 +95,16 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (editorItem?.item_id) {
|
||||||
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
|
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
|
||||||
toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.name}`);
|
toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.display_name || store.name}`);
|
||||||
|
} else {
|
||||||
|
const response = await createAvailableItem(householdId, store.id, payload);
|
||||||
|
toast.success(
|
||||||
|
"Created store item",
|
||||||
|
`Created ${response.data?.item?.item_name || payload.itemName} for ${store.display_name || store.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
setShowEditor(false);
|
setShowEditor(false);
|
||||||
setEditorItem(null);
|
setEditorItem(null);
|
||||||
await loadItems(query);
|
await loadItems(query);
|
||||||
@ -95,7 +122,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
|
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
|
||||||
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.name}`);
|
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`);
|
||||||
setPendingDeleteItem(null);
|
setPendingDeleteItem(null);
|
||||||
await loadItems(query);
|
await loadItems(query);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -104,10 +131,6 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@ -123,8 +146,8 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
|
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
<div className="store-items-modal-header">
|
<div className="store-items-modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>{store.name} Items</h3>
|
<h3>{store.display_name || store.name} Items</h3>
|
||||||
<p>Manage the household/store items used for suggestions and store defaults.</p>
|
<p>Manage location-specific items used for suggestions and defaults.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -150,6 +173,17 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
placeholder="Search household/store items"
|
placeholder="Search household/store items"
|
||||||
disabled={!catalogReady}
|
disabled={!catalogReady}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary btn-small"
|
||||||
|
disabled={!catalogReady}
|
||||||
|
onClick={() => {
|
||||||
|
setEditorItem(null);
|
||||||
|
setShowEditor(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="store-items-modal-body">
|
<div className="store-items-modal-body">
|
||||||
@ -209,6 +243,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
>
|
>
|
||||||
Edit Settings
|
Edit Settings
|
||||||
</button>
|
</button>
|
||||||
|
{isAdmin ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-danger btn-small"
|
className="btn-danger btn-small"
|
||||||
@ -216,6 +251,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
>
|
>
|
||||||
Delete Item
|
Delete Item
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -232,6 +268,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
<AvailableItemEditorModal
|
<AvailableItemEditorModal
|
||||||
isOpen={showEditor}
|
isOpen={showEditor}
|
||||||
item={editorItem}
|
item={editorItem}
|
||||||
|
zones={zones}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowEditor(false);
|
setShowEditor(false);
|
||||||
setEditorItem(null);
|
setEditorItem(null);
|
||||||
@ -244,7 +281,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
|
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
|
||||||
description={
|
description={
|
||||||
pendingDeleteItem
|
pendingDeleteItem
|
||||||
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.name} for this household, including current list entries and history.`
|
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
confirmLabel="Delete Item"
|
confirmLabel="Delete Item"
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import ClassificationSection from "../forms/ClassificationSection";
|
|||||||
import useActionToast from "../../hooks/useActionToast";
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
import ImageUploadSection from "../forms/ImageUploadSection";
|
import ImageUploadSection from "../forms/ImageUploadSection";
|
||||||
|
|
||||||
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
|
export default function AddItemWithDetailsModal({ itemName, zones = [], onConfirm, onCancel }) {
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
const [imagePreview, setImagePreview] = useState(null);
|
const [imagePreview, setImagePreview] = useState(null);
|
||||||
@ -47,15 +47,12 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
|
|||||||
onConfirm(selectedImage, classification);
|
onConfirm(selectedImage, classification);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSkip = () => {
|
|
||||||
onSkip();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="add-item-details-overlay" onClick={onCancel}>
|
<div className="add-item-details-overlay" onClick={onCancel}>
|
||||||
<div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 className="add-item-details-title">Add Details for "{itemName}"</h2>
|
<div className="add-item-details-item-name">
|
||||||
<p className="add-item-details-subtitle">Add an image and classification to help organize your list</p>
|
{itemName}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Image Section */}
|
{/* Image Section */}
|
||||||
<div className="add-item-details-section">
|
<div className="add-item-details-section">
|
||||||
@ -63,6 +60,9 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
|
|||||||
imagePreview={imagePreview}
|
imagePreview={imagePreview}
|
||||||
onImageChange={handleImageChange}
|
onImageChange={handleImageChange}
|
||||||
onImageRemove={handleImageRemove}
|
onImageRemove={handleImageRemove}
|
||||||
|
title={null}
|
||||||
|
cameraLabel="Use Image"
|
||||||
|
galleryLabel="Choose Photo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -75,6 +75,8 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
|
|||||||
onItemTypeChange={handleItemTypeChange}
|
onItemTypeChange={handleItemTypeChange}
|
||||||
onItemGroupChange={setItemGroup}
|
onItemGroupChange={setItemGroup}
|
||||||
onZoneChange={setZone}
|
onZoneChange={setZone}
|
||||||
|
zones={zones}
|
||||||
|
title={null}
|
||||||
fieldClass="add-item-details-field"
|
fieldClass="add-item-details-field"
|
||||||
selectClass="add-item-details-select"
|
selectClass="add-item-details-select"
|
||||||
/>
|
/>
|
||||||
@ -85,9 +87,6 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
|
|||||||
<button onClick={onCancel} className="add-item-details-btn cancel">
|
<button onClick={onCancel} className="add-item-details-btn cancel">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleSkip} className="add-item-details-btn skip">
|
|
||||||
Skip All
|
|
||||||
</button>
|
|
||||||
<button onClick={handleConfirm} className="add-item-details-btn confirm">
|
<button onClick={handleConfirm} className="add-item-details-btn confirm">
|
||||||
Add Item
|
Add Item
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ function buildPreview(item) {
|
|||||||
return `data:${mimeType};base64,${item.item_image}`;
|
return `data:${mimeType};base64,${item.item_image}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AvailableItemEditorModal({ isOpen, item = null, onCancel, onSave }) {
|
export default function AvailableItemEditorModal({ isOpen, item = null, zones = [], onCancel, onSave }) {
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const [itemName, setItemName] = useState("");
|
const [itemName, setItemName] = useState("");
|
||||||
const [itemType, setItemType] = useState("");
|
const [itemType, setItemType] = useState("");
|
||||||
@ -136,6 +136,7 @@ export default function AvailableItemEditorModal({ isOpen, item = null, onCancel
|
|||||||
onItemTypeChange={handleItemTypeChange}
|
onItemTypeChange={handleItemTypeChange}
|
||||||
onItemGroupChange={setItemGroup}
|
onItemGroupChange={setItemGroup}
|
||||||
onZoneChange={setZone}
|
onZoneChange={setZone}
|
||||||
|
zones={zones}
|
||||||
fieldClass="available-item-editor-field"
|
fieldClass="available-item-editor-field"
|
||||||
selectClass="available-item-editor-select"
|
selectClass="available-item-editor-select"
|
||||||
title="Store Classification (Optional)"
|
title="Store Classification (Optional)"
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import useActionToast from "../../hooks/useActionToast";
|
|||||||
import "../../styles/components/EditItemModal.css";
|
import "../../styles/components/EditItemModal.css";
|
||||||
import AddImageModal from "./AddImageModal";
|
import AddImageModal from "./AddImageModal";
|
||||||
|
|
||||||
export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) {
|
export default function EditItemModal({ item, zones = [], onSave, onCancel, onImageUpdate }) {
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const [itemName, setItemName] = useState(item.item_name || "");
|
const [itemName, setItemName] = useState(item.item_name || "");
|
||||||
const [quantity, setQuantity] = useState(item.quantity || 1);
|
const [quantity, setQuantity] = useState(item.quantity || 1);
|
||||||
@ -89,6 +89,9 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
|
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
|
||||||
|
const zoneOptions = Array.isArray(zones) && zones.length > 0
|
||||||
|
? zones.map((candidateZone) => candidateZone.name || candidateZone).filter(Boolean)
|
||||||
|
: getZoneValues();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="edit-modal-overlay" onClick={onCancel}>
|
<div className="edit-modal-overlay" onClick={onCancel}>
|
||||||
@ -172,7 +175,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
className="edit-modal-select"
|
className="edit-modal-select"
|
||||||
>
|
>
|
||||||
<option value="">-- Select Zone --</option>
|
<option value="">-- Select Zone --</option>
|
||||||
{getZoneValues().map((candidateZone) => (
|
{zoneOptions.map((candidateZone) => (
|
||||||
<option key={candidateZone} value={candidateZone}>
|
<option key={candidateZone} value={candidateZone}>
|
||||||
{candidateZone}
|
{candidateZone}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export default function StoreTabs() {
|
|||||||
onClick={() => setActiveStore(store)}
|
onClick={() => setActiveStore(store)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<span className="store-name">{store.name}</span>
|
<span className="store-name">{store.display_name || store.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { createContext, useState } from 'react';
|
import { createContext, useState } from 'react';
|
||||||
|
import { clearApiCacheForCurrentUser } from '../api/offlineCache';
|
||||||
|
|
||||||
export const AuthContext = createContext({
|
export const AuthContext = createContext({
|
||||||
token: null,
|
token: null,
|
||||||
@ -16,6 +17,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const [username, setUsername] = useState(localStorage.getItem('username') || null);
|
const [username, setUsername] = useState(localStorage.getItem('username') || null);
|
||||||
|
|
||||||
const clearAuthStorage = () => {
|
const clearAuthStorage = () => {
|
||||||
|
clearApiCacheForCurrentUser();
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem("userId");
|
localStorage.removeItem("userId");
|
||||||
localStorage.removeItem("role");
|
localStorage.removeItem("role");
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households';
|
import {
|
||||||
|
createHousehold as createHouseholdApi,
|
||||||
|
getUserHouseholds,
|
||||||
|
reorderHouseholds as reorderHouseholdsApi,
|
||||||
|
} from '../api/households';
|
||||||
|
import { isTransientApiError } from '../api/offlineCache';
|
||||||
import { AuthContext } from './AuthContext';
|
import { AuthContext } from './AuthContext';
|
||||||
|
|
||||||
const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId';
|
const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId';
|
||||||
@ -13,6 +18,7 @@ export const HouseholdContext = createContext({
|
|||||||
setActiveHousehold: () => { },
|
setActiveHousehold: () => { },
|
||||||
refreshHouseholds: () => { },
|
refreshHouseholds: () => { },
|
||||||
createHousehold: () => { },
|
createHousehold: () => { },
|
||||||
|
reorderHouseholds: () => { },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const HouseholdProvider = ({ children }) => {
|
export const HouseholdProvider = ({ children }) => {
|
||||||
@ -43,9 +49,15 @@ export const HouseholdProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[HouseholdContext] Failed to load households:', err);
|
console.error('[HouseholdContext] Failed to load households:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to load households');
|
setError(
|
||||||
|
err.response?.data?.error?.message ||
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Failed to load households'
|
||||||
|
);
|
||||||
|
if (!isTransientApiError(err)) {
|
||||||
setHouseholds([]);
|
setHouseholds([]);
|
||||||
clearActiveHousehold();
|
clearActiveHousehold();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
setHasLoaded(true);
|
||||||
@ -114,6 +126,57 @@ export const HouseholdProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reorderHouseholds = async (orderedHouseholdIds) => {
|
||||||
|
const householdById = new Map(
|
||||||
|
households.map((household) => [String(household.id), household])
|
||||||
|
);
|
||||||
|
const nextHouseholds = orderedHouseholdIds
|
||||||
|
.map((householdId) => householdById.get(String(householdId)))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (nextHouseholds.length !== households.length) {
|
||||||
|
throw new Error("Household order is out of date");
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousHouseholds = households;
|
||||||
|
setHouseholds(nextHouseholds);
|
||||||
|
if (activeHousehold) {
|
||||||
|
const nextActiveHousehold = nextHouseholds.find(
|
||||||
|
(household) => String(household.id) === String(activeHousehold.id)
|
||||||
|
);
|
||||||
|
if (nextActiveHousehold) {
|
||||||
|
setActiveHouseholdState(nextActiveHousehold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await reorderHouseholdsApi(orderedHouseholdIds);
|
||||||
|
const savedHouseholds = Array.isArray(response.data.households)
|
||||||
|
? response.data.households
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (savedHouseholds.length > 0) {
|
||||||
|
setHouseholds(savedHouseholds);
|
||||||
|
if (activeHousehold) {
|
||||||
|
const savedActiveHousehold = savedHouseholds.find(
|
||||||
|
(household) => String(household.id) === String(activeHousehold.id)
|
||||||
|
);
|
||||||
|
if (savedActiveHousehold) {
|
||||||
|
setActiveHouseholdState(savedActiveHousehold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedHouseholds;
|
||||||
|
} catch (err) {
|
||||||
|
setHouseholds(previousHouseholds);
|
||||||
|
if (activeHousehold) {
|
||||||
|
setActiveHouseholdState(activeHousehold);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
households,
|
households,
|
||||||
activeHousehold,
|
activeHousehold,
|
||||||
@ -123,6 +186,7 @@ export const HouseholdProvider = ({ children }) => {
|
|||||||
setActiveHousehold,
|
setActiveHousehold,
|
||||||
refreshHouseholds: loadHouseholds,
|
refreshHouseholds: loadHouseholds,
|
||||||
createHousehold,
|
createHousehold,
|
||||||
|
reorderHouseholds,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -8,7 +8,6 @@ const DEFAULT_SETTINGS = {
|
|||||||
compactView: false,
|
compactView: false,
|
||||||
|
|
||||||
// List Display
|
// List Display
|
||||||
defaultSortMode: "zone",
|
|
||||||
showRecentlyBought: true,
|
showRecentlyBought: true,
|
||||||
recentlyBoughtCount: 10,
|
recentlyBoughtCount: 10,
|
||||||
recentlyBoughtCollapsed: false,
|
recentlyBoughtCollapsed: false,
|
||||||
@ -22,6 +21,17 @@ const DEFAULT_SETTINGS = {
|
|||||||
debugMode: false,
|
debugMode: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS);
|
||||||
|
|
||||||
|
function normalizeSettings(savedSettings = {}) {
|
||||||
|
return SETTINGS_KEYS.reduce((normalized, key) => {
|
||||||
|
normalized[key] = Object.prototype.hasOwnProperty.call(savedSettings, key)
|
||||||
|
? savedSettings[key]
|
||||||
|
: DEFAULT_SETTINGS[key];
|
||||||
|
return normalized;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const SettingsContext = createContext({
|
export const SettingsContext = createContext({
|
||||||
settings: DEFAULT_SETTINGS,
|
settings: DEFAULT_SETTINGS,
|
||||||
@ -48,7 +58,9 @@ export const SettingsProvider = ({ children }) => {
|
|||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(savedSettings);
|
const parsed = JSON.parse(savedSettings);
|
||||||
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
|
const normalized = normalizeSettings(parsed);
|
||||||
|
setSettings(normalized);
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(normalized));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to parse settings:", error);
|
console.error("Failed to parse settings:", error);
|
||||||
setSettings(DEFAULT_SETTINGS);
|
setSettings(DEFAULT_SETTINGS);
|
||||||
@ -88,7 +100,7 @@ export const SettingsProvider = ({ children }) => {
|
|||||||
const updateSettings = (newSettings) => {
|
const updateSettings = (newSettings) => {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
|
|
||||||
const updated = { ...settings, ...newSettings };
|
const updated = normalizeSettings({ ...settings, ...newSettings });
|
||||||
setSettings(updated);
|
setSettings(updated);
|
||||||
|
|
||||||
const storageKey = `user_preferences_${username}`;
|
const storageKey = `user_preferences_${username}`;
|
||||||
|
|||||||
@ -1,15 +1,19 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
import { getHouseholdStores } from '../api/stores';
|
import { isTransientApiError } from '../api/offlineCache';
|
||||||
|
import { getHouseholdStores, getLocationZones } from '../api/stores';
|
||||||
import { AuthContext } from './AuthContext';
|
import { AuthContext } from './AuthContext';
|
||||||
import { HouseholdContext } from './HouseholdContext';
|
import { HouseholdContext } from './HouseholdContext';
|
||||||
|
|
||||||
export const StoreContext = createContext({
|
export const StoreContext = createContext({
|
||||||
stores: [],
|
stores: [],
|
||||||
activeStore: null,
|
activeStore: null,
|
||||||
|
zones: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
zonesLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
setActiveStore: () => { },
|
setActiveStore: () => { },
|
||||||
refreshStores: () => { },
|
refreshStores: () => { },
|
||||||
|
refreshZones: () => { },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const StoreProvider = ({ children }) => {
|
export const StoreProvider = ({ children }) => {
|
||||||
@ -17,7 +21,9 @@ export const StoreProvider = ({ children }) => {
|
|||||||
const { activeHousehold } = useContext(HouseholdContext);
|
const { activeHousehold } = useContext(HouseholdContext);
|
||||||
const [stores, setStores] = useState([]);
|
const [stores, setStores] = useState([]);
|
||||||
const [activeStore, setActiveStoreState] = useState(null);
|
const [activeStore, setActiveStoreState] = useState(null);
|
||||||
|
const [zones, setZones] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [zonesLoading, setZonesLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Load stores when household changes
|
// Load stores when household changes
|
||||||
@ -28,6 +34,7 @@ export const StoreProvider = ({ children }) => {
|
|||||||
// Clear state when logged out or no household
|
// Clear state when logged out or no household
|
||||||
setStores([]);
|
setStores([]);
|
||||||
setActiveStoreState(null);
|
setActiveStoreState(null);
|
||||||
|
setZones([]);
|
||||||
}
|
}
|
||||||
}, [token, activeHousehold?.id]);
|
}, [token, activeHousehold?.id]);
|
||||||
|
|
||||||
@ -40,7 +47,7 @@ export const StoreProvider = ({ children }) => {
|
|||||||
const savedStoreId = localStorage.getItem(storageKey);
|
const savedStoreId = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
if (savedStoreId) {
|
if (savedStoreId) {
|
||||||
const store = stores.find(s => s.id === parseInt(savedStoreId));
|
const store = stores.find(s => String(s.id) === String(savedStoreId));
|
||||||
if (store) {
|
if (store) {
|
||||||
console.log('[StoreContext] Found saved store:', store);
|
console.log('[StoreContext] Found saved store:', store);
|
||||||
setActiveStoreState(store);
|
setActiveStoreState(store);
|
||||||
@ -55,6 +62,14 @@ export const StoreProvider = ({ children }) => {
|
|||||||
localStorage.setItem(storageKey, defaultStore.id);
|
localStorage.setItem(storageKey, defaultStore.id);
|
||||||
}, [stores, activeHousehold]);
|
}, [stores, activeHousehold]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token && activeHousehold?.id && activeStore?.id) {
|
||||||
|
loadZones();
|
||||||
|
} else {
|
||||||
|
setZones([]);
|
||||||
|
}
|
||||||
|
}, [token, activeHousehold?.id, activeStore?.id]);
|
||||||
|
|
||||||
const loadStores = async () => {
|
const loadStores = async () => {
|
||||||
if (!token || !activeHousehold) return;
|
if (!token || !activeHousehold) return;
|
||||||
|
|
||||||
@ -67,8 +82,15 @@ export const StoreProvider = ({ children }) => {
|
|||||||
setStores(response.data);
|
setStores(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[StoreContext] Failed to load stores:', err);
|
console.error('[StoreContext] Failed to load stores:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to load stores');
|
setError(
|
||||||
|
err.response?.data?.error?.message ||
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Failed to load stores'
|
||||||
|
);
|
||||||
|
if (!isTransientApiError(err)) {
|
||||||
setStores([]);
|
setStores([]);
|
||||||
|
setActiveStoreState(null);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -78,17 +100,37 @@ export const StoreProvider = ({ children }) => {
|
|||||||
setActiveStoreState(store);
|
setActiveStoreState(store);
|
||||||
if (store && activeHousehold) {
|
if (store && activeHousehold) {
|
||||||
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
||||||
localStorage.setItem(storageKey, store.id);
|
localStorage.setItem(storageKey, String(store.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadZones = async () => {
|
||||||
|
if (!token || !activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
|
||||||
|
setZonesLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getLocationZones(activeHousehold.id, activeStore.id);
|
||||||
|
setZones(response.data?.zones || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[StoreContext] Failed to load zones:', err);
|
||||||
|
if (!isTransientApiError(err)) {
|
||||||
|
setZones([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setZonesLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
stores,
|
stores,
|
||||||
activeStore,
|
activeStore,
|
||||||
|
zones,
|
||||||
loading,
|
loading,
|
||||||
|
zonesLoading,
|
||||||
error,
|
error,
|
||||||
setActiveStore,
|
setActiveStore,
|
||||||
refreshStores: loadStores,
|
refreshStores: loadStores,
|
||||||
|
refreshZones: loadZones,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
updateItemWithClassification
|
updateItemWithClassification
|
||||||
} from "../api/list";
|
} from "../api/list";
|
||||||
import { getHouseholdMembers } from "../api/households";
|
import { getHouseholdMembers } from "../api/households";
|
||||||
import SortDropdown from "../components/common/SortDropdown";
|
import ListSearchInput from "../components/common/ListSearchInput";
|
||||||
import AddItemForm from "../components/forms/AddItemForm";
|
import AddItemForm from "../components/forms/AddItemForm";
|
||||||
import NoHouseholdState from "../components/household/NoHouseholdState";
|
import NoHouseholdState from "../components/household/NoHouseholdState";
|
||||||
import GroceryListItem from "../components/items/GroceryListItem";
|
import GroceryListItem from "../components/items/GroceryListItem";
|
||||||
@ -33,21 +33,16 @@ import getApiErrorMessage from "../lib/getApiErrorMessage";
|
|||||||
import "../styles/pages/GroceryList.css";
|
import "../styles/pages/GroceryList.css";
|
||||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||||
|
|
||||||
function sortItemsForMode(items, sortMode) {
|
function sortItemsByZone(items) {
|
||||||
const sorted = [...items];
|
const sorted = [...items];
|
||||||
|
|
||||||
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
|
||||||
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) => {
|
sorted.sort((a, b) => {
|
||||||
if (!a.zone && b.zone) return 1;
|
if (!a.zone && b.zone) return 1;
|
||||||
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);
|
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
|
||||||
|
|
||||||
const aZoneIndex = ZONE_FLOW.indexOf(a.zone);
|
const aZoneIndex = Number.isInteger(a.zone_sort_order) ? a.zone_sort_order : ZONE_FLOW.indexOf(a.zone);
|
||||||
const bZoneIndex = ZONE_FLOW.indexOf(b.zone);
|
const bZoneIndex = Number.isInteger(b.zone_sort_order) ? b.zone_sort_order : ZONE_FLOW.indexOf(b.zone);
|
||||||
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
|
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
|
||||||
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
|
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
|
||||||
|
|
||||||
@ -62,11 +57,30 @@ function sortItemsForMode(items, sortMode) {
|
|||||||
|
|
||||||
return a.item_name.localeCompare(b.item_name);
|
return a.item_name.localeCompare(b.item_name);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSearchableItemText(item) {
|
||||||
|
return [
|
||||||
|
item.item_name,
|
||||||
|
item.item_type,
|
||||||
|
item.item_group,
|
||||||
|
item.zone,
|
||||||
|
...(Array.isArray(item.added_by_users) ? item.added_by_users : []),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterItemsForSearch(items, query) {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) return items;
|
||||||
|
|
||||||
|
return items.filter((item) => getSearchableItemText(item).includes(normalizedQuery));
|
||||||
|
}
|
||||||
|
|
||||||
function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
|
function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
|
||||||
const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId);
|
const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId);
|
||||||
|
|
||||||
@ -79,7 +93,6 @@ function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
|
|||||||
|
|
||||||
|
|
||||||
export default function GroceryList() {
|
export default function GroceryList() {
|
||||||
const pageTitle = "Grocery List";
|
|
||||||
const { userId } = useContext(AuthContext);
|
const { userId } = useContext(AuthContext);
|
||||||
const {
|
const {
|
||||||
activeHousehold,
|
activeHousehold,
|
||||||
@ -87,7 +100,7 @@ export default function GroceryList() {
|
|||||||
loading: householdLoading,
|
loading: householdLoading,
|
||||||
hasLoaded: householdsLoaded
|
hasLoaded: householdsLoaded
|
||||||
} = useContext(HouseholdContext);
|
} = useContext(HouseholdContext);
|
||||||
const { activeStore, stores, loading: storeLoading } = useContext(StoreContext);
|
const { activeStore, stores, zones, loading: storeLoading } = useContext(StoreContext);
|
||||||
const { settings } = useContext(SettingsContext);
|
const { settings } = useContext(SettingsContext);
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const { enqueueImageUpload } = useUploadQueue();
|
const { enqueueImageUpload } = useUploadQueue();
|
||||||
@ -103,7 +116,7 @@ export default function GroceryList() {
|
|||||||
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||||
const [householdMembers, setHouseholdMembers] = useState([]);
|
const [householdMembers, setHouseholdMembers] = useState([]);
|
||||||
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
|
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
|
||||||
const [sortMode, setSortMode] = useState(settings.defaultSortMode);
|
const [listSearchQuery, setListSearchQuery] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [buttonText, setButtonText] = useState("Add Item");
|
const [buttonText, setButtonText] = useState("Add Item");
|
||||||
@ -238,10 +251,13 @@ export default function GroceryList() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// === Sorted Items Computation ===
|
// === Visible Items Computation ===
|
||||||
|
const normalizedListSearchQuery = listSearchQuery.trim().toLowerCase();
|
||||||
|
const isListSearchActive = normalizedListSearchQuery.length > 0;
|
||||||
|
|
||||||
const sortedItems = useMemo(() => {
|
const sortedItems = useMemo(() => {
|
||||||
return sortItemsForMode(items, sortMode);
|
return sortItemsByZone(filterItemsForSearch(items, normalizedListSearchQuery));
|
||||||
}, [items, sortMode]);
|
}, [items, normalizedListSearchQuery]);
|
||||||
|
|
||||||
const visibleRecentlyBoughtItems = useMemo(
|
const visibleRecentlyBoughtItems = useMemo(
|
||||||
() => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount),
|
() => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount),
|
||||||
@ -538,42 +554,6 @@ export default function GroceryList() {
|
|||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]);
|
}, [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(() => {
|
const handleAddDetailsCancel = useCallback(() => {
|
||||||
setShowAddDetailsModal(false);
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
@ -614,7 +594,9 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
setItems(nextItems);
|
setItems(nextItems);
|
||||||
|
|
||||||
const nextSortedItems = sortItemsForMode(nextItems, sortMode);
|
const nextSortedItems = sortItemsByZone(
|
||||||
|
filterItemsForSearch(nextItems, normalizedListSearchQuery)
|
||||||
|
);
|
||||||
const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id);
|
const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id);
|
||||||
|
|
||||||
setBuyModalState(
|
setBuyModalState(
|
||||||
@ -633,7 +615,7 @@ export default function GroceryList() {
|
|||||||
const message = getApiErrorMessage(error, "Failed to mark item as bought");
|
const message = getApiErrorMessage(error, "Failed to mark item as bought");
|
||||||
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
|
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, buyModalState, items, sortMode, sortedItems, toast]);
|
}, [activeHousehold?.id, activeStore?.id, buyModalState, items, normalizedListSearchQuery, sortedItems, toast]);
|
||||||
|
|
||||||
const openActiveBuyModal = useCallback((item) => {
|
const openActiveBuyModal = useCallback((item) => {
|
||||||
setBuyModalState({
|
setBuyModalState({
|
||||||
@ -772,7 +754,6 @@ export default function GroceryList() {
|
|||||||
return (
|
return (
|
||||||
<div className="glist-body">
|
<div className="glist-body">
|
||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<h1 className="glist-title">{pageTitle}</h1>
|
|
||||||
<p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}>
|
<p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}>
|
||||||
Loading households...
|
Loading households...
|
||||||
</p>
|
</p>
|
||||||
@ -785,7 +766,6 @@ export default function GroceryList() {
|
|||||||
return (
|
return (
|
||||||
<div className="glist-body">
|
<div className="glist-body">
|
||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<h1 className="glist-title">{pageTitle}</h1>
|
|
||||||
<NoHouseholdState />
|
<NoHouseholdState />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -796,7 +776,6 @@ export default function GroceryList() {
|
|||||||
return (
|
return (
|
||||||
<div className="glist-body">
|
<div className="glist-body">
|
||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<h1 className="glist-title">{pageTitle}</h1>
|
|
||||||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
||||||
Loading stores...
|
Loading stores...
|
||||||
</p>
|
</p>
|
||||||
@ -809,7 +788,6 @@ export default function GroceryList() {
|
|||||||
return (
|
return (
|
||||||
<div className="glist-body">
|
<div className="glist-body">
|
||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<h1 className="glist-title">{pageTitle}</h1>
|
|
||||||
<div className="glist-empty-state">
|
<div className="glist-empty-state">
|
||||||
<h2 className="glist-empty-title">No stores found</h2>
|
<h2 className="glist-empty-title">No stores found</h2>
|
||||||
<p className="glist-empty-text">
|
<p className="glist-empty-text">
|
||||||
@ -837,7 +815,6 @@ export default function GroceryList() {
|
|||||||
return (
|
return (
|
||||||
<div className="glist-body">
|
<div className="glist-body">
|
||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<h1 className="glist-title">{pageTitle}</h1>
|
|
||||||
<StoreTabs />
|
<StoreTabs />
|
||||||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
||||||
Loading stores...
|
Loading stores...
|
||||||
@ -851,7 +828,6 @@ export default function GroceryList() {
|
|||||||
return (
|
return (
|
||||||
<div className="glist-body">
|
<div className="glist-body">
|
||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<h1 className="glist-title">{pageTitle}</h1>
|
|
||||||
<StoreTabs />
|
<StoreTabs />
|
||||||
<p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p>
|
<p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p>
|
||||||
</div>
|
</div>
|
||||||
@ -863,8 +839,6 @@ export default function GroceryList() {
|
|||||||
return (
|
return (
|
||||||
<div className="glist-body">
|
<div className="glist-body">
|
||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<h1 className="glist-title">{pageTitle}</h1>
|
|
||||||
|
|
||||||
<StoreTabs />
|
<StoreTabs />
|
||||||
|
|
||||||
{canEditList && (
|
{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);
|
const grouped = groupItemsByZone(sortedItems);
|
||||||
return Object.keys(grouped).map(zone => {
|
return Object.keys(grouped).map(zone => {
|
||||||
const isCollapsed = collapsedZones[zone];
|
const isCollapsed = isListSearchActive ? false : collapsedZones[zone];
|
||||||
const itemCount = grouped[zone].length;
|
const itemCount = grouped[zone].length;
|
||||||
return (
|
return (
|
||||||
<div key={zone} className="glist-classification-group">
|
<div key={zone} className="glist-classification-group">
|
||||||
@ -923,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 && (
|
{recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
|
||||||
@ -993,8 +960,8 @@ export default function GroceryList() {
|
|||||||
{showAddDetailsModal && pendingItem && (
|
{showAddDetailsModal && pendingItem && (
|
||||||
<AddItemWithDetailsModal
|
<AddItemWithDetailsModal
|
||||||
itemName={pendingItem.itemName}
|
itemName={pendingItem.itemName}
|
||||||
|
zones={zones}
|
||||||
onConfirm={handleAddWithDetails}
|
onConfirm={handleAddWithDetails}
|
||||||
onSkip={handleAddDetailsSkip}
|
|
||||||
onCancel={handleAddDetailsCancel}
|
onCancel={handleAddDetailsCancel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -1012,6 +979,7 @@ export default function GroceryList() {
|
|||||||
{showEditModal && editingItem && (
|
{showEditModal && editingItem && (
|
||||||
<EditItemModal
|
<EditItemModal
|
||||||
item={editingItem}
|
item={editingItem}
|
||||||
|
zones={zones}
|
||||||
onSave={handleEditSave}
|
onSave={handleEditSave}
|
||||||
onCancel={handleEditCancel}
|
onCancel={handleEditCancel}
|
||||||
onImageUpdate={handleImageAdded}
|
onImageUpdate={handleImageAdded}
|
||||||
|
|||||||
@ -137,12 +137,6 @@ export default function Settings() {
|
|||||||
updateSettings({ [key]: parseInt(value, 10) });
|
updateSettings({ [key]: parseInt(value, 10) });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleSelectChange = (key, value) => {
|
|
||||||
updateSettings({ [key]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
if (window.confirm("Reset all settings to defaults?")) {
|
if (window.confirm("Reset all settings to defaults?")) {
|
||||||
resetSettings();
|
resetSettings();
|
||||||
@ -252,24 +246,6 @@ export default function Settings() {
|
|||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="text-xl font-semibold mb-4">List Display</h2>
|
<h2 className="text-xl font-semibold mb-4">List Display</h2>
|
||||||
|
|
||||||
<div className="settings-group">
|
|
||||||
<label className="settings-label">Default Sort Mode</label>
|
|
||||||
<select
|
|
||||||
value={settings.defaultSortMode}
|
|
||||||
onChange={(e) => handleSelectChange("defaultSortMode", e.target.value)}
|
|
||||||
className="form-select mt-2"
|
|
||||||
>
|
|
||||||
<option value="zone">By Zone</option>
|
|
||||||
<option value="az">A → Z</option>
|
|
||||||
<option value="za">Z → A</option>
|
|
||||||
<option value="qty-high">Quantity: High → Low</option>
|
|
||||||
<option value="qty-low">Quantity: Low → High</option>
|
|
||||||
</select>
|
|
||||||
<p className="settings-description">
|
|
||||||
Your preferred sorting method when opening the list
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-group">
|
<div className="settings-group">
|
||||||
<label className="settings-label">
|
<label className="settings-label">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -15,14 +15,28 @@
|
|||||||
.add-item-details-modal {
|
.add-item-details-modal {
|
||||||
background: var(--modal-bg);
|
background: var(--modal-bg);
|
||||||
border-radius: var(--border-radius-xl);
|
border-radius: var(--border-radius-xl);
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-lg);
|
||||||
max-width: 500px;
|
max-width: 520px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: var(--shadow-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-item-details-item-name {
|
||||||
|
margin: 0 0 var(--spacing-md);
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border: var(--border-width-thin) solid var(--color-border-light);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
line-height: var(--line-height-tight);
|
||||||
|
text-align: center;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.add-item-details-title {
|
.add-item-details-title {
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
margin: 0 0 var(--spacing-xs) 0;
|
margin: 0 0 var(--spacing-xs) 0;
|
||||||
@ -38,13 +52,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-section {
|
.add-item-details-section {
|
||||||
margin-bottom: var(--spacing-xl);
|
margin-bottom: var(--spacing-md);
|
||||||
padding-bottom: var(--spacing-xl);
|
padding-bottom: var(--spacing-md);
|
||||||
border-bottom: var(--border-width-thin) solid var(--color-border-light);
|
border-bottom: var(--border-width-thin) solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-section:last-of-type {
|
.add-item-details-section:last-of-type {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-section-title {
|
.add-item-details-section-title {
|
||||||
@ -55,6 +71,68 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Image Upload Section */
|
/* Image Upload Section */
|
||||||
|
.add-item-details-modal .image-upload-section {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-modal .image-upload-content {
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: var(--border-width-thin) solid var(--color-border-light);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-modal .image-upload-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-modal .image-upload-btn {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
border-radius: var(--button-border-radius);
|
||||||
|
border: var(--border-width-thin) solid transparent;
|
||||||
|
font-size: 0;
|
||||||
|
font-weight: var(--button-font-weight);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-modal .image-upload-btn::after {
|
||||||
|
content: attr(aria-label);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-modal .image-upload-btn.camera {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
border-color: var(--color-primary-dark);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-modal .image-upload-btn.camera:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
border-color: var(--color-primary-hover);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-modal .image-upload-btn.gallery {
|
||||||
|
background: var(--button-secondary-bg);
|
||||||
|
border-color: var(--button-secondary-border);
|
||||||
|
color: var(--button-secondary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-modal .image-upload-btn.gallery:hover {
|
||||||
|
background: var(--button-secondary-hover-bg);
|
||||||
|
border-color: var(--button-secondary-border-hover);
|
||||||
|
color: var(--button-secondary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-modal .image-upload-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.add-item-details-image-content {
|
.add-item-details-image-content {
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
}
|
}
|
||||||
@ -119,22 +197,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Classification Section */
|
/* Classification Section */
|
||||||
|
.add-item-details-modal .classification-section {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.add-item-details-field {
|
.add-item-details-field {
|
||||||
margin-bottom: var(--spacing-md);
|
display: grid;
|
||||||
|
grid-template-columns: 6.75rem minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-field label {
|
.add-item-details-field label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: var(--spacing-sm);
|
margin: 0;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: var(--line-height-tight);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-select {
|
.add-item-details-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--input-padding-y) var(--input-padding-x);
|
min-height: 2.5rem;
|
||||||
font-size: var(--font-size-base);
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
border: var(--border-width-thin) solid var(--input-border-color);
|
border: var(--border-width-thin) solid var(--input-border-color);
|
||||||
border-radius: var(--input-border-radius);
|
border-radius: var(--input-border-radius);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -153,7 +242,7 @@
|
|||||||
.add-item-details-actions {
|
.add-item-details-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
margin-top: var(--spacing-lg);
|
margin-top: var(--spacing-md);
|
||||||
padding-top: var(--spacing-md);
|
padding-top: var(--spacing-md);
|
||||||
border-top: var(--border-width-thin) solid var(--color-border-light);
|
border-top: var(--border-width-thin) solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
@ -162,38 +251,36 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--button-padding-y) var(--button-padding-x);
|
padding: var(--button-padding-y) var(--button-padding-x);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
border: none;
|
border: var(--border-width-thin) solid transparent;
|
||||||
border-radius: var(--button-border-radius);
|
border-radius: var(--button-border-radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: var(--button-font-weight);
|
font-weight: var(--button-font-weight);
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.cancel {
|
.add-item-details-btn.cancel {
|
||||||
background: var(--color-secondary);
|
background: var(--button-secondary-bg);
|
||||||
color: var(--color-text-inverse);
|
border-color: var(--button-secondary-border);
|
||||||
|
color: var(--button-secondary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.cancel:hover {
|
.add-item-details-btn.cancel:hover {
|
||||||
background: var(--color-secondary-hover);
|
background: var(--button-secondary-hover-bg);
|
||||||
}
|
border-color: var(--button-secondary-border-hover);
|
||||||
|
color: var(--button-secondary-text);
|
||||||
.add-item-details-btn.skip {
|
|
||||||
background: var(--color-warning);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-details-btn.skip:hover {
|
|
||||||
background: var(--color-warning-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.confirm {
|
.add-item-details-btn.confirm {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary-dark);
|
||||||
|
border-color: var(--color-primary-dark);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.confirm:hover {
|
.add-item-details-btn.confirm:hover {
|
||||||
background: var(--color-primary-hover);
|
background: var(--color-primary-hover);
|
||||||
|
border-color: var(--color-primary-hover);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsiveness */
|
/* Mobile responsiveness */
|
||||||
@ -225,6 +312,10 @@
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-item-details-field {
|
||||||
|
grid-template-columns: 6rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.add-item-details-actions {
|
.add-item-details-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@ -70,8 +70,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 0.5rem);
|
top: calc(100% + 0.5rem);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
max-width: min(320px, calc(100vw - 2rem));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
@ -80,15 +82,32 @@
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.household-option-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 48px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.household-option-row:hover {
|
||||||
|
background: var(--button-secondary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.household-option-row.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.household-option {
|
.household-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 0.625rem 0.875rem 1rem;
|
||||||
background: var(--card-bg);
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@ -96,25 +115,59 @@
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-option:last-child {
|
.household-option-name {
|
||||||
border-bottom: none;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-option:hover {
|
.household-option:hover {
|
||||||
background: var(--button-secondary-bg);
|
color: var(--primary);
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-option.active {
|
.household-option-row.active .household-option {
|
||||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-mark {
|
.household-reorder-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.125rem;
|
||||||
|
width: 28px;
|
||||||
|
padding: 0.25rem 0.25rem 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.household-reorder-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.household-reorder-button:hover:not(:disabled),
|
||||||
|
.household-reorder-button:focus-visible {
|
||||||
|
border-color: var(--primary);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-size: 1.1rem;
|
outline: none;
|
||||||
font-weight: bold;
|
}
|
||||||
|
|
||||||
|
.household-reorder-button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-divider {
|
.household-divider {
|
||||||
@ -124,6 +177,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.create-household-btn {
|
.create-household-btn {
|
||||||
|
border-bottom: none;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,8 +383,8 @@ body.dark-mode .invite-status-badge.is-used {
|
|||||||
.member-card {
|
.member-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.9rem;
|
gap: 0.7rem;
|
||||||
padding: 0.95rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
background: rgba(255, 255, 255, 1);
|
background: rgba(255, 255, 255, 1);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
@ -408,74 +408,48 @@ body.dark-mode .member-card:hover {
|
|||||||
background: rgba(20, 32, 48, 0.98);
|
background: rgba(20, 32, 48, 0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-avatar {
|
|
||||||
width: 2.6rem;
|
|
||||||
height: 2.6rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--primary-light);
|
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-main {
|
.member-main {
|
||||||
display: grid;
|
min-width: 0;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
gap: 0.85rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-info {
|
.member-info {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-topline {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
flex-wrap: wrap;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-name {
|
.member-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-meta {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-role {
|
.member-role {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
font-size: 0.78rem;
|
flex: 0 0 auto;
|
||||||
padding: 0.24rem 0.55rem;
|
font-size: 0.88rem;
|
||||||
border-radius: var(--border-radius-full);
|
|
||||||
width: fit-content;
|
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-role-owner {
|
.member-role-owner {
|
||||||
background: rgba(245, 158, 11, 0.18);
|
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-role-admin {
|
.member-role-admin {
|
||||||
background: rgba(30, 144, 255, 0.16);
|
|
||||||
color: var(--primary-dark);
|
color: var(--primary-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-role-member,
|
.member-role-member,
|
||||||
.member-role-viewer {
|
.member-role-viewer {
|
||||||
background: rgba(139, 92, 246, 0.12);
|
|
||||||
color: #6d28d9;
|
color: #6d28d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,7 +457,8 @@ body.dark-mode .member-card:hover {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.24rem 0.5rem;
|
flex: 0 0 auto;
|
||||||
|
padding: 0.18rem 0.45rem;
|
||||||
border-radius: var(--border-radius-full);
|
border-radius: var(--border-radius-full);
|
||||||
background: rgba(245, 158, 11, 0.16);
|
background: rgba(245, 158, 11, 0.16);
|
||||||
color: #a16207;
|
color: #a16207;
|
||||||
@ -497,7 +472,7 @@ body.dark-mode .member-card:hover {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.65rem;
|
||||||
border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent);
|
border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -93,6 +93,94 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-location-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-location-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-location-row > .store-zones-panel,
|
||||||
|
.store-location-row > .store-available-items-trigger {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-location-panel,
|
||||||
|
.store-zone-create-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-location-panel input,
|
||||||
|
.add-store-panel input,
|
||||||
|
.store-zone-create-row input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-zones-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-zones-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-zone-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-zone-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2rem minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-zone-order {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-zone-name {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-zone-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
/* Add Store Panel */
|
/* Add Store Panel */
|
||||||
.add-store-panel {
|
.add-store-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -172,4 +260,23 @@
|
|||||||
.available-store-card button {
|
.available-store-card button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-location-row,
|
||||||
|
.add-location-panel,
|
||||||
|
.store-zone-create-row,
|
||||||
|
.store-zone-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-zone-order {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-zone-actions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-zone-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,13 +14,6 @@
|
|||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Title */
|
|
||||||
.glist-title {
|
|
||||||
text-align: center;
|
|
||||||
font-size: var(--font-size-2xl);
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-section-title {
|
.glist-section-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
@ -116,14 +109,14 @@
|
|||||||
|
|
||||||
/* Classification Groups */
|
/* Classification Groups */
|
||||||
.glist-classification-group {
|
.glist-classification-group {
|
||||||
margin-bottom: var(--spacing-xl);
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-classification-header {
|
.glist-classification-header {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
margin: var(--spacing-md) 0 var(--spacing-sm) 0;
|
margin: var(--spacing-md) 0 0 0;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
background: var(--color-primary-light);
|
background: var(--color-primary-light);
|
||||||
border-left: var(--border-width-thick) solid var(--color-primary);
|
border-left: var(--border-width-thick) solid var(--color-primary);
|
||||||
@ -239,6 +232,10 @@
|
|||||||
margin-top: var(--spacing-md);
|
margin-top: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glist-classification-group .glist-ul {
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.glist-li {
|
.glist-li {
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
border: var(--border-width-thin) solid var(--color-border-light);
|
||||||
@ -254,6 +251,14 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glist-classification-group .glist-li {
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-classification-group .glist-li:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.glist-item-layout {
|
.glist-item-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
@ -359,10 +364,21 @@
|
|||||||
font-size: 0.65em;
|
font-size: 0.65em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sorting dropdown */
|
/* List search */
|
||||||
.glist-sort {
|
.glist-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: var(--spacing-xs) 0;
|
margin: var(--spacing-xs) 0 var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-search-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
padding: var(--spacing-sm);
|
padding: var(--spacing-sm);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
@ -371,6 +387,48 @@
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glist-search-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-search-clear {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 var(--spacing-sm);
|
||||||
|
border: var(--border-width-thin) solid var(--color-border-medium);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-search-clear:hover,
|
||||||
|
.glist-search-clear:focus-visible {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-search-meta {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-empty-search {
|
||||||
|
margin: var(--spacing-md) 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
/* Image upload */
|
/* Image upload */
|
||||||
.glist-image-upload {
|
.glist-image-upload {
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
|
|||||||
@ -33,8 +33,8 @@ test("selected household stays active after refreshing on settings and home page
|
|||||||
];
|
];
|
||||||
|
|
||||||
const storesByHousehold = {
|
const storesByHousehold = {
|
||||||
1: [{ id: 101, name: "Costco", is_default: true }],
|
1: [{ id: 101, household_store_id: 1001, name: "Costco", is_default: true }],
|
||||||
2: [{ id: 201, name: "Trader Joe's", is_default: true }],
|
2: [{ id: 201, household_store_id: 2001, name: "Trader Joe's", is_default: true }],
|
||||||
};
|
};
|
||||||
|
|
||||||
await page.route("**/households", async (route) => {
|
await page.route("**/households", async (route) => {
|
||||||
@ -45,8 +45,8 @@ test("selected household stays active after refreshing on settings and home page
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/stores/household/*", async (route) => {
|
await page.route("**/households/*/stores", async (route) => {
|
||||||
const householdId = Number(route.request().url().split("/").pop());
|
const householdId = Number(route.request().url().match(/households\/(\d+)\/stores/)?.[1]);
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -56,7 +56,15 @@ test("selected household stays active after refreshing on settings and home page
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/*/stores/*/list/recent", async (route) => {
|
await page.route("**/households/*/locations/*/zones", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ zones: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/*/locations/*/list/recent", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -64,7 +72,7 @@ test("selected household stays active after refreshing on settings and home page
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/*/stores/*/list", async (route) => {
|
await page.route("**/households/*/locations/*/list", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -84,8 +92,20 @@ test("selected household stays active after refreshing on settings and home page
|
|||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Alpha Home" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Alpha Home" })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Alpha Home" }).click();
|
const householdTrigger = page.locator(".household-switcher-toggle");
|
||||||
await page.getByRole("button", { name: "Bravo Home" }).click();
|
await expect(householdTrigger).toContainText("Alpha Home");
|
||||||
|
await householdTrigger.click();
|
||||||
|
const householdDropdown = page.locator(".household-switcher-dropdown");
|
||||||
|
await expect(householdDropdown).toBeVisible();
|
||||||
|
await expect(page.locator(".check-mark")).toHaveCount(0);
|
||||||
|
|
||||||
|
const triggerBox = await householdTrigger.boundingBox();
|
||||||
|
const dropdownBox = await householdDropdown.boundingBox();
|
||||||
|
expect(triggerBox).not.toBeNull();
|
||||||
|
expect(dropdownBox).not.toBeNull();
|
||||||
|
expect(Math.abs((dropdownBox?.x ?? 0) - (triggerBox?.x ?? 0))).toBeLessThan(1);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Bravo Home", exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible();
|
||||||
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2");
|
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2");
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
confirmSlide,
|
confirmSlide,
|
||||||
expectNoFailedApiRequests,
|
expectNoFailedApiRequests,
|
||||||
mockConfig,
|
mockConfig,
|
||||||
|
mockHouseholdAndStoreShell,
|
||||||
seedAuthStorage,
|
seedAuthStorage,
|
||||||
} from "./helpers/e2e";
|
} from "./helpers/e2e";
|
||||||
|
|
||||||
@ -179,6 +180,65 @@ test("household management shows pending invite approvals and can approve them",
|
|||||||
await expect(page.getByText("Members (2)")).toBeVisible();
|
await expect(page.getByText("Members (2)")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("household member removal opens slide confirmation instead of browser dialog", async ({ page }) => {
|
||||||
|
await seedAuthStorage(page, { role: "owner", username: "manager-user" });
|
||||||
|
await mockConfig(page);
|
||||||
|
await mockHouseholdAndStoreShell(page, {
|
||||||
|
household: { name: "Removal Home", role: "owner" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let dialogCount = 0;
|
||||||
|
page.on("dialog", async (dialog) => {
|
||||||
|
dialogCount += 1;
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/members", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{ id: 1, username: "manager-user", role: "owner" },
|
||||||
|
{ id: 2, username: "remove-me", role: "member" },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/groups/join-policy", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ joinPolicy: "APPROVAL_REQUIRED" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/groups/invites", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ links: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/groups/join-requests", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ requests: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/manage?tab=household");
|
||||||
|
|
||||||
|
const memberCard = page.locator(".member-card").filter({ hasText: "remove-me" });
|
||||||
|
await memberCard.getByRole("button", { name: "Remove" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Remove remove-me?" })).toBeVisible();
|
||||||
|
await expect(page.getByText("Slide to confirm. They will lose access to this household.")).toBeVisible();
|
||||||
|
await expect(page.locator(".confirm-slide-label")).toHaveText("Remove Member");
|
||||||
|
expect(dialogCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("household owner can transfer ownership from household settings", async ({ page }) => {
|
test("household owner can transfer ownership from household settings", async ({ page }) => {
|
||||||
const failedApiRequests = collectFailedApiRequests(page);
|
const failedApiRequests = collectFailedApiRequests(page);
|
||||||
await seedAuthStorage(page, { role: "owner", username: "manager-user" });
|
await seedAuthStorage(page, { role: "owner", username: "manager-user" });
|
||||||
@ -206,6 +266,14 @@ test("household owner can transfer ownership from household settings", async ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await page.route("**/stores/household/1", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([{ id: 10, name: "Costco", location: "Warehouse", is_default: true }]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/members/*/role", async (route) => {
|
await page.route("**/households/1/members/*/role", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
if (request.method() !== "PATCH") {
|
if (request.method() !== "PATCH") {
|
||||||
|
|||||||
@ -19,6 +19,11 @@
|
|||||||
"db:migrate:new": "node scripts/db-migrate-new.js",
|
"db:migrate:new": "node scripts/db-migrate-new.js",
|
||||||
"db:migrate:stale": "node scripts/db-stale-sql-tracker.js --write",
|
"db:migrate:stale": "node scripts/db-stale-sql-tracker.js --write",
|
||||||
"db:migrate:stale:check": "node scripts/db-stale-sql-tracker.js --fail-on-stale",
|
"db:migrate:stale:check": "node scripts/db-stale-sql-tracker.js --fail-on-stale",
|
||||||
|
"pr:auth": "node scripts/gitea-pr.js auth-check",
|
||||||
|
"pr:create": "node scripts/gitea-pr.js create",
|
||||||
|
"pr:view": "node scripts/gitea-pr.js view",
|
||||||
|
"pr:update": "node scripts/gitea-pr.js update",
|
||||||
|
"pr:merge": "node scripts/gitea-pr.js merge",
|
||||||
"test": "jest --runInBand",
|
"test": "jest --runInBand",
|
||||||
"test:e2e": "npm --prefix frontend run test:e2e --",
|
"test:e2e": "npm --prefix frontend run test:e2e --",
|
||||||
"test:e2e:headed": "npm --prefix frontend run test:e2e:headed --",
|
"test:e2e:headed": "npm --prefix frontend run test:e2e:headed --",
|
||||||
|
|||||||
@ -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