feature-custom-store-locations #4

Merged
nalalangan merged 16 commits from feature-custom-store-locations into main 2026-05-31 00:35:29 -09:00
11 changed files with 2418 additions and 524 deletions
Showing only changes of commit d1c4fcdfe6 - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -169,18 +169,6 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse
try { try {
await client.query("BEGIN"); await client.query("BEGIN");
const promoteResult = await client.query(
`UPDATE household_members
SET role = 'owner'
WHERE household_id = $1 AND user_id = $2
RETURNING user_id, role`,
[householdId, nextOwnerUserId]
);
if (promoteResult.rows.length === 0) {
throw new Error("TARGET_MEMBER_NOT_FOUND");
}
const demoteResult = await client.query( const demoteResult = await client.query(
`UPDATE household_members `UPDATE household_members
SET role = 'admin' SET role = 'admin'
@ -193,6 +181,18 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse
throw new Error("CURRENT_OWNER_NOT_FOUND"); throw new Error("CURRENT_OWNER_NOT_FOUND");
} }
const promoteResult = await client.query(
`UPDATE household_members
SET role = 'owner'
WHERE household_id = $1 AND user_id = $2
RETURNING user_id, role`,
[householdId, nextOwnerUserId]
);
if (promoteResult.rows.length === 0) {
throw new Error("TARGET_MEMBER_NOT_FOUND");
}
await client.query("COMMIT"); await client.query("COMMIT");
return promoteResult.rows[0]; return promoteResult.rows[0];
} catch (error) { } catch (error) {

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ Base URL: `http://localhost:5000/api`
## Table of Contents ## Table of Contents
- [Authentication](#authentication) - [Authentication](#authentication)
- [Current Household Location Scope](#current-household-location-scope)
- [Grocery List Management](#grocery-list-management) - [Grocery List Management](#grocery-list-management)
- [User Management](#user-management) - [User Management](#user-management)
- [Admin Operations](#admin-operations) - [Admin Operations](#admin-operations)
@ -12,6 +13,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 ## Authentication
All authenticated endpoints require a JWT token in the `Authorization` header: All authenticated endpoints require a JWT token in the `Authorization` header:
@ -129,7 +147,7 @@ Retrieve all unbought grocery items.
- `bought` - Purchase status (always false for this endpoint) - `bought` - Purchase status (always false for this endpoint)
- `item_image` - Base64 encoded image (nullable) - `item_image` - Base64 encoded image (nullable)
- `image_mime_type` - MIME type of image (nullable) - `image_mime_type` - MIME type of image (nullable)
- `added_by_users` - Array of user names who added/modified this item - `added_by_users` - Array of user names whose additions account for the current listed quantity
- `modified_on` - Last modification timestamp - `modified_on` - Last modification timestamp
- `item_type` - Classification type (nullable) - `item_type` - Classification type (nullable)
- `item_group` - Classification group (nullable) - `item_group` - Classification group (nullable)

View File

@ -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;