chore: harden reliability checks #2

Merged
nalalangan merged 67 commits from main-new into main 2026-05-25 14:28:32 -09:00
9 changed files with 537 additions and 488 deletions
Showing only changes of commit 7c8c655cba - Show all commits

View File

@ -14,7 +14,7 @@ function parseBoolean(value) {
} }
function isCatalogTableMissing(error) { 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) { function parseClassificationInput(value) {
@ -129,7 +129,7 @@ exports.getAvailableItems = async (req, res) => {
return res.json({ return res.json({
items: [], items: [],
catalog_ready: false, 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); logError(req, "availableItems.getAvailableItems", error);
@ -186,7 +186,7 @@ exports.createAvailableItem = async (req, res) => {
return sendError( return sendError(
res, res,
503, 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); logError(req, "availableItems.createAvailableItem", error);
@ -256,7 +256,7 @@ exports.updateAvailableItem = async (req, res) => {
return sendError( return sendError(
res, res,
503, 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); 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"); return sendError(res, 400, "Item ID must be a positive integer");
} }
const [deletedCatalogEntry, deletedClassification] = await Promise.all([ const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId);
AvailableItems.deleteAvailableItem(householdId, storeId, itemId),
List.deleteClassification(householdId, storeId, itemId),
]);
if (!deletedCatalogEntry && !deletedClassification) { if (!deleted) {
return sendError(res, 404, "Managed item settings not found"); return sendError(res, 404, "Store item not found");
} }
res.json({ message: "Store item settings cleared" }); res.json({ message: "Store item deleted" });
} catch (error) { } catch (error) {
if (isCatalogTableMissing(error)) { if (isCatalogTableMissing(error)) {
return sendError( return sendError(
res, res,
503, 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); 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( return sendError(
res, res,
503, 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); logError(req, "availableItems.importCurrentItems", error);

View File

@ -134,7 +134,7 @@ exports.addItem = async (req, res) => {
); );
// Add history record // Add history record
await List.addHistoryRecord(result.listId, quantity || "1", historyUserId); await List.addHistoryRecord(result.listId, result.householdStoreItemId, quantity || "1", historyUserId);
res.json({ res.json({
message: result.isNew ? "Item added" : "Item updated", message: result.isNew ? "Item added" : "Item updated",
@ -342,16 +342,12 @@ exports.setClassification = async (req, res) => {
if (!item) { if (!item) {
// Item doesn't exist in list, need to get from items table or create // 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, householdId,
storeId, storeId,
item_name, item_name
"1",
req.user.id,
null,
null
); );
itemId = itemResult.itemId; itemId = itemResult.id;
} else { } else {
itemId = item.item_id; itemId = item.item_id;
} }

View File

@ -4,11 +4,52 @@ function normalizeItemName(itemName) {
return String(itemName || "").trim().toLowerCase(); 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 normalizedName = normalizeItemName(itemName);
const existing = await pool.query( const existing = await pool.query(
"SELECT id, name FROM items WHERE name ILIKE $1", `SELECT id, name
[normalizedName] FROM household_store_items
WHERE household_id = $1
AND store_id = $2
AND normalized_name = $3`,
[householdId, storeId, normalizedName]
); );
if (existing.rowCount > 0) { if (existing.rowCount > 0) {
@ -19,8 +60,11 @@ async function findOrCreateItem(itemName) {
} }
const created = await pool.query( const created = await pool.query(
"INSERT INTO items (name) VALUES ($1) RETURNING id, name", `INSERT INTO household_store_items
[normalizedName] (household_id, store_id, name, normalized_name, updated_at)
VALUES ($1, $2, $3, $4, NOW())
RETURNING id, name`,
[householdId, storeId, normalizedName, normalizedName]
); );
return { 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 = "") => { exports.listAvailableItems = async (householdId, storeId, query = "") => {
const trimmedQuery = String(query || "").trim(); const trimmedQuery = String(query || "").trim();
const values = [householdId, storeId]; const values = [householdId, storeId];
@ -90,24 +80,13 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => {
if (trimmedQuery) { if (trimmedQuery) {
values.push(`%${trimmedQuery}%`); values.push(`%${trimmedQuery}%`);
filterClause = "WHERE i.name ILIKE $3"; filterClause = "AND hsi.name ILIKE $3";
} }
const result = await pool.query( const result = await pool.query(
`WITH manageable_items AS ( `WITH latest_list_items AS (
SELECT DISTINCT hl.item_id SELECT DISTINCT ON (hl.household_store_item_id)
FROM household_lists hl hl.household_store_item_id,
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,
hl.custom_image_mime_type, hl.custom_image_mime_type,
hl.modified_on, hl.modified_on,
@ -115,32 +94,30 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => {
FROM household_lists hl FROM household_lists hl
WHERE hl.household_id = $1 WHERE hl.household_id = $1
AND hl.store_id = $2 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 SELECT
mi.item_id, hsi.id AS item_id,
i.name AS item_name, hsi.name AS item_name,
ENCODE(COALESCE(hsai.custom_image, lli.custom_image), 'base64') AS item_image, ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image,
COALESCE(hsai.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type, COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
hic.item_type, hic.item_type,
hic.item_group, hic.item_group,
hic.zone, hic.zone,
hsai.created_at, (
hsai.updated_at, hsi.custom_image IS NOT NULL
(hsai.item_id IS NOT NULL OR hic.item_id IS NOT NULL) AS has_managed_settings OR hic.household_store_item_id IS NOT NULL
FROM manageable_items mi ) AS has_managed_settings
JOIN items i ON i.id = mi.item_id FROM household_store_items hsi
LEFT JOIN latest_list_items lli ON lli.item_id = mi.item_id LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.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 LEFT JOIN household_item_classifications hic
ON hic.household_id = $1 ON hic.household_id = hsi.household_id
AND hic.store_id = $2 AND hic.store_id = hsi.store_id
AND hic.item_id = mi.item_id AND hic.household_store_item_id = hsi.id
WHERE hsi.household_id = $1
AND hsi.store_id = $2
${filterClause} ${filterClause}
ORDER BY i.name ASC ORDER BY hsi.name ASC
LIMIT 100`, LIMIT 100`,
values values
); );
@ -149,21 +126,20 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => {
}; };
exports.getAvailableItemById = async (householdId, storeId, itemId) => exports.getAvailableItemById = async (householdId, storeId, itemId) =>
getAvailableItemRecord(householdId, storeId, itemId); getHouseholdStoreItemRecord(householdId, storeId, itemId);
exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => { exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => {
const normalizedName = normalizeItemName(itemName); const normalizedName = normalizeItemName(itemName);
const result = await pool.query( const result = await pool.query(
`SELECT `SELECT
hsai.item_id, id AS item_id,
i.name AS item_name, name AS item_name,
hsai.custom_image, custom_image,
hsai.custom_image_mime_type custom_image_mime_type
FROM household_store_available_items hsai FROM household_store_items
JOIN items i ON i.id = hsai.item_id WHERE household_id = $1
WHERE hsai.household_id = $1 AND store_id = $2
AND hsai.store_id = $2 AND normalized_name = $3`,
AND i.name ILIKE $3`,
[householdId, storeId, normalizedName] [householdId, storeId, normalizedName]
); );
@ -177,16 +153,22 @@ exports.createAvailableItem = async (
imageBuffer = null, imageBuffer = null,
mimeType = null mimeType = null
) => { ) => {
const { itemId } = await findOrCreateItem(itemName); const { itemId } = await findOrCreateHouseholdStoreItem(householdId, storeId, itemName);
if (imageBuffer && mimeType) {
await pool.query( await pool.query(
`INSERT INTO household_store_available_items `UPDATE household_store_items
(household_id, store_id, item_id, custom_image, custom_image_mime_type, updated_at) SET custom_image = $1,
VALUES ($1, $2, $3, $4, $5, NOW())`, custom_image_mime_type = $2,
[householdId, storeId, itemId, imageBuffer, mimeType] 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 = {}) => { exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => {
@ -197,53 +179,19 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {})
removeImage = false, removeImage = false,
} = updates; } = 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 assignments = ["updated_at = NOW()"];
const values = [householdId, storeId, itemId]; const values = [householdId, storeId, itemId];
let parameterIndex = values.length; let parameterIndex = values.length;
let targetItemId = itemId;
if (itemName !== undefined && String(itemName).trim() !== "") { if (itemName !== undefined && String(itemName).trim() !== "") {
const { itemId: nextItemId } = await findOrCreateItem(itemName); const normalizedName = normalizeItemName(itemName);
targetItemId = nextItemId;
parameterIndex += 1; parameterIndex += 1;
assignments.push(`item_id = $${parameterIndex}`); assignments.push(`name = $${parameterIndex}`);
values.push(nextItemId); values.push(normalizedName);
}
if (existing.rowCount === 0) { parameterIndex += 1;
if (imageBuffer && mimeType) { assignments.push(`normalized_name = $${parameterIndex}`);
await pool.query( values.push(normalizedName);
`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);
} }
if (removeImage) { if (removeImage) {
@ -259,12 +207,12 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {})
} }
const result = await pool.query( const result = await pool.query(
`UPDATE household_store_available_items `UPDATE household_store_items
SET ${assignments.join(", ")} SET ${assignments.join(", ")}
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_id = $2
AND item_id = $3 AND id = $3
RETURNING item_id`, RETURNING id`,
values values
); );
@ -272,15 +220,15 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {})
return null; 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) => { exports.deleteAvailableItem = async (householdId, storeId, itemId) => {
const result = await pool.query( const result = await pool.query(
`DELETE FROM household_store_available_items `DELETE FROM household_store_items
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_id = $2
AND item_id = $3`, AND id = $3`,
[householdId, storeId, itemId] [householdId, storeId, itemId]
); );
@ -289,20 +237,22 @@ exports.deleteAvailableItem = async (householdId, storeId, itemId) => {
exports.importCurrentListItems = async (householdId, storeId) => { exports.importCurrentListItems = async (householdId, storeId) => {
const result = await pool.query( const result = await pool.query(
`INSERT INTO household_store_available_items `INSERT INTO household_store_items
(household_id, store_id, item_id, custom_image, custom_image_mime_type, updated_at) (household_id, store_id, name, normalized_name, custom_image, custom_image_mime_type, updated_at)
SELECT SELECT DISTINCT ON (hl.household_store_item_id)
hl.household_id, hl.household_id,
hl.store_id, hl.store_id,
hl.item_id, hsi.name,
hl.custom_image, hsi.normalized_name,
hl.custom_image_mime_type, hsi.custom_image,
hsi.custom_image_mime_type,
NOW() NOW()
FROM household_lists hl FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
WHERE hl.household_id = $1 WHERE hl.household_id = $1
AND hl.store_id = $2 AND hl.store_id = $2
ON CONFLICT (household_id, store_id, item_id) DO NOTHING ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING
RETURNING item_id`, RETURNING id`,
[householdId, storeId] [householdId, storeId]
); );
@ -312,7 +262,7 @@ exports.importCurrentListItems = async (householdId, storeId) => {
exports.hasAvailableItems = async (householdId, storeId) => { exports.hasAvailableItems = async (householdId, storeId) => {
const result = await pool.query( const result = await pool.query(
`SELECT 1 `SELECT 1
FROM household_store_available_items FROM household_store_items
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_id = $2
LIMIT 1`, LIMIT 1`,

View File

@ -1,5 +1,41 @@
const pool = require("../db/pool"); 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 * Get list items for a specific household and store
* @param {number} householdId - Household ID * @param {number} householdId - Household ID
@ -11,12 +47,13 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
const result = await pool.query( const result = await pool.query(
`SELECT `SELECT
hl.id, hl.id,
hl.item_id, hl.household_store_item_id AS item_id,
i.name AS item_name, hl.household_store_item_id,
hsi.name AS item_name,
hl.quantity, hl.quantity,
hl.bought, hl.bought,
ENCODE(hl.custom_image, 'base64') as item_image, ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
hl.custom_image_mime_type as image_mime_type, COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type,
${includeHistory ? ` ${includeHistory ? `
( (
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
@ -27,18 +64,18 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
JOIN users u ON hlh.added_by = u.id JOIN users u ON hlh.added_by = u.id
WHERE hlh.household_list_id = hl.id WHERE hlh.household_list_id = hl.id
) added_by_labels ) added_by_labels
) as added_by_users, ) AS added_by_users,
` : 'NULL as added_by_users,'} ` : "NULL AS added_by_users,"}
hl.modified_on as last_added_on, hl.modified_on AS last_added_on,
hic.item_type, hic.item_type,
hic.item_group, hic.item_group,
hic.zone hic.zone
FROM household_lists hl FROM household_lists hl
JOIN items i ON hl.item_id = i.id JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
LEFT JOIN household_item_classifications hic LEFT JOIN household_item_classifications hic
ON hl.household_id = hic.household_id ON hic.household_id = hl.household_id
AND hl.store_id = hic.store_id AND hic.store_id = hl.store_id
AND hl.item_id = hic.item_id AND hic.household_store_item_id = hl.household_store_item_id
WHERE hl.household_id = $1 WHERE hl.household_id = $1
AND hl.store_id = $2 AND hl.store_id = $2
AND hl.bought = FALSE AND hl.bought = FALSE
@ -56,26 +93,17 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
* @returns {Promise<Object|null>} Item or null * @returns {Promise<Object|null>} Item or null
*/ */
exports.getItemByName = async (householdId, storeId, itemName) => { exports.getItemByName = async (householdId, storeId, itemName) => {
// First check if item exists in master catalog const normalizedName = normalizeItemName(itemName);
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 result = await pool.query( const result = await pool.query(
`SELECT `SELECT
hl.id, hl.id,
hl.item_id, hl.household_store_item_id AS item_id,
i.name AS item_name, hl.household_store_item_id,
hsi.name AS item_name,
hl.quantity, hl.quantity,
hl.bought, hl.bought,
ENCODE(hl.custom_image, 'base64') as item_image, ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
hl.custom_image_mime_type as image_mime_type, 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) SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
FROM ( FROM (
@ -85,35 +113,28 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
JOIN users u ON hlh.added_by = u.id JOIN users u ON hlh.added_by = u.id
WHERE hlh.household_list_id = hl.id WHERE hlh.household_list_id = hl.id
) added_by_labels ) added_by_labels
) as added_by_users, ) AS added_by_users,
hl.modified_on as last_added_on, hl.modified_on AS last_added_on,
hic.item_type, hic.item_type,
hic.item_group, hic.item_group,
hic.zone hic.zone
FROM household_lists hl FROM household_lists hl
JOIN items i ON hl.item_id = i.id JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
LEFT JOIN household_item_classifications hic LEFT JOIN household_item_classifications hic
ON hl.household_id = hic.household_id ON hic.household_id = hl.household_id
AND hl.store_id = hic.store_id AND hic.store_id = hl.store_id
AND hl.item_id = hic.item_id AND hic.household_store_item_id = hl.household_store_item_id
WHERE hl.household_id = $1 WHERE hl.household_id = $1
AND hl.store_id = $2 AND hl.store_id = $2
AND hl.item_id = $3`, AND hsi.normalized_name = $3`,
[householdId, storeId, itemId] [householdId, storeId, normalizedName]
); );
return result.rows[0] || null; return result.rows[0] || null;
}; };
/** /**
* Add or update an item in household list * Add or update an item in household list
* @param {number} householdId - Household ID * @returns {Promise<{listId:number,itemId:number,householdStoreItemId:number,itemName:string,isNew:boolean}>}
* @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
*/ */
exports.addOrUpdateItem = async ( exports.addOrUpdateItem = async (
householdId, householdId,
@ -124,30 +145,14 @@ exports.addOrUpdateItem = async (
imageBuffer = null, imageBuffer = null,
mimeType = null mimeType = null
) => { ) => {
const lowerItemName = itemName.toLowerCase(); const householdStoreItem = await exports.ensureHouseholdStoreItem(householdId, storeId, itemName);
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 listResult = await pool.query( const listResult = await pool.query(
`SELECT id, bought FROM household_lists `SELECT id, bought
FROM household_lists
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_id = $2
AND item_id = $3`, AND household_store_item_id = $3`,
[householdId, storeId, itemId] [householdId, storeId, householdStoreItem.id]
); );
if (listResult.rowCount > 0) { if (listResult.rowCount > 0) {
@ -173,38 +178,35 @@ exports.addOrUpdateItem = async (
[quantity, listId] [quantity, listId]
); );
} }
return { return {
listId, listId,
itemId, itemId: householdStoreItem.id,
itemName: lowerItemName, householdStoreItemId: householdStoreItem.id,
itemName: householdStoreItem.name,
isNew: false, isNew: false,
}; };
} else { }
const insert = await pool.query( const insert = await pool.query(
`INSERT INTO household_lists `INSERT INTO household_lists
(household_id, store_id, item_id, quantity, custom_image, custom_image_mime_type) (household_id, store_id, household_store_item_id, quantity, custom_image, custom_image_mime_type, added_by)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`, RETURNING id`,
[householdId, storeId, itemId, quantity, imageBuffer, mimeType] [householdId, storeId, householdStoreItem.id, quantity, imageBuffer, mimeType, userId]
); );
return { return {
listId: insert.rows[0].id, listId: insert.rows[0].id,
itemId, itemId: householdStoreItem.id,
itemName: lowerItemName, householdStoreItemId: householdStoreItem.id,
itemName: householdStoreItem.name,
isNew: true, 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) => { exports.setBought = async (listId, bought, quantityBought = null) => {
if (bought === false) { if (bought === false) {
// Unmarking - just set bought to false
await pool.query( await pool.query(
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1", "UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
[listId] [listId]
@ -212,9 +214,7 @@ exports.setBought = async (listId, bought, quantityBought = null) => {
return; return;
} }
// Marking as bought
if (quantityBought && quantityBought > 0) { if (quantityBought && quantityBought > 0) {
// Partial purchase - reduce quantity
const item = await pool.query( const item = await pool.query(
"SELECT quantity FROM household_lists WHERE id = $1", "SELECT quantity FROM household_lists WHERE id = $1",
[listId] [listId]
@ -226,20 +226,17 @@ exports.setBought = async (listId, bought, quantityBought = null) => {
const remainingQuantity = currentQuantity - quantityBought; const remainingQuantity = currentQuantity - quantityBought;
if (remainingQuantity <= 0) { if (remainingQuantity <= 0) {
// All bought - mark as bought
await pool.query( await pool.query(
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[listId] [listId]
); );
} else { } else {
// Partial - reduce quantity
await pool.query( await pool.query(
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2", "UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
[remainingQuantity, listId] [remainingQuantity, listId]
); );
} }
} else { } else {
// Full purchase - mark as bought
await pool.query( await pool.query(
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[listId] [listId]
@ -247,61 +244,45 @@ exports.setBought = async (listId, bought, quantityBought = null) => {
} }
}; };
/** exports.addHistoryRecord = async (listId, householdStoreItemId, quantity, userId) => {
* 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) => {
await pool.query( await pool.query(
`INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on) `INSERT INTO household_list_history (household_list_id, household_store_item_id, quantity, added_by, added_on)
VALUES ($1, $2, $3, NOW())`, VALUES ($1, $2, $3, $4, NOW())`,
[listId, quantity, userId] [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<Array>} Suggestions
*/
exports.getSuggestions = async (query, householdId, storeId) => { exports.getSuggestions = async (query, householdId, storeId) => {
// Get items from both master catalog and household history
const result = await pool.query( const result = await pool.query(
`SELECT DISTINCT `SELECT DISTINCT
i.name as item_name, hsi.name AS item_name,
CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END as sort_order CASE WHEN hl.id IS NOT NULL AND hl.bought = FALSE THEN 0 ELSE 1 END AS sort_order
FROM items i FROM household_store_items hsi
LEFT JOIN household_lists hl LEFT JOIN household_lists hl
ON i.id = hl.item_id ON hl.household_store_item_id = hsi.id
AND hl.household_id = $2 AND hl.household_id = $2
AND hl.store_id = $3 AND hl.store_id = $3
WHERE i.name ILIKE $1 WHERE hsi.household_id = $2
ORDER BY sort_order, i.name AND hsi.store_id = $3
AND hsi.name ILIKE $1
ORDER BY sort_order, hsi.name
LIMIT 10`, LIMIT 10`,
[`%${query}%`, householdId, storeId] [`%${query}%`, householdId, storeId]
); );
return result.rows; return result.rows;
}; };
/**
* Get recently bought items for household/store
* @param {number} householdId - Household ID
* @param {number} storeId - Store ID
* @returns {Promise<Array>} Recently bought items
*/
exports.getRecentlyBoughtItems = async (householdId, storeId) => { exports.getRecentlyBoughtItems = async (householdId, storeId) => {
const result = await pool.query( const result = await pool.query(
`SELECT `SELECT
hl.id, 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.quantity,
hl.bought, hl.bought,
ENCODE(hl.custom_image, 'base64') as item_image, ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
hl.custom_image_mime_type as image_mime_type, 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) SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
FROM ( FROM (
@ -311,10 +292,10 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
JOIN users u ON hlh.added_by = u.id JOIN users u ON hlh.added_by = u.id
WHERE hlh.household_list_id = hl.id WHERE hlh.household_list_id = hl.id
) added_by_labels ) added_by_labels
) as added_by_users, ) AS added_by_users,
hl.modified_on as last_added_on hl.modified_on AS last_added_on
FROM household_lists hl FROM household_lists hl
JOIN items i ON hl.item_id = i.id JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
WHERE hl.household_id = $1 WHERE hl.household_id = $1
AND hl.store_id = $2 AND hl.store_id = $2
AND hl.bought = TRUE AND hl.bought = TRUE
@ -325,39 +306,24 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
return result.rows; 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<Object|null>} Classification or null
*/
exports.getClassification = async (householdId, storeId, itemId) => { exports.getClassification = async (householdId, storeId, itemId) => {
const result = await pool.query( const result = await pool.query(
`SELECT item_type, item_group, zone, confidence, source `SELECT item_type, item_group, zone, confidence, source
FROM household_item_classifications 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] [householdId, storeId, itemId]
); );
return result.rows[0] || null; 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<Object>} Updated classification
*/
exports.upsertClassification = async (householdId, storeId, itemId, classification) => { exports.upsertClassification = async (householdId, storeId, itemId, classification) => {
const { item_type, item_group, zone, confidence, source } = classification; const { item_type, item_group, zone, confidence, source } = classification;
const result = await pool.query( const result = await pool.query(
`INSERT INTO household_item_classifications `INSERT INTO household_item_classifications
(household_id, store_id, item_id, item_type, item_group, zone, confidence, source) (household_id, store_id, household_store_item_id, item_type, item_group, zone, confidence, source)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (household_id, store_id, item_id) ON CONFLICT (household_id, store_id, household_store_item_id)
DO UPDATE SET DO UPDATE SET
item_type = EXCLUDED.item_type, item_type = EXCLUDED.item_type,
item_group = EXCLUDED.item_group, item_group = EXCLUDED.item_group,
@ -370,73 +336,52 @@ exports.upsertClassification = async (householdId, storeId, itemId, classificati
return result.rows[0]; 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) => { exports.deleteClassification = async (householdId, storeId, itemId) => {
const result = await pool.query( const result = await pool.query(
`DELETE FROM household_item_classifications `DELETE FROM household_item_classifications
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_id = $2
AND item_id = $3`, AND household_store_item_id = $3`,
[householdId, storeId, itemId] [householdId, storeId, itemId]
); );
return result.rowCount > 0; 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<Object>} Updated item
*/
exports.updateItem = async (listId, itemName, quantity, notes) => { exports.updateItem = async (listId, itemName, quantity, notes) => {
// Build dynamic update query
const updates = []; const updates = [];
const values = [listId]; const values = [listId];
let paramCount = 1; let paramCount = 1;
if (quantity !== undefined) { if (quantity !== undefined) {
paramCount++; paramCount += 1;
updates.push(`quantity = $${paramCount}`); updates.push(`quantity = $${paramCount}`);
values.push(quantity); values.push(quantity);
} }
if (notes !== undefined) { if (notes !== undefined) {
paramCount++; paramCount += 1;
updates.push(`notes = $${paramCount}`); updates.push(`notes = $${paramCount}`);
values.push(notes); values.push(notes);
} }
// Always update modified_on updates.push("modified_on = NOW()");
updates.push(`modified_on = NOW()`);
if (updates.length === 1) { if (updates.length === 1) {
// Only modified_on update
const result = await pool.query( 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] [listId]
); );
return result.rows[0]; return result.rows[0];
} }
const result = await pool.query( 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 values
); );
return result.rows[0]; return result.rows[0];
}; };
/**
* Delete a list item
* @param {number} listId - List item ID
*/
exports.deleteItem = async (listId) => { exports.deleteItem = async (listId) => {
await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]); await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]);
}; };

View File

@ -10,7 +10,7 @@ describe("available-item.model", () => {
pool.query.mockReset(); 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({ pool.query.mockResolvedValueOnce({
rowCount: 1, rowCount: 1,
rows: [ rows: [
@ -33,59 +33,18 @@ describe("available-item.model", () => {
expect.objectContaining({ expect.objectContaining({
item_id: 55, item_id: 55,
item_name: "milk", item_name: "milk",
has_managed_settings: false,
}), }),
]); ]);
expect(pool.query).toHaveBeenCalledWith( expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("WITH manageable_items AS"), expect.stringContaining("FROM household_store_items hsi"),
[1, 2] [1, 2]
); );
}); });
test("creates an available item using an existing catalog item", async () => { test("creates a household store item when needed", 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 pool.query
.mockResolvedValueOnce({ rowCount: 0, rows: [] }) .mockResolvedValueOnce({ rowCount: 0, rows: [] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 77, name: "granola" }] }) .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 77, name: "granola" }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [] })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
rowCount: 1, rowCount: 1,
rows: [{ item_id: 77, item_name: "granola" }], 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(result).toEqual(expect.objectContaining({ item_id: 77, item_name: "granola" }));
expect(pool.query).toHaveBeenNthCalledWith( expect(pool.query).toHaveBeenNthCalledWith(
2, 2,
"INSERT INTO items (name) VALUES ($1) RETURNING id, name", expect.stringContaining("INSERT INTO household_store_items"),
["granola"] [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"); const imageBuffer = Buffer.from("abc");
pool.query pool.query
.mockResolvedValueOnce({ rowCount: 1, rows: [{ item_id: 55 }] }) .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ item_id: 55 }] })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
rowCount: 1, rowCount: 1,
rows: [{ rows: [{ item_id: 55, item_name: "milk", item_image: "YWJj", image_mime_type: "image/jpeg" }],
item_id: 55,
item_name: "milk",
item_image: "YWJj",
image_mime_type: "image/jpeg",
has_managed_settings: true,
}],
}); });
const result = await AvailableItems.updateAvailableItem(1, 2, 55, { 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(result).toEqual(expect.objectContaining({ item_id: 55, image_mime_type: "image/jpeg" }));
expect(pool.query).toHaveBeenNthCalledWith( expect(pool.query).toHaveBeenNthCalledWith(
2, 1,
expect.stringContaining("UPDATE household_store_available_items"), expect.stringContaining("UPDATE household_store_items"),
[1, 2, 55, imageBuffer, "image/jpeg"] [1, 2, 55, imageBuffer, "image/jpeg"]
); );
}); });
test("imports current household list items idempotently", async () => { test("deletes the household store item", 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: [] }); pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [] });
const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55); const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55);
expect(deleted).toBe(true); expect(deleted).toBe(true);
expect(pool.query).toHaveBeenCalledWith( expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("DELETE FROM household_store_available_items"), expect.stringContaining("DELETE FROM household_store_items"),
[1, 2, 55] [1, 2, 55]
); );
}); });

View File

@ -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 = { const req = {
params: { householdId: "1", storeId: "2", itemId: "99" }, params: { householdId: "1", storeId: "2", itemId: "99" },
}; };
const res = createResponse(); const res = createResponse();
AvailableItems.deleteAvailableItem.mockResolvedValueOnce(false);
List.deleteClassification.mockResolvedValueOnce(true);
await controller.deleteAvailableItem(req, res); await controller.deleteAvailableItem(req, res);
expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99); expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99);
expect(List.deleteClassification).toHaveBeenCalledWith("1", "2", 99); expect(List.deleteClassification).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({ message: "Store item settings cleared" }); expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" });
}); });
test("returns an empty catalog payload when the available items table is missing", async () => { 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({ AvailableItems.listAvailableItems.mockRejectedValueOnce({
code: "42P01", code: "42P01",
message: 'relation "household_store_available_items" does not exist', message: 'relation "household_store_items" does not exist',
}); });
await controller.getAvailableItems(req, res); await controller.getAvailableItems(req, res);
@ -185,7 +182,7 @@ describe("available-items.controller", () => {
AvailableItems.createAvailableItem.mockRejectedValueOnce({ AvailableItems.createAvailableItem.mockRejectedValueOnce({
code: "42P01", code: "42P01",
message: 'relation "household_store_available_items" does not exist', message: 'relation "household_store_items" does not exist',
}); });
await controller.createAvailableItem(req, res); await controller.createAvailableItem(req, res);

View File

@ -10,10 +10,10 @@ describe("list.model.v2 addOrUpdateItem", () => {
pool.query.mockReset(); 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 pool.query
.mockResolvedValueOnce({ rowCount: 0, rows: [] }) .mockResolvedValueOnce({ rowCount: 0, rows: [] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] }) .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
.mockResolvedValueOnce({ rowCount: 0, rows: [] }) .mockResolvedValueOnce({ rowCount: 0, rows: [] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88 }] }); .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88 }] });
@ -22,24 +22,25 @@ describe("list.model.v2 addOrUpdateItem", () => {
expect(result).toEqual({ expect(result).toEqual({
listId: 88, listId: 88,
itemId: 55, itemId: 55,
householdStoreItemId: 55,
itemName: "milk", itemName: "milk",
isNew: true, isNew: true,
}); });
expect(pool.query).toHaveBeenNthCalledWith( expect(pool.query).toHaveBeenNthCalledWith(
1, 1,
"SELECT id FROM items WHERE name ILIKE $1", expect.stringContaining("FROM household_store_items"),
["milk"] [1, 2, "milk"]
); );
expect(pool.query).toHaveBeenNthCalledWith( expect(pool.query).toHaveBeenNthCalledWith(
2, 2,
"INSERT INTO items (name) VALUES ($1) RETURNING id", expect.stringContaining("INSERT INTO household_store_items"),
["milk"] [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 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: [{ id: 88, bought: false }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [] }); .mockResolvedValueOnce({ rowCount: 1, rows: [] });
@ -48,6 +49,7 @@ describe("list.model.v2 addOrUpdateItem", () => {
expect(result).toEqual({ expect(result).toEqual({
listId: 88, listId: 88,
itemId: 55, itemId: 55,
householdStoreItemId: 55,
itemName: "milk", itemName: "milk",
isNew: false, isNew: false,
}); });
@ -64,7 +66,7 @@ describe("list.model.v2 classification helpers", () => {
pool.query.mockReset(); 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({ pool.query.mockResolvedValueOnce({
rowCount: 1, rowCount: 1,
rows: [ rows: [
@ -88,19 +90,19 @@ describe("list.model.v2 classification helpers", () => {
source: "user", source: "user",
}); });
expect(pool.query).toHaveBeenCalledWith( 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] [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({ pool.query.mockResolvedValueOnce({
rowCount: 1, rowCount: 1,
rows: [ rows: [
{ {
household_id: 1, household_id: 1,
store_id: 2, store_id: 2,
item_id: 55, household_store_item_id: 55,
item_type: "dairy", item_type: "dairy",
item_group: "Milk", item_group: "Milk",
zone: "Dairy & Refrigerated", zone: "Dairy & Refrigerated",
@ -122,12 +124,12 @@ describe("list.model.v2 classification helpers", () => {
expect.objectContaining({ expect.objectContaining({
household_id: 1, household_id: 1,
store_id: 2, store_id: 2,
item_id: 55, household_store_item_id: 55,
item_type: "dairy", item_type: "dairy",
}) })
); );
expect(pool.query).toHaveBeenCalledWith( 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"] [1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"]
); );
}); });

View File

@ -1,6 +1,7 @@
jest.mock("../models/list.model.v2", () => ({ jest.mock("../models/list.model.v2", () => ({
addHistoryRecord: jest.fn(), addHistoryRecord: jest.fn(),
addOrUpdateItem: jest.fn(), addOrUpdateItem: jest.fn(),
ensureHouseholdStoreItem: jest.fn(),
getItemByName: jest.fn(), getItemByName: jest.fn(),
upsertClassification: jest.fn(), upsertClassification: jest.fn(),
})); }));
@ -30,6 +31,7 @@ describe("lists.controller.v2 addItem", () => {
List.addOrUpdateItem.mockResolvedValue({ List.addOrUpdateItem.mockResolvedValue({
listId: 42, listId: 42,
itemId: 99, itemId: 99,
householdStoreItemId: 99,
itemName: "milk", itemName: "milk",
isNew: true, isNew: true,
}); });
@ -52,7 +54,7 @@ describe("lists.controller.v2 addItem", () => {
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9); expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
expect(List.addOrUpdateItem).toHaveBeenCalled(); 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); expect(res.status).not.toHaveBeenCalledWith(400);
}); });
@ -69,7 +71,7 @@ describe("lists.controller.v2 addItem", () => {
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled(); expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
expect(List.addOrUpdateItem).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); expect(res.status).not.toHaveBeenCalledWith(400);
}); });
@ -86,7 +88,7 @@ describe("lists.controller.v2 addItem", () => {
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled(); expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
expect(List.addOrUpdateItem).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); expect(res.status).not.toHaveBeenCalledWith(400);
}); });
@ -168,12 +170,7 @@ describe("lists.controller.v2 setClassification", () => {
jest.clearAllMocks(); jest.clearAllMocks();
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" }); List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
List.upsertClassification.mockResolvedValue(undefined); List.upsertClassification.mockResolvedValue(undefined);
List.addOrUpdateItem.mockResolvedValue({ List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" });
listId: 42,
itemId: 99,
itemName: "milk",
isNew: true,
});
}); });
test("accepts object classification with type, group, and zone", async () => { 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); 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);
});
}); });

View File

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