319 lines
9.1 KiB
JavaScript
319 lines
9.1 KiB
JavaScript
const AvailableItems = require("../models/available-item.model");
|
|
const List = require("../models/list.model.v2");
|
|
const { isValidItemType, isValidItemGroup, isValidZone } = 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 isCatalogTableMissing(error) {
|
|
return error?.code === "42P01" && /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 };
|
|
}
|
|
|
|
function validateClassification(res, 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 && !isValidZone(zone)) {
|
|
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, storeId } = req.params;
|
|
const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || "");
|
|
res.json({ items, catalog_ready: true });
|
|
} catch (error) {
|
|
if (isCatalogTableMissing(error)) {
|
|
return res.json({
|
|
items: [],
|
|
catalog_ready: false,
|
|
message: "Store item catalog 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, storeId } = req.params;
|
|
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 (validateClassification(res, normalizedClassification)) {
|
|
return;
|
|
}
|
|
|
|
const imageBuffer = req.processedImage?.buffer || null;
|
|
const mimeType = req.processedImage?.mimeType || null;
|
|
|
|
const item = await AvailableItems.createAvailableItem(
|
|
householdId,
|
|
storeId,
|
|
item_name,
|
|
imageBuffer,
|
|
mimeType
|
|
);
|
|
|
|
if (normalizedClassification) {
|
|
await List.upsertClassification(householdId, storeId, item.item_id, {
|
|
...normalizedClassification,
|
|
confidence: 1.0,
|
|
source: "user",
|
|
});
|
|
}
|
|
|
|
const refreshedItem = await AvailableItems.getAvailableItemById(householdId, storeId, item.item_id);
|
|
|
|
res.status(201).json({
|
|
message: "Available item added",
|
|
item: refreshedItem,
|
|
});
|
|
} catch (error) {
|
|
if (isCatalogTableMissing(error)) {
|
|
return sendError(
|
|
res,
|
|
503,
|
|
"Store item catalog 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, storeId, itemId: rawItemId } = req.params;
|
|
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 && validateClassification(res, normalizedClassification)) {
|
|
return;
|
|
}
|
|
|
|
const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeId, itemId, {
|
|
itemName: req.body.item_name,
|
|
imageBuffer: req.processedImage?.buffer || null,
|
|
mimeType: req.processedImage?.mimeType || null,
|
|
removeImage: parseBoolean(req.body.remove_image),
|
|
});
|
|
|
|
if (!updatedItem) {
|
|
return sendError(res, 404, "Available item not found");
|
|
}
|
|
|
|
if (hasClassificationField) {
|
|
if (normalizedClassification) {
|
|
await List.upsertClassification(householdId, storeId, updatedItem.item_id, {
|
|
...normalizedClassification,
|
|
confidence: 1.0,
|
|
source: "user",
|
|
});
|
|
} else {
|
|
await List.deleteClassification(householdId, storeId, updatedItem.item_id);
|
|
}
|
|
}
|
|
|
|
const refreshedItem = await AvailableItems.getAvailableItemById(
|
|
householdId,
|
|
storeId,
|
|
updatedItem.item_id
|
|
);
|
|
|
|
res.json({
|
|
message: "Available item updated",
|
|
item: refreshedItem,
|
|
});
|
|
} catch (error) {
|
|
if (isCatalogTableMissing(error)) {
|
|
return sendError(
|
|
res,
|
|
503,
|
|
"Store item catalog 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, storeId, itemId: rawItemId } = req.params;
|
|
const itemId = parseItemId(rawItemId);
|
|
|
|
if (!itemId) {
|
|
return sendError(res, 400, "Item ID must be a positive integer");
|
|
}
|
|
|
|
const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId);
|
|
if (!deleted) {
|
|
return sendError(res, 404, "Available item not found");
|
|
}
|
|
|
|
res.json({ message: "Available item removed" });
|
|
} catch (error) {
|
|
if (isCatalogTableMissing(error)) {
|
|
return sendError(
|
|
res,
|
|
503,
|
|
"Store item catalog is unavailable until the latest database migration is applied"
|
|
);
|
|
}
|
|
logError(req, "availableItems.deleteAvailableItem", error);
|
|
sendError(res, 500, "Failed to remove available item");
|
|
}
|
|
};
|
|
|
|
exports.importCurrentItems = async (req, res) => {
|
|
try {
|
|
const { householdId, storeId } = req.params;
|
|
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeId);
|
|
|
|
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 catalog is unavailable until the latest database migration is applied"
|
|
);
|
|
}
|
|
logError(req, "availableItems.importCurrentItems", error);
|
|
sendError(res, 500, "Failed to import current list items");
|
|
}
|
|
};
|