chore: harden reliability checks #2
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
${filterClause}
|
WHERE hsi.household_id = $1
|
||||||
ORDER BY i.name ASC
|
AND hsi.store_id = $2
|
||||||
|
${filterClause}
|
||||||
|
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);
|
||||||
|
|
||||||
await pool.query(
|
if (imageBuffer && mimeType) {
|
||||||
`INSERT INTO household_store_available_items
|
await pool.query(
|
||||||
(household_id, store_id, item_id, custom_image, custom_image_mime_type, updated_at)
|
`UPDATE household_store_items
|
||||||
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
SET custom_image = $1,
|
||||||
[householdId, storeId, itemId, imageBuffer, mimeType]
|
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 = {}) => {
|
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`,
|
||||||
|
|||||||
@ -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(
|
|
||||||
`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) => {
|
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]);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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"]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
Loading…
Reference in New Issue
Block a user