feat: add store catalog backend

This commit is contained in:
Nico 2026-03-28 22:35:34 -07:00
parent 77ae5be445
commit 86eebcc6f4
11 changed files with 1470 additions and 25 deletions

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

View File

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

View 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;
};

View File

@ -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

View File

@ -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",

View 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]
);
});
});

View 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,
})
);
});
});

View 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();
});
});

View 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]
);
});
});

View File

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

View File

@ -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;