feature-custom-store-locations #4
@ -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",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
const List = require("../models/list.model.v2");
|
const List = require("../models/list.model.v2");
|
||||||
const householdModel = require("../models/household.model");
|
const householdModel = require("../models/household.model");
|
||||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
const { isValidItemType, isValidItemGroup } = require("../constants/classifications");
|
||||||
const { sendError } = require("../utils/http");
|
const { sendError } = require("../utils/http");
|
||||||
const { logError } = require("../utils/logger");
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
@ -9,6 +9,10 @@ const LEGACY_ITEM_TYPE_MAP = {
|
|||||||
snacks: "snack",
|
snacks: "snack",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getStoreLocationId(req) {
|
||||||
|
return req.params.locationId || req.params.storeId;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeClassificationPayload(classification) {
|
function normalizeClassificationPayload(classification) {
|
||||||
if (typeof classification === "string") {
|
if (typeof classification === "string") {
|
||||||
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
||||||
@ -43,14 +47,40 @@ function normalizeClassificationPayload(classification) {
|
|||||||
return { item_type, item_group, zone };
|
return { item_type, item_group, zone };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function validateClassification(res, householdId, storeLocationId, classification) {
|
||||||
* Get list items for household and store
|
const { item_type, item_group, zone } = classification;
|
||||||
* GET /households/:householdId/stores/:storeId/list
|
|
||||||
*/
|
if (item_type && !isValidItemType(item_type)) {
|
||||||
|
sendError(res, 400, "Invalid item_type");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !item_type) {
|
||||||
|
sendError(res, 400, "Item type is required when item group is provided");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
||||||
|
sendError(res, 400, "Invalid item_group for selected item_type");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zone) {
|
||||||
|
const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone);
|
||||||
|
if (!zoneRecord) {
|
||||||
|
sendError(res, 400, "Invalid zone");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
exports.getList = async (req, res) => {
|
exports.getList = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
const items = await List.getHouseholdStoreList(householdId, storeId);
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const items = await List.getHouseholdStoreList(householdId, storeLocationId);
|
||||||
res.json({ items });
|
res.json({ items });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.getList", error);
|
logError(req, "listsV2.getList", error);
|
||||||
@ -58,20 +88,17 @@ exports.getList = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get specific item by name
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.getItemByName = async (req, res) => {
|
exports.getItemByName = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name } = req.query;
|
const { item_name } = req.query;
|
||||||
|
|
||||||
if (!item_name) {
|
if (!item_name) {
|
||||||
return sendError(res, 400, "Item name is required");
|
return sendError(res, 400, "Item name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return sendError(res, 404, "Item not found");
|
return sendError(res, 404, "Item not found");
|
||||||
}
|
}
|
||||||
@ -83,13 +110,10 @@ exports.getItemByName = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Add or update item in household store list
|
|
||||||
* POST /households/:householdId/stores/:storeId/list/add
|
|
||||||
*/
|
|
||||||
exports.addItem = async (req, res) => {
|
exports.addItem = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name, quantity, notes, added_for_user_id } = req.body;
|
const { item_name, quantity, notes, added_for_user_id } = req.body;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
let historyUserId = userId;
|
let historyUserId = userId;
|
||||||
@ -118,13 +142,12 @@ exports.addItem = async (req, res) => {
|
|||||||
historyUserId = parsedUserId;
|
historyUserId = parsedUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get processed image if uploaded
|
|
||||||
const imageBuffer = req.processedImage?.buffer || null;
|
const imageBuffer = req.processedImage?.buffer || null;
|
||||||
const mimeType = req.processedImage?.mimeType || null;
|
const mimeType = req.processedImage?.mimeType || null;
|
||||||
|
|
||||||
const result = await List.addOrUpdateItem(
|
const result = await List.addOrUpdateItem(
|
||||||
householdId,
|
householdId,
|
||||||
storeId,
|
storeLocationId,
|
||||||
item_name,
|
item_name,
|
||||||
quantity || "1",
|
quantity || "1",
|
||||||
userId,
|
userId,
|
||||||
@ -133,17 +156,38 @@ exports.addItem = async (req, res) => {
|
|||||||
notes
|
notes
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add history record
|
await List.addHistoryRecord(
|
||||||
await List.addHistoryRecord(result.listId, result.householdStoreItemId, quantity || "1", historyUserId);
|
result.listId,
|
||||||
|
result.householdStoreItemId,
|
||||||
|
result.historyQuantity ?? quantity ?? "1",
|
||||||
|
historyUserId,
|
||||||
|
storeLocationId
|
||||||
|
);
|
||||||
|
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: result.householdStoreItemId,
|
||||||
|
householdListId: result.listId,
|
||||||
|
actorUserId: historyUserId,
|
||||||
|
eventType: "ITEM_ADDED",
|
||||||
|
quantityDelta: result.historyQuantity ?? Number.parseInt(quantity || "1", 10),
|
||||||
|
quantityAfter: result.quantity,
|
||||||
|
metadata: {
|
||||||
|
item_name: result.itemName,
|
||||||
|
is_new_list_item: result.isNew,
|
||||||
|
added_by_request_user_id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: result.isNew ? "Item added" : "Item updated",
|
message: result.isNew ? "Item added" : "Item updated",
|
||||||
item: {
|
item: {
|
||||||
id: result.listId,
|
id: result.listId,
|
||||||
item_name: result.itemName,
|
item_name: result.itemName,
|
||||||
quantity: quantity || "1",
|
quantity: result.quantity ?? quantity ?? "1",
|
||||||
bought: false
|
bought: false,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.addItem", error);
|
logError(req, "listsV2.addItem", error);
|
||||||
@ -151,23 +195,35 @@ exports.addItem = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark item as bought or unbought
|
|
||||||
* PATCH /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.markBought = async (req, res) => {
|
exports.markBought = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name, bought, quantity_bought } = req.body;
|
const { item_name, bought, quantity_bought } = req.body;
|
||||||
|
|
||||||
if (!item_name) return sendError(res, 400, "Item name is required");
|
if (!item_name) return sendError(res, 400, "Item name is required");
|
||||||
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
if (!item) return sendError(res, 404, "Item not found");
|
if (!item) return sendError(res, 404, "Item not found");
|
||||||
|
|
||||||
|
const eventDetails = await List.setBought(item.id, bought, quantity_bought);
|
||||||
|
|
||||||
// Update bought status (with optional partial purchase)
|
if (eventDetails) {
|
||||||
await List.setBought(item.id, bought, quantity_bought);
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: item.household_store_item_id,
|
||||||
|
householdListId: item.id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: eventDetails.eventType,
|
||||||
|
quantityDelta: eventDetails.quantityDelta,
|
||||||
|
quantityAfter: eventDetails.quantityAfter,
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
requested_quantity: quantity_bought || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
|
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -176,27 +232,42 @@ exports.markBought = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update item details (quantity, notes)
|
|
||||||
* PUT /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.updateItem = async (req, res) => {
|
exports.updateItem = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name, quantity, notes } = req.body;
|
const { item_name, quantity, notes } = req.body;
|
||||||
|
|
||||||
if (!item_name) {
|
if (!item_name) {
|
||||||
return sendError(res, 400, "Item name is required");
|
return sendError(res, 400, "Item name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the list item
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return sendError(res, 404, "Item not found");
|
return sendError(res, 404, "Item not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update item
|
const updateResult = await List.updateItem(item.id, item_name, quantity, notes);
|
||||||
await List.updateItem(item.id, item_name, quantity, notes);
|
if (!updateResult) {
|
||||||
|
return sendError(res, 404, "Item not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quantity !== undefined && Number(quantity) !== Number(updateResult.previous.quantity)) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: item.household_store_item_id,
|
||||||
|
householdListId: item.id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_QUANTITY_CHANGED",
|
||||||
|
quantityDelta: Number(quantity) - Number(updateResult.previous.quantity),
|
||||||
|
quantityAfter: Number(quantity),
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
previous_quantity: updateResult.previous.quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Item updated",
|
message: "Item updated",
|
||||||
@ -204,8 +275,8 @@ exports.updateItem = async (req, res) => {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
item_name,
|
item_name,
|
||||||
quantity,
|
quantity,
|
||||||
notes
|
notes,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.updateItem", error);
|
logError(req, "listsV2.updateItem", error);
|
||||||
@ -213,26 +284,36 @@ exports.updateItem = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete item from list
|
|
||||||
* DELETE /households/:householdId/stores/:storeId/list/item
|
|
||||||
*/
|
|
||||||
exports.deleteItem = async (req, res) => {
|
exports.deleteItem = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name } = req.body;
|
const { item_name } = req.body;
|
||||||
|
|
||||||
if (!item_name) {
|
if (!item_name) {
|
||||||
return sendError(res, 400, "Item name is required");
|
return sendError(res, 400, "Item name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the list item
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return sendError(res, 404, "Item not found");
|
return sendError(res, 404, "Item not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await List.deleteItem(item.id);
|
const deleted = await List.deleteItem(item.id);
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: item.household_store_item_id,
|
||||||
|
householdListId: item.id,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_DELETED",
|
||||||
|
quantityDelta: -Number(item.quantity || 0),
|
||||||
|
quantityAfter: 0,
|
||||||
|
metadata: { item_name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ message: "Item deleted" });
|
res.json({ message: "Item deleted" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -241,16 +322,13 @@ exports.deleteItem = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get item suggestions based on query
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/suggestions
|
|
||||||
*/
|
|
||||||
exports.getSuggestions = async (req, res) => {
|
exports.getSuggestions = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { query } = req.query;
|
const { query } = req.query;
|
||||||
|
|
||||||
const suggestions = await List.getSuggestions(query || "", householdId, storeId);
|
const suggestions = await List.getSuggestions(query || "", householdId, storeLocationId);
|
||||||
res.json(suggestions);
|
res.json(suggestions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.getSuggestions", error);
|
logError(req, "listsV2.getSuggestions", error);
|
||||||
@ -258,14 +336,11 @@ exports.getSuggestions = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recently bought items
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/recent
|
|
||||||
*/
|
|
||||||
exports.getRecentlyBought = async (req, res) => {
|
exports.getRecentlyBought = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
const items = await List.getRecentlyBoughtItems(householdId, storeId);
|
const storeLocationId = getStoreLocationId(req);
|
||||||
|
const items = await List.getRecentlyBoughtItems(householdId, storeLocationId);
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.getRecentlyBought", error);
|
logError(req, "listsV2.getRecentlyBought", error);
|
||||||
@ -273,26 +348,26 @@ exports.getRecentlyBought = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get item classification
|
|
||||||
* GET /households/:householdId/stores/:storeId/list/classification
|
|
||||||
*/
|
|
||||||
exports.getClassification = async (req, res) => {
|
exports.getClassification = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name } = req.query;
|
const { item_name } = req.query;
|
||||||
|
|
||||||
if (!item_name) {
|
if (!item_name) {
|
||||||
return sendError(res, 400, "Item name is required");
|
return sendError(res, 400, "Item name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get item ID from name
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return res.json({ classification: null });
|
return res.json({ classification: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
const classification = await List.getClassification(householdId, storeId, item.item_id);
|
const classification = await List.getClassification(
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
item.item_id
|
||||||
|
);
|
||||||
res.json({ classification });
|
res.json({ classification });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.getClassification", error);
|
logError(req, "listsV2.getClassification", error);
|
||||||
@ -300,13 +375,10 @@ exports.getClassification = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Set/update item classification
|
|
||||||
* POST /households/:householdId/stores/:storeId/list/classification
|
|
||||||
*/
|
|
||||||
exports.setClassification = async (req, res) => {
|
exports.setClassification = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name, classification } = req.body;
|
const { item_name, classification } = req.body;
|
||||||
|
|
||||||
if (!item_name) {
|
if (!item_name) {
|
||||||
@ -318,33 +390,17 @@ exports.setClassification = async (req, res) => {
|
|||||||
return sendError(res, 400, "Classification is required");
|
return sendError(res, 400, "Classification is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { item_type, item_group, zone } = normalizedClassification;
|
if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) {
|
||||||
|
return;
|
||||||
if (item_type && !isValidItemType(item_type)) {
|
|
||||||
return sendError(res, 400, "Invalid item_type");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item_group && !item_type) {
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
||||||
return sendError(res, 400, "Item type is required when item group is provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
|
||||||
return sendError(res, 400, "Invalid item_group for selected item_type");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zone && !isValidZone(zone)) {
|
|
||||||
return sendError(res, 400, "Invalid zone");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get item - add to master items if not exists
|
|
||||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
|
||||||
let itemId;
|
let itemId;
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
// Item doesn't exist in list, need to get from items table or create
|
|
||||||
const itemResult = await List.ensureHouseholdStoreItem(
|
const itemResult = await List.ensureHouseholdStoreItem(
|
||||||
householdId,
|
householdId,
|
||||||
storeId,
|
storeLocationId,
|
||||||
item_name
|
item_name
|
||||||
);
|
);
|
||||||
itemId = itemResult.id;
|
itemId = itemResult.id;
|
||||||
@ -352,14 +408,43 @@ exports.setClassification = async (req, res) => {
|
|||||||
itemId = item.item_id;
|
itemId = item.item_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await List.upsertClassification(householdId, storeId, itemId, {
|
const updated = await List.upsertClassification(householdId, storeLocationId, itemId, {
|
||||||
item_type,
|
...normalizedClassification,
|
||||||
item_group,
|
|
||||||
zone,
|
|
||||||
confidence: 1.0,
|
confidence: 1.0,
|
||||||
source: "user",
|
source: "user",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: itemId,
|
||||||
|
householdListId: item?.id || null,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_CLASSIFICATION_CHANGED",
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
item_type: normalizedClassification.item_type,
|
||||||
|
item_group: normalizedClassification.item_group,
|
||||||
|
zone: normalizedClassification.zone,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (normalizedClassification.zone) {
|
||||||
|
await List.recordItemEvent({
|
||||||
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
householdStoreItemId: itemId,
|
||||||
|
householdListId: item?.id || null,
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
eventType: "ITEM_ZONE_CHANGED",
|
||||||
|
metadata: {
|
||||||
|
item_name,
|
||||||
|
zone: normalizedClassification.zone,
|
||||||
|
zone_id: updated.zone_id || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ message: "Classification set", classification: normalizedClassification });
|
res.json({ message: "Classification set", classification: normalizedClassification });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.setClassification", error);
|
logError(req, "listsV2.setClassification", error);
|
||||||
@ -367,17 +452,13 @@ exports.setClassification = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update item image
|
|
||||||
* POST /households/:householdId/stores/:storeId/list/update-image
|
|
||||||
*/
|
|
||||||
exports.updateItemImage = async (req, res) => {
|
exports.updateItemImage = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { householdId, storeId } = req.params;
|
const { householdId } = req.params;
|
||||||
|
const storeLocationId = getStoreLocationId(req);
|
||||||
const { item_name, quantity } = req.body;
|
const { item_name, quantity } = req.body;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
// Get processed image
|
|
||||||
const imageBuffer = req.processedImage?.buffer || null;
|
const imageBuffer = req.processedImage?.buffer || null;
|
||||||
const mimeType = req.processedImage?.mimeType || null;
|
const mimeType = req.processedImage?.mimeType || null;
|
||||||
|
|
||||||
@ -385,8 +466,15 @@ exports.updateItemImage = async (req, res) => {
|
|||||||
return sendError(res, 400, "No image provided");
|
return sendError(res, 400, "No image provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the item with new image
|
await List.addOrUpdateItem(
|
||||||
await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType);
|
householdId,
|
||||||
|
storeLocationId,
|
||||||
|
item_name,
|
||||||
|
quantity,
|
||||||
|
userId,
|
||||||
|
imageBuffer,
|
||||||
|
mimeType
|
||||||
|
);
|
||||||
|
|
||||||
res.json({ message: "Image updated successfully" });
|
res.json({ message: "Image updated successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -2,7 +2,20 @@ const storeModel = require("../models/store.model");
|
|||||||
const { sendError } = require("../utils/http");
|
const { sendError } = require("../utils/http");
|
||||||
const { logError } = require("../utils/logger");
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
// Get all available stores
|
function parsePositiveInteger(value) {
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHouseholdId(req) {
|
||||||
|
return req.params.householdId || req.household?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocationId(req) {
|
||||||
|
return req.params.locationId || req.params.storeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy global store catalog. Kept for system-admin compatibility.
|
||||||
exports.getAllStores = async (req, res) => {
|
exports.getAllStores = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const stores = await storeModel.getAllStores();
|
const stores = await storeModel.getAllStores();
|
||||||
@ -13,78 +26,6 @@ exports.getAllStores = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get stores for household
|
|
||||||
exports.getHouseholdStores = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const stores = await storeModel.getHouseholdStores(req.params.householdId);
|
|
||||||
res.json(stores);
|
|
||||||
} catch (error) {
|
|
||||||
logError(req, "stores.getHouseholdStores", error);
|
|
||||||
sendError(res, 500, "Failed to fetch household stores");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add store to household
|
|
||||||
exports.addStoreToHousehold = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { storeId, isDefault } = req.body;
|
|
||||||
// console.log("Adding store to household:", { householdId: req.params.householdId, storeId, isDefault });
|
|
||||||
if (!storeId) {
|
|
||||||
return sendError(res, 400, "Store ID is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = await storeModel.getStoreById(storeId);
|
|
||||||
if (!store) return sendError(res, 404, "Store not found");
|
|
||||||
const foundStores = await storeModel.getHouseholdStores(req.params.householdId);
|
|
||||||
// if (foundStores.length == 0) isDefault = 'true';
|
|
||||||
|
|
||||||
await storeModel.addStoreToHousehold(
|
|
||||||
req.params.householdId,
|
|
||||||
storeId,
|
|
||||||
foundStores.length == 0 ? true : isDefault || false
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
message: "Store added to household successfully",
|
|
||||||
store
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(req, "stores.addStoreToHousehold", error);
|
|
||||||
sendError(res, 500, "Failed to add store to household");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove store from household
|
|
||||||
exports.removeStoreFromHousehold = async (req, res) => {
|
|
||||||
try {
|
|
||||||
await storeModel.removeStoreFromHousehold(
|
|
||||||
req.params.householdId,
|
|
||||||
req.params.storeId
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ message: "Store removed from household successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
logError(req, "stores.removeStoreFromHousehold", error);
|
|
||||||
sendError(res, 500, "Failed to remove store from household");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set default store
|
|
||||||
exports.setDefaultStore = async (req, res) => {
|
|
||||||
try {
|
|
||||||
await storeModel.setDefaultStore(
|
|
||||||
req.params.householdId,
|
|
||||||
req.params.storeId
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ message: "Default store updated successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
logError(req, "stores.setDefaultStore", error);
|
|
||||||
sendError(res, 500, "Failed to set default store");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create store (system admin only)
|
|
||||||
exports.createStore = async (req, res) => {
|
exports.createStore = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, default_zones } = req.body;
|
const { name, default_zones } = req.body;
|
||||||
@ -97,25 +38,24 @@ exports.createStore = async (req, res) => {
|
|||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: "Store created successfully",
|
message: "Store created successfully",
|
||||||
store
|
store,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "stores.createStore", error);
|
logError(req, "stores.createStore", error);
|
||||||
if (error.code === '23505') { // Unique violation
|
if (error.code === "23505") {
|
||||||
return sendError(res, 400, "Store with this name already exists");
|
return sendError(res, 400, "Store with this name already exists");
|
||||||
}
|
}
|
||||||
sendError(res, 500, "Failed to create store");
|
sendError(res, 500, "Failed to create store");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update store (system admin only)
|
|
||||||
exports.updateStore = async (req, res) => {
|
exports.updateStore = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, default_zones } = req.body;
|
const { name, default_zones } = req.body;
|
||||||
|
|
||||||
const store = await storeModel.updateStore(req.params.storeId, {
|
const store = await storeModel.updateStore(req.params.storeId, {
|
||||||
name: name?.trim(),
|
name: name?.trim(),
|
||||||
default_zones
|
default_zones,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!store) {
|
if (!store) {
|
||||||
@ -124,7 +64,7 @@ exports.updateStore = async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Store updated successfully",
|
message: "Store updated successfully",
|
||||||
store
|
store,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "stores.updateStore", error);
|
logError(req, "stores.updateStore", error);
|
||||||
@ -132,16 +72,341 @@ exports.updateStore = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete store (system admin only)
|
|
||||||
exports.deleteStore = async (req, res) => {
|
exports.deleteStore = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await storeModel.deleteStore(req.params.storeId);
|
await storeModel.deleteStore(req.params.storeId);
|
||||||
res.json({ message: "Store deleted successfully" });
|
res.json({ message: "Store deleted successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "stores.deleteStore", error);
|
logError(req, "stores.deleteStore", error);
|
||||||
if (error.message.includes('in use')) {
|
if (error.message.includes("in use")) {
|
||||||
return sendError(res, 400, error.message);
|
return sendError(res, 400, error.message);
|
||||||
}
|
}
|
||||||
sendError(res, 500, "Failed to delete store");
|
sendError(res, 500, "Failed to delete store");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Household-owned store/location management.
|
||||||
|
exports.getHouseholdStores = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stores = await storeModel.getHouseholdStores(getHouseholdId(req));
|
||||||
|
res.json(stores);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.getHouseholdStores", error);
|
||||||
|
sendError(res, 500, "Failed to fetch household stores");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createHouseholdStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const householdId = getHouseholdId(req);
|
||||||
|
const { name, location_name, address } = req.body;
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Store name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await storeModel.createHouseholdStore(
|
||||||
|
householdId,
|
||||||
|
name,
|
||||||
|
location_name || "Default Location",
|
||||||
|
address || null,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Store location created successfully",
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.createHouseholdStore", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Store or location already exists for this household");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to create store location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateHouseholdStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body;
|
||||||
|
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||||
|
|
||||||
|
if (!householdStoreId) {
|
||||||
|
return sendError(res, 400, "Store ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Store name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await storeModel.updateHouseholdStore(getHouseholdId(req), householdStoreId, {
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Store updated successfully", store });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.updateHouseholdStore", error);
|
||||||
|
sendError(res, 500, "Failed to update store");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteHouseholdStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||||
|
if (!householdStoreId) {
|
||||||
|
return sendError(res, 400, "Store ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await storeModel.deleteHouseholdStore(getHouseholdId(req), householdStoreId);
|
||||||
|
if (!deleted) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Store deleted successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.deleteHouseholdStore", error);
|
||||||
|
if (error.message.includes("last store location")) {
|
||||||
|
return sendError(res, 400, error.message);
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to delete store");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.addLocationToStore = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const householdStoreId = parsePositiveInteger(req.params.householdStoreId);
|
||||||
|
const { name, address } = req.body;
|
||||||
|
|
||||||
|
if (!householdStoreId) {
|
||||||
|
return sendError(res, 400, "Store ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Location name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await storeModel.addLocationToStore(
|
||||||
|
getHouseholdId(req),
|
||||||
|
householdStoreId,
|
||||||
|
name,
|
||||||
|
address || null,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Location added successfully",
|
||||||
|
store: location,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.addLocationToStore", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Location already exists for this store");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to add location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateLocation = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const { name, address, map_data } = req.body;
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await storeModel.updateLocation(getHouseholdId(req), locationId, {
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
map_data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
return sendError(res, 404, "Location not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Location updated successfully", store: location });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.updateLocation", error);
|
||||||
|
sendError(res, 500, "Failed to update location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteLocation = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await storeModel.deleteLocation(getHouseholdId(req), locationId);
|
||||||
|
if (!deleted) {
|
||||||
|
return sendError(res, 404, "Location not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Location removed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.deleteLocation", error);
|
||||||
|
if (error.message.includes("last store location")) {
|
||||||
|
return sendError(res, 400, error.message);
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to remove location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setDefaultLocation = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeModel.setDefaultLocation(getHouseholdId(req), locationId);
|
||||||
|
res.json({ message: "Default location updated successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.setDefaultLocation", error);
|
||||||
|
sendError(res, 500, "Failed to set default location");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getLocationZones = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const zones = await storeModel.listLocationZones(getHouseholdId(req), locationId);
|
||||||
|
res.json({ zones });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.getLocationZones", error);
|
||||||
|
sendError(res, 500, "Failed to load zones");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createZone = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const { name, sort_order, color, map_metadata } = req.body;
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
return sendError(res, 400, "Location ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return sendError(res, 400, "Zone name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const zone = await storeModel.createZone(getHouseholdId(req), locationId, {
|
||||||
|
name,
|
||||||
|
sort_order: Number.isInteger(sort_order) ? sort_order : Number.parseInt(sort_order, 10),
|
||||||
|
color,
|
||||||
|
map_metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ message: "Zone created successfully", zone });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.createZone", error);
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return sendError(res, 400, "Zone already exists for this location");
|
||||||
|
}
|
||||||
|
sendError(res, 500, "Failed to create zone");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateZone = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const zoneId = parsePositiveInteger(req.params.zoneId);
|
||||||
|
|
||||||
|
if (!locationId || !zoneId) {
|
||||||
|
return sendError(res, 400, "Location ID and zone ID must be positive integers");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrder = req.body.sort_order;
|
||||||
|
const zone = await storeModel.updateZone(getHouseholdId(req), locationId, zoneId, {
|
||||||
|
name: req.body.name,
|
||||||
|
sort_order:
|
||||||
|
sortOrder === undefined
|
||||||
|
? undefined
|
||||||
|
: Number.isInteger(sortOrder)
|
||||||
|
? sortOrder
|
||||||
|
: Number.parseInt(sortOrder, 10),
|
||||||
|
color: req.body.color,
|
||||||
|
map_metadata: req.body.map_metadata,
|
||||||
|
is_active: req.body.is_active,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!zone) {
|
||||||
|
return sendError(res, 404, "Zone not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Zone updated successfully", zone });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.updateZone", error);
|
||||||
|
sendError(res, 500, "Failed to update zone");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteZone = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const locationId = parsePositiveInteger(getLocationId(req));
|
||||||
|
const zoneId = parsePositiveInteger(req.params.zoneId);
|
||||||
|
|
||||||
|
if (!locationId || !zoneId) {
|
||||||
|
return sendError(res, 400, "Location ID and zone ID must be positive integers");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await storeModel.deleteZone(getHouseholdId(req), locationId, zoneId);
|
||||||
|
if (!deleted) {
|
||||||
|
return sendError(res, 404, "Zone not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Zone removed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.deleteZone", error);
|
||||||
|
sendError(res, 500, "Failed to remove zone");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backward-compatible handlers for the old /stores/household routes.
|
||||||
|
exports.addStoreToHousehold = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { storeId } = req.body;
|
||||||
|
if (!storeId) {
|
||||||
|
return sendError(res, 400, "Store ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyStore = await storeModel.getStoreById(storeId);
|
||||||
|
if (!legacyStore) {
|
||||||
|
return sendError(res, 404, "Store not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await storeModel.createHouseholdStore(
|
||||||
|
getHouseholdId(req),
|
||||||
|
legacyStore.name,
|
||||||
|
"Default Location",
|
||||||
|
null,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Store added to household successfully",
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "stores.addStoreToHousehold", error);
|
||||||
|
sendError(res, 500, "Failed to add store to household");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.removeStoreFromHousehold = exports.deleteLocation;
|
||||||
|
exports.setDefaultStore = exports.setDefaultLocation;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,70 @@
|
|||||||
const pool = require("../db/pool");
|
const pool = require("../db/pool");
|
||||||
|
const { ZONE_FLOW } = require("../constants/classifications");
|
||||||
|
|
||||||
// Get all available stores
|
const DEFAULT_LOCATION_NAME = "Default Location";
|
||||||
|
|
||||||
|
function normalizeName(value) {
|
||||||
|
return String(value || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayLocationName(storeName, locationName) {
|
||||||
|
if (!locationName || locationName === DEFAULT_LOCATION_NAME) {
|
||||||
|
return storeName;
|
||||||
|
}
|
||||||
|
return `${storeName} - ${locationName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLocationRow(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
id: row.location_id,
|
||||||
|
display_name: row.display_name || displayLocationName(row.name, row.location_name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryLocationById(db, householdId, locationId) {
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT
|
||||||
|
sl.id AS location_id,
|
||||||
|
sl.id,
|
||||||
|
sl.household_id,
|
||||||
|
sl.household_store_id,
|
||||||
|
hcs.name,
|
||||||
|
sl.name AS location_name,
|
||||||
|
sl.address,
|
||||||
|
sl.is_default,
|
||||||
|
sl.map_data,
|
||||||
|
sl.created_at,
|
||||||
|
sl.updated_at,
|
||||||
|
CASE
|
||||||
|
WHEN sl.name = $3 THEN hcs.name
|
||||||
|
ELSE hcs.name || ' - ' || sl.name
|
||||||
|
END AS display_name
|
||||||
|
FROM store_locations sl
|
||||||
|
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
|
||||||
|
WHERE sl.household_id = $1
|
||||||
|
AND sl.id = $2`,
|
||||||
|
[householdId, locationId, DEFAULT_LOCATION_NAME]
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapLocationRow(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDefaultZones(db, householdId, locationId) {
|
||||||
|
for (let index = 0; index < ZONE_FLOW.length; index += 1) {
|
||||||
|
const zoneName = ZONE_FLOW[index];
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO store_location_zones
|
||||||
|
(household_id, store_location_id, name, normalized_name, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (store_location_id, normalized_name) DO NOTHING`,
|
||||||
|
[householdId, locationId, zoneName, normalizeName(zoneName), (index + 1) * 10]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy global store catalog. Kept for system-admin compatibility only.
|
||||||
exports.getAllStores = async () => {
|
exports.getAllStores = async () => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT id, name, default_zones, created_at
|
`SELECT id, name, default_zones, created_at
|
||||||
@ -10,7 +74,6 @@ exports.getAllStores = async () => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get store by ID
|
|
||||||
exports.getStoreById = async (storeId) => {
|
exports.getStoreById = async (storeId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT id, name, default_zones, created_at
|
`SELECT id, name, default_zones, created_at
|
||||||
@ -21,77 +84,6 @@ exports.getStoreById = async (storeId) => {
|
|||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get stores for a specific household
|
|
||||||
exports.getHouseholdStores = async (householdId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
s.id,
|
|
||||||
s.name,
|
|
||||||
s.default_zones,
|
|
||||||
hs.is_default,
|
|
||||||
hs.added_at
|
|
||||||
FROM stores s
|
|
||||||
JOIN household_stores hs ON s.id = hs.store_id
|
|
||||||
WHERE hs.household_id = $1
|
|
||||||
ORDER BY hs.is_default DESC, s.name ASC`,
|
|
||||||
[householdId]
|
|
||||||
);
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add store to household
|
|
||||||
exports.addStoreToHousehold = async (householdId, storeId, isDefault = false) => {
|
|
||||||
// If setting as default, unset other defaults
|
|
||||||
if (isDefault) {
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_stores
|
|
||||||
SET is_default = FALSE
|
|
||||||
WHERE household_id = $1`,
|
|
||||||
[householdId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`INSERT INTO household_stores (household_id, store_id, is_default)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (household_id, store_id)
|
|
||||||
DO UPDATE SET is_default = $3
|
|
||||||
RETURNING household_id, store_id, is_default`,
|
|
||||||
[householdId, storeId, isDefault]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove store from household
|
|
||||||
exports.removeStoreFromHousehold = async (householdId, storeId) => {
|
|
||||||
await pool.query(
|
|
||||||
`DELETE FROM household_stores
|
|
||||||
WHERE household_id = $1 AND store_id = $2`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set default store for household
|
|
||||||
exports.setDefaultStore = async (householdId, storeId) => {
|
|
||||||
// Unset all defaults
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_stores
|
|
||||||
SET is_default = FALSE
|
|
||||||
WHERE household_id = $1`,
|
|
||||||
[householdId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set new default
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_stores
|
|
||||||
SET is_default = TRUE
|
|
||||||
WHERE household_id = $1 AND store_id = $2`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create new store (system admin only)
|
|
||||||
exports.createStore = async (name, defaultZones) => {
|
exports.createStore = async (name, defaultZones) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO stores (name, default_zones)
|
`INSERT INTO stores (name, default_zones)
|
||||||
@ -102,7 +94,6 @@ exports.createStore = async (name, defaultZones) => {
|
|||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update store (system admin only)
|
|
||||||
exports.updateStore = async (storeId, updates) => {
|
exports.updateStore = async (storeId, updates) => {
|
||||||
const { name, default_zones } = updates;
|
const { name, default_zones } = updates;
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@ -117,27 +108,452 @@ exports.updateStore = async (storeId, updates) => {
|
|||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete store (system admin only, only if not in use)
|
|
||||||
exports.deleteStore = async (storeId) => {
|
exports.deleteStore = async (storeId) => {
|
||||||
// Check if store is in use
|
|
||||||
const usage = await pool.query(
|
const usage = await pool.query(
|
||||||
`SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`,
|
`SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`,
|
||||||
[storeId]
|
[storeId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (parseInt(usage.rows[0].count) > 0) {
|
if (parseInt(usage.rows[0].count, 10) > 0) {
|
||||||
throw new Error('Cannot delete store that is in use by households');
|
throw new Error("Cannot delete store that is in use by households");
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query('DELETE FROM stores WHERE id = $1', [storeId]);
|
await pool.query("DELETE FROM stores WHERE id = $1", [storeId]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if household has store
|
// Household-owned store locations.
|
||||||
|
exports.getHouseholdStores = async (householdId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
sl.id AS location_id,
|
||||||
|
sl.id,
|
||||||
|
sl.household_id,
|
||||||
|
sl.household_store_id,
|
||||||
|
hcs.name,
|
||||||
|
sl.name AS location_name,
|
||||||
|
sl.address,
|
||||||
|
sl.is_default,
|
||||||
|
sl.map_data,
|
||||||
|
sl.created_at,
|
||||||
|
sl.updated_at,
|
||||||
|
CASE
|
||||||
|
WHEN sl.name = $2 THEN hcs.name
|
||||||
|
ELSE hcs.name || ' - ' || sl.name
|
||||||
|
END AS display_name
|
||||||
|
FROM store_locations sl
|
||||||
|
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
|
||||||
|
WHERE sl.household_id = $1
|
||||||
|
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
|
||||||
|
[householdId, DEFAULT_LOCATION_NAME]
|
||||||
|
);
|
||||||
|
return result.rows.map(mapLocationRow);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createHouseholdStore = async (
|
||||||
|
householdId,
|
||||||
|
name,
|
||||||
|
locationName = DEFAULT_LOCATION_NAME,
|
||||||
|
address = null,
|
||||||
|
createdBy = null
|
||||||
|
) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
const normalizedStoreName = normalizeName(name);
|
||||||
|
const normalizedLocationName = normalizeName(locationName || DEFAULT_LOCATION_NAME);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const storeResult = await client.query(
|
||||||
|
`INSERT INTO household_custom_stores
|
||||||
|
(household_id, name, normalized_name, created_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT (household_id, normalized_name)
|
||||||
|
DO UPDATE SET name = EXCLUDED.name, updated_at = NOW()
|
||||||
|
RETURNING id, name`,
|
||||||
|
[householdId, name.trim(), normalizedStoreName, createdBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasDefault = await client.query(
|
||||||
|
`SELECT 1 FROM store_locations
|
||||||
|
WHERE household_id = $1 AND is_default = TRUE
|
||||||
|
LIMIT 1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationResult = await client.query(
|
||||||
|
`INSERT INTO store_locations
|
||||||
|
(household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
|
ON CONFLICT (household_store_id, normalized_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
address = COALESCE(EXCLUDED.address, store_locations.address),
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
storeResult.rows[0].id,
|
||||||
|
(locationName || DEFAULT_LOCATION_NAME).trim(),
|
||||||
|
normalizedLocationName,
|
||||||
|
address || null,
|
||||||
|
hasDefault.rowCount === 0,
|
||||||
|
createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedDefaultZones(client, householdId, locationResult.rows[0].id);
|
||||||
|
|
||||||
|
const location = await queryLocationById(client, householdId, locationResult.rows[0].id);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return location;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateHouseholdStore = async (householdId, householdStoreId, updates = {}) => {
|
||||||
|
const { name } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE household_custom_stores
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
normalized_name = COALESCE($2, normalized_name),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $3
|
||||||
|
AND id = $4
|
||||||
|
RETURNING id, household_id, name, created_at, updated_at`,
|
||||||
|
[
|
||||||
|
name?.trim() || null,
|
||||||
|
name ? normalizeName(name) : null,
|
||||||
|
householdId,
|
||||||
|
householdStoreId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteHouseholdStore = async (householdId, householdStoreId) => {
|
||||||
|
const countResult = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS count
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const storeLocationCount = countResult.rows[0]?.count || 0;
|
||||||
|
const targetLocations = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS count
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND household_store_id = $2`,
|
||||||
|
[householdId, householdStoreId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (storeLocationCount <= targetLocations.rows[0]?.count) {
|
||||||
|
throw new Error("Cannot remove the last store location for a household");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM household_custom_stores
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2`,
|
||||||
|
[householdId, householdStoreId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.addLocationToStore = async (
|
||||||
|
householdId,
|
||||||
|
householdStoreId,
|
||||||
|
name,
|
||||||
|
address = null,
|
||||||
|
createdBy = null
|
||||||
|
) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const storeResult = await client.query(
|
||||||
|
`SELECT id FROM household_custom_stores
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2`,
|
||||||
|
[householdId, householdStoreId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (storeResult.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDefault = await client.query(
|
||||||
|
`SELECT 1 FROM store_locations
|
||||||
|
WHERE household_id = $1 AND is_default = TRUE
|
||||||
|
LIMIT 1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationResult = await client.query(
|
||||||
|
`INSERT INTO store_locations
|
||||||
|
(household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
householdStoreId,
|
||||||
|
name.trim(),
|
||||||
|
normalizeName(name),
|
||||||
|
address || null,
|
||||||
|
hasDefault.rowCount === 0,
|
||||||
|
createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedDefaultZones(client, householdId, locationResult.rows[0].id);
|
||||||
|
const location = await queryLocationById(client, householdId, locationResult.rows[0].id);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return location;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateLocation = async (householdId, locationId, updates = {}) => {
|
||||||
|
const { name, address, map_data } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
normalized_name = COALESCE($2, normalized_name),
|
||||||
|
address = COALESCE($3, address),
|
||||||
|
map_data = COALESCE($4::jsonb, map_data),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $5
|
||||||
|
AND id = $6
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
name?.trim() || null,
|
||||||
|
name ? normalizeName(name) : null,
|
||||||
|
address === undefined ? null : address,
|
||||||
|
map_data ? JSON.stringify(map_data) : null,
|
||||||
|
householdId,
|
||||||
|
locationId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) return null;
|
||||||
|
return queryLocationById(pool, householdId, locationId);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteLocation = async (householdId, locationId) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const countResult = await client.query(
|
||||||
|
`SELECT COUNT(*)::int AS count
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((countResult.rows[0]?.count || 0) <= 1) {
|
||||||
|
throw new Error("Cannot remove the last store location for a household");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await client.query(
|
||||||
|
`DELETE FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2
|
||||||
|
RETURNING is_default`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deleted.rowCount === 0) {
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted.rows[0].is_default) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET is_default = TRUE, updated_at = NOW()
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
)`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.setDefaultLocation = async (householdId, locationId) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET is_default = FALSE, updated_at = NOW()
|
||||||
|
WHERE household_id = $1`,
|
||||||
|
[householdId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE store_locations
|
||||||
|
SET is_default = TRUE, updated_at = NOW()
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2
|
||||||
|
RETURNING id`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error("Location not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.householdHasLocation = async (householdId, locationId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM store_locations
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND id = $2`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getLocationById = async (householdId, locationId) =>
|
||||||
|
queryLocationById(pool, householdId, locationId);
|
||||||
|
|
||||||
|
exports.listLocationZones = async (householdId, locationId, includeInactive = false) => {
|
||||||
|
const values = [householdId, locationId];
|
||||||
|
const inactiveClause = includeInactive ? "" : "AND is_active = TRUE";
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name, sort_order, color, map_metadata, is_active, created_at, updated_at
|
||||||
|
FROM store_location_zones
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
${inactiveClause}
|
||||||
|
ORDER BY sort_order ASC, name ASC`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getZoneByName = async (householdId, locationId, zoneName) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, name, sort_order, color, is_active
|
||||||
|
FROM store_location_zones
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND normalized_name = $3
|
||||||
|
AND is_active = TRUE`,
|
||||||
|
[householdId, locationId, normalizeName(zoneName)]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createZone = async (householdId, locationId, zone) => {
|
||||||
|
const { name, sort_order, color, map_metadata } = zone;
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO store_location_zones
|
||||||
|
(household_id, store_location_id, name, normalized_name, sort_order, color, map_metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7::jsonb, '{}'::jsonb))
|
||||||
|
ON CONFLICT (store_location_id, normalized_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
color = EXCLUDED.color,
|
||||||
|
map_metadata = EXCLUDED.map_metadata,
|
||||||
|
is_active = TRUE,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, name, sort_order, color, map_metadata, is_active`,
|
||||||
|
[
|
||||||
|
householdId,
|
||||||
|
locationId,
|
||||||
|
name.trim(),
|
||||||
|
normalizeName(name),
|
||||||
|
Number.isInteger(sort_order) ? sort_order : 0,
|
||||||
|
color || null,
|
||||||
|
map_metadata ? JSON.stringify(map_metadata) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateZone = async (householdId, locationId, zoneId, updates = {}) => {
|
||||||
|
const { name, sort_order, color, map_metadata, is_active } = updates;
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE store_location_zones
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
normalized_name = COALESCE($2, normalized_name),
|
||||||
|
sort_order = COALESCE($3, sort_order),
|
||||||
|
color = COALESCE($4, color),
|
||||||
|
map_metadata = COALESCE($5::jsonb, map_metadata),
|
||||||
|
is_active = COALESCE($6, is_active),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $7
|
||||||
|
AND store_location_id = $8
|
||||||
|
AND id = $9
|
||||||
|
RETURNING id, name, sort_order, color, map_metadata, is_active`,
|
||||||
|
[
|
||||||
|
name?.trim() || null,
|
||||||
|
name ? normalizeName(name) : null,
|
||||||
|
Number.isInteger(sort_order) ? sort_order : null,
|
||||||
|
color === undefined ? null : color,
|
||||||
|
map_metadata ? JSON.stringify(map_metadata) : null,
|
||||||
|
typeof is_active === "boolean" ? is_active : null,
|
||||||
|
householdId,
|
||||||
|
locationId,
|
||||||
|
zoneId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteZone = async (householdId, locationId, zoneId) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE store_location_zones
|
||||||
|
SET is_active = FALSE, updated_at = NOW()
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND id = $3`,
|
||||||
|
[householdId, locationId, zoneId]
|
||||||
|
);
|
||||||
|
return result.rowCount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backward-compatible check for legacy routes. Prefer householdHasLocation.
|
||||||
exports.householdHasStore = async (householdId, storeId) => {
|
exports.householdHasStore = async (householdId, storeId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT 1 FROM household_stores
|
`SELECT 1 FROM household_stores
|
||||||
WHERE household_id = $1 AND store_id = $2`,
|
WHERE household_id = $1 AND store_id = $2`,
|
||||||
[householdId, storeId]
|
[householdId, storeId]
|
||||||
);
|
);
|
||||||
return result.rows.length > 0;
|
return result.rowCount > 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,9 +3,11 @@ const router = express.Router();
|
|||||||
const controller = require("../controllers/households.controller");
|
const controller = require("../controllers/households.controller");
|
||||||
const listsController = require("../controllers/lists.controller.v2");
|
const listsController = require("../controllers/lists.controller.v2");
|
||||||
const availableItemsController = require("../controllers/available-items.controller");
|
const availableItemsController = require("../controllers/available-items.controller");
|
||||||
|
const storesController = require("../controllers/stores.controller");
|
||||||
const auth = require("../middleware/auth");
|
const auth = require("../middleware/auth");
|
||||||
const {
|
const {
|
||||||
householdAccess,
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
requireHouseholdAdmin,
|
requireHouseholdAdmin,
|
||||||
storeAccess,
|
storeAccess,
|
||||||
} = require("../middleware/household");
|
} = require("../middleware/household");
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -0,0 +1,465 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Household-owned store brands. The legacy public.stores table remains for
|
||||||
|
-- historical data and system-admin compatibility, but the household shopping
|
||||||
|
-- flow should use these records plus store_locations.
|
||||||
|
CREATE TABLE IF NOT EXISTS household_custom_stores (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
normalized_name VARCHAR(120) NOT NULL,
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(household_id, normalized_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_household_custom_stores_household
|
||||||
|
ON household_custom_stores(household_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS store_locations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
household_store_id INTEGER NOT NULL REFERENCES household_custom_stores(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(160) NOT NULL,
|
||||||
|
normalized_name VARCHAR(160) NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
map_data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(household_store_id, normalized_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_locations_household
|
||||||
|
ON store_locations(household_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_locations_store
|
||||||
|
ON store_locations(household_store_id);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_store_locations_default_per_household
|
||||||
|
ON store_locations(household_id)
|
||||||
|
WHERE is_default;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS store_location_zones (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
normalized_name VARCHAR(120) NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
color VARCHAR(32),
|
||||||
|
map_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(store_location_id, normalized_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_location_zones_location_order
|
||||||
|
ON store_location_zones(store_location_id, is_active, sort_order, name);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS household_item_images (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
store_location_id INTEGER REFERENCES store_locations(id) ON DELETE CASCADE,
|
||||||
|
household_store_item_id INTEGER REFERENCES household_store_items(id) ON DELETE CASCADE,
|
||||||
|
household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE,
|
||||||
|
image_scope VARCHAR(20) NOT NULL CHECK (image_scope IN ('catalog', 'list')),
|
||||||
|
image BYTEA NOT NULL,
|
||||||
|
mime_type VARCHAR(50) NOT NULL,
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_household_item_images_item
|
||||||
|
ON household_item_images(household_store_item_id, image_scope, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_household_item_images_location
|
||||||
|
ON household_item_images(household_id, store_location_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS household_item_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE,
|
||||||
|
household_store_item_id INTEGER REFERENCES household_store_items(id) ON DELETE SET NULL,
|
||||||
|
household_list_id INTEGER REFERENCES household_lists(id) ON DELETE SET NULL,
|
||||||
|
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
event_type VARCHAR(50) NOT NULL CHECK (
|
||||||
|
event_type IN (
|
||||||
|
'ITEM_ADDED',
|
||||||
|
'ITEM_BOUGHT',
|
||||||
|
'ITEM_UNBOUGHT',
|
||||||
|
'ITEM_QUANTITY_CHANGED',
|
||||||
|
'ITEM_DELETED',
|
||||||
|
'ITEM_CLASSIFICATION_CHANGED',
|
||||||
|
'ITEM_ZONE_CHANGED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
quantity_delta INTEGER,
|
||||||
|
quantity_after INTEGER,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_household_item_events_location_time
|
||||||
|
ON household_item_events(household_id, store_location_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_household_item_events_item_time
|
||||||
|
ON household_item_events(household_store_item_id, created_at DESC);
|
||||||
|
|
||||||
|
-- Allow new location-scoped rows to be independent of the legacy global
|
||||||
|
-- stores/items catalog.
|
||||||
|
ALTER TABLE household_store_items
|
||||||
|
ALTER COLUMN store_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE household_lists
|
||||||
|
ADD COLUMN IF NOT EXISTS store_location_id INTEGER;
|
||||||
|
|
||||||
|
ALTER TABLE household_store_items
|
||||||
|
ADD COLUMN IF NOT EXISTS store_location_id INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS image_id BIGINT REFERENCES household_item_images(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE household_item_classifications
|
||||||
|
ADD COLUMN IF NOT EXISTS store_location_id INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS zone_id INTEGER REFERENCES store_location_zones(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE household_list_history
|
||||||
|
ADD COLUMN IF NOT EXISTS store_location_id INTEGER;
|
||||||
|
|
||||||
|
ALTER TABLE household_lists
|
||||||
|
ADD COLUMN IF NOT EXISTS image_id BIGINT REFERENCES household_item_images(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- One owner per household, with defensive cleanup for older data.
|
||||||
|
WITH ranked_owners AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY household_id
|
||||||
|
ORDER BY joined_at ASC, id ASC
|
||||||
|
) AS owner_rank
|
||||||
|
FROM household_members
|
||||||
|
WHERE role = 'owner'
|
||||||
|
)
|
||||||
|
UPDATE household_members hm
|
||||||
|
SET role = 'admin'
|
||||||
|
FROM ranked_owners ro
|
||||||
|
WHERE hm.id = ro.id
|
||||||
|
AND ro.owner_rank > 1;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_household_members_one_owner
|
||||||
|
ON household_members(household_id)
|
||||||
|
WHERE role = 'owner';
|
||||||
|
|
||||||
|
-- Backfill custom stores from current household/global-store links.
|
||||||
|
INSERT INTO household_custom_stores (
|
||||||
|
household_id,
|
||||||
|
name,
|
||||||
|
normalized_name,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
hs.household_id,
|
||||||
|
s.name,
|
||||||
|
LOWER(TRIM(s.name)),
|
||||||
|
COALESCE(MIN(hs.added_at), NOW()),
|
||||||
|
NOW()
|
||||||
|
FROM household_stores hs
|
||||||
|
JOIN stores s ON s.id = hs.store_id
|
||||||
|
GROUP BY hs.household_id, s.name
|
||||||
|
ON CONFLICT (household_id, normalized_name) DO NOTHING;
|
||||||
|
|
||||||
|
WITH ranked_links AS (
|
||||||
|
SELECT
|
||||||
|
hs.household_id,
|
||||||
|
hcs.id AS household_store_id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY hs.household_id
|
||||||
|
ORDER BY hs.is_default DESC, hs.added_at ASC, hs.id ASC
|
||||||
|
) AS household_rank
|
||||||
|
FROM household_stores hs
|
||||||
|
JOIN stores s ON s.id = hs.store_id
|
||||||
|
JOIN household_custom_stores hcs
|
||||||
|
ON hcs.household_id = hs.household_id
|
||||||
|
AND hcs.normalized_name = LOWER(TRIM(s.name))
|
||||||
|
)
|
||||||
|
INSERT INTO store_locations (
|
||||||
|
household_id,
|
||||||
|
household_store_id,
|
||||||
|
name,
|
||||||
|
normalized_name,
|
||||||
|
is_default,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
household_id,
|
||||||
|
household_store_id,
|
||||||
|
'Default Location',
|
||||||
|
'default location',
|
||||||
|
household_rank = 1,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM ranked_links
|
||||||
|
ON CONFLICT (household_store_id, normalized_name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Backfill location ids onto existing records.
|
||||||
|
UPDATE household_store_items hsi
|
||||||
|
SET store_location_id = sl.id
|
||||||
|
FROM stores s
|
||||||
|
JOIN household_custom_stores hcs
|
||||||
|
ON hcs.normalized_name = LOWER(TRIM(s.name))
|
||||||
|
JOIN store_locations sl
|
||||||
|
ON sl.household_store_id = hcs.id
|
||||||
|
AND sl.normalized_name = 'default location'
|
||||||
|
WHERE hsi.store_id = s.id
|
||||||
|
AND hcs.household_id = hsi.household_id
|
||||||
|
AND hsi.store_location_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE household_lists hl
|
||||||
|
SET store_location_id = hsi.store_location_id
|
||||||
|
FROM household_store_items hsi
|
||||||
|
WHERE hl.household_store_item_id = hsi.id
|
||||||
|
AND hl.store_location_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE household_item_classifications hic
|
||||||
|
SET store_location_id = hsi.store_location_id
|
||||||
|
FROM household_store_items hsi
|
||||||
|
WHERE hic.household_store_item_id = hsi.id
|
||||||
|
AND hic.store_location_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE household_list_history hlh
|
||||||
|
SET store_location_id = hl.store_location_id
|
||||||
|
FROM household_lists hl
|
||||||
|
WHERE hlh.household_list_id = hl.id
|
||||||
|
AND hlh.store_location_id IS NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM household_store_items WHERE store_location_id IS NULL) THEN
|
||||||
|
RAISE EXCEPTION 'Failed to backfill household_store_items.store_location_id';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM household_lists WHERE store_location_id IS NULL) THEN
|
||||||
|
RAISE EXCEPTION 'Failed to backfill household_lists.store_location_id';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM household_item_classifications WHERE store_location_id IS NULL) THEN
|
||||||
|
RAISE EXCEPTION 'Failed to backfill household_item_classifications.store_location_id';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE household_store_items
|
||||||
|
ALTER COLUMN store_location_id SET NOT NULL,
|
||||||
|
ADD CONSTRAINT household_store_items_store_location_id_fkey
|
||||||
|
FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE household_lists
|
||||||
|
ALTER COLUMN store_location_id SET NOT NULL,
|
||||||
|
ADD CONSTRAINT household_lists_store_location_id_fkey
|
||||||
|
FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE household_item_classifications
|
||||||
|
ALTER COLUMN store_location_id SET NOT NULL,
|
||||||
|
ADD CONSTRAINT household_item_classifications_store_location_id_fkey
|
||||||
|
FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE household_list_history
|
||||||
|
ADD CONSTRAINT household_list_history_store_location_id_fkey
|
||||||
|
FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Seed v1 zones for every migrated/default location.
|
||||||
|
WITH default_zones(name, sort_order, color) AS (
|
||||||
|
VALUES
|
||||||
|
('Entrance & Seasonal', 10, '#64748b'),
|
||||||
|
('Produce & Fresh Vegetables', 20, '#16a34a'),
|
||||||
|
('Meat & Seafood Counter', 30, '#dc2626'),
|
||||||
|
('Deli & Prepared Foods', 40, '#ea580c'),
|
||||||
|
('Bakery', 50, '#ca8a04'),
|
||||||
|
('Dairy & Refrigerated', 60, '#0284c7'),
|
||||||
|
('Frozen Foods', 70, '#2563eb'),
|
||||||
|
('Center Aisles (Dry Goods)', 80, '#7c3aed'),
|
||||||
|
('Beverages & Water', 90, '#0891b2'),
|
||||||
|
('Snacks & Candy', 100, '#db2777'),
|
||||||
|
('Household & Cleaning', 110, '#475569'),
|
||||||
|
('Health & Beauty', 120, '#9333ea'),
|
||||||
|
('Checkout Area', 130, '#0f172a')
|
||||||
|
)
|
||||||
|
INSERT INTO store_location_zones (
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
name,
|
||||||
|
normalized_name,
|
||||||
|
sort_order,
|
||||||
|
color
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
sl.household_id,
|
||||||
|
sl.id,
|
||||||
|
dz.name,
|
||||||
|
LOWER(TRIM(dz.name)),
|
||||||
|
dz.sort_order,
|
||||||
|
dz.color
|
||||||
|
FROM store_locations sl
|
||||||
|
CROSS JOIN default_zones dz
|
||||||
|
ON CONFLICT (store_location_id, normalized_name) DO NOTHING;
|
||||||
|
|
||||||
|
WITH existing_zone_names AS (
|
||||||
|
SELECT DISTINCT
|
||||||
|
hic.household_id,
|
||||||
|
hic.store_location_id,
|
||||||
|
TRIM(hic.zone) AS name
|
||||||
|
FROM household_item_classifications hic
|
||||||
|
WHERE hic.zone IS NOT NULL
|
||||||
|
AND TRIM(hic.zone) <> ''
|
||||||
|
),
|
||||||
|
ranked_existing_zones AS (
|
||||||
|
SELECT
|
||||||
|
ezn.*,
|
||||||
|
1000 + ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY ezn.store_location_id
|
||||||
|
ORDER BY ezn.name
|
||||||
|
) AS sort_order
|
||||||
|
FROM existing_zone_names ezn
|
||||||
|
)
|
||||||
|
INSERT INTO store_location_zones (
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
name,
|
||||||
|
normalized_name,
|
||||||
|
sort_order
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
name,
|
||||||
|
LOWER(TRIM(name)),
|
||||||
|
sort_order
|
||||||
|
FROM ranked_existing_zones
|
||||||
|
ON CONFLICT (store_location_id, normalized_name) DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE household_item_classifications hic
|
||||||
|
SET zone_id = slz.id
|
||||||
|
FROM store_location_zones slz
|
||||||
|
WHERE slz.store_location_id = hic.store_location_id
|
||||||
|
AND slz.normalized_name = LOWER(TRIM(hic.zone))
|
||||||
|
AND hic.zone_id IS NULL
|
||||||
|
AND hic.zone IS NOT NULL
|
||||||
|
AND TRIM(hic.zone) <> '';
|
||||||
|
|
||||||
|
-- Backfill image references while retaining old BYTEA columns for rollback and
|
||||||
|
-- old-code compatibility.
|
||||||
|
INSERT INTO household_item_images (
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
household_store_item_id,
|
||||||
|
image_scope,
|
||||||
|
image,
|
||||||
|
mime_type,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
hsi.household_id,
|
||||||
|
hsi.store_location_id,
|
||||||
|
hsi.id,
|
||||||
|
'catalog',
|
||||||
|
hsi.custom_image,
|
||||||
|
COALESCE(hsi.custom_image_mime_type, 'image/jpeg'),
|
||||||
|
COALESCE(hsi.updated_at, NOW())
|
||||||
|
FROM household_store_items hsi
|
||||||
|
WHERE hsi.custom_image IS NOT NULL
|
||||||
|
AND hsi.image_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE household_store_items hsi
|
||||||
|
SET image_id = img.id
|
||||||
|
FROM household_item_images img
|
||||||
|
WHERE img.household_store_item_id = hsi.id
|
||||||
|
AND img.image_scope = 'catalog'
|
||||||
|
AND hsi.image_id IS NULL;
|
||||||
|
|
||||||
|
INSERT INTO household_item_images (
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
household_store_item_id,
|
||||||
|
household_list_id,
|
||||||
|
image_scope,
|
||||||
|
image,
|
||||||
|
mime_type,
|
||||||
|
created_by,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
hl.household_id,
|
||||||
|
hl.store_location_id,
|
||||||
|
hl.household_store_item_id,
|
||||||
|
hl.id,
|
||||||
|
'list',
|
||||||
|
hl.custom_image,
|
||||||
|
COALESCE(hl.custom_image_mime_type, 'image/jpeg'),
|
||||||
|
hl.added_by,
|
||||||
|
COALESCE(hl.modified_on, NOW())
|
||||||
|
FROM household_lists hl
|
||||||
|
WHERE hl.custom_image IS NOT NULL
|
||||||
|
AND hl.image_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE household_lists hl
|
||||||
|
SET image_id = img.id
|
||||||
|
FROM household_item_images img
|
||||||
|
WHERE img.household_list_id = hl.id
|
||||||
|
AND img.image_scope = 'list'
|
||||||
|
AND hl.image_id IS NULL;
|
||||||
|
|
||||||
|
-- Backfill known add history into the new event log.
|
||||||
|
INSERT INTO household_item_events (
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
household_store_item_id,
|
||||||
|
household_list_id,
|
||||||
|
actor_user_id,
|
||||||
|
event_type,
|
||||||
|
quantity_delta,
|
||||||
|
metadata,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
hl.household_id,
|
||||||
|
hl.store_location_id,
|
||||||
|
hlh.household_store_item_id,
|
||||||
|
hlh.household_list_id,
|
||||||
|
hlh.added_by,
|
||||||
|
'ITEM_ADDED',
|
||||||
|
hlh.quantity,
|
||||||
|
jsonb_build_object('source', 'household_list_history'),
|
||||||
|
hlh.added_on
|
||||||
|
FROM household_list_history hlh
|
||||||
|
JOIN household_lists hl ON hl.id = hlh.household_list_id
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM household_item_events hie
|
||||||
|
WHERE hie.household_list_id = hlh.household_list_id
|
||||||
|
AND hie.household_store_item_id = hlh.household_store_item_id
|
||||||
|
AND hie.event_type = 'ITEM_ADDED'
|
||||||
|
AND hie.created_at = hlh.added_on
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_household_store_items_location_name
|
||||||
|
ON household_store_items(household_id, store_location_id, normalized_name);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_household_lists_location_item
|
||||||
|
ON household_lists(household_id, store_location_id, household_store_item_id);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_household_item_classifications_location_item
|
||||||
|
ON household_item_classifications(household_id, store_location_id, household_store_item_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_household_lists_location_bought
|
||||||
|
ON household_lists(household_id, store_location_id, bought);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_household_classifications_location_zone
|
||||||
|
ON household_item_classifications(household_id, store_location_id, zone_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_household_list_history_location_item
|
||||||
|
ON household_list_history(store_location_id, household_store_item_id, added_on DESC);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Loading…
Reference in New Issue
Block a user