grocery-app/backend/controllers/lists.controller.v2.js

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");
}
};