diff --git a/backend/controllers/available-items.controller.js b/backend/controllers/available-items.controller.js new file mode 100644 index 0000000..c23734e --- /dev/null +++ b/backend/controllers/available-items.controller.js @@ -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"); + } +}; diff --git a/backend/controllers/lists.controller.v2.js b/backend/controllers/lists.controller.v2.js index 31c6a62..6053dfb 100644 --- a/backend/controllers/lists.controller.v2.js +++ b/backend/controllers/lists.controller.v2.js @@ -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"); diff --git a/backend/models/available-item.model.js b/backend/models/available-item.model.js new file mode 100644 index 0000000..764475f --- /dev/null +++ b/backend/models/available-item.model.js @@ -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; +}; diff --git a/backend/models/list.model.v2.js b/backend/models/list.model.v2.js index 9809c82..82e775d 100644 --- a/backend/models/list.model.v2.js +++ b/backend/models/list.model.v2.js @@ -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} 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} 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} 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} 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 diff --git a/backend/routes/households.routes.js b/backend/routes/households.routes.js index 25480bf..20aef5a 100644 --- a/backend/routes/households.routes.js +++ b/backend/routes/households.routes.js @@ -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", diff --git a/backend/tests/available-item.model.test.js b/backend/tests/available-item.model.test.js new file mode 100644 index 0000000..ae5533c --- /dev/null +++ b/backend/tests/available-item.model.test.js @@ -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] + ); + }); +}); diff --git a/backend/tests/available-items.controller.test.js b/backend/tests/available-items.controller.test.js new file mode 100644 index 0000000..1fc0252 --- /dev/null +++ b/backend/tests/available-items.controller.test.js @@ -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, + }) + ); + }); +}); diff --git a/backend/tests/available-items.routes.test.js b/backend/tests/available-items.routes.test.js new file mode 100644 index 0000000..05d5784 --- /dev/null +++ b/backend/tests/available-items.routes.test.js @@ -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(); + }); +}); diff --git a/backend/tests/list.model.v2.test.js b/backend/tests/list.model.v2.test.js new file mode 100644 index 0000000..925eb0c --- /dev/null +++ b/backend/tests/list.model.v2.test.js @@ -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] + ); + }); +}); diff --git a/backend/tests/lists.controller.v2.test.js b/backend/tests/lists.controller.v2.test.js index f955b42..5d6a927 100644 --- a/backend/tests/lists.controller.v2.test.js +++ b/backend/tests/lists.controller.v2.test.js @@ -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); + }); +}); diff --git a/packages/db/migrations/20260328_010000_add_household_store_available_items.sql b/packages/db/migrations/20260328_010000_add_household_store_available_items.sql new file mode 100644 index 0000000..be70b0a --- /dev/null +++ b/packages/db/migrations/20260328_010000_add_household_store_available_items.sql @@ -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;