grocery-app/backend/models/available-item.model.js

334 lines
9.7 KiB
JavaScript

const pool = require("../db/pool");
const List = require("./list.model.v2");
function normalizeItemName(itemName) {
return String(itemName || "").trim().toLowerCase();
}
async function getHouseholdStoreItemRecord(householdId, storeLocationId, 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.image_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_location_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(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image),
'base64'
) AS item_image,
COALESCE(
catalog_img.mime_type,
hsi.custom_image_mime_type,
list_img.mime_type,
lli.custom_image_mime_type
) AS image_mime_type,
hic.item_type,
hic.item_group,
COALESCE(slz.name, hic.zone) AS zone,
slz.sort_order AS zone_sort_order
FROM household_store_items hsi
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hsi.household_id
AND hic.store_location_id = hsi.store_location_id
AND hic.household_store_item_id = hsi.id
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
WHERE hsi.household_id = $1
AND hsi.store_location_id = $2
AND hsi.id = $3`,
[householdId, storeLocationId, itemId]
);
return result.rows[0] || null;
}
async function findOrCreateHouseholdStoreItem(householdId, storeLocationId, itemName) {
const normalizedName = normalizeItemName(itemName);
const existing = await pool.query(
`SELECT id, name
FROM household_store_items
WHERE household_id = $1
AND store_location_id = $2
AND normalized_name = $3`,
[householdId, storeLocationId, normalizedName]
);
if (existing.rowCount > 0) {
return {
itemId: existing.rows[0].id,
itemName: existing.rows[0].name,
isNew: false,
};
}
const created = await pool.query(
`INSERT INTO household_store_items
(household_id, store_location_id, name, normalized_name, updated_at)
VALUES ($1, $2, $3, $4, NOW())
RETURNING id, name`,
[householdId, storeLocationId, normalizedName, normalizedName]
);
return {
itemId: created.rows[0].id,
itemName: created.rows[0].name,
isNew: true,
};
}
exports.listAvailableItems = async (householdId, storeLocationId, query = "") => {
const trimmedQuery = String(query || "").trim();
const values = [householdId, storeLocationId];
let filterClause = "";
if (trimmedQuery) {
values.push(`%${trimmedQuery}%`);
filterClause = "AND hsi.name ILIKE $3";
}
const result = await pool.query(
`WITH latest_list_items AS (
SELECT DISTINCT ON (hl.household_store_item_id)
hl.household_store_item_id,
hl.image_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_location_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(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image),
'base64'
) AS item_image,
COALESCE(
catalog_img.mime_type,
hsi.custom_image_mime_type,
list_img.mime_type,
lli.custom_image_mime_type
) AS image_mime_type,
hic.item_type,
hic.item_group,
COALESCE(slz.name, hic.zone) AS zone,
slz.sort_order AS zone_sort_order,
(
hsi.image_id IS NOT NULL
OR hsi.custom_image IS NOT NULL
OR hic.household_store_item_id IS NOT NULL
) AS has_managed_settings
FROM household_store_items hsi
LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hsi.household_id
AND hic.store_location_id = hsi.store_location_id
AND hic.household_store_item_id = hsi.id
LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id
WHERE hsi.household_id = $1
AND hsi.store_location_id = $2
${filterClause}
ORDER BY hsi.name ASC
LIMIT 100`,
values
);
return result.rows;
};
exports.getAvailableItemById = async (householdId, storeLocationId, itemId) =>
getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
exports.getAvailableItemImageByName = async (householdId, storeLocationId, itemName) => {
const normalizedName = normalizeItemName(itemName);
const result = await pool.query(
`SELECT
hsi.id AS item_id,
hsi.name AS item_name,
COALESCE(img.image, hsi.custom_image) AS custom_image,
COALESCE(img.mime_type, hsi.custom_image_mime_type) AS custom_image_mime_type
FROM household_store_items hsi
LEFT JOIN household_item_images img ON img.id = hsi.image_id
WHERE hsi.household_id = $1
AND hsi.store_location_id = $2
AND hsi.normalized_name = $3`,
[householdId, storeLocationId, normalizedName]
);
return result.rows[0] || null;
};
exports.createAvailableItem = async (
householdId,
storeLocationId,
itemName,
imageBuffer = null,
mimeType = null,
userId = null
) => {
const { itemId, isNew } = await findOrCreateHouseholdStoreItem(
householdId,
storeLocationId,
itemName
);
if (imageBuffer && mimeType) {
await List.setCatalogItemImage(
householdId,
storeLocationId,
itemId,
imageBuffer,
mimeType,
userId
);
}
if (isNew) {
await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: itemId,
actorUserId: userId,
eventType: "ITEM_ADDED",
metadata: { source: "catalog" },
});
}
return getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
};
exports.updateAvailableItem = async (householdId, storeLocationId, itemId, updates = {}) => {
const {
itemName,
imageBuffer,
mimeType,
removeImage = false,
userId = null,
} = updates;
const assignments = ["updated_at = NOW()"];
const values = [householdId, storeLocationId, itemId];
let parameterIndex = values.length;
if (itemName !== undefined && String(itemName).trim() !== "") {
const normalizedName = normalizeItemName(itemName);
parameterIndex += 1;
assignments.push(`name = $${parameterIndex}`);
values.push(normalizedName);
parameterIndex += 1;
assignments.push(`normalized_name = $${parameterIndex}`);
values.push(normalizedName);
}
if (removeImage) {
assignments.push("image_id = NULL", "custom_image = NULL", "custom_image_mime_type = NULL");
}
const result = await pool.query(
`UPDATE household_store_items
SET ${assignments.join(", ")}
WHERE household_id = $1
AND store_location_id = $2
AND id = $3
RETURNING id`,
values
);
if (result.rowCount === 0) {
return null;
}
if (!removeImage && imageBuffer && mimeType) {
await List.setCatalogItemImage(
householdId,
storeLocationId,
result.rows[0].id,
imageBuffer,
mimeType,
userId
);
}
return getHouseholdStoreItemRecord(householdId, storeLocationId, result.rows[0].id);
};
exports.deleteAvailableItem = async (householdId, storeLocationId, itemId, userId = null) => {
const item = await getHouseholdStoreItemRecord(householdId, storeLocationId, itemId);
const result = await pool.query(
`DELETE FROM household_store_items
WHERE household_id = $1
AND store_location_id = $2
AND id = $3`,
[householdId, storeLocationId, itemId]
);
if (result.rowCount > 0) {
await List.recordItemEvent({
householdId,
storeLocationId,
householdStoreItemId: itemId,
actorUserId: userId,
eventType: "ITEM_DELETED",
metadata: { item_name: item?.item_name || null },
});
}
return result.rowCount > 0;
};
exports.importCurrentListItems = async (householdId, storeLocationId) => {
const result = await pool.query(
`INSERT INTO household_store_items
(household_id, store_location_id, name, normalized_name, image_id, updated_at)
SELECT DISTINCT ON (hl.household_store_item_id)
hl.household_id,
hl.store_location_id,
hsi.name,
hsi.normalized_name,
hsi.image_id,
NOW()
FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
WHERE hl.household_id = $1
AND hl.store_location_id = $2
ON CONFLICT (household_id, store_location_id, normalized_name) DO NOTHING
RETURNING id`,
[householdId, storeLocationId]
);
return result.rowCount;
};
exports.hasAvailableItems = async (householdId, storeLocationId) => {
const result = await pool.query(
`SELECT 1
FROM household_store_items
WHERE household_id = $1
AND store_location_id = $2
LIMIT 1`,
[householdId, storeLocationId]
);
return result.rowCount > 0;
};