feat: add custom store location backend
This commit is contained in:
parent
905f7256fa
commit
d1c4fcdfe6
@ -1,6 +1,6 @@
|
||||
const AvailableItems = require("../models/available-item.model");
|
||||
const List = require("../models/list.model.v2");
|
||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
||||
const { isValidItemType, isValidItemGroup } = require("../constants/classifications");
|
||||
const { sendError } = require("../utils/http");
|
||||
const { logError } = require("../utils/logger");
|
||||
|
||||
@ -13,6 +13,10 @@ function parseBoolean(value) {
|
||||
return value === true || value === "true" || value === "1";
|
||||
}
|
||||
|
||||
function getStoreLocationId(req) {
|
||||
return req.params.locationId || req.params.storeId;
|
||||
}
|
||||
|
||||
function isCatalogTableMissing(error) {
|
||||
return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || "");
|
||||
}
|
||||
@ -84,7 +88,7 @@ function normalizeClassificationPayload(classification) {
|
||||
return { item_type, item_group, zone };
|
||||
}
|
||||
|
||||
function validateClassification(res, classification) {
|
||||
async function validateClassification(res, householdId, storeLocationId, classification) {
|
||||
if (!classification) {
|
||||
return false;
|
||||
}
|
||||
@ -106,10 +110,13 @@ function validateClassification(res, classification) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (zone && !isValidZone(zone)) {
|
||||
if (zone) {
|
||||
const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone);
|
||||
if (!zoneRecord) {
|
||||
sendError(res, 400, "Invalid zone");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@ -121,8 +128,13 @@ function parseItemId(value) {
|
||||
|
||||
exports.getAvailableItems = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || "");
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const items = await AvailableItems.listAvailableItems(
|
||||
householdId,
|
||||
storeLocationId,
|
||||
req.query.query || ""
|
||||
);
|
||||
res.json({ items, catalog_ready: true });
|
||||
} catch (error) {
|
||||
if (isCatalogTableMissing(error)) {
|
||||
@ -139,7 +151,8 @@ exports.getAvailableItems = async (req, res) => {
|
||||
|
||||
exports.createAvailableItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const { item_name } = req.body;
|
||||
|
||||
if (!item_name || item_name.trim() === "") {
|
||||
@ -152,7 +165,7 @@ exports.createAvailableItem = async (req, res) => {
|
||||
}
|
||||
|
||||
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
||||
if (validateClassification(res, normalizedClassification)) {
|
||||
if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -161,21 +174,37 @@ exports.createAvailableItem = async (req, res) => {
|
||||
|
||||
const item = await AvailableItems.createAvailableItem(
|
||||
householdId,
|
||||
storeId,
|
||||
storeLocationId,
|
||||
item_name,
|
||||
imageBuffer,
|
||||
mimeType
|
||||
mimeType,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
if (normalizedClassification) {
|
||||
await List.upsertClassification(householdId, storeId, item.item_id, {
|
||||
await List.upsertClassification(householdId, storeLocationId, item.item_id, {
|
||||
...normalizedClassification,
|
||||
confidence: 1.0,
|
||||
source: "user",
|
||||
});
|
||||
await List.recordItemEvent({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: item.item_id,
|
||||
actorUserId: req.user.id,
|
||||
eventType: "ITEM_CLASSIFICATION_CHANGED",
|
||||
metadata: {
|
||||
item_name,
|
||||
...normalizedClassification,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const refreshedItem = await AvailableItems.getAvailableItemById(householdId, storeId, item.item_id);
|
||||
const refreshedItem = await AvailableItems.getAvailableItemById(
|
||||
householdId,
|
||||
storeLocationId,
|
||||
item.item_id
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: "Available item added",
|
||||
@ -199,7 +228,8 @@ exports.createAvailableItem = async (req, res) => {
|
||||
|
||||
exports.updateAvailableItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId, itemId: rawItemId } = req.params;
|
||||
const { householdId, itemId: rawItemId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const itemId = parseItemId(rawItemId);
|
||||
|
||||
if (!itemId) {
|
||||
@ -214,15 +244,19 @@ exports.updateAvailableItem = async (req, res) => {
|
||||
}
|
||||
|
||||
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
||||
if (normalizedClassification && validateClassification(res, normalizedClassification)) {
|
||||
if (
|
||||
normalizedClassification &&
|
||||
(await validateClassification(res, householdId, storeLocationId, normalizedClassification))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeId, itemId, {
|
||||
const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeLocationId, itemId, {
|
||||
itemName: req.body.item_name,
|
||||
imageBuffer: req.processedImage?.buffer || null,
|
||||
mimeType: req.processedImage?.mimeType || null,
|
||||
removeImage: parseBoolean(req.body.remove_image),
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
if (!updatedItem) {
|
||||
@ -231,19 +265,30 @@ exports.updateAvailableItem = async (req, res) => {
|
||||
|
||||
if (hasClassificationField) {
|
||||
if (normalizedClassification) {
|
||||
await List.upsertClassification(householdId, storeId, updatedItem.item_id, {
|
||||
await List.upsertClassification(householdId, storeLocationId, updatedItem.item_id, {
|
||||
...normalizedClassification,
|
||||
confidence: 1.0,
|
||||
source: "user",
|
||||
});
|
||||
await List.recordItemEvent({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: updatedItem.item_id,
|
||||
actorUserId: req.user.id,
|
||||
eventType: "ITEM_CLASSIFICATION_CHANGED",
|
||||
metadata: {
|
||||
item_name: updatedItem.item_name,
|
||||
...normalizedClassification,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await List.deleteClassification(householdId, storeId, updatedItem.item_id);
|
||||
await List.deleteClassification(householdId, storeLocationId, updatedItem.item_id);
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedItem = await AvailableItems.getAvailableItemById(
|
||||
householdId,
|
||||
storeId,
|
||||
storeLocationId,
|
||||
updatedItem.item_id
|
||||
);
|
||||
|
||||
@ -269,14 +314,20 @@ exports.updateAvailableItem = async (req, res) => {
|
||||
|
||||
exports.deleteAvailableItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId, itemId: rawItemId } = req.params;
|
||||
const { householdId, itemId: rawItemId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const itemId = parseItemId(rawItemId);
|
||||
|
||||
if (!itemId) {
|
||||
return sendError(res, 400, "Item ID must be a positive integer");
|
||||
}
|
||||
|
||||
const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId);
|
||||
const deleted = await AvailableItems.deleteAvailableItem(
|
||||
householdId,
|
||||
storeLocationId,
|
||||
itemId,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return sendError(res, 404, "Store item not found");
|
||||
@ -298,8 +349,9 @@ exports.deleteAvailableItem = async (req, res) => {
|
||||
|
||||
exports.importCurrentItems = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeId);
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeLocationId);
|
||||
|
||||
res.json({
|
||||
message: importedCount > 0 ? "Imported current list items" : "No current list items to import",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
const List = require("../models/list.model.v2");
|
||||
const householdModel = require("../models/household.model");
|
||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
||||
const { isValidItemType, isValidItemGroup } = require("../constants/classifications");
|
||||
const { sendError } = require("../utils/http");
|
||||
const { logError } = require("../utils/logger");
|
||||
|
||||
@ -9,6 +9,10 @@ const LEGACY_ITEM_TYPE_MAP = {
|
||||
snacks: "snack",
|
||||
};
|
||||
|
||||
function getStoreLocationId(req) {
|
||||
return req.params.locationId || req.params.storeId;
|
||||
}
|
||||
|
||||
function normalizeClassificationPayload(classification) {
|
||||
if (typeof classification === "string") {
|
||||
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
||||
@ -43,14 +47,40 @@ function normalizeClassificationPayload(classification) {
|
||||
return { item_type, item_group, zone };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list items for household and store
|
||||
* GET /households/:householdId/stores/:storeId/list
|
||||
*/
|
||||
async function validateClassification(res, householdId, storeLocationId, classification) {
|
||||
const { item_type, item_group, zone } = classification;
|
||||
|
||||
if (item_type && !isValidItemType(item_type)) {
|
||||
sendError(res, 400, "Invalid item_type");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item_group && !item_type) {
|
||||
sendError(res, 400, "Item type is required when item group is provided");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
||||
sendError(res, 400, "Invalid item_group for selected item_type");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (zone) {
|
||||
const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone);
|
||||
if (!zoneRecord) {
|
||||
sendError(res, 400, "Invalid zone");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
exports.getList = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const items = await List.getHouseholdStoreList(householdId, storeId);
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const items = await List.getHouseholdStoreList(householdId, storeLocationId);
|
||||
res.json({ items });
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.getList", error);
|
||||
@ -58,20 +88,17 @@ exports.getList = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get specific item by name
|
||||
* GET /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.getItemByName = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const { item_name } = req.query;
|
||||
|
||||
if (!item_name) {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||
if (!item) {
|
||||
return sendError(res, 404, "Item not found");
|
||||
}
|
||||
@ -83,13 +110,10 @@ exports.getItemByName = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add or update item in household store list
|
||||
* POST /households/:householdId/stores/:storeId/list/add
|
||||
*/
|
||||
exports.addItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const { item_name, quantity, notes, added_for_user_id } = req.body;
|
||||
const userId = req.user.id;
|
||||
let historyUserId = userId;
|
||||
@ -118,13 +142,12 @@ exports.addItem = async (req, res) => {
|
||||
historyUserId = parsedUserId;
|
||||
}
|
||||
|
||||
// Get processed image if uploaded
|
||||
const imageBuffer = req.processedImage?.buffer || null;
|
||||
const mimeType = req.processedImage?.mimeType || null;
|
||||
|
||||
const result = await List.addOrUpdateItem(
|
||||
householdId,
|
||||
storeId,
|
||||
storeLocationId,
|
||||
item_name,
|
||||
quantity || "1",
|
||||
userId,
|
||||
@ -133,17 +156,38 @@ exports.addItem = async (req, res) => {
|
||||
notes
|
||||
);
|
||||
|
||||
// Add history record
|
||||
await List.addHistoryRecord(result.listId, result.householdStoreItemId, quantity || "1", historyUserId);
|
||||
await List.addHistoryRecord(
|
||||
result.listId,
|
||||
result.householdStoreItemId,
|
||||
result.historyQuantity ?? quantity ?? "1",
|
||||
historyUserId,
|
||||
storeLocationId
|
||||
);
|
||||
|
||||
await List.recordItemEvent({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: result.householdStoreItemId,
|
||||
householdListId: result.listId,
|
||||
actorUserId: historyUserId,
|
||||
eventType: "ITEM_ADDED",
|
||||
quantityDelta: result.historyQuantity ?? Number.parseInt(quantity || "1", 10),
|
||||
quantityAfter: result.quantity,
|
||||
metadata: {
|
||||
item_name: result.itemName,
|
||||
is_new_list_item: result.isNew,
|
||||
added_by_request_user_id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: result.isNew ? "Item added" : "Item updated",
|
||||
item: {
|
||||
id: result.listId,
|
||||
item_name: result.itemName,
|
||||
quantity: quantity || "1",
|
||||
bought: false
|
||||
}
|
||||
quantity: result.quantity ?? quantity ?? "1",
|
||||
bought: false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.addItem", error);
|
||||
@ -151,23 +195,35 @@ exports.addItem = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark item as bought or unbought
|
||||
* PATCH /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.markBought = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const { item_name, bought, quantity_bought } = req.body;
|
||||
|
||||
if (!item_name) return sendError(res, 400, "Item name is required");
|
||||
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||
if (!item) return sendError(res, 404, "Item not found");
|
||||
|
||||
const eventDetails = await List.setBought(item.id, bought, quantity_bought);
|
||||
|
||||
// Update bought status (with optional partial purchase)
|
||||
await List.setBought(item.id, bought, quantity_bought);
|
||||
if (eventDetails) {
|
||||
await List.recordItemEvent({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: item.household_store_item_id,
|
||||
householdListId: item.id,
|
||||
actorUserId: req.user.id,
|
||||
eventType: eventDetails.eventType,
|
||||
quantityDelta: eventDetails.quantityDelta,
|
||||
quantityAfter: eventDetails.quantityAfter,
|
||||
metadata: {
|
||||
item_name,
|
||||
requested_quantity: quantity_bought || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
|
||||
} catch (error) {
|
||||
@ -176,27 +232,42 @@ exports.markBought = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update item details (quantity, notes)
|
||||
* PUT /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.updateItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const { item_name, quantity, notes } = req.body;
|
||||
|
||||
if (!item_name) {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
// Get the list item
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||
if (!item) {
|
||||
return sendError(res, 404, "Item not found");
|
||||
}
|
||||
|
||||
// Update item
|
||||
await List.updateItem(item.id, item_name, quantity, notes);
|
||||
const updateResult = await List.updateItem(item.id, item_name, quantity, notes);
|
||||
if (!updateResult) {
|
||||
return sendError(res, 404, "Item not found");
|
||||
}
|
||||
|
||||
if (quantity !== undefined && Number(quantity) !== Number(updateResult.previous.quantity)) {
|
||||
await List.recordItemEvent({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: item.household_store_item_id,
|
||||
householdListId: item.id,
|
||||
actorUserId: req.user.id,
|
||||
eventType: "ITEM_QUANTITY_CHANGED",
|
||||
quantityDelta: Number(quantity) - Number(updateResult.previous.quantity),
|
||||
quantityAfter: Number(quantity),
|
||||
metadata: {
|
||||
item_name,
|
||||
previous_quantity: updateResult.previous.quantity,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "Item updated",
|
||||
@ -204,8 +275,8 @@ exports.updateItem = async (req, res) => {
|
||||
id: item.id,
|
||||
item_name,
|
||||
quantity,
|
||||
notes
|
||||
}
|
||||
notes,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.updateItem", error);
|
||||
@ -213,26 +284,36 @@ exports.updateItem = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete item from list
|
||||
* DELETE /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.deleteItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const { item_name } = req.body;
|
||||
|
||||
if (!item_name) {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
// Get the list item
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||
if (!item) {
|
||||
return sendError(res, 404, "Item not found");
|
||||
}
|
||||
|
||||
await List.deleteItem(item.id);
|
||||
const deleted = await List.deleteItem(item.id);
|
||||
|
||||
if (deleted) {
|
||||
await List.recordItemEvent({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: item.household_store_item_id,
|
||||
householdListId: item.id,
|
||||
actorUserId: req.user.id,
|
||||
eventType: "ITEM_DELETED",
|
||||
quantityDelta: -Number(item.quantity || 0),
|
||||
quantityAfter: 0,
|
||||
metadata: { item_name },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: "Item deleted" });
|
||||
} catch (error) {
|
||||
@ -241,16 +322,13 @@ exports.deleteItem = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get item suggestions based on query
|
||||
* GET /households/:householdId/stores/:storeId/list/suggestions
|
||||
*/
|
||||
exports.getSuggestions = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const { query } = req.query;
|
||||
|
||||
const suggestions = await List.getSuggestions(query || "", householdId, storeId);
|
||||
const suggestions = await List.getSuggestions(query || "", householdId, storeLocationId);
|
||||
res.json(suggestions);
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.getSuggestions", error);
|
||||
@ -258,14 +336,11 @@ exports.getSuggestions = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get recently bought items
|
||||
* GET /households/:householdId/stores/:storeId/list/recent
|
||||
*/
|
||||
exports.getRecentlyBought = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const items = await List.getRecentlyBoughtItems(householdId, storeId);
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const items = await List.getRecentlyBoughtItems(householdId, storeLocationId);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.getRecentlyBought", error);
|
||||
@ -273,26 +348,26 @@ exports.getRecentlyBought = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get item classification
|
||||
* GET /households/:householdId/stores/:storeId/list/classification
|
||||
*/
|
||||
exports.getClassification = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const { item_name } = req.query;
|
||||
|
||||
if (!item_name) {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
// Get item ID from name
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||
if (!item) {
|
||||
return res.json({ classification: null });
|
||||
}
|
||||
|
||||
const classification = await List.getClassification(householdId, storeId, item.item_id);
|
||||
const classification = await List.getClassification(
|
||||
householdId,
|
||||
storeLocationId,
|
||||
item.item_id
|
||||
);
|
||||
res.json({ classification });
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.getClassification", error);
|
||||
@ -300,13 +375,10 @@ exports.getClassification = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set/update item classification
|
||||
* POST /households/:householdId/stores/:storeId/list/classification
|
||||
*/
|
||||
exports.setClassification = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const { item_name, classification } = req.body;
|
||||
|
||||
if (!item_name) {
|
||||
@ -318,33 +390,17 @@ exports.setClassification = async (req, res) => {
|
||||
return sendError(res, 400, "Classification is required");
|
||||
}
|
||||
|
||||
const { item_type, item_group, zone } = normalizedClassification;
|
||||
|
||||
if (item_type && !isValidItemType(item_type)) {
|
||||
return sendError(res, 400, "Invalid item_type");
|
||||
if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item_group && !item_type) {
|
||||
return sendError(res, 400, "Item type is required when item group is provided");
|
||||
}
|
||||
|
||||
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
||||
return sendError(res, 400, "Invalid item_group for selected item_type");
|
||||
}
|
||||
|
||||
if (zone && !isValidZone(zone)) {
|
||||
return sendError(res, 400, "Invalid zone");
|
||||
}
|
||||
|
||||
// Get item - add to master items if not exists
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||
let itemId;
|
||||
|
||||
if (!item) {
|
||||
// Item doesn't exist in list, need to get from items table or create
|
||||
const itemResult = await List.ensureHouseholdStoreItem(
|
||||
householdId,
|
||||
storeId,
|
||||
storeLocationId,
|
||||
item_name
|
||||
);
|
||||
itemId = itemResult.id;
|
||||
@ -352,14 +408,43 @@ exports.setClassification = async (req, res) => {
|
||||
itemId = item.item_id;
|
||||
}
|
||||
|
||||
await List.upsertClassification(householdId, storeId, itemId, {
|
||||
item_type,
|
||||
item_group,
|
||||
zone,
|
||||
const updated = await List.upsertClassification(householdId, storeLocationId, itemId, {
|
||||
...normalizedClassification,
|
||||
confidence: 1.0,
|
||||
source: "user",
|
||||
});
|
||||
|
||||
await List.recordItemEvent({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: itemId,
|
||||
householdListId: item?.id || null,
|
||||
actorUserId: req.user.id,
|
||||
eventType: "ITEM_CLASSIFICATION_CHANGED",
|
||||
metadata: {
|
||||
item_name,
|
||||
item_type: normalizedClassification.item_type,
|
||||
item_group: normalizedClassification.item_group,
|
||||
zone: normalizedClassification.zone,
|
||||
},
|
||||
});
|
||||
|
||||
if (normalizedClassification.zone) {
|
||||
await List.recordItemEvent({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: itemId,
|
||||
householdListId: item?.id || null,
|
||||
actorUserId: req.user.id,
|
||||
eventType: "ITEM_ZONE_CHANGED",
|
||||
metadata: {
|
||||
item_name,
|
||||
zone: normalizedClassification.zone,
|
||||
zone_id: updated.zone_id || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: "Classification set", classification: normalizedClassification });
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.setClassification", error);
|
||||
@ -367,17 +452,13 @@ exports.setClassification = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update item image
|
||||
* POST /households/:householdId/stores/:storeId/list/update-image
|
||||
*/
|
||||
exports.updateItemImage = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { householdId } = req.params;
|
||||
const storeLocationId = getStoreLocationId(req);
|
||||
const { item_name, quantity } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Get processed image
|
||||
const imageBuffer = req.processedImage?.buffer || null;
|
||||
const mimeType = req.processedImage?.mimeType || null;
|
||||
|
||||
@ -385,8 +466,15 @@ exports.updateItemImage = async (req, res) => {
|
||||
return sendError(res, 400, "No image provided");
|
||||
}
|
||||
|
||||
// Update the item with new image
|
||||
await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType);
|
||||
await List.addOrUpdateItem(
|
||||
householdId,
|
||||
storeLocationId,
|
||||
item_name,
|
||||
quantity,
|
||||
userId,
|
||||
imageBuffer,
|
||||
mimeType
|
||||
);
|
||||
|
||||
res.json({ message: "Image updated successfully" });
|
||||
} catch (error) {
|
||||
|
||||
@ -2,7 +2,20 @@ const storeModel = require("../models/store.model");
|
||||
const { sendError } = require("../utils/http");
|
||||
const { logError } = require("../utils/logger");
|
||||
|
||||
// Get all available stores
|
||||
function parsePositiveInteger(value) {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function getHouseholdId(req) {
|
||||
return req.params.householdId || req.household?.id;
|
||||
}
|
||||
|
||||
function getLocationId(req) {
|
||||
return req.params.locationId || req.params.storeId;
|
||||
}
|
||||
|
||||
// Legacy global store catalog. Kept for system-admin compatibility.
|
||||
exports.getAllStores = async (req, res) => {
|
||||
try {
|
||||
const stores = await storeModel.getAllStores();
|
||||
@ -13,78 +26,6 @@ exports.getAllStores = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Get stores for household
|
||||
exports.getHouseholdStores = async (req, res) => {
|
||||
try {
|
||||
const stores = await storeModel.getHouseholdStores(req.params.householdId);
|
||||
res.json(stores);
|
||||
} catch (error) {
|
||||
logError(req, "stores.getHouseholdStores", error);
|
||||
sendError(res, 500, "Failed to fetch household stores");
|
||||
}
|
||||
};
|
||||
|
||||
// Add store to household
|
||||
exports.addStoreToHousehold = async (req, res) => {
|
||||
try {
|
||||
const { storeId, isDefault } = req.body;
|
||||
// console.log("Adding store to household:", { householdId: req.params.householdId, storeId, isDefault });
|
||||
if (!storeId) {
|
||||
return sendError(res, 400, "Store ID is required");
|
||||
}
|
||||
|
||||
const store = await storeModel.getStoreById(storeId);
|
||||
if (!store) return sendError(res, 404, "Store not found");
|
||||
const foundStores = await storeModel.getHouseholdStores(req.params.householdId);
|
||||
// if (foundStores.length == 0) isDefault = 'true';
|
||||
|
||||
await storeModel.addStoreToHousehold(
|
||||
req.params.householdId,
|
||||
storeId,
|
||||
foundStores.length == 0 ? true : isDefault || false
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: "Store added to household successfully",
|
||||
store
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "stores.addStoreToHousehold", error);
|
||||
sendError(res, 500, "Failed to add store to household");
|
||||
}
|
||||
};
|
||||
|
||||
// Remove store from household
|
||||
exports.removeStoreFromHousehold = async (req, res) => {
|
||||
try {
|
||||
await storeModel.removeStoreFromHousehold(
|
||||
req.params.householdId,
|
||||
req.params.storeId
|
||||
);
|
||||
|
||||
res.json({ message: "Store removed from household successfully" });
|
||||
} catch (error) {
|
||||
logError(req, "stores.removeStoreFromHousehold", error);
|
||||
sendError(res, 500, "Failed to remove store from household");
|
||||
}
|
||||
};
|
||||
|
||||
// Set default store
|
||||
exports.setDefaultStore = async (req, res) => {
|
||||
try {
|
||||
await storeModel.setDefaultStore(
|
||||
req.params.householdId,
|
||||
req.params.storeId
|
||||
);
|
||||
|
||||
res.json({ message: "Default store updated successfully" });
|
||||
} catch (error) {
|
||||
logError(req, "stores.setDefaultStore", error);
|
||||
sendError(res, 500, "Failed to set default store");
|
||||
}
|
||||
};
|
||||
|
||||
// Create store (system admin only)
|
||||
exports.createStore = async (req, res) => {
|
||||
try {
|
||||
const { name, default_zones } = req.body;
|
||||
@ -97,25 +38,24 @@ exports.createStore = async (req, res) => {
|
||||
|
||||
res.status(201).json({
|
||||
message: "Store created successfully",
|
||||
store
|
||||
store,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "stores.createStore", error);
|
||||
if (error.code === '23505') { // Unique violation
|
||||
if (error.code === "23505") {
|
||||
return sendError(res, 400, "Store with this name already exists");
|
||||
}
|
||||
sendError(res, 500, "Failed to create store");
|
||||
}
|
||||
};
|
||||
|
||||
// Update store (system admin only)
|
||||
exports.updateStore = async (req, res) => {
|
||||
try {
|
||||
const { name, default_zones } = req.body;
|
||||
|
||||
const store = await storeModel.updateStore(req.params.storeId, {
|
||||
name: name?.trim(),
|
||||
default_zones
|
||||
default_zones,
|
||||
});
|
||||
|
||||
if (!store) {
|
||||
@ -124,7 +64,7 @@ exports.updateStore = async (req, res) => {
|
||||
|
||||
res.json({
|
||||
message: "Store updated successfully",
|
||||
store
|
||||
store,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "stores.updateStore", error);
|
||||
@ -132,16 +72,341 @@ exports.updateStore = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Delete store (system admin only)
|
||||
exports.deleteStore = async (req, res) => {
|
||||
try {
|
||||
await storeModel.deleteStore(req.params.storeId);
|
||||
res.json({ message: "Store deleted successfully" });
|
||||
} catch (error) {
|
||||
logError(req, "stores.deleteStore", error);
|
||||
if (error.message.includes('in use')) {
|
||||
if (error.message.includes("in use")) {
|
||||
return sendError(res, 400, error.message);
|
||||
}
|
||||
sendError(res, 500, "Failed to delete store");
|
||||
}
|
||||
};
|
||||
|
||||
// Household-owned store/location management.
|
||||
exports.getHouseholdStores = async (req, res) => {
|
||||
try {
|
||||
const stores = await storeModel.getHouseholdStores(getHouseholdId(req));
|
||||
res.json(stores);
|
||||
} catch (error) {
|
||||
logError(req, "stores.getHouseholdStores", error);
|
||||
sendError(res, 500, "Failed to fetch household stores");
|
||||
}
|
||||
};
|
||||
|
||||
exports.createHouseholdStore = async (req, res) => {
|
||||
try {
|
||||
const householdId = getHouseholdId(req);
|
||||
const { name, location_name, address } = req.body;
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return sendError(res, 400, "Store name is required");
|
||||
}
|
||||
|
||||
const store = await storeModel.createHouseholdStore(
|
||||
householdId,
|
||||
name,
|
||||
location_name || "Default Location",
|
||||
address || null,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: "Store location created successfully",
|
||||
store,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "stores.createHouseholdStore", error);
|
||||
if (error.code === "23505") {
|
||||
return sendError(res, 400, "Store or location already exists for this household");
|
||||
}
|
||||
sendError(res, 500, "Failed to create store location");
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateHouseholdStore = async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||
|
||||
if (!householdStoreId) {
|
||||
return sendError(res, 400, "Store ID must be a positive integer");
|
||||
}
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return sendError(res, 400, "Store name is required");
|
||||
}
|
||||
|
||||
const store = await storeModel.updateHouseholdStore(getHouseholdId(req), householdStoreId, {
|
||||
name,
|
||||
});
|
||||
|
||||
if (!store) {
|
||||
return sendError(res, 404, "Store not found");
|
||||
}
|
||||
|
||||
res.json({ message: "Store updated successfully", store });
|
||||
} catch (error) {
|
||||
logError(req, "stores.updateHouseholdStore", error);
|
||||
sendError(res, 500, "Failed to update store");
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteHouseholdStore = async (req, res) => {
|
||||
try {
|
||||
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||
if (!householdStoreId) {
|
||||
return sendError(res, 400, "Store ID must be a positive integer");
|
||||
}
|
||||
|
||||
const deleted = await storeModel.deleteHouseholdStore(getHouseholdId(req), householdStoreId);
|
||||
if (!deleted) {
|
||||
return sendError(res, 404, "Store not found");
|
||||
}
|
||||
|
||||
res.json({ message: "Store deleted successfully" });
|
||||
} catch (error) {
|
||||
logError(req, "stores.deleteHouseholdStore", error);
|
||||
if (error.message.includes("last store location")) {
|
||||
return sendError(res, 400, error.message);
|
||||
}
|
||||
sendError(res, 500, "Failed to delete store");
|
||||
}
|
||||
};
|
||||
|
||||
exports.addLocationToStore = async (req, res) => {
|
||||
try {
|
||||
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||
const { name, address } = req.body;
|
||||
|
||||
if (!householdStoreId) {
|
||||
return sendError(res, 400, "Store ID must be a positive integer");
|
||||
}
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return sendError(res, 400, "Location name is required");
|
||||
}
|
||||
|
||||
const location = await storeModel.addLocationToStore(
|
||||
getHouseholdId(req),
|
||||
householdStoreId,
|
||||
name,
|
||||
address || null,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
if (!location) {
|
||||
return sendError(res, 404, "Store not found");
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
message: "Location added successfully",
|
||||
store: location,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "stores.addLocationToStore", error);
|
||||
if (error.code === "23505") {
|
||||
return sendError(res, 400, "Location already exists for this store");
|
||||
}
|
||||
sendError(res, 500, "Failed to add location");
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateLocation = async (req, res) => {
|
||||
try {
|
||||
const locationId = parsePositiveInteger(getLocationId(req));
|
||||
const { name, address, map_data } = req.body;
|
||||
|
||||
if (!locationId) {
|
||||
return sendError(res, 400, "Location ID must be a positive integer");
|
||||
}
|
||||
|
||||
const location = await storeModel.updateLocation(getHouseholdId(req), locationId, {
|
||||
name,
|
||||
address,
|
||||
map_data,
|
||||
});
|
||||
|
||||
if (!location) {
|
||||
return sendError(res, 404, "Location not found");
|
||||
}
|
||||
|
||||
res.json({ message: "Location updated successfully", store: location });
|
||||
} catch (error) {
|
||||
logError(req, "stores.updateLocation", error);
|
||||
sendError(res, 500, "Failed to update location");
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteLocation = async (req, res) => {
|
||||
try {
|
||||
const locationId = parsePositiveInteger(getLocationId(req));
|
||||
if (!locationId) {
|
||||
return sendError(res, 400, "Location ID must be a positive integer");
|
||||
}
|
||||
|
||||
const deleted = await storeModel.deleteLocation(getHouseholdId(req), locationId);
|
||||
if (!deleted) {
|
||||
return sendError(res, 404, "Location not found");
|
||||
}
|
||||
|
||||
res.json({ message: "Location removed successfully" });
|
||||
} catch (error) {
|
||||
logError(req, "stores.deleteLocation", error);
|
||||
if (error.message.includes("last store location")) {
|
||||
return sendError(res, 400, error.message);
|
||||
}
|
||||
sendError(res, 500, "Failed to remove location");
|
||||
}
|
||||
};
|
||||
|
||||
exports.setDefaultLocation = async (req, res) => {
|
||||
try {
|
||||
const locationId = parsePositiveInteger(getLocationId(req));
|
||||
if (!locationId) {
|
||||
return sendError(res, 400, "Location ID must be a positive integer");
|
||||
}
|
||||
|
||||
await storeModel.setDefaultLocation(getHouseholdId(req), locationId);
|
||||
res.json({ message: "Default location updated successfully" });
|
||||
} catch (error) {
|
||||
logError(req, "stores.setDefaultLocation", error);
|
||||
sendError(res, 500, "Failed to set default location");
|
||||
}
|
||||
};
|
||||
|
||||
exports.getLocationZones = async (req, res) => {
|
||||
try {
|
||||
const locationId = parsePositiveInteger(getLocationId(req));
|
||||
if (!locationId) {
|
||||
return sendError(res, 400, "Location ID must be a positive integer");
|
||||
}
|
||||
|
||||
const zones = await storeModel.listLocationZones(getHouseholdId(req), locationId);
|
||||
res.json({ zones });
|
||||
} catch (error) {
|
||||
logError(req, "stores.getLocationZones", error);
|
||||
sendError(res, 500, "Failed to load zones");
|
||||
}
|
||||
};
|
||||
|
||||
exports.createZone = async (req, res) => {
|
||||
try {
|
||||
const locationId = parsePositiveInteger(getLocationId(req));
|
||||
const { name, sort_order, color, map_metadata } = req.body;
|
||||
|
||||
if (!locationId) {
|
||||
return sendError(res, 400, "Location ID must be a positive integer");
|
||||
}
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return sendError(res, 400, "Zone name is required");
|
||||
}
|
||||
|
||||
const zone = await storeModel.createZone(getHouseholdId(req), locationId, {
|
||||
name,
|
||||
sort_order: Number.isInteger(sort_order) ? sort_order : Number.parseInt(sort_order, 10),
|
||||
color,
|
||||
map_metadata,
|
||||
});
|
||||
|
||||
res.status(201).json({ message: "Zone created successfully", zone });
|
||||
} catch (error) {
|
||||
logError(req, "stores.createZone", error);
|
||||
if (error.code === "23505") {
|
||||
return sendError(res, 400, "Zone already exists for this location");
|
||||
}
|
||||
sendError(res, 500, "Failed to create zone");
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateZone = async (req, res) => {
|
||||
try {
|
||||
const locationId = parsePositiveInteger(getLocationId(req));
|
||||
const zoneId = parsePositiveInteger(req.params.zoneId);
|
||||
|
||||
if (!locationId || !zoneId) {
|
||||
return sendError(res, 400, "Location ID and zone ID must be positive integers");
|
||||
}
|
||||
|
||||
const sortOrder = req.body.sort_order;
|
||||
const zone = await storeModel.updateZone(getHouseholdId(req), locationId, zoneId, {
|
||||
name: req.body.name,
|
||||
sort_order:
|
||||
sortOrder === undefined
|
||||
? undefined
|
||||
: Number.isInteger(sortOrder)
|
||||
? sortOrder
|
||||
: Number.parseInt(sortOrder, 10),
|
||||
color: req.body.color,
|
||||
map_metadata: req.body.map_metadata,
|
||||
is_active: req.body.is_active,
|
||||
});
|
||||
|
||||
if (!zone) {
|
||||
return sendError(res, 404, "Zone not found");
|
||||
}
|
||||
|
||||
res.json({ message: "Zone updated successfully", zone });
|
||||
} catch (error) {
|
||||
logError(req, "stores.updateZone", error);
|
||||
sendError(res, 500, "Failed to update zone");
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteZone = async (req, res) => {
|
||||
try {
|
||||
const locationId = parsePositiveInteger(getLocationId(req));
|
||||
const zoneId = parsePositiveInteger(req.params.zoneId);
|
||||
|
||||
if (!locationId || !zoneId) {
|
||||
return sendError(res, 400, "Location ID and zone ID must be positive integers");
|
||||
}
|
||||
|
||||
const deleted = await storeModel.deleteZone(getHouseholdId(req), locationId, zoneId);
|
||||
if (!deleted) {
|
||||
return sendError(res, 404, "Zone not found");
|
||||
}
|
||||
|
||||
res.json({ message: "Zone removed successfully" });
|
||||
} catch (error) {
|
||||
logError(req, "stores.deleteZone", error);
|
||||
sendError(res, 500, "Failed to remove zone");
|
||||
}
|
||||
};
|
||||
|
||||
// Backward-compatible handlers for the old /stores/household routes.
|
||||
exports.addStoreToHousehold = async (req, res) => {
|
||||
try {
|
||||
const { storeId } = req.body;
|
||||
if (!storeId) {
|
||||
return sendError(res, 400, "Store ID is required");
|
||||
}
|
||||
|
||||
const legacyStore = await storeModel.getStoreById(storeId);
|
||||
if (!legacyStore) {
|
||||
return sendError(res, 404, "Store not found");
|
||||
}
|
||||
|
||||
const store = await storeModel.createHouseholdStore(
|
||||
getHouseholdId(req),
|
||||
legacyStore.name,
|
||||
"Default Location",
|
||||
null,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: "Store added to household successfully",
|
||||
store,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "stores.addStoreToHousehold", error);
|
||||
sendError(res, 500, "Failed to add store to household");
|
||||
}
|
||||
};
|
||||
|
||||
exports.removeStoreFromHousehold = exports.deleteLocation;
|
||||
exports.setDefaultStore = exports.setDefaultLocation;
|
||||
|
||||
@ -90,6 +90,43 @@ exports.storeAccess = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware to check location access (household must own the store location)
|
||||
exports.locationAccess = async (req, res, next) => {
|
||||
try {
|
||||
const locationId = parseInt(req.params.locationId || req.params.storeId);
|
||||
|
||||
if (!locationId) {
|
||||
return sendError(res, 400, "Location ID required");
|
||||
}
|
||||
|
||||
if (!req.household) {
|
||||
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
|
||||
}
|
||||
|
||||
const storeModel = require("../models/store.model");
|
||||
const hasLocation = await storeModel.householdHasLocation(req.household.id, locationId);
|
||||
|
||||
if (!hasLocation) {
|
||||
return sendError(res, 403, "This household does not have access to this store location.");
|
||||
}
|
||||
|
||||
req.storeLocation = {
|
||||
id: locationId
|
||||
};
|
||||
|
||||
// Keep req.store populated so older controller code and tests can continue
|
||||
// to refer to the active shopping scope as a store.
|
||||
req.store = {
|
||||
id: locationId
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logError(req, "middleware.locationAccess", error);
|
||||
sendError(res, 500, "Server error checking location access");
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware to require system admin role
|
||||
exports.requireSystemAdmin = (req, res, next) => {
|
||||
if (!req.user) {
|
||||
|
||||
@ -1,81 +1,97 @@
|
||||
const pool = require("../db/pool");
|
||||
const List = require("./list.model.v2");
|
||||
|
||||
function normalizeItemName(itemName) {
|
||||
return String(itemName || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function getHouseholdStoreItemRecord(householdId, storeId, itemId) {
|
||||
async function getHouseholdStoreItemRecord(householdId, storeLocationId, itemId) {
|
||||
const result = await pool.query(
|
||||
`WITH latest_list_items AS (
|
||||
SELECT DISTINCT ON (hl.household_store_item_id)
|
||||
hl.household_store_item_id,
|
||||
hl.image_id,
|
||||
hl.custom_image,
|
||||
hl.custom_image_mime_type,
|
||||
hl.modified_on,
|
||||
hl.id
|
||||
FROM household_lists hl
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
AND hl.store_location_id = $2
|
||||
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
|
||||
)
|
||||
SELECT
|
||||
hsi.id AS item_id,
|
||||
hsi.name AS item_name,
|
||||
ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image,
|
||||
COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
|
||||
ENCODE(
|
||||
COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image),
|
||||
'base64'
|
||||
) AS item_image,
|
||||
COALESCE(
|
||||
catalog_img.mime_type,
|
||||
hsi.custom_image_mime_type,
|
||||
list_img.mime_type,
|
||||
lli.custom_image_mime_type
|
||||
) AS image_mime_type,
|
||||
hic.item_type,
|
||||
hic.item_group,
|
||||
hic.zone
|
||||
COALESCE(slz.name, hic.zone) AS zone,
|
||||
slz.sort_order AS zone_sort_order
|
||||
FROM household_store_items hsi
|
||||
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
|
||||
LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hic.household_id = hsi.household_id
|
||||
AND hic.store_id = hsi.store_id
|
||||
AND hic.store_location_id = hsi.store_location_id
|
||||
AND hic.household_store_item_id = hsi.id
|
||||
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||
WHERE hsi.household_id = $1
|
||||
AND hsi.store_id = $2
|
||||
AND hsi.store_location_id = $2
|
||||
AND hsi.id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
[householdId, storeLocationId, itemId]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function findOrCreateHouseholdStoreItem(householdId, storeId, itemName) {
|
||||
async function findOrCreateHouseholdStoreItem(householdId, storeLocationId, itemName) {
|
||||
const normalizedName = normalizeItemName(itemName);
|
||||
const existing = await pool.query(
|
||||
`SELECT id, name
|
||||
FROM household_store_items
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND store_location_id = $2
|
||||
AND normalized_name = $3`,
|
||||
[householdId, storeId, normalizedName]
|
||||
[householdId, storeLocationId, normalizedName]
|
||||
);
|
||||
|
||||
if (existing.rowCount > 0) {
|
||||
return {
|
||||
itemId: existing.rows[0].id,
|
||||
itemName: existing.rows[0].name,
|
||||
isNew: false,
|
||||
};
|
||||
}
|
||||
|
||||
const created = await pool.query(
|
||||
`INSERT INTO household_store_items
|
||||
(household_id, store_id, name, normalized_name, updated_at)
|
||||
(household_id, store_location_id, name, normalized_name, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
RETURNING id, name`,
|
||||
[householdId, storeId, normalizedName, normalizedName]
|
||||
[householdId, storeLocationId, normalizedName, normalizedName]
|
||||
);
|
||||
|
||||
return {
|
||||
itemId: created.rows[0].id,
|
||||
itemName: created.rows[0].name,
|
||||
isNew: true,
|
||||
};
|
||||
}
|
||||
|
||||
exports.listAvailableItems = async (householdId, storeId, query = "") => {
|
||||
exports.listAvailableItems = async (householdId, storeLocationId, query = "") => {
|
||||
const trimmedQuery = String(query || "").trim();
|
||||
const values = [householdId, storeId];
|
||||
const values = [householdId, storeLocationId];
|
||||
let filterClause = "";
|
||||
|
||||
if (trimmedQuery) {
|
||||
@ -87,35 +103,49 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => {
|
||||
`WITH latest_list_items AS (
|
||||
SELECT DISTINCT ON (hl.household_store_item_id)
|
||||
hl.household_store_item_id,
|
||||
hl.image_id,
|
||||
hl.custom_image,
|
||||
hl.custom_image_mime_type,
|
||||
hl.modified_on,
|
||||
hl.id
|
||||
FROM household_lists hl
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
AND hl.store_location_id = $2
|
||||
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
|
||||
)
|
||||
SELECT
|
||||
hsi.id AS item_id,
|
||||
hsi.name AS item_name,
|
||||
ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image,
|
||||
COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
|
||||
ENCODE(
|
||||
COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image),
|
||||
'base64'
|
||||
) AS item_image,
|
||||
COALESCE(
|
||||
catalog_img.mime_type,
|
||||
hsi.custom_image_mime_type,
|
||||
list_img.mime_type,
|
||||
lli.custom_image_mime_type
|
||||
) AS image_mime_type,
|
||||
hic.item_type,
|
||||
hic.item_group,
|
||||
hic.zone,
|
||||
COALESCE(slz.name, hic.zone) AS zone,
|
||||
slz.sort_order AS zone_sort_order,
|
||||
(
|
||||
hsi.custom_image IS NOT NULL
|
||||
hsi.image_id IS NOT NULL
|
||||
OR hsi.custom_image IS NOT NULL
|
||||
OR hic.household_store_item_id IS NOT NULL
|
||||
) AS has_managed_settings
|
||||
FROM household_store_items hsi
|
||||
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
|
||||
LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hic.household_id = hsi.household_id
|
||||
AND hic.store_id = hsi.store_id
|
||||
AND hic.store_location_id = hsi.store_location_id
|
||||
AND hic.household_store_item_id = hsi.id
|
||||
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||
WHERE hsi.household_id = $1
|
||||
AND hsi.store_id = $2
|
||||
AND hsi.store_location_id = $2
|
||||
${filterClause}
|
||||
ORDER BY hsi.name ASC
|
||||
LIMIT 100`,
|
||||
@ -125,22 +155,23 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => {
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
exports.getAvailableItemById = async (householdId, storeId, itemId) =>
|
||||
getHouseholdStoreItemRecord(householdId, storeId, itemId);
|
||||
exports.getAvailableItemById = async (householdId, storeLocationId, itemId) =>
|
||||
getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
|
||||
|
||||
exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => {
|
||||
exports.getAvailableItemImageByName = async (householdId, storeLocationId, itemName) => {
|
||||
const normalizedName = normalizeItemName(itemName);
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id AS item_id,
|
||||
name AS item_name,
|
||||
custom_image,
|
||||
custom_image_mime_type
|
||||
FROM household_store_items
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND normalized_name = $3`,
|
||||
[householdId, storeId, normalizedName]
|
||||
hsi.id AS item_id,
|
||||
hsi.name AS item_name,
|
||||
COALESCE(img.image, hsi.custom_image) AS custom_image,
|
||||
COALESCE(img.mime_type, hsi.custom_image_mime_type) AS custom_image_mime_type
|
||||
FROM household_store_items hsi
|
||||
LEFT JOIN household_item_images img ON img.id = hsi.image_id
|
||||
WHERE hsi.household_id = $1
|
||||
AND hsi.store_location_id = $2
|
||||
AND hsi.normalized_name = $3`,
|
||||
[householdId, storeLocationId, normalizedName]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
@ -148,39 +179,54 @@ exports.getAvailableItemImageByName = async (householdId, storeId, itemName) =>
|
||||
|
||||
exports.createAvailableItem = async (
|
||||
householdId,
|
||||
storeId,
|
||||
storeLocationId,
|
||||
itemName,
|
||||
imageBuffer = null,
|
||||
mimeType = null
|
||||
mimeType = null,
|
||||
userId = null
|
||||
) => {
|
||||
const { itemId } = await findOrCreateHouseholdStoreItem(householdId, storeId, itemName);
|
||||
const { itemId, isNew } = await findOrCreateHouseholdStoreItem(
|
||||
householdId,
|
||||
storeLocationId,
|
||||
itemName
|
||||
);
|
||||
|
||||
if (imageBuffer && mimeType) {
|
||||
await pool.query(
|
||||
`UPDATE household_store_items
|
||||
SET custom_image = $1,
|
||||
custom_image_mime_type = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
AND household_id = $4
|
||||
AND store_id = $5`,
|
||||
[imageBuffer, mimeType, itemId, householdId, storeId]
|
||||
await List.setCatalogItemImage(
|
||||
householdId,
|
||||
storeLocationId,
|
||||
itemId,
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
return getHouseholdStoreItemRecord(householdId, storeId, itemId);
|
||||
if (isNew) {
|
||||
await List.recordItemEvent({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: itemId,
|
||||
actorUserId: userId,
|
||||
eventType: "ITEM_ADDED",
|
||||
metadata: { source: "catalog" },
|
||||
});
|
||||
}
|
||||
|
||||
return getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
|
||||
};
|
||||
|
||||
exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => {
|
||||
exports.updateAvailableItem = async (householdId, storeLocationId, itemId, updates = {}) => {
|
||||
const {
|
||||
itemName,
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
removeImage = false,
|
||||
userId = null,
|
||||
} = updates;
|
||||
|
||||
const assignments = ["updated_at = NOW()"];
|
||||
const values = [householdId, storeId, itemId];
|
||||
const values = [householdId, storeLocationId, itemId];
|
||||
let parameterIndex = values.length;
|
||||
|
||||
if (itemName !== undefined && String(itemName).trim() !== "") {
|
||||
@ -195,22 +241,14 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {})
|
||||
}
|
||||
|
||||
if (removeImage) {
|
||||
assignments.push("custom_image = NULL", "custom_image_mime_type = NULL");
|
||||
} else if (imageBuffer && mimeType) {
|
||||
parameterIndex += 1;
|
||||
assignments.push(`custom_image = $${parameterIndex}`);
|
||||
values.push(imageBuffer);
|
||||
|
||||
parameterIndex += 1;
|
||||
assignments.push(`custom_image_mime_type = $${parameterIndex}`);
|
||||
values.push(mimeType);
|
||||
assignments.push("image_id = NULL", "custom_image = NULL", "custom_image_mime_type = NULL");
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE household_store_items
|
||||
SET ${assignments.join(", ")}
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND store_location_id = $2
|
||||
AND id = $3
|
||||
RETURNING id`,
|
||||
values
|
||||
@ -220,53 +258,75 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {})
|
||||
return null;
|
||||
}
|
||||
|
||||
return getHouseholdStoreItemRecord(householdId, storeId, result.rows[0].id);
|
||||
if (!removeImage && imageBuffer && mimeType) {
|
||||
await List.setCatalogItemImage(
|
||||
householdId,
|
||||
storeLocationId,
|
||||
result.rows[0].id,
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
return getHouseholdStoreItemRecord(householdId, storeLocationId, result.rows[0].id);
|
||||
};
|
||||
|
||||
exports.deleteAvailableItem = async (householdId, storeId, itemId) => {
|
||||
exports.deleteAvailableItem = async (householdId, storeLocationId, itemId, userId = null) => {
|
||||
const item = await getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
|
||||
const result = await pool.query(
|
||||
`DELETE FROM household_store_items
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND store_location_id = $2
|
||||
AND id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
[householdId, storeLocationId, itemId]
|
||||
);
|
||||
|
||||
if (result.rowCount > 0) {
|
||||
await List.recordItemEvent({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: itemId,
|
||||
actorUserId: userId,
|
||||
eventType: "ITEM_DELETED",
|
||||
metadata: { item_name: item?.item_name || null },
|
||||
});
|
||||
}
|
||||
|
||||
return result.rowCount > 0;
|
||||
};
|
||||
|
||||
exports.importCurrentListItems = async (householdId, storeId) => {
|
||||
exports.importCurrentListItems = async (householdId, storeLocationId) => {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO household_store_items
|
||||
(household_id, store_id, name, normalized_name, custom_image, custom_image_mime_type, updated_at)
|
||||
(household_id, store_location_id, name, normalized_name, image_id, updated_at)
|
||||
SELECT DISTINCT ON (hl.household_store_item_id)
|
||||
hl.household_id,
|
||||
hl.store_id,
|
||||
hl.store_location_id,
|
||||
hsi.name,
|
||||
hsi.normalized_name,
|
||||
hsi.custom_image,
|
||||
hsi.custom_image_mime_type,
|
||||
hsi.image_id,
|
||||
NOW()
|
||||
FROM household_lists hl
|
||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING
|
||||
AND hl.store_location_id = $2
|
||||
ON CONFLICT (household_id, store_location_id, normalized_name) DO NOTHING
|
||||
RETURNING id`,
|
||||
[householdId, storeId]
|
||||
[householdId, storeLocationId]
|
||||
);
|
||||
|
||||
return result.rowCount;
|
||||
};
|
||||
|
||||
exports.hasAvailableItems = async (householdId, storeId) => {
|
||||
exports.hasAvailableItems = async (householdId, storeLocationId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT 1
|
||||
FROM household_store_items
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND store_location_id = $2
|
||||
LIMIT 1`,
|
||||
[householdId, storeId]
|
||||
[householdId, storeLocationId]
|
||||
);
|
||||
|
||||
return result.rowCount > 0;
|
||||
|
||||
@ -169,18 +169,6 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const promoteResult = await client.query(
|
||||
`UPDATE household_members
|
||||
SET role = 'owner'
|
||||
WHERE household_id = $1 AND user_id = $2
|
||||
RETURNING user_id, role`,
|
||||
[householdId, nextOwnerUserId]
|
||||
);
|
||||
|
||||
if (promoteResult.rows.length === 0) {
|
||||
throw new Error("TARGET_MEMBER_NOT_FOUND");
|
||||
}
|
||||
|
||||
const demoteResult = await client.query(
|
||||
`UPDATE household_members
|
||||
SET role = 'admin'
|
||||
@ -193,6 +181,18 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse
|
||||
throw new Error("CURRENT_OWNER_NOT_FOUND");
|
||||
}
|
||||
|
||||
const promoteResult = await client.query(
|
||||
`UPDATE household_members
|
||||
SET role = 'owner'
|
||||
WHERE household_id = $1 AND user_id = $2
|
||||
RETURNING user_id, role`,
|
||||
[householdId, nextOwnerUserId]
|
||||
);
|
||||
|
||||
if (promoteResult.rows.length === 0) {
|
||||
throw new Error("TARGET_MEMBER_NOT_FOUND");
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return promoteResult.rows[0];
|
||||
} catch (error) {
|
||||
|
||||
@ -4,22 +4,104 @@ function normalizeItemName(itemName) {
|
||||
return String(itemName || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName) {
|
||||
function toPositiveInteger(value, fallback = 1) {
|
||||
const numberValue = Number(value);
|
||||
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : fallback;
|
||||
}
|
||||
|
||||
const ACTIVE_ADDED_BY_USERS_SQL = `
|
||||
(
|
||||
SELECT ARRAY_AGG(
|
||||
active_added_by_users.user_label
|
||||
ORDER BY active_added_by_users.last_added_on DESC, active_added_by_users.user_label
|
||||
)
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label,
|
||||
MAX(active_history.added_on) AS last_added_on
|
||||
FROM (
|
||||
SELECT
|
||||
hlh.*,
|
||||
COALESCE(
|
||||
SUM(hlh.quantity) OVER (
|
||||
PARTITION BY hlh.household_list_id
|
||||
ORDER BY hlh.added_on DESC, hlh.id DESC
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||||
),
|
||||
0
|
||||
) AS newer_quantity
|
||||
FROM household_list_history hlh
|
||||
WHERE hlh.household_list_id = hl.id
|
||||
) active_history
|
||||
JOIN users u ON active_history.added_by = u.id
|
||||
WHERE active_history.newer_quantity < GREATEST(hl.quantity, 0)
|
||||
GROUP BY user_label
|
||||
) active_added_by_users
|
||||
) AS added_by_users`;
|
||||
|
||||
async function getHouseholdStoreItemByNormalizedName(householdId, storeLocationId, normalizedName) {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, normalized_name, custom_image, custom_image_mime_type
|
||||
`SELECT id, name, normalized_name, image_id
|
||||
FROM household_store_items
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND store_location_id = $2
|
||||
AND normalized_name = $3`,
|
||||
[householdId, storeId, normalizedName]
|
||||
[householdId, storeLocationId, normalizedName]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => {
|
||||
async function createItemImage({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId,
|
||||
householdListId = null,
|
||||
imageScope,
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
userId = null,
|
||||
}) {
|
||||
if (!imageBuffer || !mimeType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO household_item_images
|
||||
(
|
||||
household_id,
|
||||
store_location_id,
|
||||
household_store_item_id,
|
||||
household_list_id,
|
||||
image_scope,
|
||||
image,
|
||||
mime_type,
|
||||
created_by
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id`,
|
||||
[
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId,
|
||||
householdListId,
|
||||
imageScope,
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0].id;
|
||||
}
|
||||
|
||||
exports.ensureHouseholdStoreItem = async (householdId, storeLocationId, itemName) => {
|
||||
const normalizedName = normalizeItemName(itemName);
|
||||
let item = await getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName);
|
||||
let item = await getHouseholdStoreItemByNormalizedName(
|
||||
householdId,
|
||||
storeLocationId,
|
||||
normalizedName
|
||||
);
|
||||
|
||||
if (item) {
|
||||
return item;
|
||||
@ -27,23 +109,16 @@ exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => {
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO household_store_items
|
||||
(household_id, store_id, name, normalized_name, updated_at)
|
||||
(household_id, store_location_id, name, normalized_name, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
RETURNING id, name, normalized_name, custom_image, custom_image_mime_type`,
|
||||
[householdId, storeId, normalizedName, normalizedName]
|
||||
RETURNING id, name, normalized_name, image_id`,
|
||||
[householdId, storeLocationId, normalizedName, normalizedName]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get list items for a specific household and store
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @param {boolean} includeHistory - Include purchase history
|
||||
* @returns {Promise<Array>} List of items
|
||||
*/
|
||||
exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => {
|
||||
exports.getHouseholdStoreList = async (householdId, storeLocationId, includeHistory = true) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
@ -52,130 +127,141 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
|
||||
hsi.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
|
||||
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||
${includeHistory ? `
|
||||
(
|
||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
|
||||
FROM household_list_history hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
WHERE hlh.household_list_id = hl.id
|
||||
) added_by_labels
|
||||
) AS added_by_users,
|
||||
` : "NULL AS added_by_users,"}
|
||||
hl.notes,
|
||||
ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
|
||||
COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||
${includeHistory ? `${ACTIVE_ADDED_BY_USERS_SQL},` : "NULL AS added_by_users,"}
|
||||
hl.modified_on AS last_added_on,
|
||||
hic.item_type,
|
||||
hic.item_group,
|
||||
hic.zone
|
||||
COALESCE(slz.name, hic.zone) AS zone,
|
||||
slz.sort_order AS zone_sort_order
|
||||
FROM household_lists hl
|
||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
|
||||
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hic.household_id = hl.household_id
|
||||
AND hic.store_id = hl.store_id
|
||||
AND hic.store_location_id = hl.store_location_id
|
||||
AND hic.household_store_item_id = hl.household_store_item_id
|
||||
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
AND hl.store_location_id = $2
|
||||
AND hl.bought = FALSE
|
||||
ORDER BY hl.id ASC`,
|
||||
[householdId, storeId]
|
||||
ORDER BY slz.sort_order ASC NULLS LAST, hsi.name ASC`,
|
||||
[householdId, storeLocationId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific item from household list by name
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @param {string} itemName - Item name to search for
|
||||
* @returns {Promise<Object|null>} Item or null
|
||||
*/
|
||||
exports.getItemByName = async (householdId, storeId, itemName) => {
|
||||
exports.getItemByName = async (householdId, storeLocationId, itemName) => {
|
||||
const normalizedName = normalizeItemName(itemName);
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
hl.household_id,
|
||||
hl.store_location_id,
|
||||
hl.household_store_item_id AS item_id,
|
||||
hl.household_store_item_id,
|
||||
hsi.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
|
||||
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||
(
|
||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
|
||||
FROM household_list_history hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
WHERE hlh.household_list_id = hl.id
|
||||
) added_by_labels
|
||||
) AS added_by_users,
|
||||
hl.notes,
|
||||
ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
|
||||
COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||
${ACTIVE_ADDED_BY_USERS_SQL},
|
||||
hl.modified_on AS last_added_on,
|
||||
hic.item_type,
|
||||
hic.item_group,
|
||||
hic.zone
|
||||
COALESCE(slz.name, hic.zone) AS zone,
|
||||
slz.sort_order AS zone_sort_order
|
||||
FROM household_lists hl
|
||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
|
||||
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hic.household_id = hl.household_id
|
||||
AND hic.store_id = hl.store_id
|
||||
AND hic.store_location_id = hl.store_location_id
|
||||
AND hic.household_store_item_id = hl.household_store_item_id
|
||||
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
AND hl.store_location_id = $2
|
||||
AND hsi.normalized_name = $3`,
|
||||
[householdId, storeId, normalizedName]
|
||||
[householdId, storeLocationId, normalizedName]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add or update an item in household list
|
||||
* @returns {Promise<{listId:number,itemId:number,householdStoreItemId:number,itemName:string,isNew:boolean}>}
|
||||
*/
|
||||
exports.addOrUpdateItem = async (
|
||||
householdId,
|
||||
storeId,
|
||||
storeLocationId,
|
||||
itemName,
|
||||
quantity,
|
||||
userId,
|
||||
imageBuffer = null,
|
||||
mimeType = null
|
||||
mimeType = null,
|
||||
notes = undefined
|
||||
) => {
|
||||
const householdStoreItem = await exports.ensureHouseholdStoreItem(householdId, storeId, itemName);
|
||||
const nextQuantity = toPositiveInteger(quantity);
|
||||
const householdStoreItem = await exports.ensureHouseholdStoreItem(
|
||||
householdId,
|
||||
storeLocationId,
|
||||
itemName
|
||||
);
|
||||
const listResult = await pool.query(
|
||||
`SELECT id, bought
|
||||
`SELECT id, bought, quantity
|
||||
FROM household_lists
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND store_location_id = $2
|
||||
AND household_store_item_id = $3`,
|
||||
[householdId, storeId, householdStoreItem.id]
|
||||
[householdId, storeLocationId, householdStoreItem.id]
|
||||
);
|
||||
|
||||
if (listResult.rowCount > 0) {
|
||||
const listId = listResult.rows[0].id;
|
||||
const previousQuantity = toPositiveInteger(listResult.rows[0].quantity, 0);
|
||||
const wasBought = Boolean(listResult.rows[0].bought);
|
||||
const historyQuantity =
|
||||
!wasBought && nextQuantity > previousQuantity
|
||||
? nextQuantity - previousQuantity
|
||||
: nextQuantity;
|
||||
|
||||
let imageId = null;
|
||||
if (imageBuffer && mimeType) {
|
||||
imageId = await createItemImage({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: householdStoreItem.id,
|
||||
householdListId: listId,
|
||||
imageScope: "list",
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (imageId) {
|
||||
await pool.query(
|
||||
`UPDATE household_lists
|
||||
SET quantity = $1,
|
||||
bought = FALSE,
|
||||
custom_image = $2,
|
||||
custom_image_mime_type = $3,
|
||||
image_id = $2,
|
||||
custom_image = NULL,
|
||||
custom_image_mime_type = NULL,
|
||||
notes = COALESCE($3, notes),
|
||||
modified_on = NOW()
|
||||
WHERE id = $4`,
|
||||
[quantity, imageBuffer, mimeType, listId]
|
||||
[nextQuantity, imageId, notes, listId]
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`UPDATE household_lists
|
||||
SET quantity = $1,
|
||||
bought = FALSE,
|
||||
notes = COALESCE($2, notes),
|
||||
modified_on = NOW()
|
||||
WHERE id = $2`,
|
||||
[quantity, listId]
|
||||
WHERE id = $3`,
|
||||
[nextQuantity, notes, listId]
|
||||
);
|
||||
}
|
||||
|
||||
@ -184,46 +270,87 @@ exports.addOrUpdateItem = async (
|
||||
itemId: householdStoreItem.id,
|
||||
householdStoreItemId: householdStoreItem.id,
|
||||
itemName: householdStoreItem.name,
|
||||
quantity: nextQuantity,
|
||||
previousQuantity,
|
||||
historyQuantity,
|
||||
wasBought,
|
||||
isNew: false,
|
||||
};
|
||||
}
|
||||
|
||||
const insert = await pool.query(
|
||||
`INSERT INTO household_lists
|
||||
(household_id, store_id, household_store_item_id, quantity, custom_image, custom_image_mime_type, added_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
(household_id, store_location_id, household_store_item_id, quantity, added_by, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[householdId, storeId, householdStoreItem.id, quantity, imageBuffer, mimeType, userId]
|
||||
[householdId, storeLocationId, householdStoreItem.id, nextQuantity, userId, notes || null]
|
||||
);
|
||||
|
||||
if (imageBuffer && mimeType) {
|
||||
const imageId = await createItemImage({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId: householdStoreItem.id,
|
||||
householdListId: insert.rows[0].id,
|
||||
imageScope: "list",
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
userId,
|
||||
});
|
||||
|
||||
await pool.query(
|
||||
`UPDATE household_lists
|
||||
SET image_id = $1,
|
||||
custom_image = NULL,
|
||||
custom_image_mime_type = NULL
|
||||
WHERE id = $2`,
|
||||
[imageId, insert.rows[0].id]
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
listId: insert.rows[0].id,
|
||||
itemId: householdStoreItem.id,
|
||||
householdStoreItemId: householdStoreItem.id,
|
||||
itemName: householdStoreItem.name,
|
||||
quantity: nextQuantity,
|
||||
previousQuantity: 0,
|
||||
historyQuantity: nextQuantity,
|
||||
wasBought: false,
|
||||
isNew: true,
|
||||
};
|
||||
};
|
||||
|
||||
exports.setBought = async (listId, bought, quantityBought = null) => {
|
||||
const item = await pool.query(
|
||||
`SELECT id, household_id, store_location_id, household_store_item_id, quantity, bought
|
||||
FROM household_lists
|
||||
WHERE id = $1`,
|
||||
[listId]
|
||||
);
|
||||
|
||||
if (!item.rows[0]) return null;
|
||||
|
||||
const current = item.rows[0];
|
||||
const currentQuantity = toPositiveInteger(current.quantity, 0);
|
||||
|
||||
if (bought === false) {
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
return;
|
||||
return {
|
||||
...current,
|
||||
eventType: "ITEM_UNBOUGHT",
|
||||
quantityDelta: null,
|
||||
quantityAfter: currentQuantity,
|
||||
};
|
||||
}
|
||||
|
||||
if (quantityBought && quantityBought > 0) {
|
||||
const item = await pool.query(
|
||||
"SELECT quantity FROM household_lists WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
|
||||
if (!item.rows[0]) return;
|
||||
|
||||
const currentQuantity = item.rows[0].quantity;
|
||||
const remainingQuantity = currentQuantity - quantityBought;
|
||||
const requestedQuantity = toPositiveInteger(quantityBought, 0);
|
||||
if (requestedQuantity > 0) {
|
||||
const boughtQuantity = Math.min(requestedQuantity, currentQuantity);
|
||||
const remainingQuantity = currentQuantity - boughtQuantity;
|
||||
|
||||
if (remainingQuantity <= 0) {
|
||||
await pool.query(
|
||||
@ -236,23 +363,90 @@ exports.setBought = async (listId, bought, quantityBought = null) => {
|
||||
[remainingQuantity, listId]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
||||
return {
|
||||
...current,
|
||||
eventType: "ITEM_BOUGHT",
|
||||
quantityDelta: -boughtQuantity,
|
||||
quantityAfter: Math.max(remainingQuantity, 0),
|
||||
};
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
eventType: "ITEM_BOUGHT",
|
||||
quantityDelta: -currentQuantity,
|
||||
quantityAfter: 0,
|
||||
};
|
||||
};
|
||||
|
||||
exports.addHistoryRecord = async (listId, householdStoreItemId, quantity, userId) => {
|
||||
exports.addHistoryRecord = async (
|
||||
listId,
|
||||
householdStoreItemId,
|
||||
quantity,
|
||||
userId,
|
||||
storeLocationId = null
|
||||
) => {
|
||||
await pool.query(
|
||||
`INSERT INTO household_list_history (household_list_id, household_store_item_id, quantity, added_by, added_on)
|
||||
VALUES ($1, $2, $3, $4, NOW())`,
|
||||
[listId, householdStoreItemId, quantity, userId]
|
||||
`INSERT INTO household_list_history
|
||||
(household_list_id, store_location_id, household_store_item_id, quantity, added_by, added_on)
|
||||
VALUES (
|
||||
$1,
|
||||
COALESCE($5, (SELECT store_location_id FROM household_lists WHERE id = $1)),
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
NOW()
|
||||
)`,
|
||||
[listId, householdStoreItemId, quantity, userId, storeLocationId]
|
||||
);
|
||||
};
|
||||
|
||||
exports.getSuggestions = async (query, householdId, storeId) => {
|
||||
exports.recordItemEvent = async ({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId,
|
||||
householdListId = null,
|
||||
actorUserId = null,
|
||||
eventType,
|
||||
quantityDelta = null,
|
||||
quantityAfter = null,
|
||||
metadata = {},
|
||||
}) => {
|
||||
await pool.query(
|
||||
`INSERT INTO household_item_events
|
||||
(
|
||||
household_id,
|
||||
store_location_id,
|
||||
household_store_item_id,
|
||||
household_list_id,
|
||||
actor_user_id,
|
||||
event_type,
|
||||
quantity_delta,
|
||||
quantity_after,
|
||||
metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
|
||||
[
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId,
|
||||
householdListId,
|
||||
actorUserId,
|
||||
eventType,
|
||||
quantityDelta,
|
||||
quantityAfter,
|
||||
JSON.stringify(metadata || {}),
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
exports.getSuggestions = async (query, householdId, storeLocationId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT
|
||||
hsi.name AS item_name,
|
||||
@ -261,18 +455,18 @@ exports.getSuggestions = async (query, householdId, storeId) => {
|
||||
LEFT JOIN household_lists hl
|
||||
ON hl.household_store_item_id = hsi.id
|
||||
AND hl.household_id = $2
|
||||
AND hl.store_id = $3
|
||||
AND hl.store_location_id = $3
|
||||
WHERE hsi.household_id = $2
|
||||
AND hsi.store_id = $3
|
||||
AND hsi.store_location_id = $3
|
||||
AND hsi.name ILIKE $1
|
||||
ORDER BY sort_order, hsi.name
|
||||
LIMIT 10`,
|
||||
[`%${query}%`, householdId, storeId]
|
||||
[`%${query}%`, householdId, storeLocationId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
||||
exports.getRecentlyBoughtItems = async (householdId, storeLocationId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
@ -281,73 +475,121 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
||||
hsi.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
|
||||
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||
(
|
||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label
|
||||
FROM household_list_history hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
WHERE hlh.household_list_id = hl.id
|
||||
) added_by_labels
|
||||
) AS added_by_users,
|
||||
ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image,
|
||||
COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
||||
${ACTIVE_ADDED_BY_USERS_SQL},
|
||||
hl.modified_on AS last_added_on
|
||||
FROM household_lists hl
|
||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||
LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id
|
||||
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
AND hl.store_location_id = $2
|
||||
AND hl.bought = TRUE
|
||||
AND hl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY hl.modified_on DESC`,
|
||||
[householdId, storeId]
|
||||
[householdId, storeLocationId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
exports.getClassification = async (householdId, storeId, itemId) => {
|
||||
exports.getZoneByName = async (householdId, storeLocationId, zoneName) => {
|
||||
const result = await pool.query(
|
||||
`SELECT item_type, item_group, zone, confidence, source
|
||||
FROM household_item_classifications
|
||||
WHERE household_id = $1 AND store_id = $2 AND household_store_item_id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
`SELECT id, name, sort_order
|
||||
FROM store_location_zones
|
||||
WHERE household_id = $1
|
||||
AND store_location_id = $2
|
||||
AND normalized_name = $3
|
||||
AND is_active = TRUE`,
|
||||
[householdId, storeLocationId, normalizeItemName(zoneName)]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
exports.upsertClassification = async (householdId, storeId, itemId, classification) => {
|
||||
exports.getClassification = async (householdId, storeLocationId, itemId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hic.item_type,
|
||||
hic.item_group,
|
||||
COALESCE(slz.name, hic.zone) AS zone,
|
||||
hic.confidence,
|
||||
hic.source
|
||||
FROM household_item_classifications hic
|
||||
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
|
||||
WHERE hic.household_id = $1
|
||||
AND hic.store_location_id = $2
|
||||
AND hic.household_store_item_id = $3`,
|
||||
[householdId, storeLocationId, itemId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
exports.upsertClassification = async (householdId, storeLocationId, itemId, classification) => {
|
||||
const { item_type, item_group, zone, confidence, source } = classification;
|
||||
const zoneRecord = zone ? await exports.getZoneByName(householdId, storeLocationId, zone) : null;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO household_item_classifications
|
||||
(household_id, store_id, household_store_item_id, item_type, item_group, zone, confidence, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (household_id, store_id, household_store_item_id)
|
||||
(
|
||||
household_id,
|
||||
store_location_id,
|
||||
household_store_item_id,
|
||||
item_type,
|
||||
item_group,
|
||||
zone,
|
||||
zone_id,
|
||||
confidence,
|
||||
source
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (household_id, store_location_id, household_store_item_id)
|
||||
DO UPDATE SET
|
||||
item_type = EXCLUDED.item_type,
|
||||
item_group = EXCLUDED.item_group,
|
||||
zone = EXCLUDED.zone,
|
||||
zone_id = EXCLUDED.zone_id,
|
||||
confidence = EXCLUDED.confidence,
|
||||
source = EXCLUDED.source
|
||||
source = EXCLUDED.source,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[householdId, storeId, itemId, item_type, item_group, zone, confidence, source]
|
||||
[
|
||||
householdId,
|
||||
storeLocationId,
|
||||
itemId,
|
||||
item_type,
|
||||
item_group,
|
||||
zone,
|
||||
zoneRecord?.id || null,
|
||||
confidence,
|
||||
source,
|
||||
]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
exports.deleteClassification = async (householdId, storeId, itemId) => {
|
||||
exports.deleteClassification = async (householdId, storeLocationId, itemId) => {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM household_item_classifications
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND store_location_id = $2
|
||||
AND household_store_item_id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
[householdId, storeLocationId, itemId]
|
||||
);
|
||||
return result.rowCount > 0;
|
||||
};
|
||||
|
||||
exports.updateItem = async (listId, itemName, quantity, notes) => {
|
||||
const existing = await pool.query(
|
||||
`SELECT id, household_id, store_location_id, household_store_item_id, quantity, notes
|
||||
FROM household_lists
|
||||
WHERE id = $1`,
|
||||
[listId]
|
||||
);
|
||||
|
||||
if (existing.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const values = [listId];
|
||||
let paramCount = 1;
|
||||
@ -366,22 +608,56 @@ exports.updateItem = async (listId, itemName, quantity, notes) => {
|
||||
|
||||
updates.push("modified_on = NOW()");
|
||||
|
||||
if (updates.length === 1) {
|
||||
const result = await pool.query(
|
||||
"UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *",
|
||||
[listId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE household_lists SET ${updates.join(", ")} WHERE id = $1 RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
return {
|
||||
previous: existing.rows[0],
|
||||
updated: result.rows[0],
|
||||
};
|
||||
};
|
||||
|
||||
exports.deleteItem = async (listId) => {
|
||||
await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]);
|
||||
const result = await pool.query(
|
||||
`DELETE FROM household_lists
|
||||
WHERE id = $1
|
||||
RETURNING id, household_id, store_location_id, household_store_item_id, quantity`,
|
||||
[listId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
exports.setCatalogItemImage = async (
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId,
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
userId = null
|
||||
) => {
|
||||
const imageId = await createItemImage({
|
||||
householdId,
|
||||
storeLocationId,
|
||||
householdStoreItemId,
|
||||
imageScope: "catalog",
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
userId,
|
||||
});
|
||||
|
||||
await pool.query(
|
||||
`UPDATE household_store_items
|
||||
SET image_id = $1,
|
||||
custom_image = NULL,
|
||||
custom_image_mime_type = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE household_id = $2
|
||||
AND store_location_id = $3
|
||||
AND id = $4`,
|
||||
[imageId, householdId, storeLocationId, householdStoreItemId]
|
||||
);
|
||||
|
||||
return imageId;
|
||||
};
|
||||
|
||||
@ -1,6 +1,70 @@
|
||||
const pool = require("../db/pool");
|
||||
const { ZONE_FLOW } = require("../constants/classifications");
|
||||
|
||||
// Get all available stores
|
||||
const DEFAULT_LOCATION_NAME = "Default Location";
|
||||
|
||||
function normalizeName(value) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function displayLocationName(storeName, locationName) {
|
||||
if (!locationName || locationName === DEFAULT_LOCATION_NAME) {
|
||||
return storeName;
|
||||
}
|
||||
return `${storeName} - ${locationName}`;
|
||||
}
|
||||
|
||||
function mapLocationRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
id: row.location_id,
|
||||
display_name: row.display_name || displayLocationName(row.name, row.location_name),
|
||||
};
|
||||
}
|
||||
|
||||
async function queryLocationById(db, householdId, locationId) {
|
||||
const result = await db.query(
|
||||
`SELECT
|
||||
sl.id AS location_id,
|
||||
sl.id,
|
||||
sl.household_id,
|
||||
sl.household_store_id,
|
||||
hcs.name,
|
||||
sl.name AS location_name,
|
||||
sl.address,
|
||||
sl.is_default,
|
||||
sl.map_data,
|
||||
sl.created_at,
|
||||
sl.updated_at,
|
||||
CASE
|
||||
WHEN sl.name = $3 THEN hcs.name
|
||||
ELSE hcs.name || ' - ' || sl.name
|
||||
END AS display_name
|
||||
FROM store_locations sl
|
||||
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
|
||||
WHERE sl.household_id = $1
|
||||
AND sl.id = $2`,
|
||||
[householdId, locationId, DEFAULT_LOCATION_NAME]
|
||||
);
|
||||
|
||||
return mapLocationRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async function seedDefaultZones(db, householdId, locationId) {
|
||||
for (let index = 0; index < ZONE_FLOW.length; index += 1) {
|
||||
const zoneName = ZONE_FLOW[index];
|
||||
await db.query(
|
||||
`INSERT INTO store_location_zones
|
||||
(household_id, store_location_id, name, normalized_name, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (store_location_id, normalized_name) DO NOTHING`,
|
||||
[householdId, locationId, zoneName, normalizeName(zoneName), (index + 1) * 10]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy global store catalog. Kept for system-admin compatibility only.
|
||||
exports.getAllStores = async () => {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, default_zones, created_at
|
||||
@ -10,7 +74,6 @@ exports.getAllStores = async () => {
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
// Get store by ID
|
||||
exports.getStoreById = async (storeId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, default_zones, created_at
|
||||
@ -21,77 +84,6 @@ exports.getStoreById = async (storeId) => {
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Get stores for a specific household
|
||||
exports.getHouseholdStores = async (householdId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.default_zones,
|
||||
hs.is_default,
|
||||
hs.added_at
|
||||
FROM stores s
|
||||
JOIN household_stores hs ON s.id = hs.store_id
|
||||
WHERE hs.household_id = $1
|
||||
ORDER BY hs.is_default DESC, s.name ASC`,
|
||||
[householdId]
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
// Add store to household
|
||||
exports.addStoreToHousehold = async (householdId, storeId, isDefault = false) => {
|
||||
// If setting as default, unset other defaults
|
||||
if (isDefault) {
|
||||
await pool.query(
|
||||
`UPDATE household_stores
|
||||
SET is_default = FALSE
|
||||
WHERE household_id = $1`,
|
||||
[householdId]
|
||||
);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO household_stores (household_id, store_id, is_default)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (household_id, store_id)
|
||||
DO UPDATE SET is_default = $3
|
||||
RETURNING household_id, store_id, is_default`,
|
||||
[householdId, storeId, isDefault]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Remove store from household
|
||||
exports.removeStoreFromHousehold = async (householdId, storeId) => {
|
||||
await pool.query(
|
||||
`DELETE FROM household_stores
|
||||
WHERE household_id = $1 AND store_id = $2`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
};
|
||||
|
||||
// Set default store for household
|
||||
exports.setDefaultStore = async (householdId, storeId) => {
|
||||
// Unset all defaults
|
||||
await pool.query(
|
||||
`UPDATE household_stores
|
||||
SET is_default = FALSE
|
||||
WHERE household_id = $1`,
|
||||
[householdId]
|
||||
);
|
||||
|
||||
// Set new default
|
||||
await pool.query(
|
||||
`UPDATE household_stores
|
||||
SET is_default = TRUE
|
||||
WHERE household_id = $1 AND store_id = $2`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
};
|
||||
|
||||
// Create new store (system admin only)
|
||||
exports.createStore = async (name, defaultZones) => {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO stores (name, default_zones)
|
||||
@ -102,7 +94,6 @@ exports.createStore = async (name, defaultZones) => {
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Update store (system admin only)
|
||||
exports.updateStore = async (storeId, updates) => {
|
||||
const { name, default_zones } = updates;
|
||||
const result = await pool.query(
|
||||
@ -117,27 +108,452 @@ exports.updateStore = async (storeId, updates) => {
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
// Delete store (system admin only, only if not in use)
|
||||
exports.deleteStore = async (storeId) => {
|
||||
// Check if store is in use
|
||||
const usage = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`,
|
||||
[storeId]
|
||||
);
|
||||
|
||||
if (parseInt(usage.rows[0].count) > 0) {
|
||||
throw new Error('Cannot delete store that is in use by households');
|
||||
if (parseInt(usage.rows[0].count, 10) > 0) {
|
||||
throw new Error("Cannot delete store that is in use by households");
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM stores WHERE id = $1', [storeId]);
|
||||
await pool.query("DELETE FROM stores WHERE id = $1", [storeId]);
|
||||
};
|
||||
|
||||
// Check if household has store
|
||||
// Household-owned store locations.
|
||||
exports.getHouseholdStores = async (householdId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
sl.id AS location_id,
|
||||
sl.id,
|
||||
sl.household_id,
|
||||
sl.household_store_id,
|
||||
hcs.name,
|
||||
sl.name AS location_name,
|
||||
sl.address,
|
||||
sl.is_default,
|
||||
sl.map_data,
|
||||
sl.created_at,
|
||||
sl.updated_at,
|
||||
CASE
|
||||
WHEN sl.name = $2 THEN hcs.name
|
||||
ELSE hcs.name || ' - ' || sl.name
|
||||
END AS display_name
|
||||
FROM store_locations sl
|
||||
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
|
||||
WHERE sl.household_id = $1
|
||||
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
|
||||
[householdId, DEFAULT_LOCATION_NAME]
|
||||
);
|
||||
return result.rows.map(mapLocationRow);
|
||||
};
|
||||
|
||||
exports.createHouseholdStore = async (
|
||||
householdId,
|
||||
name,
|
||||
locationName = DEFAULT_LOCATION_NAME,
|
||||
address = null,
|
||||
createdBy = null
|
||||
) => {
|
||||
const client = await pool.connect();
|
||||
const normalizedStoreName = normalizeName(name);
|
||||
const normalizedLocationName = normalizeName(locationName || DEFAULT_LOCATION_NAME);
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const storeResult = await client.query(
|
||||
`INSERT INTO household_custom_stores
|
||||
(household_id, name, normalized_name, created_by, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (household_id, normalized_name)
|
||||
DO UPDATE SET name = EXCLUDED.name, updated_at = NOW()
|
||||
RETURNING id, name`,
|
||||
[householdId, name.trim(), normalizedStoreName, createdBy]
|
||||
);
|
||||
|
||||
const hasDefault = await client.query(
|
||||
`SELECT 1 FROM store_locations
|
||||
WHERE household_id = $1 AND is_default = TRUE
|
||||
LIMIT 1`,
|
||||
[householdId]
|
||||
);
|
||||
|
||||
const locationResult = await client.query(
|
||||
`INSERT INTO store_locations
|
||||
(household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT (household_store_id, normalized_name)
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
address = COALESCE(EXCLUDED.address, store_locations.address),
|
||||
updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[
|
||||
householdId,
|
||||
storeResult.rows[0].id,
|
||||
(locationName || DEFAULT_LOCATION_NAME).trim(),
|
||||
normalizedLocationName,
|
||||
address || null,
|
||||
hasDefault.rowCount === 0,
|
||||
createdBy,
|
||||
]
|
||||
);
|
||||
|
||||
await seedDefaultZones(client, householdId, locationResult.rows[0].id);
|
||||
|
||||
const location = await queryLocationById(client, householdId, locationResult.rows[0].id);
|
||||
await client.query("COMMIT");
|
||||
return location;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateHouseholdStore = async (householdId, householdStoreId, updates = {}) => {
|
||||
const { name } = updates;
|
||||
const result = await pool.query(
|
||||
`UPDATE household_custom_stores
|
||||
SET name = COALESCE($1, name),
|
||||
normalized_name = COALESCE($2, normalized_name),
|
||||
updated_at = NOW()
|
||||
WHERE household_id = $3
|
||||
AND id = $4
|
||||
RETURNING id, household_id, name, created_at, updated_at`,
|
||||
[
|
||||
name?.trim() || null,
|
||||
name ? normalizeName(name) : null,
|
||||
householdId,
|
||||
householdStoreId,
|
||||
]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
exports.deleteHouseholdStore = async (householdId, householdStoreId) => {
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM store_locations
|
||||
WHERE household_id = $1`,
|
||||
[householdId]
|
||||
);
|
||||
|
||||
const storeLocationCount = countResult.rows[0]?.count || 0;
|
||||
const targetLocations = await pool.query(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM store_locations
|
||||
WHERE household_id = $1
|
||||
AND household_store_id = $2`,
|
||||
[householdId, householdStoreId]
|
||||
);
|
||||
|
||||
if (storeLocationCount <= targetLocations.rows[0]?.count) {
|
||||
throw new Error("Cannot remove the last store location for a household");
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM household_custom_stores
|
||||
WHERE household_id = $1
|
||||
AND id = $2`,
|
||||
[householdId, householdStoreId]
|
||||
);
|
||||
|
||||
return result.rowCount > 0;
|
||||
};
|
||||
|
||||
exports.addLocationToStore = async (
|
||||
householdId,
|
||||
householdStoreId,
|
||||
name,
|
||||
address = null,
|
||||
createdBy = null
|
||||
) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const storeResult = await client.query(
|
||||
`SELECT id FROM household_custom_stores
|
||||
WHERE household_id = $1
|
||||
AND id = $2`,
|
||||
[householdId, householdStoreId]
|
||||
);
|
||||
|
||||
if (storeResult.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasDefault = await client.query(
|
||||
`SELECT 1 FROM store_locations
|
||||
WHERE household_id = $1 AND is_default = TRUE
|
||||
LIMIT 1`,
|
||||
[householdId]
|
||||
);
|
||||
|
||||
const locationResult = await client.query(
|
||||
`INSERT INTO store_locations
|
||||
(household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
RETURNING id`,
|
||||
[
|
||||
householdId,
|
||||
householdStoreId,
|
||||
name.trim(),
|
||||
normalizeName(name),
|
||||
address || null,
|
||||
hasDefault.rowCount === 0,
|
||||
createdBy,
|
||||
]
|
||||
);
|
||||
|
||||
await seedDefaultZones(client, householdId, locationResult.rows[0].id);
|
||||
const location = await queryLocationById(client, householdId, locationResult.rows[0].id);
|
||||
await client.query("COMMIT");
|
||||
return location;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateLocation = async (householdId, locationId, updates = {}) => {
|
||||
const { name, address, map_data } = updates;
|
||||
const result = await pool.query(
|
||||
`UPDATE store_locations
|
||||
SET name = COALESCE($1, name),
|
||||
normalized_name = COALESCE($2, normalized_name),
|
||||
address = COALESCE($3, address),
|
||||
map_data = COALESCE($4::jsonb, map_data),
|
||||
updated_at = NOW()
|
||||
WHERE household_id = $5
|
||||
AND id = $6
|
||||
RETURNING id`,
|
||||
[
|
||||
name?.trim() || null,
|
||||
name ? normalizeName(name) : null,
|
||||
address === undefined ? null : address,
|
||||
map_data ? JSON.stringify(map_data) : null,
|
||||
householdId,
|
||||
locationId,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) return null;
|
||||
return queryLocationById(pool, householdId, locationId);
|
||||
};
|
||||
|
||||
exports.deleteLocation = async (householdId, locationId) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const countResult = await client.query(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM store_locations
|
||||
WHERE household_id = $1`,
|
||||
[householdId]
|
||||
);
|
||||
|
||||
if ((countResult.rows[0]?.count || 0) <= 1) {
|
||||
throw new Error("Cannot remove the last store location for a household");
|
||||
}
|
||||
|
||||
const deleted = await client.query(
|
||||
`DELETE FROM store_locations
|
||||
WHERE household_id = $1
|
||||
AND id = $2
|
||||
RETURNING is_default`,
|
||||
[householdId, locationId]
|
||||
);
|
||||
|
||||
if (deleted.rowCount === 0) {
|
||||
await client.query("COMMIT");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deleted.rows[0].is_default) {
|
||||
await client.query(
|
||||
`UPDATE store_locations
|
||||
SET is_default = TRUE, updated_at = NOW()
|
||||
WHERE id = (
|
||||
SELECT id
|
||||
FROM store_locations
|
||||
WHERE household_id = $1
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
)`,
|
||||
[householdId]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return true;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
exports.setDefaultLocation = async (householdId, locationId) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query(
|
||||
`UPDATE store_locations
|
||||
SET is_default = FALSE, updated_at = NOW()
|
||||
WHERE household_id = $1`,
|
||||
[householdId]
|
||||
);
|
||||
|
||||
const result = await client.query(
|
||||
`UPDATE store_locations
|
||||
SET is_default = TRUE, updated_at = NOW()
|
||||
WHERE household_id = $1
|
||||
AND id = $2
|
||||
RETURNING id`,
|
||||
[householdId, locationId]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error("Location not found");
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
exports.householdHasLocation = async (householdId, locationId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT 1
|
||||
FROM store_locations
|
||||
WHERE household_id = $1
|
||||
AND id = $2`,
|
||||
[householdId, locationId]
|
||||
);
|
||||
return result.rowCount > 0;
|
||||
};
|
||||
|
||||
exports.getLocationById = async (householdId, locationId) =>
|
||||
queryLocationById(pool, householdId, locationId);
|
||||
|
||||
exports.listLocationZones = async (householdId, locationId, includeInactive = false) => {
|
||||
const values = [householdId, locationId];
|
||||
const inactiveClause = includeInactive ? "" : "AND is_active = TRUE";
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, sort_order, color, map_metadata, is_active, created_at, updated_at
|
||||
FROM store_location_zones
|
||||
WHERE household_id = $1
|
||||
AND store_location_id = $2
|
||||
${inactiveClause}
|
||||
ORDER BY sort_order ASC, name ASC`,
|
||||
values
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
exports.getZoneByName = async (householdId, locationId, zoneName) => {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, sort_order, color, is_active
|
||||
FROM store_location_zones
|
||||
WHERE household_id = $1
|
||||
AND store_location_id = $2
|
||||
AND normalized_name = $3
|
||||
AND is_active = TRUE`,
|
||||
[householdId, locationId, normalizeName(zoneName)]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
exports.createZone = async (householdId, locationId, zone) => {
|
||||
const { name, sort_order, color, map_metadata } = zone;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO store_location_zones
|
||||
(household_id, store_location_id, name, normalized_name, sort_order, color, map_metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7::jsonb, '{}'::jsonb))
|
||||
ON CONFLICT (store_location_id, normalized_name)
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
color = EXCLUDED.color,
|
||||
map_metadata = EXCLUDED.map_metadata,
|
||||
is_active = TRUE,
|
||||
updated_at = NOW()
|
||||
RETURNING id, name, sort_order, color, map_metadata, is_active`,
|
||||
[
|
||||
householdId,
|
||||
locationId,
|
||||
name.trim(),
|
||||
normalizeName(name),
|
||||
Number.isInteger(sort_order) ? sort_order : 0,
|
||||
color || null,
|
||||
map_metadata ? JSON.stringify(map_metadata) : null,
|
||||
]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
exports.updateZone = async (householdId, locationId, zoneId, updates = {}) => {
|
||||
const { name, sort_order, color, map_metadata, is_active } = updates;
|
||||
const result = await pool.query(
|
||||
`UPDATE store_location_zones
|
||||
SET name = COALESCE($1, name),
|
||||
normalized_name = COALESCE($2, normalized_name),
|
||||
sort_order = COALESCE($3, sort_order),
|
||||
color = COALESCE($4, color),
|
||||
map_metadata = COALESCE($5::jsonb, map_metadata),
|
||||
is_active = COALESCE($6, is_active),
|
||||
updated_at = NOW()
|
||||
WHERE household_id = $7
|
||||
AND store_location_id = $8
|
||||
AND id = $9
|
||||
RETURNING id, name, sort_order, color, map_metadata, is_active`,
|
||||
[
|
||||
name?.trim() || null,
|
||||
name ? normalizeName(name) : null,
|
||||
Number.isInteger(sort_order) ? sort_order : null,
|
||||
color === undefined ? null : color,
|
||||
map_metadata ? JSON.stringify(map_metadata) : null,
|
||||
typeof is_active === "boolean" ? is_active : null,
|
||||
householdId,
|
||||
locationId,
|
||||
zoneId,
|
||||
]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
exports.deleteZone = async (householdId, locationId, zoneId) => {
|
||||
const result = await pool.query(
|
||||
`UPDATE store_location_zones
|
||||
SET is_active = FALSE, updated_at = NOW()
|
||||
WHERE household_id = $1
|
||||
AND store_location_id = $2
|
||||
AND id = $3`,
|
||||
[householdId, locationId, zoneId]
|
||||
);
|
||||
return result.rowCount > 0;
|
||||
};
|
||||
|
||||
// Backward-compatible check for legacy routes. Prefer householdHasLocation.
|
||||
exports.householdHasStore = async (householdId, storeId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT 1 FROM household_stores
|
||||
WHERE household_id = $1 AND store_id = $2`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
return result.rowCount > 0;
|
||||
};
|
||||
|
||||
@ -3,9 +3,11 @@ const router = express.Router();
|
||||
const controller = require("../controllers/households.controller");
|
||||
const listsController = require("../controllers/lists.controller.v2");
|
||||
const availableItemsController = require("../controllers/available-items.controller");
|
||||
const storesController = require("../controllers/stores.controller");
|
||||
const auth = require("../middleware/auth");
|
||||
const {
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
storeAccess,
|
||||
} = require("../middleware/household");
|
||||
@ -40,6 +42,139 @@ router.post(
|
||||
controller.refreshInviteCode
|
||||
);
|
||||
|
||||
// Household-owned stores and locations
|
||||
router.get(
|
||||
"/:householdId/stores",
|
||||
auth,
|
||||
householdAccess,
|
||||
storesController.getHouseholdStores
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/stores",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
storesController.createHouseholdStore
|
||||
);
|
||||
router.patch(
|
||||
"/:householdId/stores/:householdStoreId",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
storesController.updateHouseholdStore
|
||||
);
|
||||
router.delete(
|
||||
"/:householdId/stores/:householdStoreId",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
storesController.deleteHouseholdStore
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/stores/:householdStoreId/locations",
|
||||
auth,
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
storesController.addLocationToStore
|
||||
);
|
||||
router.patch(
|
||||
"/:householdId/locations/:locationId",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
storesController.updateLocation
|
||||
);
|
||||
router.delete(
|
||||
"/:householdId/locations/:locationId",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
storesController.deleteLocation
|
||||
);
|
||||
router.patch(
|
||||
"/:householdId/locations/:locationId/default",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
storesController.setDefaultLocation
|
||||
);
|
||||
router.get(
|
||||
"/:householdId/locations/:locationId/zones",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
storesController.getLocationZones
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/locations/:locationId/zones",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
storesController.createZone
|
||||
);
|
||||
router.patch(
|
||||
"/:householdId/locations/:locationId/zones/:zoneId",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
storesController.updateZone
|
||||
);
|
||||
router.delete(
|
||||
"/:householdId/locations/:locationId/zones/:zoneId",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
storesController.deleteZone
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:householdId/locations/:locationId/available-items",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
availableItemsController.getAvailableItems
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/locations/:locationId/available-items",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
upload.single("image"),
|
||||
processImage,
|
||||
availableItemsController.createAvailableItem
|
||||
);
|
||||
router.patch(
|
||||
"/:householdId/locations/:locationId/available-items/:itemId",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
upload.single("image"),
|
||||
processImage,
|
||||
availableItemsController.updateAvailableItem
|
||||
);
|
||||
router.delete(
|
||||
"/:householdId/locations/:locationId/available-items/:itemId",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
availableItemsController.deleteAvailableItem
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/locations/:locationId/available-items/import-current",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
availableItemsController.importCurrentItems
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/available-items",
|
||||
auth,
|
||||
@ -109,6 +244,88 @@ router.delete(
|
||||
// All list routes require household access AND store access
|
||||
|
||||
// Get grocery list
|
||||
router.get(
|
||||
"/:householdId/locations/:locationId/list",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
listsController.getList
|
||||
);
|
||||
router.get(
|
||||
"/:householdId/locations/:locationId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
listsController.getItemByName
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/locations/:locationId/list/add",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
upload.single("image"),
|
||||
processImage,
|
||||
listsController.addItem
|
||||
);
|
||||
router.patch(
|
||||
"/:householdId/locations/:locationId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
listsController.markBought
|
||||
);
|
||||
router.put(
|
||||
"/:householdId/locations/:locationId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
listsController.updateItem
|
||||
);
|
||||
router.delete(
|
||||
"/:householdId/locations/:locationId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
listsController.deleteItem
|
||||
);
|
||||
router.get(
|
||||
"/:householdId/locations/:locationId/list/suggestions",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
listsController.getSuggestions
|
||||
);
|
||||
router.get(
|
||||
"/:householdId/locations/:locationId/list/recent",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
listsController.getRecentlyBought
|
||||
);
|
||||
router.get(
|
||||
"/:householdId/locations/:locationId/list/classification",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
listsController.getClassification
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/locations/:locationId/list/classification",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
listsController.setClassification
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/locations/:locationId/list/update-image",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
upload.single("image"),
|
||||
processImage,
|
||||
listsController.updateItemImage
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/list",
|
||||
auth,
|
||||
|
||||
@ -4,6 +4,7 @@ Base URL: `http://localhost:5000/api`
|
||||
|
||||
## Table of Contents
|
||||
- [Authentication](#authentication)
|
||||
- [Current Household Location Scope](#current-household-location-scope)
|
||||
- [Grocery List Management](#grocery-list-management)
|
||||
- [User Management](#user-management)
|
||||
- [Admin Operations](#admin-operations)
|
||||
@ -12,6 +13,23 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
All authenticated endpoints require a JWT token in the `Authorization` header:
|
||||
@ -129,7 +147,7 @@ Retrieve all unbought grocery items.
|
||||
- `bought` - Purchase status (always false for this endpoint)
|
||||
- `item_image` - Base64 encoded image (nullable)
|
||||
- `image_mime_type` - MIME type of image (nullable)
|
||||
- `added_by_users` - Array of user names who added/modified this item
|
||||
- `added_by_users` - Array of user names whose additions account for the current listed quantity
|
||||
- `modified_on` - Last modification timestamp
|
||||
- `item_type` - Classification type (nullable)
|
||||
- `item_group` - Classification group (nullable)
|
||||
|
||||
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user