diff --git a/backend/controllers/available-items.controller.js b/backend/controllers/available-items.controller.js index f06767c..74617a1 100644 --- a/backend/controllers/available-items.controller.js +++ b/backend/controllers/available-items.controller.js @@ -14,7 +14,7 @@ function parseBoolean(value) { } function isCatalogTableMissing(error) { - return error?.code === "42P01" && /household_store_available_items/i.test(error?.message || ""); + return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || ""); } function parseClassificationInput(value) { @@ -129,7 +129,7 @@ exports.getAvailableItems = async (req, res) => { return res.json({ items: [], catalog_ready: false, - message: "Store item catalog is unavailable until the latest database migration is applied.", + message: "Store item management is unavailable until the latest database migration is applied.", }); } logError(req, "availableItems.getAvailableItems", error); @@ -186,7 +186,7 @@ exports.createAvailableItem = async (req, res) => { return sendError( res, 503, - "Store item catalog is unavailable until the latest database migration is applied" + "Store item management is unavailable until the latest database migration is applied" ); } logError(req, "availableItems.createAvailableItem", error); @@ -256,7 +256,7 @@ exports.updateAvailableItem = async (req, res) => { return sendError( res, 503, - "Store item catalog is unavailable until the latest database migration is applied" + "Store item management is unavailable until the latest database migration is applied" ); } logError(req, "availableItems.updateAvailableItem", error); @@ -276,26 +276,23 @@ exports.deleteAvailableItem = async (req, res) => { return sendError(res, 400, "Item ID must be a positive integer"); } - const [deletedCatalogEntry, deletedClassification] = await Promise.all([ - AvailableItems.deleteAvailableItem(householdId, storeId, itemId), - List.deleteClassification(householdId, storeId, itemId), - ]); + const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId); - if (!deletedCatalogEntry && !deletedClassification) { - return sendError(res, 404, "Managed item settings not found"); + if (!deleted) { + return sendError(res, 404, "Store item not found"); } - res.json({ message: "Store item settings cleared" }); + res.json({ message: "Store item deleted" }); } catch (error) { if (isCatalogTableMissing(error)) { return sendError( res, 503, - "Store item catalog is unavailable until the latest database migration is applied" + "Store item management is unavailable until the latest database migration is applied" ); } logError(req, "availableItems.deleteAvailableItem", error); - sendError(res, 500, "Failed to remove available item"); + sendError(res, 500, "Failed to delete store item"); } }; @@ -313,7 +310,7 @@ exports.importCurrentItems = async (req, res) => { return sendError( res, 503, - "Store item catalog is unavailable until the latest database migration is applied" + "Store item management is unavailable until the latest database migration is applied" ); } logError(req, "availableItems.importCurrentItems", error); diff --git a/backend/controllers/lists.controller.v2.js b/backend/controllers/lists.controller.v2.js index 1d6cd0a..8295fdb 100644 --- a/backend/controllers/lists.controller.v2.js +++ b/backend/controllers/lists.controller.v2.js @@ -134,7 +134,7 @@ exports.addItem = async (req, res) => { ); // Add history record - await List.addHistoryRecord(result.listId, quantity || "1", historyUserId); + await List.addHistoryRecord(result.listId, result.householdStoreItemId, quantity || "1", historyUserId); res.json({ message: result.isNew ? "Item added" : "Item updated", @@ -342,16 +342,12 @@ exports.setClassification = async (req, res) => { if (!item) { // Item doesn't exist in list, need to get from items table or create - const itemResult = await List.addOrUpdateItem( + const itemResult = await List.ensureHouseholdStoreItem( householdId, storeId, - item_name, - "1", - req.user.id, - null, - null + item_name ); - itemId = itemResult.itemId; + itemId = itemResult.id; } else { itemId = item.item_id; } diff --git a/backend/models/available-item.model.js b/backend/models/available-item.model.js index f9cf29a..0d4c7d6 100644 --- a/backend/models/available-item.model.js +++ b/backend/models/available-item.model.js @@ -4,11 +4,52 @@ function normalizeItemName(itemName) { return String(itemName || "").trim().toLowerCase(); } -async function findOrCreateItem(itemName) { +async function getHouseholdStoreItemRecord(householdId, storeId, itemId) { + const result = await pool.query( + `WITH latest_list_items AS ( + SELECT DISTINCT ON (hl.household_store_item_id) + hl.household_store_item_id, + hl.custom_image, + hl.custom_image_mime_type, + hl.modified_on, + hl.id + FROM household_lists hl + WHERE hl.household_id = $1 + AND hl.store_id = $2 + ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC + ) + SELECT + hsi.id AS item_id, + hsi.name AS item_name, + ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image, + COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type, + hic.item_type, + hic.item_group, + hic.zone + FROM household_store_items hsi + LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id + LEFT JOIN household_item_classifications hic + ON hic.household_id = hsi.household_id + AND hic.store_id = hsi.store_id + AND hic.household_store_item_id = hsi.id + WHERE hsi.household_id = $1 + AND hsi.store_id = $2 + AND hsi.id = $3`, + [householdId, storeId, itemId] + ); + + return result.rows[0] || null; +} + +async function findOrCreateHouseholdStoreItem(householdId, storeId, itemName) { const normalizedName = normalizeItemName(itemName); const existing = await pool.query( - "SELECT id, name FROM items WHERE name ILIKE $1", - [normalizedName] + `SELECT id, name + FROM household_store_items + WHERE household_id = $1 + AND store_id = $2 + AND normalized_name = $3`, + [householdId, storeId, normalizedName] ); if (existing.rowCount > 0) { @@ -19,8 +60,11 @@ async function findOrCreateItem(itemName) { } const created = await pool.query( - "INSERT INTO items (name) VALUES ($1) RETURNING id, name", - [normalizedName] + `INSERT INTO household_store_items + (household_id, store_id, name, normalized_name, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + RETURNING id, name`, + [householdId, storeId, normalizedName, normalizedName] ); return { @@ -29,60 +73,6 @@ async function findOrCreateItem(itemName) { }; } -async function getAvailableItemRecord(householdId, storeId, itemId) { - const result = await pool.query( - `WITH manageable_items AS ( - SELECT DISTINCT hl.item_id - FROM household_lists hl - WHERE hl.household_id = $1 - AND hl.store_id = $2 - UNION - SELECT hsai.item_id - FROM household_store_available_items hsai - WHERE hsai.household_id = $1 - AND hsai.store_id = $2 - ), - latest_list_items AS ( - SELECT DISTINCT ON (hl.item_id) - hl.item_id, - hl.custom_image, - hl.custom_image_mime_type, - hl.modified_on, - hl.id - FROM household_lists hl - WHERE hl.household_id = $1 - AND hl.store_id = $2 - ORDER BY hl.item_id, hl.modified_on DESC NULLS LAST, hl.id DESC - ) - SELECT - mi.item_id, - i.name AS item_name, - ENCODE(COALESCE(hsai.custom_image, lli.custom_image), 'base64') AS item_image, - COALESCE(hsai.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type, - hic.item_type, - hic.item_group, - hic.zone, - hsai.created_at, - hsai.updated_at, - (hsai.item_id IS NOT NULL OR hic.item_id IS NOT NULL) AS has_managed_settings - FROM manageable_items mi - JOIN items i ON i.id = mi.item_id - LEFT JOIN latest_list_items lli ON lli.item_id = mi.item_id - LEFT JOIN household_store_available_items hsai - ON hsai.household_id = $1 - AND hsai.store_id = $2 - AND hsai.item_id = mi.item_id - LEFT JOIN household_item_classifications hic - ON hic.household_id = $1 - AND hic.store_id = $2 - AND hic.item_id = mi.item_id - WHERE mi.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]; @@ -90,24 +80,13 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => { if (trimmedQuery) { values.push(`%${trimmedQuery}%`); - filterClause = "WHERE i.name ILIKE $3"; + filterClause = "AND hsi.name ILIKE $3"; } const result = await pool.query( - `WITH manageable_items AS ( - SELECT DISTINCT hl.item_id - FROM household_lists hl - WHERE hl.household_id = $1 - AND hl.store_id = $2 - UNION - SELECT hsai.item_id - FROM household_store_available_items hsai - WHERE hsai.household_id = $1 - AND hsai.store_id = $2 - ), - latest_list_items AS ( - SELECT DISTINCT ON (hl.item_id) - hl.item_id, + `WITH latest_list_items AS ( + SELECT DISTINCT ON (hl.household_store_item_id) + hl.household_store_item_id, hl.custom_image, hl.custom_image_mime_type, hl.modified_on, @@ -115,32 +94,30 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => { FROM household_lists hl WHERE hl.household_id = $1 AND hl.store_id = $2 - ORDER BY hl.item_id, hl.modified_on DESC NULLS LAST, hl.id DESC + ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC ) SELECT - mi.item_id, - i.name AS item_name, - ENCODE(COALESCE(hsai.custom_image, lli.custom_image), 'base64') AS item_image, - COALESCE(hsai.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type, + hsi.id AS item_id, + hsi.name AS item_name, + ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image, + COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type, hic.item_type, hic.item_group, hic.zone, - hsai.created_at, - hsai.updated_at, - (hsai.item_id IS NOT NULL OR hic.item_id IS NOT NULL) AS has_managed_settings - FROM manageable_items mi - JOIN items i ON i.id = mi.item_id - LEFT JOIN latest_list_items lli ON lli.item_id = mi.item_id - LEFT JOIN household_store_available_items hsai - ON hsai.household_id = $1 - AND hsai.store_id = $2 - AND hsai.item_id = mi.item_id + ( + hsi.custom_image IS NOT NULL + OR hic.household_store_item_id IS NOT NULL + ) AS has_managed_settings + FROM household_store_items hsi + LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id LEFT JOIN household_item_classifications hic - ON hic.household_id = $1 - AND hic.store_id = $2 - AND hic.item_id = mi.item_id - ${filterClause} - ORDER BY i.name ASC + ON hic.household_id = hsi.household_id + AND hic.store_id = hsi.store_id + AND hic.household_store_item_id = hsi.id + WHERE hsi.household_id = $1 + AND hsi.store_id = $2 + ${filterClause} + ORDER BY hsi.name ASC LIMIT 100`, values ); @@ -149,21 +126,20 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => { }; exports.getAvailableItemById = async (householdId, storeId, itemId) => - getAvailableItemRecord(householdId, storeId, itemId); + getHouseholdStoreItemRecord(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`, + id AS item_id, + name AS item_name, + custom_image, + custom_image_mime_type + FROM household_store_items + WHERE household_id = $1 + AND store_id = $2 + AND normalized_name = $3`, [householdId, storeId, normalizedName] ); @@ -177,16 +153,22 @@ exports.createAvailableItem = async ( imageBuffer = null, mimeType = null ) => { - const { itemId } = await findOrCreateItem(itemName); + const { itemId } = await findOrCreateHouseholdStoreItem(householdId, storeId, 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] - ); + if (imageBuffer && mimeType) { + await pool.query( + `UPDATE household_store_items + SET custom_image = $1, + custom_image_mime_type = $2, + updated_at = NOW() + WHERE id = $3 + AND household_id = $4 + AND store_id = $5`, + [imageBuffer, mimeType, itemId, householdId, storeId] + ); + } - return getAvailableItemRecord(householdId, storeId, itemId); + return getHouseholdStoreItemRecord(householdId, storeId, itemId); }; exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => { @@ -197,53 +179,19 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) removeImage = false, } = updates; - const existing = await pool.query( - `SELECT item_id - FROM household_store_available_items - WHERE household_id = $1 - AND store_id = $2 - AND item_id = $3`, - [householdId, storeId, itemId] - ); - const assignments = ["updated_at = NOW()"]; const values = [householdId, storeId, itemId]; let parameterIndex = values.length; - let targetItemId = itemId; if (itemName !== undefined && String(itemName).trim() !== "") { - const { itemId: nextItemId } = await findOrCreateItem(itemName); - targetItemId = nextItemId; + const normalizedName = normalizeItemName(itemName); parameterIndex += 1; - assignments.push(`item_id = $${parameterIndex}`); - values.push(nextItemId); - } + assignments.push(`name = $${parameterIndex}`); + values.push(normalizedName); - if (existing.rowCount === 0) { - if (imageBuffer && mimeType) { - 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()) - ON CONFLICT (household_id, store_id, item_id) - DO UPDATE SET - custom_image = EXCLUDED.custom_image, - custom_image_mime_type = EXCLUDED.custom_image_mime_type, - updated_at = NOW()`, - [householdId, storeId, targetItemId, imageBuffer, mimeType] - ); - } else if (targetItemId !== itemId) { - await pool.query( - `INSERT INTO household_store_available_items - (household_id, store_id, item_id, updated_at) - VALUES ($1, $2, $3, NOW()) - ON CONFLICT (household_id, store_id, item_id) - DO UPDATE SET updated_at = NOW()`, - [householdId, storeId, targetItemId] - ); - } - - return getAvailableItemRecord(householdId, storeId, targetItemId); + parameterIndex += 1; + assignments.push(`normalized_name = $${parameterIndex}`); + values.push(normalizedName); } if (removeImage) { @@ -259,12 +207,12 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) } const result = await pool.query( - `UPDATE household_store_available_items + `UPDATE household_store_items SET ${assignments.join(", ")} WHERE household_id = $1 AND store_id = $2 - AND item_id = $3 - RETURNING item_id`, + AND id = $3 + RETURNING id`, values ); @@ -272,15 +220,15 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) return null; } - return getAvailableItemRecord(householdId, storeId, result.rows[0].item_id); + return getHouseholdStoreItemRecord(householdId, storeId, result.rows[0].id); }; exports.deleteAvailableItem = async (householdId, storeId, itemId) => { const result = await pool.query( - `DELETE FROM household_store_available_items + `DELETE FROM household_store_items WHERE household_id = $1 AND store_id = $2 - AND item_id = $3`, + AND id = $3`, [householdId, storeId, itemId] ); @@ -289,20 +237,22 @@ exports.deleteAvailableItem = async (householdId, storeId, itemId) => { 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 + `INSERT INTO household_store_items + (household_id, store_id, name, normalized_name, custom_image, custom_image_mime_type, updated_at) + SELECT DISTINCT ON (hl.household_store_item_id) hl.household_id, hl.store_id, - hl.item_id, - hl.custom_image, - hl.custom_image_mime_type, + hsi.name, + hsi.normalized_name, + hsi.custom_image, + hsi.custom_image_mime_type, NOW() FROM household_lists hl + JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id WHERE hl.household_id = $1 AND hl.store_id = $2 - ON CONFLICT (household_id, store_id, item_id) DO NOTHING - RETURNING item_id`, + ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING + RETURNING id`, [householdId, storeId] ); @@ -312,7 +262,7 @@ exports.importCurrentListItems = async (householdId, storeId) => { exports.hasAvailableItems = async (householdId, storeId) => { const result = await pool.query( `SELECT 1 - FROM household_store_available_items + FROM household_store_items WHERE household_id = $1 AND store_id = $2 LIMIT 1`, diff --git a/backend/models/list.model.v2.js b/backend/models/list.model.v2.js index 03a2cd1..7f513bb 100644 --- a/backend/models/list.model.v2.js +++ b/backend/models/list.model.v2.js @@ -1,5 +1,41 @@ const pool = require("../db/pool"); +function normalizeItemName(itemName) { + return String(itemName || "").trim().toLowerCase(); +} + +async function getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName) { + const result = await pool.query( + `SELECT id, name, normalized_name, custom_image, custom_image_mime_type + FROM household_store_items + WHERE household_id = $1 + AND store_id = $2 + AND normalized_name = $3`, + [householdId, storeId, normalizedName] + ); + + return result.rows[0] || null; +} + +exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => { + const normalizedName = normalizeItemName(itemName); + let item = await getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName); + + if (item) { + return item; + } + + const result = await pool.query( + `INSERT INTO household_store_items + (household_id, store_id, name, normalized_name, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + RETURNING id, name, normalized_name, custom_image, custom_image_mime_type`, + [householdId, storeId, normalizedName, normalizedName] + ); + + return result.rows[0]; +}; + /** * Get list items for a specific household and store * @param {number} householdId - Household ID @@ -9,14 +45,15 @@ const pool = require("../db/pool"); */ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => { const result = await pool.query( - `SELECT + `SELECT hl.id, - hl.item_id, - i.name AS item_name, + hl.household_store_item_id AS item_id, + hl.household_store_item_id, + hsi.name AS item_name, hl.quantity, hl.bought, - ENCODE(hl.custom_image, 'base64') as item_image, - hl.custom_image_mime_type as image_mime_type, + ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, + COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, ${includeHistory ? ` ( SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) @@ -27,20 +64,20 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr JOIN users u ON hlh.added_by = u.id WHERE hlh.household_list_id = hl.id ) added_by_labels - ) as added_by_users, - ` : 'NULL as added_by_users,'} - hl.modified_on as last_added_on, + ) AS added_by_users, + ` : "NULL AS added_by_users,"} + hl.modified_on AS last_added_on, hic.item_type, hic.item_group, hic.zone FROM household_lists hl - 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 + JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_classifications hic + ON hic.household_id = hl.household_id + AND hic.store_id = hl.store_id + AND hic.household_store_item_id = hl.household_store_item_id + WHERE hl.household_id = $1 + AND hl.store_id = $2 AND hl.bought = FALSE ORDER BY hl.id ASC`, [householdId, storeId] @@ -56,26 +93,17 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr * @returns {Promise} Item or null */ exports.getItemByName = async (householdId, storeId, itemName) => { - // First check if item exists in master catalog - const itemResult = await pool.query( - "SELECT id FROM items WHERE name ILIKE $1", - [itemName] - ); - - if (itemResult.rowCount === 0) { - return null; - } - - const itemId = itemResult.rows[0].id; + const normalizedName = normalizeItemName(itemName); const result = await pool.query( - `SELECT + `SELECT hl.id, - hl.item_id, - i.name AS item_name, + hl.household_store_item_id AS item_id, + hl.household_store_item_id, + hsi.name AS item_name, hl.quantity, hl.bought, - ENCODE(hl.custom_image, 'base64') as item_image, - hl.custom_image_mime_type as image_mime_type, + ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, + COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, ( SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) FROM ( @@ -85,35 +113,28 @@ exports.getItemByName = async (householdId, storeId, itemName) => { JOIN users u ON hlh.added_by = u.id WHERE hlh.household_list_id = hl.id ) added_by_labels - ) as added_by_users, - hl.modified_on as last_added_on, + ) AS added_by_users, + hl.modified_on AS last_added_on, hic.item_type, hic.item_group, hic.zone FROM household_lists hl - 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 - AND hl.item_id = $3`, - [householdId, storeId, itemId] + JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_classifications hic + ON hic.household_id = hl.household_id + AND hic.store_id = hl.store_id + AND hic.household_store_item_id = hl.household_store_item_id + WHERE hl.household_id = $1 + AND hl.store_id = $2 + AND hsi.normalized_name = $3`, + [householdId, storeId, normalizedName] ); return result.rows[0] || null; }; /** * Add or update an item in household list - * @param {number} householdId - Household ID - * @param {number} storeId - Store ID - * @param {string} itemName - Item name - * @param {number} quantity - Quantity - * @param {number} userId - User adding the item - * @param {Buffer|null} imageBuffer - Image buffer - * @param {string|null} mimeType - MIME type - * @returns {Promise<{listId: number, itemId: number, itemName: string, isNew: boolean}>} Item metadata + * @returns {Promise<{listId:number,itemId:number,householdStoreItemId:number,itemName:string,isNew:boolean}>} */ exports.addOrUpdateItem = async ( householdId, @@ -124,38 +145,22 @@ exports.addOrUpdateItem = async ( imageBuffer = null, mimeType = null ) => { - const lowerItemName = itemName.toLowerCase(); - - let itemResult = await pool.query( - "SELECT id FROM items WHERE name ILIKE $1", - [lowerItemName] - ); - - let itemId; - if (itemResult.rowCount === 0) { - const insertItem = await pool.query( - "INSERT INTO items (name) VALUES ($1) RETURNING id", - [lowerItemName] - ); - itemId = insertItem.rows[0].id; - } else { - itemId = itemResult.rows[0].id; - } - + const householdStoreItem = await exports.ensureHouseholdStoreItem(householdId, storeId, itemName); const listResult = await pool.query( - `SELECT id, bought FROM household_lists - WHERE household_id = $1 - AND store_id = $2 - AND item_id = $3`, - [householdId, storeId, itemId] + `SELECT id, bought + FROM household_lists + WHERE household_id = $1 + AND store_id = $2 + AND household_store_item_id = $3`, + [householdId, storeId, householdStoreItem.id] ); if (listResult.rowCount > 0) { const listId = listResult.rows[0].id; if (imageBuffer && mimeType) { await pool.query( - `UPDATE household_lists - SET quantity = $1, + `UPDATE household_lists + SET quantity = $1, bought = FALSE, custom_image = $2, custom_image_mime_type = $3, @@ -165,46 +170,43 @@ exports.addOrUpdateItem = async ( ); } else { await pool.query( - `UPDATE household_lists - SET quantity = $1, + `UPDATE household_lists + SET quantity = $1, bought = FALSE, modified_on = NOW() WHERE id = $2`, [quantity, listId] ); } + return { listId, - itemId, - itemName: lowerItemName, + itemId: householdStoreItem.id, + householdStoreItemId: householdStoreItem.id, + itemName: householdStoreItem.name, isNew: false, }; - } else { - const insert = await pool.query( - `INSERT INTO household_lists - (household_id, store_id, item_id, quantity, custom_image, custom_image_mime_type) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id`, - [householdId, storeId, itemId, quantity, imageBuffer, mimeType] - ); - return { - listId: insert.rows[0].id, - itemId, - itemName: lowerItemName, - isNew: true, - }; } + + const insert = await pool.query( + `INSERT INTO household_lists + (household_id, store_id, household_store_item_id, quantity, custom_image, custom_image_mime_type, added_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id`, + [householdId, storeId, householdStoreItem.id, quantity, imageBuffer, mimeType, userId] + ); + + return { + listId: insert.rows[0].id, + itemId: householdStoreItem.id, + householdStoreItemId: householdStoreItem.id, + itemName: householdStoreItem.name, + isNew: true, + }; }; -/** - * Mark item as bought (full or partial) - * @param {number} listId - List item ID - * @param {boolean} bought - True to mark as bought, false to unmark - * @param {number} quantityBought - Optional quantity bought (for partial purchases) - */ exports.setBought = async (listId, bought, quantityBought = null) => { if (bought === false) { - // Unmarking - just set bought to false await pool.query( "UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1", [listId] @@ -212,9 +214,7 @@ exports.setBought = async (listId, bought, quantityBought = null) => { return; } - // Marking as bought if (quantityBought && quantityBought > 0) { - // Partial purchase - reduce quantity const item = await pool.query( "SELECT quantity FROM household_lists WHERE id = $1", [listId] @@ -226,20 +226,17 @@ exports.setBought = async (listId, bought, quantityBought = null) => { const remainingQuantity = currentQuantity - quantityBought; if (remainingQuantity <= 0) { - // All bought - mark as bought await pool.query( "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", [listId] ); } else { - // Partial - reduce quantity await pool.query( "UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2", [remainingQuantity, listId] ); } } else { - // Full purchase - mark as bought await pool.query( "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", [listId] @@ -247,61 +244,45 @@ exports.setBought = async (listId, bought, quantityBought = null) => { } }; -/** - * Add history record for item addition - * @param {number} listId - List item ID - * @param {number} quantity - Quantity added - * @param {number} userId - User who added - */ -exports.addHistoryRecord = async (listId, quantity, userId) => { +exports.addHistoryRecord = async (listId, householdStoreItemId, quantity, userId) => { await pool.query( - `INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on) - VALUES ($1, $2, $3, NOW())`, - [listId, quantity, userId] + `INSERT INTO household_list_history (household_list_id, household_store_item_id, quantity, added_by, added_on) + VALUES ($1, $2, $3, $4, NOW())`, + [listId, householdStoreItemId, quantity, userId] ); }; -/** - * Get suggestions for autocomplete - * @param {string} query - Search query - * @param {number} householdId - Household ID (for personalized suggestions) - * @param {number} storeId - Store ID - * @returns {Promise} Suggestions - */ exports.getSuggestions = async (query, householdId, storeId) => { - // Get items from both master catalog and household history const result = await pool.query( - `SELECT DISTINCT - i.name as item_name, - CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END as sort_order - FROM items i - LEFT JOIN household_lists hl - ON i.id = hl.item_id + `SELECT DISTINCT + hsi.name AS item_name, + CASE WHEN hl.id IS NOT NULL AND hl.bought = FALSE THEN 0 ELSE 1 END AS sort_order + FROM household_store_items hsi + LEFT JOIN household_lists hl + ON hl.household_store_item_id = hsi.id AND hl.household_id = $2 AND hl.store_id = $3 - WHERE i.name ILIKE $1 - ORDER BY sort_order, i.name + WHERE hsi.household_id = $2 + AND hsi.store_id = $3 + AND hsi.name ILIKE $1 + ORDER BY sort_order, hsi.name LIMIT 10`, [`%${query}%`, householdId, storeId] ); return result.rows; }; -/** - * Get recently bought items for household/store - * @param {number} householdId - Household ID - * @param {number} storeId - Store ID - * @returns {Promise} Recently bought items - */ exports.getRecentlyBoughtItems = async (householdId, storeId) => { const result = await pool.query( - `SELECT + `SELECT hl.id, - i.name AS item_name, + hl.household_store_item_id AS item_id, + hl.household_store_item_id, + hsi.name AS item_name, hl.quantity, hl.bought, - ENCODE(hl.custom_image, 'base64') as item_image, - hl.custom_image_mime_type as image_mime_type, + ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, + COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, ( SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) FROM ( @@ -311,13 +292,13 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => { JOIN users u ON hlh.added_by = u.id WHERE hlh.household_list_id = hl.id ) added_by_labels - ) as added_by_users, - hl.modified_on as last_added_on + ) AS added_by_users, + hl.modified_on AS last_added_on FROM household_lists hl - JOIN items i ON hl.item_id = i.id - WHERE hl.household_id = $1 + JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + WHERE hl.household_id = $1 AND hl.store_id = $2 - AND hl.bought = TRUE + AND hl.bought = TRUE AND hl.modified_on >= NOW() - INTERVAL '24 hours' ORDER BY hl.modified_on DESC`, [householdId, storeId] @@ -325,40 +306,25 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => { return result.rows; }; -/** - * 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, storeId, itemId) => { const result = await pool.query( `SELECT item_type, item_group, zone, confidence, source FROM household_item_classifications - WHERE household_id = $1 AND store_id = $2 AND item_id = $3`, + WHERE household_id = $1 AND store_id = $2 AND household_store_item_id = $3`, [householdId, storeId, itemId] ); return result.rows[0] || null; }; -/** - * 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, storeId, itemId, classification) => { const { item_type, item_group, zone, confidence, source } = classification; const result = await pool.query( - `INSERT INTO household_item_classifications - (household_id, store_id, item_id, item_type, item_group, zone, confidence, source) + `INSERT INTO household_item_classifications + (household_id, store_id, household_store_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 + ON CONFLICT (household_id, store_id, household_store_item_id) + DO UPDATE SET item_type = EXCLUDED.item_type, item_group = EXCLUDED.item_group, zone = EXCLUDED.zone, @@ -370,73 +336,52 @@ exports.upsertClassification = async (householdId, storeId, itemId, classificati 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) => { const result = await pool.query( `DELETE FROM household_item_classifications WHERE household_id = $1 AND store_id = $2 - AND item_id = $3`, + AND household_store_item_id = $3`, [householdId, storeId, itemId] ); return result.rowCount > 0; }; -/** - * Update list item details - * @param {number} listId - List item ID - * @param {string} itemName - New item name (optional) - * @param {number} quantity - New quantity (optional) - * @param {string} notes - Notes (optional) - * @returns {Promise} Updated item - */ exports.updateItem = async (listId, itemName, quantity, notes) => { - // Build dynamic update query const updates = []; const values = [listId]; let paramCount = 1; if (quantity !== undefined) { - paramCount++; + paramCount += 1; updates.push(`quantity = $${paramCount}`); values.push(quantity); } if (notes !== undefined) { - paramCount++; + paramCount += 1; updates.push(`notes = $${paramCount}`); values.push(notes); } - // Always update modified_on - updates.push(`modified_on = NOW()`); + updates.push("modified_on = NOW()"); if (updates.length === 1) { - // Only modified_on update const result = await pool.query( - `UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *`, + "UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *", [listId] ); return result.rows[0]; } const result = await pool.query( - `UPDATE household_lists SET ${updates.join(', ')} WHERE id = $1 RETURNING *`, + `UPDATE household_lists SET ${updates.join(", ")} WHERE id = $1 RETURNING *`, values ); return result.rows[0]; }; -/** - * Delete a list item - * @param {number} listId - List item ID - */ exports.deleteItem = async (listId) => { await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]); }; diff --git a/backend/tests/available-item.model.test.js b/backend/tests/available-item.model.test.js index 9279ad5..50d0923 100644 --- a/backend/tests/available-item.model.test.js +++ b/backend/tests/available-item.model.test.js @@ -10,7 +10,7 @@ describe("available-item.model", () => { pool.query.mockReset(); }); - test("lists manageable items from household/store history even without stored overrides", async () => { + test("lists household store items", async () => { pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [ @@ -33,59 +33,18 @@ describe("available-item.model", () => { expect.objectContaining({ item_id: 55, item_name: "milk", - has_managed_settings: false, }), ]); expect(pool.query).toHaveBeenCalledWith( - expect.stringContaining("WITH manageable_items AS"), + expect.stringContaining("FROM household_store_items hsi"), [1, 2] ); }); - 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 () => { + test("creates a household store 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" }], @@ -96,25 +55,18 @@ describe("available-item.model", () => { 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"] + expect.stringContaining("INSERT INTO household_store_items"), + [1, 2, "granola", "granola"] ); }); - test("updates available item images and returns refreshed data", async () => { + test("updates household store 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 }] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] }) .mockResolvedValueOnce({ rowCount: 1, - rows: [{ - item_id: 55, - item_name: "milk", - item_image: "YWJj", - image_mime_type: "image/jpeg", - has_managed_settings: true, - }], + rows: [{ item_id: 55, item_name: "milk", item_image: "YWJj", image_mime_type: "image/jpeg" }], }); const result = await AvailableItems.updateAvailableItem(1, 2, 55, { @@ -124,35 +76,20 @@ describe("available-item.model", () => { expect(result).toEqual(expect.objectContaining({ item_id: 55, image_mime_type: "image/jpeg" })); expect(pool.query).toHaveBeenNthCalledWith( - 2, - expect.stringContaining("UPDATE household_store_available_items"), + 1, + expect.stringContaining("UPDATE household_store_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 () => { + test("deletes the household store item", 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"), + expect.stringContaining("DELETE FROM household_store_items"), [1, 2, 55] ); }); diff --git a/backend/tests/available-items.controller.test.js b/backend/tests/available-items.controller.test.js index a93a587..7d7ce38 100644 --- a/backend/tests/available-items.controller.test.js +++ b/backend/tests/available-items.controller.test.js @@ -135,20 +135,17 @@ describe("available-items.controller", () => { ); }); - test("clears managed settings without removing the underlying item", async () => { + test("deletes a store item", async () => { const req = { params: { householdId: "1", storeId: "2", itemId: "99" }, }; const res = createResponse(); - AvailableItems.deleteAvailableItem.mockResolvedValueOnce(false); - List.deleteClassification.mockResolvedValueOnce(true); - await controller.deleteAvailableItem(req, res); expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99); - expect(List.deleteClassification).toHaveBeenCalledWith("1", "2", 99); - expect(res.json).toHaveBeenCalledWith({ message: "Store item settings cleared" }); + expect(List.deleteClassification).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" }); }); test("returns an empty catalog payload when the available items table is missing", async () => { @@ -160,7 +157,7 @@ describe("available-items.controller", () => { AvailableItems.listAvailableItems.mockRejectedValueOnce({ code: "42P01", - message: 'relation "household_store_available_items" does not exist', + message: 'relation "household_store_items" does not exist', }); await controller.getAvailableItems(req, res); @@ -185,7 +182,7 @@ describe("available-items.controller", () => { AvailableItems.createAvailableItem.mockRejectedValueOnce({ code: "42P01", - message: 'relation "household_store_available_items" does not exist', + message: 'relation "household_store_items" does not exist', }); await controller.createAvailableItem(req, res); diff --git a/backend/tests/list.model.v2.test.js b/backend/tests/list.model.v2.test.js index 53d4d11..59e4469 100644 --- a/backend/tests/list.model.v2.test.js +++ b/backend/tests/list.model.v2.test.js @@ -10,10 +10,10 @@ describe("list.model.v2 addOrUpdateItem", () => { pool.query.mockReset(); }); - test("returns item metadata when creating a new household list item", async () => { + test("returns household store item metadata when creating a new list item", async () => { pool.query .mockResolvedValueOnce({ rowCount: 0, rows: [] }) - .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] }) .mockResolvedValueOnce({ rowCount: 0, rows: [] }) .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88 }] }); @@ -22,24 +22,25 @@ describe("list.model.v2 addOrUpdateItem", () => { expect(result).toEqual({ listId: 88, itemId: 55, + householdStoreItemId: 55, itemName: "milk", isNew: true, }); expect(pool.query).toHaveBeenNthCalledWith( 1, - "SELECT id FROM items WHERE name ILIKE $1", - ["milk"] + expect.stringContaining("FROM household_store_items"), + [1, 2, "milk"] ); expect(pool.query).toHaveBeenNthCalledWith( 2, - "INSERT INTO items (name) VALUES ($1) RETURNING id", - ["milk"] + expect.stringContaining("INSERT INTO household_store_items"), + [1, 2, "milk", "milk"] ); }); - test("returns item metadata when updating an existing household list item", async () => { + test("returns household store item metadata when updating an existing list item", async () => { pool.query - .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] }) .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false }] }) .mockResolvedValueOnce({ rowCount: 1, rows: [] }); @@ -48,6 +49,7 @@ describe("list.model.v2 addOrUpdateItem", () => { expect(result).toEqual({ listId: 88, itemId: 55, + householdStoreItemId: 55, itemName: "milk", isNew: false, }); @@ -64,7 +66,7 @@ describe("list.model.v2 classification helpers", () => { pool.query.mockReset(); }); - test("gets classification using household, store, and item ids", async () => { + test("gets classification using household, store, and household-store item ids", async () => { pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [ @@ -88,19 +90,19 @@ describe("list.model.v2 classification helpers", () => { source: "user", }); expect(pool.query).toHaveBeenCalledWith( - expect.stringContaining("WHERE household_id = $1 AND store_id = $2 AND item_id = $3"), + expect.stringContaining("household_store_item_id = $3"), [1, 2, 55] ); }); - test("upserts classification using store-scoped conflict target", async () => { + test("upserts classification using household-store item conflict target", async () => { pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [ { household_id: 1, store_id: 2, - item_id: 55, + household_store_item_id: 55, item_type: "dairy", item_group: "Milk", zone: "Dairy & Refrigerated", @@ -122,12 +124,12 @@ describe("list.model.v2 classification helpers", () => { expect.objectContaining({ household_id: 1, store_id: 2, - item_id: 55, + household_store_item_id: 55, item_type: "dairy", }) ); expect(pool.query).toHaveBeenCalledWith( - expect.stringContaining("ON CONFLICT (household_id, store_id, item_id)"), + expect.stringContaining("ON CONFLICT (household_id, store_id, household_store_item_id)"), [1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"] ); }); diff --git a/backend/tests/lists.controller.v2.test.js b/backend/tests/lists.controller.v2.test.js index f267645..0fcdb03 100644 --- a/backend/tests/lists.controller.v2.test.js +++ b/backend/tests/lists.controller.v2.test.js @@ -1,6 +1,7 @@ jest.mock("../models/list.model.v2", () => ({ addHistoryRecord: jest.fn(), addOrUpdateItem: jest.fn(), + ensureHouseholdStoreItem: jest.fn(), getItemByName: jest.fn(), upsertClassification: jest.fn(), })); @@ -30,6 +31,7 @@ describe("lists.controller.v2 addItem", () => { List.addOrUpdateItem.mockResolvedValue({ listId: 42, itemId: 99, + householdStoreItemId: 99, itemName: "milk", isNew: true, }); @@ -52,7 +54,7 @@ describe("lists.controller.v2 addItem", () => { expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9); expect(List.addOrUpdateItem).toHaveBeenCalled(); - expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 9); + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 9); expect(res.status).not.toHaveBeenCalledWith(400); }); @@ -69,7 +71,7 @@ describe("lists.controller.v2 addItem", () => { expect(householdModel.isHouseholdMember).not.toHaveBeenCalled(); expect(List.addOrUpdateItem).toHaveBeenCalled(); - expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7); + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7); expect(res.status).not.toHaveBeenCalledWith(400); }); @@ -86,7 +88,7 @@ describe("lists.controller.v2 addItem", () => { expect(householdModel.isHouseholdMember).not.toHaveBeenCalled(); expect(List.addOrUpdateItem).toHaveBeenCalled(); - expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7); + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7); expect(res.status).not.toHaveBeenCalledWith(400); }); @@ -168,12 +170,7 @@ describe("lists.controller.v2 setClassification", () => { 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, - }); + List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" }); }); test("accepts object classification with type, group, and zone", async () => { @@ -350,4 +347,33 @@ describe("lists.controller.v2 setClassification", () => { ); expect(res.status).not.toHaveBeenCalledWith(400); }); + + test("creates a household store item when classification target is not yet on the list", async () => { + List.getItemByName.mockResolvedValueOnce(null); + + const req = { + params: { householdId: "1", storeId: "2" }, + body: { + item_name: "granola", + classification: { + zone: "Snacks & Candy", + }, + }, + user: { id: 7 }, + }; + const res = createResponse(); + + await controller.setClassification(req, res); + + expect(List.ensureHouseholdStoreItem).toHaveBeenCalledWith("1", "2", "granola"); + expect(List.upsertClassification).toHaveBeenCalledWith( + "1", + "2", + 99, + expect.objectContaining({ + zone: "Snacks & Candy", + }) + ); + expect(res.status).not.toHaveBeenCalledWith(400); + }); }); diff --git a/packages/db/migrations/20260329_010000_add_household_store_items.sql b/packages/db/migrations/20260329_010000_add_household_store_items.sql new file mode 100644 index 0000000..0ebb7f7 --- /dev/null +++ b/packages/db/migrations/20260329_010000_add_household_store_items.sql @@ -0,0 +1,199 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS household_store_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, + name VARCHAR(255) NOT NULL, + normalized_name VARCHAR(255) NOT NULL, + custom_image BYTEA, + custom_image_mime_type VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(household_id, store_id, normalized_name) +); + +CREATE INDEX IF NOT EXISTS idx_household_store_items_household_store + ON household_store_items(household_id, store_id); + +CREATE INDEX IF NOT EXISTS idx_household_store_items_lookup + ON household_store_items(household_id, store_id, normalized_name); + +COMMENT ON TABLE household_store_items IS 'Household + store owned item records used for list suggestions and management'; +COMMENT ON COLUMN household_store_items.normalized_name IS 'Lowercased trimmed item name used for exact household/store matching'; + +ALTER TABLE household_lists + ADD COLUMN IF NOT EXISTS household_store_item_id INTEGER; + +ALTER TABLE household_item_classifications + ADD COLUMN IF NOT EXISTS household_store_item_id INTEGER; + +ALTER TABLE household_list_history + ADD COLUMN IF NOT EXISTS household_store_item_id INTEGER; + +INSERT INTO household_store_items ( + household_id, + store_id, + name, + normalized_name, + created_at, + updated_at +) +SELECT DISTINCT + hl.household_id, + hl.store_id, + LOWER(TRIM(i.name)) AS name, + LOWER(TRIM(i.name)) AS normalized_name, + COALESCE(MIN(hl.modified_on) OVER (PARTITION BY hl.household_id, hl.store_id, LOWER(TRIM(i.name))), NOW()) AS created_at, + COALESCE(MAX(hl.modified_on) OVER (PARTITION BY hl.household_id, hl.store_id, LOWER(TRIM(i.name))), NOW()) AS updated_at +FROM household_lists hl +JOIN items i ON i.id = hl.item_id +ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING; + +DO $$ +BEGIN + IF to_regclass('public.household_store_available_items') IS NOT NULL THEN + INSERT INTO household_store_items ( + household_id, + store_id, + name, + normalized_name, + custom_image, + custom_image_mime_type, + created_at, + updated_at + ) + SELECT + hsai.household_id, + hsai.store_id, + LOWER(TRIM(i.name)) AS name, + LOWER(TRIM(i.name)) AS normalized_name, + hsai.custom_image, + hsai.custom_image_mime_type, + COALESCE(hsai.created_at, NOW()), + COALESCE(hsai.updated_at, NOW()) + FROM household_store_available_items hsai + JOIN items i ON i.id = hsai.item_id + ON CONFLICT (household_id, store_id, normalized_name) DO UPDATE + SET + custom_image = COALESCE(household_store_items.custom_image, EXCLUDED.custom_image), + custom_image_mime_type = COALESCE( + household_store_items.custom_image_mime_type, + EXCLUDED.custom_image_mime_type + ), + updated_at = GREATEST(household_store_items.updated_at, EXCLUDED.updated_at); + END IF; +END $$; + +UPDATE household_lists hl +SET household_store_item_id = hsi.id +FROM items i, + household_store_items hsi +WHERE hl.item_id = i.id + AND hsi.household_id = hl.household_id + AND hsi.store_id = hl.store_id + AND hsi.normalized_name = LOWER(TRIM(i.name)) + AND hl.household_store_item_id IS NULL; + +UPDATE household_item_classifications hic +SET household_store_item_id = hsi.id +FROM items i, + household_store_items hsi +WHERE hic.item_id = i.id + AND hsi.household_id = hic.household_id + AND hsi.store_id = hic.store_id + AND hsi.normalized_name = LOWER(TRIM(i.name)) + AND hic.household_store_item_id IS NULL; + +DELETE FROM household_list_history hlh +WHERE NOT EXISTS ( + SELECT 1 + FROM household_lists hl + WHERE hl.id = hlh.household_list_id +); + +UPDATE household_list_history hlh +SET household_store_item_id = hl.household_store_item_id +FROM household_lists hl +WHERE hlh.household_list_id = hl.id + AND hlh.household_store_item_id IS NULL; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM household_lists + WHERE household_store_item_id IS NULL + ) THEN + RAISE EXCEPTION 'Failed to backfill household_lists.household_store_item_id'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM household_item_classifications + WHERE household_store_item_id IS NULL + ) THEN + RAISE EXCEPTION 'Failed to backfill household_item_classifications.household_store_item_id'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM household_list_history + WHERE household_store_item_id IS NULL + ) THEN + RAISE EXCEPTION 'Failed to backfill household_list_history.household_store_item_id'; + END IF; +END $$; + +ALTER TABLE household_lists + ALTER COLUMN household_store_item_id SET NOT NULL; + +ALTER TABLE household_item_classifications + ALTER COLUMN household_store_item_id SET NOT NULL; + +ALTER TABLE household_list_history + ALTER COLUMN household_store_item_id SET NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'household_lists_household_store_item_id_fkey' + ) THEN + ALTER TABLE household_lists + ADD CONSTRAINT household_lists_household_store_item_id_fkey + FOREIGN KEY (household_store_item_id) REFERENCES household_store_items(id) ON DELETE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'household_item_classifications_household_store_item_id_fkey' + ) THEN + ALTER TABLE household_item_classifications + ADD CONSTRAINT household_item_classifications_household_store_item_id_fkey + FOREIGN KEY (household_store_item_id) REFERENCES household_store_items(id) ON DELETE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'household_list_history_household_store_item_id_fkey' + ) THEN + ALTER TABLE household_list_history + ADD CONSTRAINT household_list_history_household_store_item_id_fkey + FOREIGN KEY (household_store_item_id) REFERENCES household_store_items(id) ON DELETE CASCADE; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_household_lists_household_store_item + ON household_lists(household_id, store_id, household_store_item_id); + +CREATE INDEX IF NOT EXISTS idx_household_item_classifications_household_store_item + ON household_item_classifications(household_id, store_id, household_store_item_id); + +CREATE INDEX IF NOT EXISTS idx_household_list_history_household_store_item + ON household_list_history(household_store_item_id); + +COMMIT;