feat: add store catalog backend
This commit is contained in:
parent
77ae5be445
commit
86eebcc6f4
279
backend/controllers/available-items.controller.js
Normal file
279
backend/controllers/available-items.controller.js
Normal file
@ -0,0 +1,279 @@
|
||||
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 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 });
|
||||
} catch (error) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
logError(req, "availableItems.importCurrentItems", error);
|
||||
sendError(res, 500, "Failed to import current list items");
|
||||
}
|
||||
};
|
||||
@ -1,9 +1,49 @@
|
||||
const List = require("../models/list.model.v2");
|
||||
const AvailableItems = require("../models/available-item.model");
|
||||
const householdModel = require("../models/household.model");
|
||||
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 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list items for household and store
|
||||
* GET /households/:householdId/stores/:storeId/list
|
||||
@ -80,8 +120,18 @@ exports.addItem = async (req, res) => {
|
||||
}
|
||||
|
||||
// Get processed image if uploaded
|
||||
const imageBuffer = req.processedImage?.buffer || null;
|
||||
const mimeType = req.processedImage?.mimeType || null;
|
||||
let imageBuffer = req.processedImage?.buffer || null;
|
||||
let mimeType = req.processedImage?.mimeType || null;
|
||||
|
||||
if (!imageBuffer) {
|
||||
const catalogItem = await AvailableItems.getAvailableItemImageByName(
|
||||
householdId,
|
||||
storeId,
|
||||
item_name
|
||||
);
|
||||
imageBuffer = catalogItem?.custom_image || null;
|
||||
mimeType = catalogItem?.custom_image_mime_type || null;
|
||||
}
|
||||
|
||||
const result = await List.addOrUpdateItem(
|
||||
householdId,
|
||||
@ -253,7 +303,7 @@ exports.getClassification = async (req, res) => {
|
||||
return res.json({ classification: null });
|
||||
}
|
||||
|
||||
const classification = await List.getClassification(householdId, item.item_id);
|
||||
const classification = await List.getClassification(householdId, storeId, item.item_id);
|
||||
res.json({ classification });
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.getClassification", error);
|
||||
@ -274,14 +324,27 @@ exports.setClassification = async (req, res) => {
|
||||
return sendError(res, 400, "Item name is required");
|
||||
}
|
||||
|
||||
if (!classification) {
|
||||
const normalizedClassification = normalizeClassificationPayload(classification);
|
||||
if (!normalizedClassification) {
|
||||
return sendError(res, 400, "Classification is required");
|
||||
}
|
||||
|
||||
// Validate classification
|
||||
const validClassifications = ['produce', 'dairy', 'meat', 'bakery', 'frozen', 'pantry', 'snacks', 'beverages', 'household', 'other'];
|
||||
if (!validClassifications.includes(classification)) {
|
||||
return sendError(res, 400, "Invalid classification value");
|
||||
const { item_type, item_group, zone } = normalizedClassification;
|
||||
|
||||
if (item_type && !isValidItemType(item_type)) {
|
||||
return sendError(res, 400, "Invalid item_type");
|
||||
}
|
||||
|
||||
if (item_group && !item_type) {
|
||||
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
|
||||
@ -304,14 +367,15 @@ exports.setClassification = async (req, res) => {
|
||||
itemId = item.item_id;
|
||||
}
|
||||
|
||||
// Set classification (using item_type field for simplicity)
|
||||
await List.upsertClassification(householdId, itemId, {
|
||||
item_type: classification,
|
||||
item_group: null,
|
||||
zone: null
|
||||
await List.upsertClassification(householdId, storeId, itemId, {
|
||||
item_type,
|
||||
item_group,
|
||||
zone,
|
||||
confidence: 1.0,
|
||||
source: "user",
|
||||
});
|
||||
|
||||
res.json({ message: "Classification set", classification });
|
||||
res.json({ message: "Classification set", classification: normalizedClassification });
|
||||
} catch (error) {
|
||||
logError(req, "listsV2.setClassification", error);
|
||||
sendError(res, 500, "Failed to set classification");
|
||||
|
||||
231
backend/models/available-item.model.js
Normal file
231
backend/models/available-item.model.js
Normal file
@ -0,0 +1,231 @@
|
||||
const pool = require("../db/pool");
|
||||
|
||||
function normalizeItemName(itemName) {
|
||||
return String(itemName || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function findOrCreateItem(itemName) {
|
||||
const normalizedName = normalizeItemName(itemName);
|
||||
const existing = await pool.query(
|
||||
"SELECT id, name FROM items WHERE name ILIKE $1",
|
||||
[normalizedName]
|
||||
);
|
||||
|
||||
if (existing.rowCount > 0) {
|
||||
return {
|
||||
itemId: existing.rows[0].id,
|
||||
itemName: existing.rows[0].name,
|
||||
};
|
||||
}
|
||||
|
||||
const created = await pool.query(
|
||||
"INSERT INTO items (name) VALUES ($1) RETURNING id, name",
|
||||
[normalizedName]
|
||||
);
|
||||
|
||||
return {
|
||||
itemId: created.rows[0].id,
|
||||
itemName: created.rows[0].name,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAvailableItemRecord(householdId, storeId, itemId) {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hsai.item_id,
|
||||
i.name AS item_name,
|
||||
ENCODE(hsai.custom_image, 'base64') AS item_image,
|
||||
hsai.custom_image_mime_type AS image_mime_type,
|
||||
hic.item_type,
|
||||
hic.item_group,
|
||||
hic.zone,
|
||||
hsai.created_at,
|
||||
hsai.updated_at
|
||||
FROM household_store_available_items hsai
|
||||
JOIN items i ON i.id = hsai.item_id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hic.household_id = hsai.household_id
|
||||
AND hic.store_id = hsai.store_id
|
||||
AND hic.item_id = hsai.item_id
|
||||
WHERE hsai.household_id = $1
|
||||
AND hsai.store_id = $2
|
||||
AND hsai.item_id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
exports.listAvailableItems = async (householdId, storeId, query = "") => {
|
||||
const trimmedQuery = String(query || "").trim();
|
||||
const values = [householdId, storeId];
|
||||
let filterClause = "";
|
||||
|
||||
if (trimmedQuery) {
|
||||
values.push(`%${trimmedQuery}%`);
|
||||
filterClause = "AND i.name ILIKE $3";
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hsai.item_id,
|
||||
i.name AS item_name,
|
||||
ENCODE(hsai.custom_image, 'base64') AS item_image,
|
||||
hsai.custom_image_mime_type AS image_mime_type,
|
||||
hic.item_type,
|
||||
hic.item_group,
|
||||
hic.zone,
|
||||
hsai.created_at,
|
||||
hsai.updated_at
|
||||
FROM household_store_available_items hsai
|
||||
JOIN items i ON i.id = hsai.item_id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hic.household_id = hsai.household_id
|
||||
AND hic.store_id = hsai.store_id
|
||||
AND hic.item_id = hsai.item_id
|
||||
WHERE hsai.household_id = $1
|
||||
AND hsai.store_id = $2
|
||||
${filterClause}
|
||||
ORDER BY i.name ASC
|
||||
LIMIT 100`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
exports.getAvailableItemById = async (householdId, storeId, itemId) =>
|
||||
getAvailableItemRecord(householdId, storeId, itemId);
|
||||
|
||||
exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => {
|
||||
const normalizedName = normalizeItemName(itemName);
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hsai.item_id,
|
||||
i.name AS item_name,
|
||||
hsai.custom_image,
|
||||
hsai.custom_image_mime_type
|
||||
FROM household_store_available_items hsai
|
||||
JOIN items i ON i.id = hsai.item_id
|
||||
WHERE hsai.household_id = $1
|
||||
AND hsai.store_id = $2
|
||||
AND i.name ILIKE $3`,
|
||||
[householdId, storeId, normalizedName]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
exports.createAvailableItem = async (
|
||||
householdId,
|
||||
storeId,
|
||||
itemName,
|
||||
imageBuffer = null,
|
||||
mimeType = null
|
||||
) => {
|
||||
const { itemId } = await findOrCreateItem(itemName);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO household_store_available_items
|
||||
(household_id, store_id, item_id, custom_image, custom_image_mime_type, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
||||
[householdId, storeId, itemId, imageBuffer, mimeType]
|
||||
);
|
||||
|
||||
return getAvailableItemRecord(householdId, storeId, itemId);
|
||||
};
|
||||
|
||||
exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => {
|
||||
const {
|
||||
itemName,
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
removeImage = false,
|
||||
} = updates;
|
||||
|
||||
const assignments = ["updated_at = NOW()"];
|
||||
const values = [householdId, storeId, itemId];
|
||||
let parameterIndex = values.length;
|
||||
|
||||
if (itemName !== undefined && String(itemName).trim() !== "") {
|
||||
const { itemId: nextItemId } = await findOrCreateItem(itemName);
|
||||
parameterIndex += 1;
|
||||
assignments.push(`item_id = $${parameterIndex}`);
|
||||
values.push(nextItemId);
|
||||
}
|
||||
|
||||
if (removeImage) {
|
||||
assignments.push("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(
|
||||
`UPDATE household_store_available_items
|
||||
SET ${assignments.join(", ")}
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND item_id = $3
|
||||
RETURNING item_id`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getAvailableItemRecord(householdId, storeId, result.rows[0].item_id);
|
||||
};
|
||||
|
||||
exports.deleteAvailableItem = async (householdId, storeId, itemId) => {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM household_store_available_items
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND item_id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
);
|
||||
|
||||
return result.rowCount > 0;
|
||||
};
|
||||
|
||||
exports.importCurrentListItems = async (householdId, storeId) => {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO household_store_available_items
|
||||
(household_id, store_id, item_id, custom_image, custom_image_mime_type, updated_at)
|
||||
SELECT
|
||||
hl.household_id,
|
||||
hl.store_id,
|
||||
hl.item_id,
|
||||
hl.custom_image,
|
||||
hl.custom_image_mime_type,
|
||||
NOW()
|
||||
FROM household_lists hl
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
ON CONFLICT (household_id, store_id, item_id) DO NOTHING
|
||||
RETURNING item_id`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
|
||||
return result.rowCount;
|
||||
};
|
||||
|
||||
exports.hasAvailableItems = async (householdId, storeId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT 1
|
||||
FROM household_store_available_items
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
LIMIT 1`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
|
||||
return result.rowCount > 0;
|
||||
};
|
||||
@ -11,6 +11,7 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
hl.item_id,
|
||||
i.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
@ -36,6 +37,7 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
|
||||
JOIN items i ON hl.item_id = i.id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hl.household_id = hic.household_id
|
||||
AND hl.store_id = hic.store_id
|
||||
AND hl.item_id = hic.item_id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
@ -68,6 +70,7 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
hl.item_id,
|
||||
i.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
@ -91,6 +94,7 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
|
||||
JOIN items i ON hl.item_id = i.id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hl.household_id = hic.household_id
|
||||
AND hl.store_id = hic.store_id
|
||||
AND hl.item_id = hic.item_id
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_id = $2
|
||||
@ -109,7 +113,7 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
|
||||
* @param {number} userId - User adding the item
|
||||
* @param {Buffer|null} imageBuffer - Image buffer
|
||||
* @param {string|null} mimeType - MIME type
|
||||
* @returns {Promise<number>} List item ID
|
||||
* @returns {Promise<{listId: number, itemId: number, itemName: string, isNew: boolean}>} Item metadata
|
||||
*/
|
||||
exports.addOrUpdateItem = async (
|
||||
householdId,
|
||||
@ -169,7 +173,12 @@ exports.addOrUpdateItem = async (
|
||||
[quantity, listId]
|
||||
);
|
||||
}
|
||||
return listId;
|
||||
return {
|
||||
listId,
|
||||
itemId,
|
||||
itemName: lowerItemName,
|
||||
isNew: false,
|
||||
};
|
||||
} else {
|
||||
const insert = await pool.query(
|
||||
`INSERT INTO household_lists
|
||||
@ -178,7 +187,12 @@ exports.addOrUpdateItem = async (
|
||||
RETURNING id`,
|
||||
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
|
||||
);
|
||||
return insert.rows[0].id;
|
||||
return {
|
||||
listId: insert.rows[0].id,
|
||||
itemId,
|
||||
itemName: lowerItemName,
|
||||
isNew: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -255,6 +269,32 @@ exports.addHistoryRecord = async (listId, quantity, userId) => {
|
||||
* @returns {Promise<Array>} Suggestions
|
||||
*/
|
||||
exports.getSuggestions = async (query, householdId, storeId) => {
|
||||
const hasCatalogResult = await pool.query(
|
||||
`SELECT 1
|
||||
FROM household_store_available_items
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
LIMIT 1`,
|
||||
[householdId, storeId]
|
||||
);
|
||||
|
||||
if (hasCatalogResult.rowCount > 0) {
|
||||
const catalogSuggestions = await pool.query(
|
||||
`SELECT
|
||||
i.name as item_name,
|
||||
0 as sort_order
|
||||
FROM household_store_available_items hsai
|
||||
JOIN items i ON i.id = hsai.item_id
|
||||
WHERE hsai.household_id = $2
|
||||
AND hsai.store_id = $3
|
||||
AND i.name ILIKE $1
|
||||
ORDER BY i.name
|
||||
LIMIT 10`,
|
||||
[`%${query}%`, householdId, storeId]
|
||||
);
|
||||
return catalogSuggestions.rows;
|
||||
}
|
||||
|
||||
// Get items from both master catalog and household history
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT
|
||||
@ -314,15 +354,16 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
||||
/**
|
||||
* Get classification for household item
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @param {number} itemId - Item ID
|
||||
* @returns {Promise<Object|null>} Classification or null
|
||||
*/
|
||||
exports.getClassification = async (householdId, itemId) => {
|
||||
exports.getClassification = async (householdId, storeId, itemId) => {
|
||||
const result = await pool.query(
|
||||
`SELECT item_type, item_group, zone, confidence, source
|
||||
FROM household_item_classifications
|
||||
WHERE household_id = $1 AND item_id = $2`,
|
||||
[householdId, itemId]
|
||||
WHERE household_id = $1 AND store_id = $2 AND item_id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
@ -330,18 +371,19 @@ exports.getClassification = async (householdId, itemId) => {
|
||||
/**
|
||||
* Upsert classification for household item
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @param {number} itemId - Item ID
|
||||
* @param {Object} classification - Classification data
|
||||
* @returns {Promise<Object>} Updated classification
|
||||
*/
|
||||
exports.upsertClassification = async (householdId, itemId, classification) => {
|
||||
exports.upsertClassification = async (householdId, storeId, itemId, classification) => {
|
||||
const { item_type, item_group, zone, confidence, source } = classification;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO household_item_classifications
|
||||
(household_id, item_id, item_type, item_group, zone, confidence, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (household_id, item_id)
|
||||
(household_id, store_id, item_id, item_type, item_group, zone, confidence, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (household_id, store_id, item_id)
|
||||
DO UPDATE SET
|
||||
item_type = EXCLUDED.item_type,
|
||||
item_group = EXCLUDED.item_group,
|
||||
@ -349,11 +391,27 @@ exports.upsertClassification = async (householdId, itemId, classification) => {
|
||||
confidence = EXCLUDED.confidence,
|
||||
source = EXCLUDED.source
|
||||
RETURNING *`,
|
||||
[householdId, itemId, item_type, item_group, zone, confidence, source]
|
||||
[householdId, storeId, itemId, item_type, item_group, zone, confidence, source]
|
||||
);
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove classification for household/store item
|
||||
* @param {number} householdId - Household ID
|
||||
* @param {number} storeId - Store ID
|
||||
* @param {number} itemId - Item ID
|
||||
*/
|
||||
exports.deleteClassification = async (householdId, storeId, itemId) => {
|
||||
await pool.query(
|
||||
`DELETE FROM household_item_classifications
|
||||
WHERE household_id = $1
|
||||
AND store_id = $2
|
||||
AND item_id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update list item details
|
||||
* @param {number} listId - List item ID
|
||||
|
||||
@ -2,6 +2,7 @@ const express = require("express");
|
||||
const router = express.Router();
|
||||
const controller = require("../controllers/households.controller");
|
||||
const listsController = require("../controllers/lists.controller.v2");
|
||||
const availableItemsController = require("../controllers/available-items.controller");
|
||||
const auth = require("../middleware/auth");
|
||||
const {
|
||||
householdAccess,
|
||||
@ -39,6 +40,50 @@ router.post(
|
||||
controller.refreshInviteCode
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/available-items",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
availableItemsController.getAvailableItems
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/stores/:storeId/available-items",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
requireHouseholdAdmin,
|
||||
upload.single("image"),
|
||||
processImage,
|
||||
availableItemsController.createAvailableItem
|
||||
);
|
||||
router.patch(
|
||||
"/:householdId/stores/:storeId/available-items/:itemId",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
requireHouseholdAdmin,
|
||||
upload.single("image"),
|
||||
processImage,
|
||||
availableItemsController.updateAvailableItem
|
||||
);
|
||||
router.delete(
|
||||
"/:householdId/stores/:storeId/available-items/:itemId",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
requireHouseholdAdmin,
|
||||
availableItemsController.deleteAvailableItem
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/stores/:storeId/available-items/import-current",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
requireHouseholdAdmin,
|
||||
availableItemsController.importCurrentItems
|
||||
);
|
||||
|
||||
// Member management routes
|
||||
router.get(
|
||||
"/:householdId/members",
|
||||
|
||||
120
backend/tests/available-item.model.test.js
Normal file
120
backend/tests/available-item.model.test.js
Normal file
@ -0,0 +1,120 @@
|
||||
jest.mock("../db/pool", () => ({
|
||||
query: jest.fn(),
|
||||
}));
|
||||
|
||||
const pool = require("../db/pool");
|
||||
const AvailableItems = require("../models/available-item.model");
|
||||
|
||||
describe("available-item.model", () => {
|
||||
beforeEach(() => {
|
||||
pool.query.mockReset();
|
||||
});
|
||||
|
||||
test("creates an available item using an existing catalog item", async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [] })
|
||||
.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
item_id: 55,
|
||||
item_name: "milk",
|
||||
item_image: null,
|
||||
image_mime_type: null,
|
||||
item_type: "dairy",
|
||||
item_group: "Milk",
|
||||
zone: "Dairy & Refrigerated",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await AvailableItems.createAvailableItem(1, 2, "Milk");
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
item_id: 55,
|
||||
item_name: "milk",
|
||||
})
|
||||
);
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"SELECT id, name FROM items WHERE name ILIKE $1",
|
||||
["milk"]
|
||||
);
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining("INSERT INTO household_store_available_items"),
|
||||
[1, 2, 55, null, null]
|
||||
);
|
||||
});
|
||||
|
||||
test("creates an available item and inserts a new master item when needed", async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 77, name: "granola" }] })
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [] })
|
||||
.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [{ item_id: 77, item_name: "granola" }],
|
||||
});
|
||||
|
||||
const result = await AvailableItems.createAvailableItem(1, 2, "Granola");
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ item_id: 77, item_name: "granola" }));
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"INSERT INTO items (name) VALUES ($1) RETURNING id, name",
|
||||
["granola"]
|
||||
);
|
||||
});
|
||||
|
||||
test("updates available item images and returns refreshed data", async () => {
|
||||
const imageBuffer = Buffer.from("abc");
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ item_id: 55 }] })
|
||||
.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [{ item_id: 55, item_name: "milk", item_image: "YWJj", image_mime_type: "image/jpeg" }],
|
||||
});
|
||||
|
||||
const result = await AvailableItems.updateAvailableItem(1, 2, 55, {
|
||||
imageBuffer,
|
||||
mimeType: "image/jpeg",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ item_id: 55, image_mime_type: "image/jpeg" }));
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining("UPDATE household_store_available_items"),
|
||||
[1, 2, 55, imageBuffer, "image/jpeg"]
|
||||
);
|
||||
});
|
||||
|
||||
test("imports current household list items idempotently", async () => {
|
||||
pool.query.mockResolvedValueOnce({
|
||||
rowCount: 2,
|
||||
rows: [{ item_id: 10 }, { item_id: 11 }],
|
||||
});
|
||||
|
||||
const result = await AvailableItems.importCurrentListItems(1, 2);
|
||||
|
||||
expect(result).toBe(2);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("INSERT INTO household_store_available_items"),
|
||||
[1, 2]
|
||||
);
|
||||
});
|
||||
|
||||
test("deletes only the catalog entry", async () => {
|
||||
pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
||||
|
||||
const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("DELETE FROM household_store_available_items"),
|
||||
[1, 2, 55]
|
||||
);
|
||||
});
|
||||
});
|
||||
137
backend/tests/available-items.controller.test.js
Normal file
137
backend/tests/available-items.controller.test.js
Normal file
@ -0,0 +1,137 @@
|
||||
jest.mock("../models/available-item.model", () => ({
|
||||
createAvailableItem: jest.fn(),
|
||||
deleteAvailableItem: jest.fn(),
|
||||
getAvailableItemById: jest.fn(),
|
||||
importCurrentListItems: jest.fn(),
|
||||
listAvailableItems: jest.fn(),
|
||||
updateAvailableItem: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../models/list.model.v2", () => ({
|
||||
deleteClassification: jest.fn(),
|
||||
upsertClassification: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/logger", () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
const AvailableItems = require("../models/available-item.model");
|
||||
const List = require("../models/list.model.v2");
|
||||
const controller = require("../controllers/available-items.controller");
|
||||
|
||||
function createResponse() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("available-items.controller", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
AvailableItems.createAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
|
||||
AvailableItems.getAvailableItemById.mockResolvedValue({
|
||||
item_id: 99,
|
||||
item_name: "milk",
|
||||
item_type: "dairy",
|
||||
item_group: "Milk",
|
||||
zone: "Dairy & Refrigerated",
|
||||
});
|
||||
AvailableItems.updateAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
|
||||
AvailableItems.deleteAvailableItem.mockResolvedValue(true);
|
||||
AvailableItems.importCurrentListItems.mockResolvedValue(2);
|
||||
AvailableItems.listAvailableItems.mockResolvedValue([]);
|
||||
List.upsertClassification.mockResolvedValue(undefined);
|
||||
List.deleteClassification.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("creates an available item and persists classification metadata", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: {
|
||||
item_name: "milk",
|
||||
classification: JSON.stringify({
|
||||
item_type: "dairy",
|
||||
item_group: "Milk",
|
||||
zone: "Dairy & Refrigerated",
|
||||
}),
|
||||
},
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.createAvailableItem(req, res);
|
||||
|
||||
expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null);
|
||||
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||
"1",
|
||||
"2",
|
||||
99,
|
||||
expect.objectContaining({
|
||||
item_type: "dairy",
|
||||
item_group: "Milk",
|
||||
zone: "Dairy & Refrigerated",
|
||||
})
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(201);
|
||||
});
|
||||
|
||||
test("rejects invalid item_group values", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: {
|
||||
item_name: "milk",
|
||||
classification: JSON.stringify({
|
||||
item_type: "dairy",
|
||||
item_group: "Bread",
|
||||
}),
|
||||
},
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.createAvailableItem(req, res);
|
||||
|
||||
expect(AvailableItems.createAvailableItem).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Invalid item_group for selected item_type",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("clears classification on update when classification is explicitly empty", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2", itemId: "99" },
|
||||
body: {
|
||||
classification: "null",
|
||||
},
|
||||
processedImage: null,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.updateAvailableItem(req, res);
|
||||
|
||||
expect(List.deleteClassification).toHaveBeenCalledWith("1", "2", 99);
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
test("imports current list items and reports the import count", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.importCurrentItems(req, res);
|
||||
|
||||
expect(AvailableItems.importCurrentListItems).toHaveBeenCalledWith("1", "2");
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
imported_count: 2,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
109
backend/tests/available-items.routes.test.js
Normal file
109
backend/tests/available-items.routes.test.js
Normal file
@ -0,0 +1,109 @@
|
||||
jest.mock("../middleware/auth", () => (req, res, next) => {
|
||||
req.user = { id: 42, role: "user" };
|
||||
next();
|
||||
});
|
||||
|
||||
jest.mock("../middleware/household", () => ({
|
||||
householdAccess: (req, res, next) => {
|
||||
req.household = {
|
||||
id: Number.parseInt(req.params.householdId, 10),
|
||||
role: req.headers["x-household-role"] || "user",
|
||||
};
|
||||
next();
|
||||
},
|
||||
requireHouseholdAdmin: (req, res, next) => {
|
||||
if (["owner", "admin"].includes(req.household?.role)) {
|
||||
return next();
|
||||
}
|
||||
return res.status(403).json({
|
||||
error: { code: "FORBIDDEN", message: "Admin role required" },
|
||||
request_id: req.request_id,
|
||||
});
|
||||
},
|
||||
storeAccess: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock("../middleware/image", () => ({
|
||||
upload: {
|
||||
single: () => (req, res, next) => next(),
|
||||
},
|
||||
processImage: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock("../controllers/households.controller", () => ({
|
||||
createHousehold: jest.fn(),
|
||||
deleteHousehold: jest.fn(),
|
||||
getHousehold: jest.fn(),
|
||||
getMembers: jest.fn(),
|
||||
getUserHouseholds: jest.fn(),
|
||||
joinHousehold: jest.fn(),
|
||||
refreshInviteCode: jest.fn(),
|
||||
removeMember: jest.fn(),
|
||||
updateHousehold: jest.fn(),
|
||||
updateMemberRole: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../controllers/lists.controller.v2", () => ({
|
||||
addItem: jest.fn(),
|
||||
deleteItem: jest.fn(),
|
||||
getClassification: jest.fn(),
|
||||
getItemByName: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
getRecentlyBought: jest.fn(),
|
||||
getSuggestions: jest.fn(),
|
||||
markBought: jest.fn(),
|
||||
setClassification: jest.fn(),
|
||||
updateItem: jest.fn(),
|
||||
updateItemImage: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../controllers/available-items.controller", () => ({
|
||||
createAvailableItem: jest.fn((req, res) => res.status(201).json({ message: "created" })),
|
||||
deleteAvailableItem: jest.fn((req, res) => res.json({ message: "deleted" })),
|
||||
getAvailableItems: jest.fn((req, res) => res.json({ items: [] })),
|
||||
importCurrentItems: jest.fn((req, res) => res.json({ imported_count: 1 })),
|
||||
updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })),
|
||||
}));
|
||||
|
||||
const express = require("express");
|
||||
const request = require("supertest");
|
||||
const router = require("../routes/households.routes");
|
||||
const availableItemsController = require("../controllers/available-items.controller");
|
||||
|
||||
describe("available-items routes", () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/households", router);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("members can read available items", async () => {
|
||||
const response = await request(app).get("/households/1/stores/2/available-items");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(availableItemsController.getAvailableItems).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("members cannot mutate available items", async () => {
|
||||
const response = await request(app)
|
||||
.post("/households/1/stores/2/available-items")
|
||||
.set("x-household-role", "user")
|
||||
.send({ item_name: "milk" });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(availableItemsController.createAvailableItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("admins can create available items", async () => {
|
||||
const response = await request(app)
|
||||
.post("/households/1/stores/2/available-items")
|
||||
.set("x-household-role", "admin")
|
||||
.send({ item_name: "milk" });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
176
backend/tests/list.model.v2.test.js
Normal file
176
backend/tests/list.model.v2.test.js
Normal file
@ -0,0 +1,176 @@
|
||||
jest.mock("../db/pool", () => ({
|
||||
query: jest.fn(),
|
||||
}));
|
||||
|
||||
const pool = require("../db/pool");
|
||||
const List = require("../models/list.model.v2");
|
||||
|
||||
describe("list.model.v2 addOrUpdateItem", () => {
|
||||
beforeEach(() => {
|
||||
pool.query.mockReset();
|
||||
});
|
||||
|
||||
test("returns item metadata when creating a new household list item", async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] })
|
||||
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88 }] });
|
||||
|
||||
const result = await List.addOrUpdateItem(1, 2, "Milk", 3, 7);
|
||||
|
||||
expect(result).toEqual({
|
||||
listId: 88,
|
||||
itemId: 55,
|
||||
itemName: "milk",
|
||||
isNew: true,
|
||||
});
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"SELECT id FROM items WHERE name ILIKE $1",
|
||||
["milk"]
|
||||
);
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
||||
["milk"]
|
||||
);
|
||||
});
|
||||
|
||||
test("returns item metadata when updating an existing household list item", async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] })
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false }] })
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
||||
|
||||
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
|
||||
|
||||
expect(result).toEqual({
|
||||
listId: 88,
|
||||
itemId: 55,
|
||||
itemName: "milk",
|
||||
isNew: false,
|
||||
});
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.stringContaining("UPDATE household_lists"),
|
||||
[4, 88]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list.model.v2 classification helpers", () => {
|
||||
beforeEach(() => {
|
||||
pool.query.mockReset();
|
||||
});
|
||||
|
||||
test("gets classification using household, store, and item ids", async () => {
|
||||
pool.query.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
item_type: "dairy",
|
||||
item_group: "Milk",
|
||||
zone: "Dairy & Refrigerated",
|
||||
confidence: 1,
|
||||
source: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await List.getClassification(1, 2, 55);
|
||||
|
||||
expect(result).toEqual({
|
||||
item_type: "dairy",
|
||||
item_group: "Milk",
|
||||
zone: "Dairy & Refrigerated",
|
||||
confidence: 1,
|
||||
source: "user",
|
||||
});
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("WHERE household_id = $1 AND store_id = $2 AND item_id = $3"),
|
||||
[1, 2, 55]
|
||||
);
|
||||
});
|
||||
|
||||
test("upserts classification using store-scoped conflict target", async () => {
|
||||
pool.query.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
household_id: 1,
|
||||
store_id: 2,
|
||||
item_id: 55,
|
||||
item_type: "dairy",
|
||||
item_group: "Milk",
|
||||
zone: "Dairy & Refrigerated",
|
||||
confidence: 1,
|
||||
source: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await List.upsertClassification(1, 2, 55, {
|
||||
item_type: "dairy",
|
||||
item_group: "Milk",
|
||||
zone: "Dairy & Refrigerated",
|
||||
confidence: 1,
|
||||
source: "user",
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
household_id: 1,
|
||||
store_id: 2,
|
||||
item_id: 55,
|
||||
item_type: "dairy",
|
||||
})
|
||||
);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("ON CONFLICT (household_id, store_id, item_id)"),
|
||||
[1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list.model.v2 suggestions", () => {
|
||||
beforeEach(() => {
|
||||
pool.query.mockReset();
|
||||
});
|
||||
|
||||
test("returns catalog suggestions when a household-store catalog exists", async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ "?column?": 1 }] })
|
||||
.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [{ item_name: "milk", sort_order: 0 }],
|
||||
});
|
||||
|
||||
const result = await List.getSuggestions("mi", 1, 2);
|
||||
|
||||
expect(result).toEqual([{ item_name: "milk", sort_order: 0 }]);
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining("FROM household_store_available_items"),
|
||||
[1, 2]
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to legacy suggestions when catalog is empty", async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
||||
.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [{ item_name: "milk", sort_order: 1 }],
|
||||
});
|
||||
|
||||
const result = await List.getSuggestions("mi", 1, 2);
|
||||
|
||||
expect(result).toEqual([{ item_name: "milk", sort_order: 1 }]);
|
||||
expect(pool.query).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining("LEFT JOIN household_lists"),
|
||||
["%mi%", 1, 2]
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,17 +1,24 @@
|
||||
jest.mock("../models/list.model.v2", () => ({
|
||||
addHistoryRecord: jest.fn(),
|
||||
addOrUpdateItem: jest.fn(),
|
||||
getItemByName: jest.fn(),
|
||||
upsertClassification: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../models/household.model", () => ({
|
||||
isHouseholdMember: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../models/available-item.model", () => ({
|
||||
getAvailableItemImageByName: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/logger", () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
const List = require("../models/list.model.v2");
|
||||
const AvailableItems = require("../models/available-item.model");
|
||||
const householdModel = require("../models/household.model");
|
||||
const controller = require("../controllers/lists.controller.v2");
|
||||
|
||||
@ -24,12 +31,17 @@ function createResponse() {
|
||||
|
||||
describe("lists.controller.v2 addItem", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
List.addOrUpdateItem.mockResolvedValue({
|
||||
listId: 42,
|
||||
itemId: 99,
|
||||
itemName: "milk",
|
||||
isNew: true,
|
||||
});
|
||||
List.addHistoryRecord.mockResolvedValue(undefined);
|
||||
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
||||
List.upsertClassification.mockResolvedValue(undefined);
|
||||
AvailableItems.getAvailableItemImageByName.mockResolvedValue(null);
|
||||
householdModel.isHouseholdMember.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
@ -156,3 +168,193 @@ describe("lists.controller.v2 addItem", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lists.controller.v2 setClassification", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
||||
List.upsertClassification.mockResolvedValue(undefined);
|
||||
List.addOrUpdateItem.mockResolvedValue({
|
||||
listId: 42,
|
||||
itemId: 99,
|
||||
itemName: "milk",
|
||||
isNew: true,
|
||||
});
|
||||
AvailableItems.getAvailableItemImageByName.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
test("accepts object classification with type, group, and zone", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: {
|
||||
item_name: "milk",
|
||||
classification: {
|
||||
item_type: "dairy",
|
||||
item_group: "Milk",
|
||||
zone: "Dairy & Refrigerated",
|
||||
},
|
||||
},
|
||||
user: { id: 7 },
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.setClassification(req, res);
|
||||
|
||||
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||
"1",
|
||||
"2",
|
||||
99,
|
||||
expect.objectContaining({
|
||||
item_type: "dairy",
|
||||
item_group: "Milk",
|
||||
zone: "Dairy & Refrigerated",
|
||||
confidence: 1.0,
|
||||
source: "user",
|
||||
})
|
||||
);
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Classification set",
|
||||
classification: {
|
||||
item_type: "dairy",
|
||||
item_group: "Milk",
|
||||
zone: "Dairy & Refrigerated",
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts zone-only classification updates", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: {
|
||||
item_name: "milk",
|
||||
classification: {
|
||||
zone: "Checkout Area",
|
||||
},
|
||||
},
|
||||
user: { id: 7 },
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.setClassification(req, res);
|
||||
|
||||
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||
"1",
|
||||
"2",
|
||||
99,
|
||||
expect.objectContaining({
|
||||
item_type: null,
|
||||
item_group: null,
|
||||
zone: "Checkout Area",
|
||||
})
|
||||
);
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
test("rejects invalid item_type", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: {
|
||||
item_name: "milk",
|
||||
classification: {
|
||||
item_type: "invalid-type",
|
||||
},
|
||||
},
|
||||
user: { id: 7 },
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.setClassification(req, res);
|
||||
|
||||
expect(List.upsertClassification).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Invalid item_type",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid item_group for selected item_type", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: {
|
||||
item_name: "milk",
|
||||
classification: {
|
||||
item_type: "dairy",
|
||||
item_group: "Bread",
|
||||
},
|
||||
},
|
||||
user: { id: 7 },
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.setClassification(req, res);
|
||||
|
||||
expect(List.upsertClassification).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Invalid item_group for selected item_type",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid zone", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: {
|
||||
item_name: "milk",
|
||||
classification: {
|
||||
zone: "Space Aisle",
|
||||
},
|
||||
},
|
||||
user: { id: 7 },
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.setClassification(req, res);
|
||||
|
||||
expect(List.upsertClassification).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Invalid zone",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts legacy string classification values", async () => {
|
||||
const req = {
|
||||
params: { householdId: "1", storeId: "2" },
|
||||
body: {
|
||||
item_name: "milk",
|
||||
classification: "beverages",
|
||||
},
|
||||
user: { id: 7 },
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.setClassification(req, res);
|
||||
|
||||
expect(List.upsertClassification).toHaveBeenCalledWith(
|
||||
"1",
|
||||
"2",
|
||||
99,
|
||||
expect.objectContaining({
|
||||
item_type: "beverage",
|
||||
item_group: null,
|
||||
zone: null,
|
||||
})
|
||||
);
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS household_store_available_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||
store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
custom_image BYTEA,
|
||||
custom_image_mime_type VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(household_id, store_id, item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_available_items_household_store
|
||||
ON household_store_available_items(household_id, store_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_available_items_item
|
||||
ON household_store_available_items(item_id);
|
||||
|
||||
COMMENT ON TABLE household_store_available_items IS 'Curated household-store item catalogs';
|
||||
COMMENT ON COLUMN household_store_available_items.custom_image IS 'Optional store-specific image override';
|
||||
|
||||
COMMIT;
|
||||
Loading…
Reference in New Issue
Block a user