485 lines
14 KiB
JavaScript
485 lines
14 KiB
JavaScript
const List = require("../models/list.model.v2");
|
|
const householdModel = require("../models/household.model");
|
|
const { isValidItemType, isValidItemGroup } = require("../constants/classifications");
|
|
const { sendError } = require("../utils/http");
|
|
const { logError } = require("../utils/logger");
|
|
|
|
const LEGACY_ITEM_TYPE_MAP = {
|
|
beverages: "beverage",
|
|
snacks: "snack",
|
|
};
|
|
|
|
function getStoreLocationId(req) {
|
|
return req.params.locationId || req.params.storeId;
|
|
}
|
|
|
|
function normalizeClassificationPayload(classification) {
|
|
if (typeof classification === "string") {
|
|
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
|
return {
|
|
item_type: normalizedItemType,
|
|
item_group: null,
|
|
zone: null,
|
|
};
|
|
}
|
|
|
|
if (!classification || typeof classification !== "object" || Array.isArray(classification)) {
|
|
return null;
|
|
}
|
|
|
|
const item_type =
|
|
typeof classification.item_type === "string" && classification.item_type.trim() !== ""
|
|
? classification.item_type.trim()
|
|
: null;
|
|
const item_group =
|
|
typeof classification.item_group === "string" && classification.item_group.trim() !== ""
|
|
? classification.item_group.trim()
|
|
: null;
|
|
const zone =
|
|
typeof classification.zone === "string" && classification.zone.trim() !== ""
|
|
? classification.zone.trim()
|
|
: null;
|
|
|
|
if (!item_type && !item_group && !zone) {
|
|
return null;
|
|
}
|
|
|
|
return { item_type, item_group, zone };
|
|
}
|
|
|
|
async function validateClassification(res, householdId, storeLocationId, classification) {
|
|
const { item_type, item_group, zone } = classification;
|
|
|
|
if (item_type && !isValidItemType(item_type)) {
|
|
sendError(res, 400, "Invalid item_type");
|
|
return true;
|
|
}
|
|
|
|
if (item_group && !item_type) {
|
|
sendError(res, 400, "Item type is required when item group is provided");
|
|
return true;
|
|
}
|
|
|
|
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
|
sendError(res, 400, "Invalid item_group for selected item_type");
|
|
return true;
|
|
}
|
|
|
|
if (zone) {
|
|
const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone);
|
|
if (!zoneRecord) {
|
|
sendError(res, 400, "Invalid zone");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
exports.getList = async (req, res) => {
|
|
try {
|
|
const { householdId } = req.params;
|
|
const storeLocationId = getStoreLocationId(req);
|
|
const items = await List.getHouseholdStoreList(householdId, storeLocationId);
|
|
res.json({ items });
|
|
} catch (error) {
|
|
logError(req, "listsV2.getList", error);
|
|
sendError(res, 500, "Failed to get list");
|
|
}
|
|
};
|
|
|
|
exports.getItemByName = async (req, res) => {
|
|
try {
|
|
const { householdId } = req.params;
|
|
const storeLocationId = getStoreLocationId(req);
|
|
const { item_name } = req.query;
|
|
|
|
if (!item_name) {
|
|
return sendError(res, 400, "Item name is required");
|
|
}
|
|
|
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
|
if (!item) {
|
|
return sendError(res, 404, "Item not found");
|
|
}
|
|
|
|
res.json(item);
|
|
} catch (error) {
|
|
logError(req, "listsV2.getItemByName", error);
|
|
sendError(res, 500, "Failed to get item");
|
|
}
|
|
};
|
|
|
|
exports.addItem = async (req, res) => {
|
|
try {
|
|
const { householdId } = req.params;
|
|
const storeLocationId = getStoreLocationId(req);
|
|
const { item_name, quantity, notes, added_for_user_id } = req.body;
|
|
const userId = req.user.id;
|
|
let historyUserId = userId;
|
|
|
|
if (!item_name || item_name.trim() === "") {
|
|
return sendError(res, 400, "Item name is required");
|
|
}
|
|
|
|
if (added_for_user_id !== undefined && added_for_user_id !== null && String(added_for_user_id).trim() !== "") {
|
|
const rawAddedForUserId = String(added_for_user_id).trim();
|
|
if (!/^\d+$/.test(rawAddedForUserId)) {
|
|
return sendError(res, 400, "Added-for user ID must be a positive integer");
|
|
}
|
|
|
|
const parsedUserId = Number.parseInt(rawAddedForUserId, 10);
|
|
|
|
if (!Number.isInteger(parsedUserId) || parsedUserId <= 0) {
|
|
return sendError(res, 400, "Added-for user ID must be a positive integer");
|
|
}
|
|
|
|
const isMember = await householdModel.isHouseholdMember(householdId, parsedUserId);
|
|
if (!isMember) {
|
|
return sendError(res, 400, "Selected user is not a member of this household");
|
|
}
|
|
|
|
historyUserId = parsedUserId;
|
|
}
|
|
|
|
const imageBuffer = req.processedImage?.buffer || null;
|
|
const mimeType = req.processedImage?.mimeType || null;
|
|
|
|
const result = await List.addOrUpdateItem(
|
|
householdId,
|
|
storeLocationId,
|
|
item_name,
|
|
quantity || "1",
|
|
userId,
|
|
imageBuffer,
|
|
mimeType,
|
|
notes
|
|
);
|
|
|
|
await List.addHistoryRecord(
|
|
result.listId,
|
|
result.householdStoreItemId,
|
|
result.historyQuantity ?? quantity ?? "1",
|
|
historyUserId,
|
|
storeLocationId
|
|
);
|
|
|
|
await List.recordItemEvent({
|
|
householdId,
|
|
storeLocationId,
|
|
householdStoreItemId: result.householdStoreItemId,
|
|
householdListId: result.listId,
|
|
actorUserId: historyUserId,
|
|
eventType: "ITEM_ADDED",
|
|
quantityDelta: result.historyQuantity ?? Number.parseInt(quantity || "1", 10),
|
|
quantityAfter: result.quantity,
|
|
metadata: {
|
|
item_name: result.itemName,
|
|
is_new_list_item: result.isNew,
|
|
added_by_request_user_id: userId,
|
|
},
|
|
});
|
|
|
|
res.json({
|
|
message: result.isNew ? "Item added" : "Item updated",
|
|
item: {
|
|
id: result.listId,
|
|
item_name: result.itemName,
|
|
quantity: result.quantity ?? quantity ?? "1",
|
|
bought: false,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logError(req, "listsV2.addItem", error);
|
|
sendError(res, 500, "Failed to add item");
|
|
}
|
|
};
|
|
|
|
exports.markBought = async (req, res) => {
|
|
try {
|
|
const { householdId } = req.params;
|
|
const storeLocationId = getStoreLocationId(req);
|
|
const { item_name, bought, quantity_bought } = req.body;
|
|
|
|
if (!item_name) return sendError(res, 400, "Item name is required");
|
|
|
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
|
if (!item) return sendError(res, 404, "Item not found");
|
|
|
|
const eventDetails = await List.setBought(item.id, bought, quantity_bought);
|
|
|
|
if (eventDetails) {
|
|
await List.recordItemEvent({
|
|
householdId,
|
|
storeLocationId,
|
|
householdStoreItemId: item.household_store_item_id,
|
|
householdListId: item.id,
|
|
actorUserId: req.user.id,
|
|
eventType: eventDetails.eventType,
|
|
quantityDelta: eventDetails.quantityDelta,
|
|
quantityAfter: eventDetails.quantityAfter,
|
|
metadata: {
|
|
item_name,
|
|
requested_quantity: quantity_bought || null,
|
|
},
|
|
});
|
|
}
|
|
|
|
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
|
|
} catch (error) {
|
|
logError(req, "listsV2.markBought", error);
|
|
sendError(res, 500, "Failed to update item");
|
|
}
|
|
};
|
|
|
|
exports.updateItem = async (req, res) => {
|
|
try {
|
|
const { householdId } = req.params;
|
|
const storeLocationId = getStoreLocationId(req);
|
|
const { item_name, quantity, notes } = req.body;
|
|
|
|
if (!item_name) {
|
|
return sendError(res, 400, "Item name is required");
|
|
}
|
|
|
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
|
if (!item) {
|
|
return sendError(res, 404, "Item not found");
|
|
}
|
|
|
|
const updateResult = await List.updateItem(item.id, item_name, quantity, notes);
|
|
if (!updateResult) {
|
|
return sendError(res, 404, "Item not found");
|
|
}
|
|
|
|
if (quantity !== undefined && Number(quantity) !== Number(updateResult.previous.quantity)) {
|
|
await List.recordItemEvent({
|
|
householdId,
|
|
storeLocationId,
|
|
householdStoreItemId: item.household_store_item_id,
|
|
householdListId: item.id,
|
|
actorUserId: req.user.id,
|
|
eventType: "ITEM_QUANTITY_CHANGED",
|
|
quantityDelta: Number(quantity) - Number(updateResult.previous.quantity),
|
|
quantityAfter: Number(quantity),
|
|
metadata: {
|
|
item_name,
|
|
previous_quantity: updateResult.previous.quantity,
|
|
},
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
message: "Item updated",
|
|
item: {
|
|
id: item.id,
|
|
item_name,
|
|
quantity,
|
|
notes,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logError(req, "listsV2.updateItem", error);
|
|
sendError(res, 500, "Failed to update item");
|
|
}
|
|
};
|
|
|
|
exports.deleteItem = async (req, res) => {
|
|
try {
|
|
const { householdId } = req.params;
|
|
const storeLocationId = getStoreLocationId(req);
|
|
const { item_name } = req.body;
|
|
|
|
if (!item_name) {
|
|
return sendError(res, 400, "Item name is required");
|
|
}
|
|
|
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
|
if (!item) {
|
|
return sendError(res, 404, "Item not found");
|
|
}
|
|
|
|
const deleted = await List.deleteItem(item.id);
|
|
|
|
if (deleted) {
|
|
await List.recordItemEvent({
|
|
householdId,
|
|
storeLocationId,
|
|
householdStoreItemId: item.household_store_item_id,
|
|
householdListId: item.id,
|
|
actorUserId: req.user.id,
|
|
eventType: "ITEM_DELETED",
|
|
quantityDelta: -Number(item.quantity || 0),
|
|
quantityAfter: 0,
|
|
metadata: { item_name },
|
|
});
|
|
}
|
|
|
|
res.json({ message: "Item deleted" });
|
|
} catch (error) {
|
|
logError(req, "listsV2.deleteItem", error);
|
|
sendError(res, 500, "Failed to delete item");
|
|
}
|
|
};
|
|
|
|
exports.getSuggestions = async (req, res) => {
|
|
try {
|
|
const { householdId } = req.params;
|
|
const storeLocationId = getStoreLocationId(req);
|
|
const { query } = req.query;
|
|
|
|
const suggestions = await List.getSuggestions(query || "", householdId, storeLocationId);
|
|
res.json(suggestions);
|
|
} catch (error) {
|
|
logError(req, "listsV2.getSuggestions", error);
|
|
sendError(res, 500, "Failed to get suggestions");
|
|
}
|
|
};
|
|
|
|
exports.getRecentlyBought = async (req, res) => {
|
|
try {
|
|
const { householdId } = req.params;
|
|
const storeLocationId = getStoreLocationId(req);
|
|
const items = await List.getRecentlyBoughtItems(householdId, storeLocationId);
|
|
res.json(items);
|
|
} catch (error) {
|
|
logError(req, "listsV2.getRecentlyBought", error);
|
|
sendError(res, 500, "Failed to get recent items");
|
|
}
|
|
};
|
|
|
|
exports.getClassification = async (req, res) => {
|
|
try {
|
|
const { householdId } = req.params;
|
|
const storeLocationId = getStoreLocationId(req);
|
|
const { item_name } = req.query;
|
|
|
|
if (!item_name) {
|
|
return sendError(res, 400, "Item name is required");
|
|
}
|
|
|
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
|
if (!item) {
|
|
return res.json({ classification: null });
|
|
}
|
|
|
|
const classification = await List.getClassification(
|
|
householdId,
|
|
storeLocationId,
|
|
item.item_id
|
|
);
|
|
res.json({ classification });
|
|
} catch (error) {
|
|
logError(req, "listsV2.getClassification", error);
|
|
sendError(res, 500, "Failed to get classification");
|
|
}
|
|
};
|
|
|
|
exports.setClassification = async (req, res) => {
|
|
try {
|
|
const { householdId } = req.params;
|
|
const storeLocationId = getStoreLocationId(req);
|
|
const { item_name, classification } = req.body;
|
|
|
|
if (!item_name) {
|
|
return sendError(res, 400, "Item name is required");
|
|
}
|
|
|
|
const normalizedClassification = normalizeClassificationPayload(classification);
|
|
if (!normalizedClassification) {
|
|
return sendError(res, 400, "Classification is required");
|
|
}
|
|
|
|
if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) {
|
|
return;
|
|
}
|
|
|
|
const item = await List.getItemByName(householdId, storeLocationId, item_name);
|
|
let itemId;
|
|
|
|
if (!item) {
|
|
const itemResult = await List.ensureHouseholdStoreItem(
|
|
householdId,
|
|
storeLocationId,
|
|
item_name
|
|
);
|
|
itemId = itemResult.id;
|
|
} else {
|
|
itemId = item.item_id;
|
|
}
|
|
|
|
const updated = await List.upsertClassification(householdId, storeLocationId, itemId, {
|
|
...normalizedClassification,
|
|
confidence: 1.0,
|
|
source: "user",
|
|
});
|
|
|
|
await List.recordItemEvent({
|
|
householdId,
|
|
storeLocationId,
|
|
householdStoreItemId: itemId,
|
|
householdListId: item?.id || null,
|
|
actorUserId: req.user.id,
|
|
eventType: "ITEM_CLASSIFICATION_CHANGED",
|
|
metadata: {
|
|
item_name,
|
|
item_type: normalizedClassification.item_type,
|
|
item_group: normalizedClassification.item_group,
|
|
zone: normalizedClassification.zone,
|
|
},
|
|
});
|
|
|
|
if (normalizedClassification.zone) {
|
|
await List.recordItemEvent({
|
|
householdId,
|
|
storeLocationId,
|
|
householdStoreItemId: itemId,
|
|
householdListId: item?.id || null,
|
|
actorUserId: req.user.id,
|
|
eventType: "ITEM_ZONE_CHANGED",
|
|
metadata: {
|
|
item_name,
|
|
zone: normalizedClassification.zone,
|
|
zone_id: updated.zone_id || null,
|
|
},
|
|
});
|
|
}
|
|
|
|
res.json({ message: "Classification set", classification: normalizedClassification });
|
|
} catch (error) {
|
|
logError(req, "listsV2.setClassification", error);
|
|
sendError(res, 500, "Failed to set classification");
|
|
}
|
|
};
|
|
|
|
exports.updateItemImage = async (req, res) => {
|
|
try {
|
|
const { householdId } = req.params;
|
|
const storeLocationId = getStoreLocationId(req);
|
|
const { item_name, quantity } = req.body;
|
|
const userId = req.user.id;
|
|
|
|
const imageBuffer = req.processedImage?.buffer || null;
|
|
const mimeType = req.processedImage?.mimeType || null;
|
|
|
|
if (!imageBuffer) {
|
|
return sendError(res, 400, "No image provided");
|
|
}
|
|
|
|
await List.addOrUpdateItem(
|
|
householdId,
|
|
storeLocationId,
|
|
item_name,
|
|
quantity,
|
|
userId,
|
|
imageBuffer,
|
|
mimeType
|
|
);
|
|
|
|
res.json({ message: "Image updated successfully" });
|
|
} catch (error) {
|
|
logError(req, "listsV2.updateItemImage", error);
|
|
sendError(res, 500, "Failed to update image");
|
|
}
|
|
};
|