const AvailableItems = require("../models/available-item.model"); const List = require("../models/list.model.v2"); 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 parseBoolean(value) { return value === true || value === "true" || value === "1"; } function getStoreLocationId(req) { return req.params.locationId || req.params.storeId; } function isCatalogTableMissing(error) { return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || ""); } function parseClassificationInput(value) { if (value === undefined) { return undefined; } if (value === null) { return null; } if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) { return null; } if (trimmed === "null") { return null; } if (trimmed.startsWith("{")) { try { return JSON.parse(trimmed); } catch (error) { return Symbol.for("invalid-classification-json"); } } return trimmed; } return value; } 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) { if (!classification) { return false; } 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; } function parseItemId(value) { const parsed = Number.parseInt(String(value), 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; } exports.getAvailableItems = async (req, res) => { try { const { householdId } = req.params; const storeLocationId = getStoreLocationId(req); const items = await AvailableItems.listAvailableItems( householdId, storeLocationId, req.query.query || "" ); res.json({ items, catalog_ready: true }); } catch (error) { if (isCatalogTableMissing(error)) { return res.json({ items: [], catalog_ready: false, message: "Store item management is unavailable until the latest database migration is applied.", }); } logError(req, "availableItems.getAvailableItems", error); sendError(res, 500, "Failed to load available items"); } }; exports.createAvailableItem = async (req, res) => { try { const { householdId } = req.params; const storeLocationId = getStoreLocationId(req); const { item_name } = req.body; if (!item_name || item_name.trim() === "") { return sendError(res, 400, "Item name is required"); } const parsedClassification = parseClassificationInput(req.body.classification); if (parsedClassification === Symbol.for("invalid-classification-json")) { return sendError(res, 400, "Classification payload must be valid JSON"); } const normalizedClassification = normalizeClassificationPayload(parsedClassification); if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) { return; } const imageBuffer = req.processedImage?.buffer || null; const mimeType = req.processedImage?.mimeType || null; const item = await AvailableItems.createAvailableItem( householdId, storeLocationId, item_name, imageBuffer, mimeType, req.user.id ); if (normalizedClassification) { await List.upsertClassification(householdId, storeLocationId, item.item_id, { ...normalizedClassification, confidence: 1.0, source: "user", }); await List.recordItemEvent({ householdId, storeLocationId, householdStoreItemId: item.item_id, actorUserId: req.user.id, eventType: "ITEM_CLASSIFICATION_CHANGED", metadata: { item_name, ...normalizedClassification, }, }); } const refreshedItem = await AvailableItems.getAvailableItemById( householdId, storeLocationId, item.item_id ); res.status(201).json({ message: "Available item added", item: refreshedItem, }); } catch (error) { if (isCatalogTableMissing(error)) { return sendError( res, 503, "Store item management is unavailable until the latest database migration is applied" ); } logError(req, "availableItems.createAvailableItem", error); if (error.code === "23505") { return sendError(res, 400, "Available item already exists for this store"); } sendError(res, 500, "Failed to add available item"); } }; exports.updateAvailableItem = async (req, res) => { try { const { householdId, itemId: rawItemId } = req.params; const storeLocationId = getStoreLocationId(req); const itemId = parseItemId(rawItemId); if (!itemId) { return sendError(res, 400, "Item ID must be a positive integer"); } const hasClassificationField = Object.prototype.hasOwnProperty.call(req.body, "classification"); const parsedClassification = parseClassificationInput(req.body.classification); if (parsedClassification === Symbol.for("invalid-classification-json")) { return sendError(res, 400, "Classification payload must be valid JSON"); } const normalizedClassification = normalizeClassificationPayload(parsedClassification); if ( normalizedClassification && (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) ) { return; } const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeLocationId, itemId, { itemName: req.body.item_name, imageBuffer: req.processedImage?.buffer || null, mimeType: req.processedImage?.mimeType || null, removeImage: parseBoolean(req.body.remove_image), userId: req.user.id, }); if (!updatedItem) { return sendError(res, 404, "Available item not found"); } if (hasClassificationField) { if (normalizedClassification) { await List.upsertClassification(householdId, storeLocationId, updatedItem.item_id, { ...normalizedClassification, confidence: 1.0, source: "user", }); await List.recordItemEvent({ householdId, storeLocationId, householdStoreItemId: updatedItem.item_id, actorUserId: req.user.id, eventType: "ITEM_CLASSIFICATION_CHANGED", metadata: { item_name: updatedItem.item_name, ...normalizedClassification, }, }); } else { await List.deleteClassification(householdId, storeLocationId, updatedItem.item_id); } } const refreshedItem = await AvailableItems.getAvailableItemById( householdId, storeLocationId, updatedItem.item_id ); res.json({ message: "Available item updated", item: refreshedItem, }); } catch (error) { if (isCatalogTableMissing(error)) { return sendError( res, 503, "Store item management is unavailable until the latest database migration is applied" ); } logError(req, "availableItems.updateAvailableItem", error); if (error.code === "23505") { return sendError(res, 400, "Available item already exists for this store"); } sendError(res, 500, "Failed to update available item"); } }; exports.deleteAvailableItem = async (req, res) => { try { const { householdId, itemId: rawItemId } = req.params; const storeLocationId = getStoreLocationId(req); const itemId = parseItemId(rawItemId); if (!itemId) { return sendError(res, 400, "Item ID must be a positive integer"); } const deleted = await AvailableItems.deleteAvailableItem( householdId, storeLocationId, itemId, req.user.id ); if (!deleted) { return sendError(res, 404, "Store item not found"); } res.json({ message: "Store item deleted" }); } catch (error) { if (isCatalogTableMissing(error)) { return sendError( res, 503, "Store item management is unavailable until the latest database migration is applied" ); } logError(req, "availableItems.deleteAvailableItem", error); sendError(res, 500, "Failed to delete store item"); } }; exports.importCurrentItems = async (req, res) => { try { const { householdId } = req.params; const storeLocationId = getStoreLocationId(req); const importedCount = await AvailableItems.importCurrentListItems(householdId, storeLocationId); res.json({ message: importedCount > 0 ? "Imported current list items" : "No current list items to import", imported_count: importedCount, }); } catch (error) { if (isCatalogTableMissing(error)) { return sendError( res, 503, "Store item management is unavailable until the latest database migration is applied" ); } logError(req, "availableItems.importCurrentItems", error); sendError(res, 500, "Failed to import current list items"); } };