Compare commits

..

No commits in common. "bd945568c8c0050998ddeb86458c36d3d7e6fd6c" and "77ae5be4453b8886700103f48bcced2060f8240d" have entirely different histories.

34 changed files with 546 additions and 4335 deletions

View File

@ -155,7 +155,6 @@ For `app/api/**/[param]/route.ts`:
- Tap targets remain >= 40px on mobile. - Tap targets remain >= 40px on mobile.
- Modal overlays must close on outside click/tap. - Modal overlays must close on outside click/tap.
- For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure). - For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure).
- Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception.
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency. - Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency.
- Add Playwright UI tests for new UI features and critical flows. - Add Playwright UI tests for new UI features and critical flows.

View File

@ -1,319 +0,0 @@
const AvailableItems = require("../models/available-item.model");
const List = require("../models/list.model.v2");
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
const LEGACY_ITEM_TYPE_MAP = {
beverages: "beverage",
snacks: "snack",
};
function parseBoolean(value) {
return value === true || value === "true" || value === "1";
}
function isCatalogTableMissing(error) {
return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || "");
}
function parseClassificationInput(value) {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed === "null") {
return null;
}
if (trimmed.startsWith("{")) {
try {
return JSON.parse(trimmed);
} catch (error) {
return Symbol.for("invalid-classification-json");
}
}
return trimmed;
}
return value;
}
function normalizeClassificationPayload(classification) {
if (typeof classification === "string") {
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
return {
item_type: normalizedItemType,
item_group: null,
zone: null,
};
}
if (!classification || typeof classification !== "object" || Array.isArray(classification)) {
return null;
}
const item_type =
typeof classification.item_type === "string" && classification.item_type.trim() !== ""
? classification.item_type.trim()
: null;
const item_group =
typeof classification.item_group === "string" && classification.item_group.trim() !== ""
? classification.item_group.trim()
: null;
const zone =
typeof classification.zone === "string" && classification.zone.trim() !== ""
? classification.zone.trim()
: null;
if (!item_type && !item_group && !zone) {
return null;
}
return { item_type, item_group, zone };
}
function validateClassification(res, classification) {
if (!classification) {
return false;
}
const { item_type, item_group, zone } = classification;
if (item_type && !isValidItemType(item_type)) {
sendError(res, 400, "Invalid item_type");
return true;
}
if (item_group && !item_type) {
sendError(res, 400, "Item type is required when item group is provided");
return true;
}
if (item_group && !isValidItemGroup(item_type, item_group)) {
sendError(res, 400, "Invalid item_group for selected item_type");
return true;
}
if (zone && !isValidZone(zone)) {
sendError(res, 400, "Invalid zone");
return true;
}
return false;
}
function parseItemId(value) {
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
exports.getAvailableItems = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || "");
res.json({ items, catalog_ready: true });
} catch (error) {
if (isCatalogTableMissing(error)) {
return res.json({
items: [],
catalog_ready: false,
message: "Store item management is unavailable until the latest database migration is applied.",
});
}
logError(req, "availableItems.getAvailableItems", error);
sendError(res, 500, "Failed to load available items");
}
};
exports.createAvailableItem = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const { item_name } = req.body;
if (!item_name || item_name.trim() === "") {
return sendError(res, 400, "Item name is required");
}
const parsedClassification = parseClassificationInput(req.body.classification);
if (parsedClassification === Symbol.for("invalid-classification-json")) {
return sendError(res, 400, "Classification payload must be valid JSON");
}
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
if (validateClassification(res, normalizedClassification)) {
return;
}
const imageBuffer = req.processedImage?.buffer || null;
const mimeType = req.processedImage?.mimeType || null;
const item = await AvailableItems.createAvailableItem(
householdId,
storeId,
item_name,
imageBuffer,
mimeType
);
if (normalizedClassification) {
await List.upsertClassification(householdId, storeId, item.item_id, {
...normalizedClassification,
confidence: 1.0,
source: "user",
});
}
const refreshedItem = await AvailableItems.getAvailableItemById(householdId, storeId, item.item_id);
res.status(201).json({
message: "Available item added",
item: refreshedItem,
});
} catch (error) {
if (isCatalogTableMissing(error)) {
return sendError(
res,
503,
"Store item management is unavailable until the latest database migration is applied"
);
}
logError(req, "availableItems.createAvailableItem", error);
if (error.code === "23505") {
return sendError(res, 400, "Available item already exists for this store");
}
sendError(res, 500, "Failed to add available item");
}
};
exports.updateAvailableItem = async (req, res) => {
try {
const { householdId, storeId, itemId: rawItemId } = req.params;
const itemId = parseItemId(rawItemId);
if (!itemId) {
return sendError(res, 400, "Item ID must be a positive integer");
}
const hasClassificationField = Object.prototype.hasOwnProperty.call(req.body, "classification");
const parsedClassification = parseClassificationInput(req.body.classification);
if (parsedClassification === Symbol.for("invalid-classification-json")) {
return sendError(res, 400, "Classification payload must be valid JSON");
}
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
if (normalizedClassification && validateClassification(res, normalizedClassification)) {
return;
}
const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeId, itemId, {
itemName: req.body.item_name,
imageBuffer: req.processedImage?.buffer || null,
mimeType: req.processedImage?.mimeType || null,
removeImage: parseBoolean(req.body.remove_image),
});
if (!updatedItem) {
return sendError(res, 404, "Available item not found");
}
if (hasClassificationField) {
if (normalizedClassification) {
await List.upsertClassification(householdId, storeId, updatedItem.item_id, {
...normalizedClassification,
confidence: 1.0,
source: "user",
});
} else {
await List.deleteClassification(householdId, storeId, updatedItem.item_id);
}
}
const refreshedItem = await AvailableItems.getAvailableItemById(
householdId,
storeId,
updatedItem.item_id
);
res.json({
message: "Available item updated",
item: refreshedItem,
});
} catch (error) {
if (isCatalogTableMissing(error)) {
return sendError(
res,
503,
"Store item management is unavailable until the latest database migration is applied"
);
}
logError(req, "availableItems.updateAvailableItem", error);
if (error.code === "23505") {
return sendError(res, 400, "Available item already exists for this store");
}
sendError(res, 500, "Failed to update available item");
}
};
exports.deleteAvailableItem = async (req, res) => {
try {
const { householdId, storeId, itemId: rawItemId } = req.params;
const itemId = parseItemId(rawItemId);
if (!itemId) {
return sendError(res, 400, "Item ID must be a positive integer");
}
const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId);
if (!deleted) {
return sendError(res, 404, "Store item not found");
}
res.json({ message: "Store item deleted" });
} catch (error) {
if (isCatalogTableMissing(error)) {
return sendError(
res,
503,
"Store item management is unavailable until the latest database migration is applied"
);
}
logError(req, "availableItems.deleteAvailableItem", error);
sendError(res, 500, "Failed to delete store item");
}
};
exports.importCurrentItems = async (req, res) => {
try {
const { householdId, storeId } = req.params;
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeId);
res.json({
message: importedCount > 0 ? "Imported current list items" : "No current list items to import",
imported_count: importedCount,
});
} catch (error) {
if (isCatalogTableMissing(error)) {
return sendError(
res,
503,
"Store item management is unavailable until the latest database migration is applied"
);
}
logError(req, "availableItems.importCurrentItems", error);
sendError(res, 500, "Failed to import current list items");
}
};

View File

@ -4,45 +4,6 @@ const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants
const { sendError } = require("../utils/http"); const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger"); const { logError } = require("../utils/logger");
const LEGACY_ITEM_TYPE_MAP = {
beverages: "beverage",
snacks: "snack",
};
function normalizeClassificationPayload(classification) {
if (typeof classification === "string") {
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
return {
item_type: normalizedItemType,
item_group: null,
zone: null,
};
}
if (!classification || typeof classification !== "object" || Array.isArray(classification)) {
return null;
}
const item_type =
typeof classification.item_type === "string" && classification.item_type.trim() !== ""
? classification.item_type.trim()
: null;
const item_group =
typeof classification.item_group === "string" && classification.item_group.trim() !== ""
? classification.item_group.trim()
: null;
const zone =
typeof classification.zone === "string" && classification.zone.trim() !== ""
? classification.zone.trim()
: null;
if (!item_type && !item_group && !zone) {
return null;
}
return { item_type, item_group, zone };
}
/** /**
* Get list items for household and store * Get list items for household and store
* GET /households/:householdId/stores/:storeId/list * GET /households/:householdId/stores/:storeId/list
@ -134,7 +95,7 @@ exports.addItem = async (req, res) => {
); );
// Add history record // Add history record
await List.addHistoryRecord(result.listId, result.householdStoreItemId, quantity || "1", historyUserId); await List.addHistoryRecord(result.listId, quantity || "1", historyUserId);
res.json({ res.json({
message: result.isNew ? "Item added" : "Item updated", message: result.isNew ? "Item added" : "Item updated",
@ -292,7 +253,7 @@ exports.getClassification = async (req, res) => {
return res.json({ classification: null }); return res.json({ classification: null });
} }
const classification = await List.getClassification(householdId, storeId, item.item_id); const classification = await List.getClassification(householdId, item.item_id);
res.json({ classification }); res.json({ classification });
} catch (error) { } catch (error) {
logError(req, "listsV2.getClassification", error); logError(req, "listsV2.getClassification", error);
@ -313,27 +274,14 @@ exports.setClassification = async (req, res) => {
return sendError(res, 400, "Item name is required"); return sendError(res, 400, "Item name is required");
} }
const normalizedClassification = normalizeClassificationPayload(classification); if (!classification) {
if (!normalizedClassification) {
return sendError(res, 400, "Classification is required"); return sendError(res, 400, "Classification is required");
} }
const { item_type, item_group, zone } = normalizedClassification; // Validate classification
const validClassifications = ['produce', 'dairy', 'meat', 'bakery', 'frozen', 'pantry', 'snacks', 'beverages', 'household', 'other'];
if (item_type && !isValidItemType(item_type)) { if (!validClassifications.includes(classification)) {
return sendError(res, 400, "Invalid item_type"); return sendError(res, 400, "Invalid classification value");
}
if (item_group && !item_type) {
return sendError(res, 400, "Item type is required when item group is provided");
}
if (item_group && !isValidItemGroup(item_type, item_group)) {
return sendError(res, 400, "Invalid item_group for selected item_type");
}
if (zone && !isValidZone(zone)) {
return sendError(res, 400, "Invalid zone");
} }
// Get item - add to master items if not exists // Get item - add to master items if not exists
@ -342,25 +290,28 @@ 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.ensureHouseholdStoreItem( const itemResult = await List.addOrUpdateItem(
householdId, householdId,
storeId, storeId,
item_name item_name,
"1",
req.user.id,
null,
null
); );
itemId = itemResult.id; itemId = itemResult.itemId;
} else { } else {
itemId = item.item_id; itemId = item.item_id;
} }
await List.upsertClassification(householdId, storeId, itemId, { // Set classification (using item_type field for simplicity)
item_type, await List.upsertClassification(householdId, itemId, {
item_group, item_type: classification,
zone, item_group: null,
confidence: 1.0, zone: null
source: "user",
}); });
res.json({ message: "Classification set", classification: normalizedClassification }); res.json({ message: "Classification set", classification });
} catch (error) { } catch (error) {
logError(req, "listsV2.setClassification", error); logError(req, "listsV2.setClassification", error);
sendError(res, 500, "Failed to set classification"); sendError(res, 500, "Failed to set classification");

View File

@ -1,273 +0,0 @@
const pool = require("../db/pool");
function normalizeItemName(itemName) {
return String(itemName || "").trim().toLowerCase();
}
async function getHouseholdStoreItemRecord(householdId, storeId, itemId) {
const result = await pool.query(
`WITH latest_list_items AS (
SELECT DISTINCT ON (hl.household_store_item_id)
hl.household_store_item_id,
hl.custom_image,
hl.custom_image_mime_type,
hl.modified_on,
hl.id
FROM household_lists hl
WHERE hl.household_id = $1
AND hl.store_id = $2
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
)
SELECT
hsi.id AS item_id,
hsi.name AS item_name,
ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image,
COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
hic.item_type,
hic.item_group,
hic.zone
FROM household_store_items hsi
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hsi.household_id
AND hic.store_id = hsi.store_id
AND hic.household_store_item_id = hsi.id
WHERE hsi.household_id = $1
AND hsi.store_id = $2
AND hsi.id = $3`,
[householdId, storeId, itemId]
);
return result.rows[0] || null;
}
async function findOrCreateHouseholdStoreItem(householdId, storeId, itemName) {
const normalizedName = normalizeItemName(itemName);
const existing = await pool.query(
`SELECT id, name
FROM household_store_items
WHERE household_id = $1
AND store_id = $2
AND normalized_name = $3`,
[householdId, storeId, normalizedName]
);
if (existing.rowCount > 0) {
return {
itemId: existing.rows[0].id,
itemName: existing.rows[0].name,
};
}
const created = 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`,
[householdId, storeId, normalizedName, normalizedName]
);
return {
itemId: created.rows[0].id,
itemName: created.rows[0].name,
};
}
exports.listAvailableItems = async (householdId, storeId, query = "") => {
const trimmedQuery = String(query || "").trim();
const values = [householdId, storeId];
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.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,
(
hsi.custom_image IS NOT NULL
OR hic.household_store_item_id IS NOT NULL
) AS has_managed_settings
FROM household_store_items hsi
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hsi.household_id
AND hic.store_id = hsi.store_id
AND hic.household_store_item_id = hsi.id
WHERE hsi.household_id = $1
AND hsi.store_id = $2
${filterClause}
ORDER BY hsi.name ASC
LIMIT 100`,
values
);
return result.rows;
};
exports.getAvailableItemById = async (householdId, storeId, itemId) =>
getHouseholdStoreItemRecord(householdId, storeId, itemId);
exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => {
const normalizedName = normalizeItemName(itemName);
const result = await pool.query(
`SELECT
id AS item_id,
name AS item_name,
custom_image,
custom_image_mime_type
FROM household_store_items
WHERE household_id = $1
AND store_id = $2
AND normalized_name = $3`,
[householdId, storeId, normalizedName]
);
return result.rows[0] || null;
};
exports.createAvailableItem = async (
householdId,
storeId,
itemName,
imageBuffer = null,
mimeType = null
) => {
const { itemId } = await findOrCreateHouseholdStoreItem(householdId, storeId, itemName);
if (imageBuffer && mimeType) {
await pool.query(
`UPDATE household_store_items
SET custom_image = $1,
custom_image_mime_type = $2,
updated_at = NOW()
WHERE id = $3
AND household_id = $4
AND store_id = $5`,
[imageBuffer, mimeType, itemId, householdId, storeId]
);
}
return getHouseholdStoreItemRecord(householdId, storeId, itemId);
};
exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => {
const {
itemName,
imageBuffer,
mimeType,
removeImage = false,
} = updates;
const assignments = ["updated_at = NOW()"];
const values = [householdId, storeId, itemId];
let parameterIndex = values.length;
if (itemName !== undefined && String(itemName).trim() !== "") {
const 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("custom_image = NULL", "custom_image_mime_type = NULL");
} else if (imageBuffer && mimeType) {
parameterIndex += 1;
assignments.push(`custom_image = $${parameterIndex}`);
values.push(imageBuffer);
parameterIndex += 1;
assignments.push(`custom_image_mime_type = $${parameterIndex}`);
values.push(mimeType);
}
const result = await pool.query(
`UPDATE household_store_items
SET ${assignments.join(", ")}
WHERE household_id = $1
AND store_id = $2
AND id = $3
RETURNING id`,
values
);
if (result.rowCount === 0) {
return null;
}
return getHouseholdStoreItemRecord(householdId, storeId, result.rows[0].id);
};
exports.deleteAvailableItem = async (householdId, storeId, itemId) => {
const result = await pool.query(
`DELETE FROM household_store_items
WHERE household_id = $1
AND store_id = $2
AND id = $3`,
[householdId, storeId, itemId]
);
return result.rowCount > 0;
};
exports.importCurrentListItems = async (householdId, storeId) => {
const result = await pool.query(
`INSERT INTO household_store_items
(household_id, store_id, name, normalized_name, custom_image, custom_image_mime_type, updated_at)
SELECT DISTINCT ON (hl.household_store_item_id)
hl.household_id,
hl.store_id,
hsi.name,
hsi.normalized_name,
hsi.custom_image,
hsi.custom_image_mime_type,
NOW()
FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
WHERE hl.household_id = $1
AND hl.store_id = $2
ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING
RETURNING id`,
[householdId, storeId]
);
return result.rowCount;
};
exports.hasAvailableItems = async (householdId, storeId) => {
const result = await pool.query(
`SELECT 1
FROM household_store_items
WHERE household_id = $1
AND store_id = $2
LIMIT 1`,
[householdId, storeId]
);
return result.rowCount > 0;
};

View File

@ -1,41 +1,5 @@
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
@ -47,13 +11,11 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
const result = await pool.query( const result = await pool.query(
`SELECT `SELECT
hl.id, hl.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(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, ENCODE(hl.custom_image, 'base64') as item_image,
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, hl.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)
@ -64,18 +26,17 @@ 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 household_store_items hsi ON hsi.id = hl.household_store_item_id JOIN items i ON hl.item_id = i.id
LEFT JOIN household_item_classifications hic LEFT JOIN household_item_classifications hic
ON hic.household_id = hl.household_id ON hl.household_id = hic.household_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
@ -93,17 +54,25 @@ 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) => {
const normalizedName = normalizeItemName(itemName); // First check if item exists in master catalog
const itemResult = await pool.query(
"SELECT id FROM items WHERE name ILIKE $1",
[itemName]
);
if (itemResult.rowCount === 0) {
return null;
}
const itemId = itemResult.rows[0].id;
const result = await pool.query( const result = await pool.query(
`SELECT `SELECT
hl.id, hl.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(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, ENCODE(hl.custom_image, 'base64') as item_image,
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, hl.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 (
@ -113,28 +82,34 @@ 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 household_store_items hsi ON hsi.id = hl.household_store_item_id JOIN items i ON hl.item_id = i.id
LEFT JOIN household_item_classifications hic LEFT JOIN household_item_classifications hic
ON hic.household_id = hl.household_id ON hl.household_id = hic.household_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 hsi.normalized_name = $3`, AND hl.item_id = $3`,
[householdId, storeId, normalizedName] [householdId, storeId, itemId]
); );
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
* @returns {Promise<{listId:number,itemId:number,householdStoreItemId:number,itemName:string,isNew:boolean}>} * @param {number} householdId - Household ID
* @param {number} storeId - Store ID
* @param {string} itemName - Item name
* @param {number} quantity - Quantity
* @param {number} userId - User adding the item
* @param {Buffer|null} imageBuffer - Image buffer
* @param {string|null} mimeType - MIME type
* @returns {Promise<number>} List item ID
*/ */
exports.addOrUpdateItem = async ( exports.addOrUpdateItem = async (
householdId, householdId,
@ -145,14 +120,30 @@ exports.addOrUpdateItem = async (
imageBuffer = null, imageBuffer = null,
mimeType = null mimeType = null
) => { ) => {
const householdStoreItem = await exports.ensureHouseholdStoreItem(householdId, storeId, itemName); const lowerItemName = itemName.toLowerCase();
let itemResult = await pool.query(
"SELECT id FROM items WHERE name ILIKE $1",
[lowerItemName]
);
let itemId;
if (itemResult.rowCount === 0) {
const insertItem = await pool.query(
"INSERT INTO items (name) VALUES ($1) RETURNING id",
[lowerItemName]
);
itemId = insertItem.rows[0].id;
} else {
itemId = itemResult.rows[0].id;
}
const listResult = await pool.query( const listResult = await pool.query(
`SELECT id, bought `SELECT id, bought FROM household_lists
FROM household_lists
WHERE household_id = $1 WHERE household_id = $1
AND store_id = $2 AND store_id = $2
AND household_store_item_id = $3`, AND item_id = $3`,
[householdId, storeId, householdStoreItem.id] [householdId, storeId, itemId]
); );
if (listResult.rowCount > 0) { if (listResult.rowCount > 0) {
@ -178,35 +169,28 @@ exports.addOrUpdateItem = async (
[quantity, listId] [quantity, listId]
); );
} }
return listId;
return { } else {
listId, const insert = await pool.query(
itemId: householdStoreItem.id, `INSERT INTO household_lists
householdStoreItemId: householdStoreItem.id, (household_id, store_id, item_id, quantity, custom_image, custom_image_mime_type)
itemName: householdStoreItem.name, VALUES ($1, $2, $3, $4, $5, $6)
isNew: false, RETURNING id`,
}; [householdId, storeId, itemId, quantity, imageBuffer, mimeType]
);
return insert.rows[0].id;
} }
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]
@ -214,7 +198,9 @@ 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,17 +212,20 @@ 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]
@ -244,45 +233,61 @@ 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, household_store_item_id, quantity, added_by, added_on) `INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on)
VALUES ($1, $2, $3, $4, NOW())`, VALUES ($1, $2, $3, NOW())`,
[listId, householdStoreItemId, quantity, userId] [listId, 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
hsi.name AS item_name, i.name as item_name,
CASE WHEN hl.id IS NOT NULL AND hl.bought = FALSE THEN 0 ELSE 1 END AS sort_order CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END as sort_order
FROM household_store_items hsi FROM items i
LEFT JOIN household_lists hl LEFT JOIN household_lists hl
ON hl.household_store_item_id = hsi.id ON i.id = hl.item_id
AND hl.household_id = $2 AND hl.household_id = $2
AND hl.store_id = $3 AND hl.store_id = $3
WHERE hsi.household_id = $2 WHERE i.name ILIKE $1
AND hsi.store_id = $3 ORDER BY sort_order, i.name
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,
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(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, ENCODE(hl.custom_image, 'base64') as item_image,
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, hl.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 (
@ -292,10 +297,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 household_store_items hsi ON hsi.id = hl.household_store_item_id JOIN items i ON hl.item_id = i.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
@ -306,24 +311,37 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
return result.rows; return result.rows;
}; };
exports.getClassification = async (householdId, storeId, itemId) => { /**
* Get classification for household item
* @param {number} householdId - Household ID
* @param {number} itemId - Item ID
* @returns {Promise<Object|null>} Classification or null
*/
exports.getClassification = async (householdId, 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 household_store_item_id = $3`, WHERE household_id = $1 AND item_id = $2`,
[householdId, storeId, itemId] [householdId, itemId]
); );
return result.rows[0] || null; return result.rows[0] || null;
}; };
exports.upsertClassification = async (householdId, storeId, itemId, classification) => { /**
* Upsert classification for household item
* @param {number} householdId - Household ID
* @param {number} itemId - Item ID
* @param {Object} classification - Classification data
* @returns {Promise<Object>} Updated classification
*/
exports.upsertClassification = async (householdId, 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, household_store_item_id, item_type, item_group, zone, confidence, source) (household_id, 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)
ON CONFLICT (household_id, store_id, household_store_item_id) ON CONFLICT (household_id, 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,
@ -331,57 +349,61 @@ exports.upsertClassification = async (householdId, storeId, itemId, classificati
confidence = EXCLUDED.confidence, confidence = EXCLUDED.confidence,
source = EXCLUDED.source source = EXCLUDED.source
RETURNING *`, RETURNING *`,
[householdId, storeId, itemId, item_type, item_group, zone, confidence, source] [householdId, itemId, item_type, item_group, zone, confidence, source]
); );
return result.rows[0]; return result.rows[0];
}; };
exports.deleteClassification = async (householdId, storeId, itemId) => { /**
const result = await pool.query( * Update list item details
`DELETE FROM household_item_classifications * @param {number} listId - List item ID
WHERE household_id = $1 * @param {string} itemName - New item name (optional)
AND store_id = $2 * @param {number} quantity - New quantity (optional)
AND household_store_item_id = $3`, * @param {string} notes - Notes (optional)
[householdId, storeId, itemId] * @returns {Promise<Object>} Updated item
); */
return result.rowCount > 0;
};
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 += 1; paramCount++;
updates.push(`quantity = $${paramCount}`); updates.push(`quantity = $${paramCount}`);
values.push(quantity); values.push(quantity);
} }
if (notes !== undefined) { if (notes !== undefined) {
paramCount += 1; paramCount++;
updates.push(`notes = $${paramCount}`); updates.push(`notes = $${paramCount}`);
values.push(notes); values.push(notes);
} }
updates.push("modified_on = NOW()"); // Always update modified_on
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

@ -2,7 +2,6 @@ const express = require("express");
const router = express.Router(); const router = express.Router();
const controller = require("../controllers/households.controller"); const controller = require("../controllers/households.controller");
const listsController = require("../controllers/lists.controller.v2"); const listsController = require("../controllers/lists.controller.v2");
const availableItemsController = require("../controllers/available-items.controller");
const auth = require("../middleware/auth"); const auth = require("../middleware/auth");
const { const {
householdAccess, householdAccess,
@ -40,50 +39,6 @@ router.post(
controller.refreshInviteCode controller.refreshInviteCode
); );
router.get(
"/:householdId/stores/:storeId/available-items",
auth,
householdAccess,
storeAccess,
availableItemsController.getAvailableItems
);
router.post(
"/:householdId/stores/:storeId/available-items",
auth,
householdAccess,
storeAccess,
requireHouseholdAdmin,
upload.single("image"),
processImage,
availableItemsController.createAvailableItem
);
router.patch(
"/:householdId/stores/:storeId/available-items/:itemId",
auth,
householdAccess,
storeAccess,
requireHouseholdAdmin,
upload.single("image"),
processImage,
availableItemsController.updateAvailableItem
);
router.delete(
"/:householdId/stores/:storeId/available-items/:itemId",
auth,
householdAccess,
storeAccess,
requireHouseholdAdmin,
availableItemsController.deleteAvailableItem
);
router.post(
"/:householdId/stores/:storeId/available-items/import-current",
auth,
householdAccess,
storeAccess,
requireHouseholdAdmin,
availableItemsController.importCurrentItems
);
// Member management routes // Member management routes
router.get( router.get(
"/:householdId/members", "/:householdId/members",

View File

@ -1,96 +0,0 @@
jest.mock("../db/pool", () => ({
query: jest.fn(),
}));
const pool = require("../db/pool");
const AvailableItems = require("../models/available-item.model");
describe("available-item.model", () => {
beforeEach(() => {
pool.query.mockReset();
});
test("lists household store items", async () => {
pool.query.mockResolvedValueOnce({
rowCount: 1,
rows: [
{
item_id: 55,
item_name: "milk",
item_image: null,
image_mime_type: null,
item_type: null,
item_group: null,
zone: null,
has_managed_settings: false,
},
],
});
const result = await AvailableItems.listAvailableItems(1, 2);
expect(result).toEqual([
expect.objectContaining({
item_id: 55,
item_name: "milk",
}),
]);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("FROM household_store_items hsi"),
[1, 2]
);
});
test("creates a household store item when needed", async () => {
pool.query
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 77, name: "granola" }] })
.mockResolvedValueOnce({
rowCount: 1,
rows: [{ item_id: 77, item_name: "granola" }],
});
const result = await AvailableItems.createAvailableItem(1, 2, "Granola");
expect(result).toEqual(expect.objectContaining({ item_id: 77, item_name: "granola" }));
expect(pool.query).toHaveBeenNthCalledWith(
2,
expect.stringContaining("INSERT INTO household_store_items"),
[1, 2, "granola", "granola"]
);
});
test("updates household store item images and returns refreshed data", async () => {
const imageBuffer = Buffer.from("abc");
pool.query
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] })
.mockResolvedValueOnce({
rowCount: 1,
rows: [{ item_id: 55, item_name: "milk", item_image: "YWJj", image_mime_type: "image/jpeg" }],
});
const result = await AvailableItems.updateAvailableItem(1, 2, 55, {
imageBuffer,
mimeType: "image/jpeg",
});
expect(result).toEqual(expect.objectContaining({ item_id: 55, image_mime_type: "image/jpeg" }));
expect(pool.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining("UPDATE household_store_items"),
[1, 2, 55, imageBuffer, "image/jpeg"]
);
});
test("deletes the household store item", async () => {
pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [] });
const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55);
expect(deleted).toBe(true);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("DELETE FROM household_store_items"),
[1, 2, 55]
);
});
});

View File

@ -1,199 +0,0 @@
jest.mock("../models/available-item.model", () => ({
createAvailableItem: jest.fn(),
deleteAvailableItem: jest.fn(),
getAvailableItemById: jest.fn(),
importCurrentListItems: jest.fn(),
listAvailableItems: jest.fn(),
updateAvailableItem: jest.fn(),
}));
jest.mock("../models/list.model.v2", () => ({
deleteClassification: jest.fn(),
upsertClassification: jest.fn(),
}));
jest.mock("../utils/logger", () => ({
logError: jest.fn(),
}));
const AvailableItems = require("../models/available-item.model");
const List = require("../models/list.model.v2");
const controller = require("../controllers/available-items.controller");
function createResponse() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
describe("available-items.controller", () => {
beforeEach(() => {
jest.clearAllMocks();
AvailableItems.createAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
AvailableItems.getAvailableItemById.mockResolvedValue({
item_id: 99,
item_name: "milk",
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
});
AvailableItems.updateAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
AvailableItems.deleteAvailableItem.mockResolvedValue(true);
AvailableItems.importCurrentListItems.mockResolvedValue(2);
AvailableItems.listAvailableItems.mockResolvedValue([]);
List.upsertClassification.mockResolvedValue(undefined);
List.deleteClassification.mockResolvedValue(false);
});
test("creates an available item and persists classification metadata", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: JSON.stringify({
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
}),
},
processedImage: null,
};
const res = createResponse();
await controller.createAvailableItem(req, res);
expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null);
expect(List.upsertClassification).toHaveBeenCalledWith(
"1",
"2",
99,
expect.objectContaining({
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
})
);
expect(res.status).toHaveBeenCalledWith(201);
});
test("rejects invalid item_group values", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: JSON.stringify({
item_type: "dairy",
item_group: "Bread",
}),
},
};
const res = createResponse();
await controller.createAvailableItem(req, res);
expect(AvailableItems.createAvailableItem).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Invalid item_group for selected item_type",
}),
})
);
});
test("clears classification on update when classification is explicitly empty", async () => {
const req = {
params: { householdId: "1", storeId: "2", itemId: "99" },
body: {
classification: "null",
},
processedImage: null,
};
const res = createResponse();
await controller.updateAvailableItem(req, res);
expect(List.deleteClassification).toHaveBeenCalledWith("1", "2", 99);
expect(res.status).not.toHaveBeenCalledWith(400);
});
test("imports current list items and reports the import count", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
};
const res = createResponse();
await controller.importCurrentItems(req, res);
expect(AvailableItems.importCurrentListItems).toHaveBeenCalledWith("1", "2");
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
imported_count: 2,
})
);
});
test("deletes a store item", async () => {
const req = {
params: { householdId: "1", storeId: "2", itemId: "99" },
};
const res = createResponse();
await controller.deleteAvailableItem(req, res);
expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99);
expect(List.deleteClassification).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" });
});
test("returns an empty catalog payload when the available items table is missing", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
query: {},
};
const res = createResponse();
AvailableItems.listAvailableItems.mockRejectedValueOnce({
code: "42P01",
message: 'relation "household_store_items" does not exist',
});
await controller.getAvailableItems(req, res);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
items: [],
catalog_ready: false,
})
);
});
test("returns a setup error when creating while the available items table is missing", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
},
processedImage: null,
};
const res = createResponse();
AvailableItems.createAvailableItem.mockRejectedValueOnce({
code: "42P01",
message: 'relation "household_store_items" does not exist',
});
await controller.createAvailableItem(req, res);
expect(res.status).toHaveBeenCalledWith(503);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: expect.stringContaining("latest database migration"),
}),
})
);
});
});

View File

@ -1,109 +0,0 @@
jest.mock("../middleware/auth", () => (req, res, next) => {
req.user = { id: 42, role: "user" };
next();
});
jest.mock("../middleware/household", () => ({
householdAccess: (req, res, next) => {
req.household = {
id: Number.parseInt(req.params.householdId, 10),
role: req.headers["x-household-role"] || "user",
};
next();
},
requireHouseholdAdmin: (req, res, next) => {
if (["owner", "admin"].includes(req.household?.role)) {
return next();
}
return res.status(403).json({
error: { code: "FORBIDDEN", message: "Admin role required" },
request_id: req.request_id,
});
},
storeAccess: (req, res, next) => next(),
}));
jest.mock("../middleware/image", () => ({
upload: {
single: () => (req, res, next) => next(),
},
processImage: (req, res, next) => next(),
}));
jest.mock("../controllers/households.controller", () => ({
createHousehold: jest.fn(),
deleteHousehold: jest.fn(),
getHousehold: jest.fn(),
getMembers: jest.fn(),
getUserHouseholds: jest.fn(),
joinHousehold: jest.fn(),
refreshInviteCode: jest.fn(),
removeMember: jest.fn(),
updateHousehold: jest.fn(),
updateMemberRole: jest.fn(),
}));
jest.mock("../controllers/lists.controller.v2", () => ({
addItem: jest.fn(),
deleteItem: jest.fn(),
getClassification: jest.fn(),
getItemByName: jest.fn(),
getList: jest.fn(),
getRecentlyBought: jest.fn(),
getSuggestions: jest.fn(),
markBought: jest.fn(),
setClassification: jest.fn(),
updateItem: jest.fn(),
updateItemImage: jest.fn(),
}));
jest.mock("../controllers/available-items.controller", () => ({
createAvailableItem: jest.fn((req, res) => res.status(201).json({ message: "created" })),
deleteAvailableItem: jest.fn((req, res) => res.json({ message: "deleted" })),
getAvailableItems: jest.fn((req, res) => res.json({ items: [] })),
importCurrentItems: jest.fn((req, res) => res.json({ imported_count: 1 })),
updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })),
}));
const express = require("express");
const request = require("supertest");
const router = require("../routes/households.routes");
const availableItemsController = require("../controllers/available-items.controller");
describe("available-items routes", () => {
let app;
beforeEach(() => {
app = express();
app.use(express.json());
app.use("/households", router);
jest.clearAllMocks();
});
test("members can read available items", async () => {
const response = await request(app).get("/households/1/stores/2/available-items");
expect(response.status).toBe(200);
expect(availableItemsController.getAvailableItems).toHaveBeenCalled();
});
test("members cannot mutate available items", async () => {
const response = await request(app)
.post("/households/1/stores/2/available-items")
.set("x-household-role", "user")
.send({ item_name: "milk" });
expect(response.status).toBe(403);
expect(availableItemsController.createAvailableItem).not.toHaveBeenCalled();
});
test("admins can create available items", async () => {
const response = await request(app)
.post("/households/1/stores/2/available-items")
.set("x-household-role", "admin")
.send({ item_name: "milk" });
expect(response.status).toBe(201);
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
});
});

View File

@ -1,136 +0,0 @@
jest.mock("../db/pool", () => ({
query: jest.fn(),
}));
const pool = require("../db/pool");
const List = require("../models/list.model.v2");
describe("list.model.v2 addOrUpdateItem", () => {
beforeEach(() => {
pool.query.mockReset();
});
test("returns household store item metadata when creating a new list item", async () => {
pool.query
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88 }] });
const result = await List.addOrUpdateItem(1, 2, "Milk", 3, 7);
expect(result).toEqual({
listId: 88,
itemId: 55,
householdStoreItemId: 55,
itemName: "milk",
isNew: true,
});
expect(pool.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining("FROM household_store_items"),
[1, 2, "milk"]
);
expect(pool.query).toHaveBeenNthCalledWith(
2,
expect.stringContaining("INSERT INTO household_store_items"),
[1, 2, "milk", "milk"]
);
});
test("returns household store item metadata when updating an existing list item", async () => {
pool.query
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false }] })
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
expect(result).toEqual({
listId: 88,
itemId: 55,
householdStoreItemId: 55,
itemName: "milk",
isNew: false,
});
expect(pool.query).toHaveBeenNthCalledWith(
3,
expect.stringContaining("UPDATE household_lists"),
[4, 88]
);
});
});
describe("list.model.v2 classification helpers", () => {
beforeEach(() => {
pool.query.mockReset();
});
test("gets classification using household, store, and household-store item ids", async () => {
pool.query.mockResolvedValueOnce({
rowCount: 1,
rows: [
{
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
confidence: 1,
source: "user",
},
],
});
const result = await List.getClassification(1, 2, 55);
expect(result).toEqual({
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
confidence: 1,
source: "user",
});
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("household_store_item_id = $3"),
[1, 2, 55]
);
});
test("upserts classification using household-store item conflict target", async () => {
pool.query.mockResolvedValueOnce({
rowCount: 1,
rows: [
{
household_id: 1,
store_id: 2,
household_store_item_id: 55,
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
confidence: 1,
source: "user",
},
],
});
const result = await List.upsertClassification(1, 2, 55, {
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
confidence: 1,
source: "user",
});
expect(result).toEqual(
expect.objectContaining({
household_id: 1,
store_id: 2,
household_store_item_id: 55,
item_type: "dairy",
})
);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("ON CONFLICT (household_id, store_id, household_store_item_id)"),
[1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"]
);
});
});

View File

@ -1,9 +1,6 @@
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(),
upsertClassification: jest.fn(),
})); }));
jest.mock("../models/household.model", () => ({ jest.mock("../models/household.model", () => ({
@ -27,17 +24,12 @@ function createResponse() {
describe("lists.controller.v2 addItem", () => { describe("lists.controller.v2 addItem", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks();
List.addOrUpdateItem.mockResolvedValue({ List.addOrUpdateItem.mockResolvedValue({
listId: 42, listId: 42,
itemId: 99,
householdStoreItemId: 99,
itemName: "milk", itemName: "milk",
isNew: true, isNew: true,
}); });
List.addHistoryRecord.mockResolvedValue(undefined); List.addHistoryRecord.mockResolvedValue(undefined);
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
List.upsertClassification.mockResolvedValue(undefined);
householdModel.isHouseholdMember.mockResolvedValue(true); householdModel.isHouseholdMember.mockResolvedValue(true);
}); });
@ -54,7 +46,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, 99, "1", 9); expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 9);
expect(res.status).not.toHaveBeenCalledWith(400); expect(res.status).not.toHaveBeenCalledWith(400);
}); });
@ -71,7 +63,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, 99, "1", 7); expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
expect(res.status).not.toHaveBeenCalledWith(400); expect(res.status).not.toHaveBeenCalledWith(400);
}); });
@ -88,7 +80,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, 99, "1", 7); expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
expect(res.status).not.toHaveBeenCalledWith(400); expect(res.status).not.toHaveBeenCalledWith(400);
}); });
@ -164,216 +156,3 @@ describe("lists.controller.v2 addItem", () => {
); );
}); });
}); });
describe("lists.controller.v2 setClassification", () => {
beforeEach(() => {
jest.clearAllMocks();
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
List.upsertClassification.mockResolvedValue(undefined);
List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" });
});
test("accepts object classification with type, group, and zone", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: {
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
},
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).toHaveBeenCalledWith(
"1",
"2",
99,
expect.objectContaining({
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
confidence: 1.0,
source: "user",
})
);
expect(res.status).not.toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: "Classification set",
classification: {
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
},
})
);
});
test("accepts zone-only classification updates", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: {
zone: "Checkout Area",
},
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).toHaveBeenCalledWith(
"1",
"2",
99,
expect.objectContaining({
item_type: null,
item_group: null,
zone: "Checkout Area",
})
);
expect(res.status).not.toHaveBeenCalledWith(400);
});
test("rejects invalid item_type", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: {
item_type: "invalid-type",
},
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Invalid item_type",
}),
})
);
});
test("rejects invalid item_group for selected item_type", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: {
item_type: "dairy",
item_group: "Bread",
},
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Invalid item_group for selected item_type",
}),
})
);
});
test("rejects invalid zone", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: {
zone: "Space Aisle",
},
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Invalid zone",
}),
})
);
});
test("accepts legacy string classification values", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: {
item_name: "milk",
classification: "beverages",
},
user: { id: 7 },
};
const res = createResponse();
await controller.setClassification(req, res);
expect(List.upsertClassification).toHaveBeenCalledWith(
"1",
"2",
99,
expect.objectContaining({
item_type: "beverage",
item_group: null,
zone: null,
})
);
expect(res.status).not.toHaveBeenCalledWith(400);
});
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

@ -1,55 +0,0 @@
import api from "./axios";
function appendClassification(formData, classification) {
if (classification === undefined) {
return;
}
formData.append("classification", JSON.stringify(classification));
}
export const getAvailableItems = (householdId, storeId, query = "") =>
api.get(`/households/${householdId}/stores/${storeId}/available-items`, {
params: query ? { query } : undefined,
});
export const createAvailableItem = (householdId, storeId, payload) => {
const formData = new FormData();
formData.append("item_name", payload.itemName);
appendClassification(formData, payload.classification ?? undefined);
if (payload.imageFile) {
formData.append("image", payload.imageFile);
}
return api.post(`/households/${householdId}/stores/${storeId}/available-items`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
};
export const updateAvailableItem = (householdId, storeId, itemId, payload) => {
const formData = new FormData();
if (payload.itemName !== undefined) {
formData.append("item_name", payload.itemName);
}
appendClassification(formData, payload.classification);
if (payload.removeImage) {
formData.append("remove_image", "true");
}
if (payload.imageFile) {
formData.append("image", payload.imageFile);
}
return api.patch(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
};
export const deleteAvailableItem = (householdId, storeId, itemId) =>
api.delete(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`);
export const importCurrentAvailableItems = (householdId, storeId) =>
api.post(`/households/${householdId}/stores/${storeId}/available-items/import-current`);

View File

@ -63,40 +63,14 @@ export const setClassification = (householdId, storeId, itemName, classification
classification classification
}); });
function normalizeClassificationPayload(classification) {
if (!classification) return null;
if (typeof classification === "string") {
return classification.trim() || null;
}
if (typeof classification !== "object" || Array.isArray(classification)) {
return null;
}
const payload = {
item_type: typeof classification.item_type === "string" && classification.item_type.trim()
? classification.item_type.trim()
: null,
item_group: typeof classification.item_group === "string" && classification.item_group.trim()
? classification.item_group.trim()
: null,
zone: typeof classification.zone === "string" && classification.zone.trim()
? classification.zone.trim()
: null,
};
return payload.item_type || payload.item_group || payload.zone ? payload : null;
}
/** /**
* Update item with optional classification details. * Update item with classification (legacy method - split into separate calls)
*/ */
export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => { export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => {
const normalizedClassification = normalizeClassificationPayload(classification); // This is now two operations: update item + set classification
return Promise.all([ return Promise.all([
updateItem(householdId, storeId, itemName, quantity), updateItem(householdId, storeId, itemName, quantity),
normalizedClassification classification ? setClassification(householdId, storeId, itemName, classification) : Promise.resolve()
? setClassification(householdId, storeId, itemName, normalizedClassification)
: Promise.resolve()
]); ]);
}; };

View File

@ -1,15 +1,11 @@
import { memo, useRef, useState } from "react"; import { memo, useRef, useState } from "react";
import AddImageModal from "../modals/AddImageModal"; import AddImageModal from "../modals/AddImageModal";
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
function GroceryListItem({ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [], compact = false }) {
item,
onClick,
onOpenBuyModal,
onImageAdded,
onLongPress,
compact = false
}) {
const [showAddImageModal, setShowAddImageModal] = useState(false); const [showAddImageModal, setShowAddImageModal] = useState(false);
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
const [currentItem, setCurrentItem] = useState(item);
const longPressTimer = useRef(null); const longPressTimer = useRef(null);
const pressStartPos = useRef({ x: 0, y: 0 }); const pressStartPos = useRef({ x: 0, y: 0 });
@ -60,14 +56,32 @@ function GroceryListItem({
const handleItemClick = () => { const handleItemClick = () => {
if (onClick) { if (onClick) {
onClick(item); setCurrentItem(item);
setShowConfirmBuyModal(true);
} }
}; };
const handleConfirmBuy = (quantity) => {
if (onClick) {
onClick(currentItem.id, quantity);
}
setShowConfirmBuyModal(false);
};
const handleCancelBuy = () => {
setShowConfirmBuyModal(false);
};
const handleNavigate = (newItem) => {
setCurrentItem(newItem);
};
const handleImageClick = (e) => { const handleImageClick = (e) => {
e.stopPropagation(); // Prevent triggering the bought action e.stopPropagation(); // Prevent triggering the bought action
if (item.item_image && onOpenBuyModal) { if (item.item_image) {
onOpenBuyModal(item); // Open buy modal which now shows the image
setCurrentItem(item);
setShowConfirmBuyModal(true);
} else { } else {
setShowAddImageModal(true); setShowAddImageModal(true);
} }
@ -154,6 +168,16 @@ function GroceryListItem({
onAddImage={handleAddImage} onAddImage={handleAddImage}
/> />
)} )}
{showConfirmBuyModal && (
<ConfirmBuyModal
item={currentItem}
onConfirm={handleConfirmBuy}
onCancel={handleCancelBuy}
allItems={allItems}
onNavigate={handleNavigate}
/>
)}
</> </>
); );
} }
@ -171,9 +195,8 @@ export default memo(GroceryListItem, (prevProps, nextProps) => {
prevProps.item.zone === nextProps.item.zone && prevProps.item.zone === nextProps.item.zone &&
prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') && prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') &&
prevProps.onClick === nextProps.onClick && prevProps.onClick === nextProps.onClick &&
prevProps.onOpenBuyModal === nextProps.onOpenBuyModal &&
prevProps.onImageAdded === nextProps.onImageAdded && prevProps.onImageAdded === nextProps.onImageAdded &&
prevProps.onLongPress === nextProps.onLongPress && prevProps.onLongPress === nextProps.onLongPress &&
prevProps.compact === nextProps.compact prevProps.allItems?.length === nextProps.allItems?.length
); );
}); });

View File

@ -5,13 +5,11 @@ import {
removeStoreFromHousehold, removeStoreFromHousehold,
setDefaultStore setDefaultStore
} from "../../api/stores"; } from "../../api/stores";
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
import { StoreContext } from "../../context/StoreContext"; import { StoreContext } from "../../context/StoreContext";
import useActionToast from "../../hooks/useActionToast"; import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage"; import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/manage/ManageStores.css"; import "../../styles/components/manage/ManageStores.css";
import "../../styles/components/manage/StoreAvailableItemsManager.css";
export default function ManageStores() { export default function ManageStores() {
const { activeHousehold } = useContext(HouseholdContext); const { activeHousehold } = useContext(HouseholdContext);
@ -91,14 +89,6 @@ export default function ManageStores() {
{/* Current Stores Section */} {/* Current Stores Section */}
<section className="manage-section"> <section className="manage-section">
<h2>Your Stores ({householdStores.length})</h2> <h2>Your Stores ({householdStores.length})</h2>
<p className="manage-stores-help">
Use each store card's Manage Items button to edit or delete the household/store item list.
</p>
{!isAdmin && (
<p className="manage-stores-note">
Only household owners and admins can manage store item catalogs.
</p>
)}
{householdStores.length === 0 ? ( {householdStores.length === 0 ? (
<p className="empty-message">No stores added yet.</p> <p className="empty-message">No stores added yet.</p>
) : ( ) : (
@ -129,11 +119,6 @@ export default function ManageStores() {
</button> </button>
</div> </div>
)} )}
<StoreAvailableItemsManager
householdId={activeHousehold.id}
store={store}
isAdmin={isAdmin}
/>
</div> </div>
))} ))}
</div> </div>

View File

@ -1,256 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import {
deleteAvailableItem,
getAvailableItems,
updateAvailableItem,
} from "../../api/availableItems";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
function itemImageSource(item) {
if (!item?.item_image) {
return null;
}
const mimeType = item.image_mime_type || "image/jpeg";
return `data:${mimeType};base64,${item.item_image}`;
}
export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) {
const toast = useActionToast();
const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState([]);
const [catalogReady, setCatalogReady] = useState(true);
const [catalogMessage, setCatalogMessage] = useState("");
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [editorItem, setEditorItem] = useState(null);
const [showEditor, setShowEditor] = useState(false);
const [pendingDeleteItem, setPendingDeleteItem] = useState(null);
const loadItems = useCallback(async (search = query) => {
if (!householdId || !store?.id) {
setItems([]);
return;
}
setLoading(true);
try {
const response = await getAvailableItems(householdId, store.id, search);
setItems(response.data.items || []);
setCatalogReady(response.data.catalog_ready !== false);
setCatalogMessage(response.data.message || "");
} catch (error) {
console.error("Failed to load store items:", error);
setCatalogReady(false);
setCatalogMessage("Store item management is unavailable right now.");
const message = getApiErrorMessage(error, "Failed to load store items");
toast.error("Load store items failed", `Load store items failed: ${message}`);
} finally {
setLoading(false);
}
}, [householdId, query, store?.id, toast]);
useEffect(() => {
if (!isOpen) {
return;
}
loadItems(query);
}, [isOpen, query, loadItems]);
const closeManager = () => {
setIsOpen(false);
setPendingDeleteItem(null);
};
const handleUpdate = async (payload) => {
if (!catalogReady) {
toast.info(
"Store item management unavailable",
catalogMessage || "Store item management is unavailable until the latest database migration is applied."
);
return;
}
try {
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.name}`);
setShowEditor(false);
setEditorItem(null);
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update store item");
toast.error("Update store item failed", `Update store item failed: ${message}`);
throw error;
}
};
const handleDeleteConfirm = async () => {
if (!pendingDeleteItem) {
return;
}
try {
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.name}`);
setPendingDeleteItem(null);
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to delete store item");
toast.error("Delete store item failed", `Delete store item failed: ${message}`);
}
};
if (!isAdmin) {
return null;
}
return (
<>
<button
type="button"
className="btn-secondary btn-small store-available-items-trigger"
onClick={() => setIsOpen(true)}
>
Manage Items
</button>
{isOpen ? (
<div className="store-items-modal-overlay" onClick={closeManager}>
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header">
<div>
<h3>{store.name} Items</h3>
<p>Manage the household/store items used for suggestions and store defaults.</p>
</div>
<button
type="button"
className="store-items-modal-close"
onClick={closeManager}
aria-label="Close manage items modal"
>
x
</button>
</div>
{!catalogReady ? (
<p className="store-available-items-notice">
{catalogMessage || "Store item management is unavailable until the latest database migration is applied."}
</p>
) : null}
<div className="store-items-modal-toolbar">
<input
className="store-available-items-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search household/store items"
disabled={!catalogReady}
/>
</div>
<div className="store-items-modal-body">
{!catalogReady ? (
<p className="empty-message">Run the latest database migrations to enable store item management.</p>
) : loading ? (
<p className="empty-message">Loading store items...</p>
) : items.length === 0 ? (
<p className="empty-message">No household items found for this store yet.</p>
) : (
<div className="store-items-table">
<div className="store-items-table-head" aria-hidden="true">
<span>Item</span>
<span>Store Defaults</span>
<span>Actions</span>
</div>
<div className="store-items-table-body">
{items.map((item) => {
const imageSrc = itemImageSource(item);
const details = [item.item_type, item.item_group, item.zone].filter(Boolean);
return (
<div key={item.item_id} className="store-items-table-row">
<div className="store-items-table-cell store-items-table-item">
<span className="store-items-mobile-label">Item</span>
<div className="store-available-items-summary">
{imageSrc ? (
<img src={imageSrc} alt="" className="store-available-items-thumb" />
) : (
<span className="store-available-items-thumb store-available-items-thumb-placeholder">
{item.item_name?.slice(0, 1).toUpperCase() || "?"}
</span>
)}
<div className="store-available-items-copy">
<strong>{item.item_name}</strong>
</div>
</div>
</div>
<div className="store-items-table-cell">
<span className="store-items-mobile-label">Store Defaults</span>
<span className="store-items-defaults-text">
{details.join(" | ") || "No store defaults set"}
</span>
</div>
<div className="store-items-table-cell store-items-table-actions">
<span className="store-items-mobile-label">Actions</span>
<div className="store-available-items-actions">
<button
type="button"
className="btn-secondary btn-small"
onClick={() => {
setEditorItem(item);
setShowEditor(true);
}}
>
Edit Settings
</button>
<button
type="button"
className="btn-danger btn-small"
onClick={() => setPendingDeleteItem(item)}
>
Delete Item
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
) : null}
<AvailableItemEditorModal
isOpen={showEditor}
item={editorItem}
onCancel={() => {
setShowEditor(false);
setEditorItem(null);
}}
onSave={handleUpdate}
/>
<ConfirmSlideModal
isOpen={Boolean(pendingDeleteItem)}
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
description={
pendingDeleteItem
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.name} for this household, including current list entries and history.`
: ""
}
confirmLabel="Delete Item"
onClose={() => setPendingDeleteItem(null)}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@ -1,11 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import "../../styles/components/AddItemWithDetailsModal.css"; import "../../styles/components/AddItemWithDetailsModal.css";
import ClassificationSection from "../forms/ClassificationSection"; import ClassificationSection from "../forms/ClassificationSection";
import useActionToast from "../../hooks/useActionToast";
import ImageUploadSection from "../forms/ImageUploadSection"; import ImageUploadSection from "../forms/ImageUploadSection";
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) { export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
const toast = useActionToast();
const [selectedImage, setSelectedImage] = useState(null); const [selectedImage, setSelectedImage] = useState(null);
const [imagePreview, setImagePreview] = useState(null); const [imagePreview, setImagePreview] = useState(null);
const [itemType, setItemType] = useState(""); const [itemType, setItemType] = useState("");
@ -32,15 +30,15 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
}; };
const handleConfirm = () => { const handleConfirm = () => {
// Validate classification if provided
if (itemType && !itemGroup) { if (itemType && !itemGroup) {
toast.error("Add item failed", `Add item failed: Select an item group for ${itemName}`); alert("Please select an item group");
return; return;
} }
const hasClassificationDetails = Boolean(itemType || itemGroup || zone); const classification = itemType ? {
const classification = hasClassificationDetails ? {
item_type: itemType, item_type: itemType,
item_group: itemGroup || null, item_group: itemGroup,
zone: zone || null zone: zone || null
} : null; } : null;

View File

@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import "../../styles/components/AssignItemForModal.css"; import "../../styles/components/AssignItemForModal.css";
function getMemberLabel(member) { function getMemberLabel(member) {
@ -20,9 +19,7 @@ export default function AssignItemForModal({
}) { }) {
const [selectedUserId, setSelectedUserId] = useState(""); const [selectedUserId, setSelectedUserId] = useState("");
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [dropdownStyle, setDropdownStyle] = useState(null); const dropdownRef = useRef(null);
const triggerRef = useRef(null);
const menuRef = useRef(null);
const hasMembers = members.length > 0; const hasMembers = members.length > 0;
const selectedMember = useMemo( const selectedMember = useMemo(
@ -30,39 +27,10 @@ export default function AssignItemForModal({
[members, selectedUserId] [members, selectedUserId]
); );
const updateDropdownPosition = useCallback(() => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const viewportPadding = 16;
const menuGap = 6;
const width = Math.min(rect.width, window.innerWidth - (2 * viewportPadding));
const left = Math.min(
Math.max(viewportPadding, rect.left),
window.innerWidth - width - viewportPadding
);
const availableBelow = window.innerHeight - rect.bottom - menuGap - viewportPadding;
const availableAbove = rect.top - menuGap - viewportPadding;
const shouldOpenAbove = availableBelow < 140 && availableAbove > availableBelow;
const maxHeight = Math.max(
120,
Math.min(240, Math.floor(shouldOpenAbove ? availableAbove : availableBelow))
);
setDropdownStyle({
left: `${Math.round(left)}px`,
width: `${Math.round(width)}px`,
maxHeight: `${maxHeight}px`,
top: shouldOpenAbove ? "auto" : `${Math.round(rect.bottom + menuGap)}px`,
bottom: shouldOpenAbove ? `${Math.round(window.innerHeight - rect.top + menuGap)}px` : "auto",
});
}, []);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
setSelectedUserId(members[0] ? String(members[0].id) : ""); setSelectedUserId(members[0] ? String(members[0].id) : "");
setIsDropdownOpen(false); setIsDropdownOpen(false);
setDropdownStyle(null);
}, [isOpen, members]); }, [isOpen, members]);
useEffect(() => { useEffect(() => {
@ -86,10 +54,8 @@ export default function AssignItemForModal({
if (!isOpen || !isDropdownOpen) return undefined; if (!isOpen || !isDropdownOpen) return undefined;
const handlePointerDown = (event) => { const handlePointerDown = (event) => {
const clickedTrigger = triggerRef.current?.contains(event.target); if (!dropdownRef.current) return;
const clickedMenu = menuRef.current?.contains(event.target); if (!dropdownRef.current.contains(event.target)) {
if (!clickedTrigger && !clickedMenu) {
setIsDropdownOpen(false); setIsDropdownOpen(false);
} }
}; };
@ -98,24 +64,6 @@ export default function AssignItemForModal({
return () => window.removeEventListener("pointerdown", handlePointerDown); return () => window.removeEventListener("pointerdown", handlePointerDown);
}, [isDropdownOpen, isOpen]); }, [isDropdownOpen, isOpen]);
useEffect(() => {
if (!isOpen || !isDropdownOpen) return undefined;
updateDropdownPosition();
const handleViewportChange = () => {
updateDropdownPosition();
};
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [isDropdownOpen, isOpen, updateDropdownPosition]);
if (!isOpen) return null; if (!isOpen) return null;
const handleConfirm = () => { const handleConfirm = () => {
@ -123,52 +71,6 @@ export default function AssignItemForModal({
onConfirm(selectedMember.id); onConfirm(selectedMember.id);
}; };
const handleToggleDropdown = () => {
if (isDropdownOpen) {
setIsDropdownOpen(false);
return;
}
updateDropdownPosition();
setIsDropdownOpen(true);
};
const dropdownMenu = isDropdownOpen && dropdownStyle
? createPortal(
<div
ref={menuRef}
className="assign-item-for-dropdown-menu"
role="listbox"
aria-label="Household member"
style={dropdownStyle}
onClick={(event) => event.stopPropagation()}
>
{members.map((member) => {
const memberId = String(member.id);
const isSelected = memberId === String(selectedUserId);
return (
<button
key={member.id}
type="button"
className={`assign-item-for-dropdown-option ${isSelected ? "is-selected" : ""}`}
role="option"
aria-selected={isSelected}
onClick={() => {
setSelectedUserId(memberId);
setIsDropdownOpen(false);
}}
title={getMemberLabel(member)}
>
{getMemberOptionLabel(member)}
</button>
);
})}
</div>,
document.body
)
: null;
return ( return (
<div className="modal-overlay" onClick={onCancel}> <div className="modal-overlay" onClick={onCancel}>
<div className="modal assign-item-for-modal" onClick={(event) => event.stopPropagation()}> <div className="modal assign-item-for-modal" onClick={(event) => event.stopPropagation()}>
@ -179,14 +81,13 @@ export default function AssignItemForModal({
<label className="form-label"> <label className="form-label">
Household member Household member
</label> </label>
<div className="assign-item-for-dropdown"> <div className="assign-item-for-dropdown" ref={dropdownRef}>
<button <button
ref={triggerRef}
type="button" type="button"
className={`assign-item-for-dropdown-trigger ${isDropdownOpen ? "is-open" : ""}`} className={`assign-item-for-dropdown-trigger ${isDropdownOpen ? "is-open" : ""}`}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={isDropdownOpen} aria-expanded={isDropdownOpen}
onClick={handleToggleDropdown} onClick={() => setIsDropdownOpen((prev) => !prev)}
> >
<span className="assign-item-for-dropdown-label"> <span className="assign-item-for-dropdown-label">
{selectedMember ? getMemberOptionLabel(selectedMember) : "Select member"} {selectedMember ? getMemberOptionLabel(selectedMember) : "Select member"}
@ -195,6 +96,32 @@ export default function AssignItemForModal({
{isDropdownOpen ? "▲" : "▼"} {isDropdownOpen ? "▲" : "▼"}
</span> </span>
</button> </button>
{isDropdownOpen ? (
<div className="assign-item-for-dropdown-menu" role="listbox" aria-label="Household member">
{members.map((member) => {
const memberId = String(member.id);
const isSelected = memberId === String(selectedUserId);
return (
<button
key={member.id}
type="button"
className={`assign-item-for-dropdown-option ${isSelected ? "is-selected" : ""}`}
role="option"
aria-selected={isSelected}
onClick={() => {
setSelectedUserId(memberId);
setIsDropdownOpen(false);
}}
title={getMemberLabel(member)}
>
{getMemberOptionLabel(member)}
</button>
);
})}
</div>
) : null}
</div> </div>
</div> </div>
) : ( ) : (
@ -217,7 +144,6 @@ export default function AssignItemForModal({
</button> </button>
</div> </div>
</div> </div>
{dropdownMenu}
</div> </div>
); );
} }

View File

@ -1,166 +0,0 @@
import { useEffect, useState } from "react";
import ClassificationSection from "../forms/ClassificationSection";
import ImageUploadSection from "../forms/ImageUploadSection";
import useActionToast from "../../hooks/useActionToast";
import "../../styles/components/AvailableItemEditorModal.css";
function buildPreview(item) {
if (!item?.item_image) {
return null;
}
const mimeType = item.image_mime_type || "image/jpeg";
return `data:${mimeType};base64,${item.item_image}`;
}
export default function AvailableItemEditorModal({ isOpen, item = null, onCancel, onSave }) {
const toast = useActionToast();
const [itemName, setItemName] = useState("");
const [itemType, setItemType] = useState("");
const [itemGroup, setItemGroup] = useState("");
const [zone, setZone] = useState("");
const [selectedImage, setSelectedImage] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
const [removeImage, setRemoveImage] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) {
return;
}
setItemName(item?.item_name || "");
setItemType(item?.item_type || "");
setItemGroup(item?.item_group || "");
setZone(item?.zone || "");
setSelectedImage(null);
setImagePreview(buildPreview(item));
setRemoveImage(false);
}, [isOpen, item]);
if (!isOpen) {
return null;
}
const handleItemTypeChange = (nextType) => {
setItemType(nextType);
setItemGroup("");
};
const handleImageChange = (file) => {
setSelectedImage(file);
setRemoveImage(false);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result);
};
reader.readAsDataURL(file);
};
const handleImageRemove = () => {
setSelectedImage(null);
setImagePreview(null);
setRemoveImage(Boolean(item?.item_image));
};
const handleSubmit = async () => {
if (!itemName.trim()) {
toast.error("Save available item failed", "Save available item failed: Item name is required");
return;
}
if (itemType && !itemGroup) {
toast.error(
"Save available item failed",
`Save available item failed: Select an item group for ${itemName.trim()}`
);
return;
}
setSaving(true);
try {
await onSave({
itemName: itemName.trim(),
classification: itemType || itemGroup || zone
? {
item_type: itemType || null,
item_group: itemGroup || null,
zone: zone || null,
}
: null,
imageFile: selectedImage,
removeImage,
});
} finally {
setSaving(false);
}
};
return (
<div className="available-item-editor-overlay" onClick={onCancel}>
<div className="available-item-editor-modal" onClick={(event) => event.stopPropagation()}>
<h2 className="available-item-editor-title">
{item ? `Edit ${item.item_name}` : "Edit Store Item"}
</h2>
<p className="available-item-editor-subtitle">
Save store-specific defaults for this household/store item.
</p>
<div className="available-item-editor-field">
<label htmlFor="available-item-name">Item Name</label>
<input
id="available-item-name"
className="available-item-editor-input"
value={itemName}
onChange={(event) => setItemName(event.target.value)}
placeholder="Enter item name"
disabled={Boolean(item)}
/>
</div>
<div className="available-item-editor-section">
<ImageUploadSection
imagePreview={imagePreview}
onImageChange={handleImageChange}
onImageRemove={handleImageRemove}
title="Store Image (Optional)"
/>
</div>
<div className="available-item-editor-section">
<ClassificationSection
itemType={itemType}
itemGroup={itemGroup}
zone={zone}
onItemTypeChange={handleItemTypeChange}
onItemGroupChange={setItemGroup}
onZoneChange={setZone}
fieldClass="available-item-editor-field"
selectClass="available-item-editor-select"
title="Store Classification (Optional)"
/>
</div>
<div className="available-item-editor-actions">
<button
type="button"
className="available-item-editor-btn available-item-editor-btn-cancel"
onClick={onCancel}
disabled={saving}
>
Cancel
</button>
<button
type="button"
className="available-item-editor-btn available-item-editor-btn-save"
onClick={handleSubmit}
disabled={saving}
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
</div>
</div>
);
}

View File

@ -9,54 +9,45 @@ export default function ConfirmBuyModal({
onNavigate onNavigate
}) { }) {
const [quantity, setQuantity] = useState(item.quantity); const [quantity, setQuantity] = useState(item.quantity);
const [isSubmitting, setIsSubmitting] = useState(false);
const maxQuantity = item.quantity; const maxQuantity = item.quantity;
// Update quantity when item changes (navigation)
useEffect(() => { useEffect(() => {
setQuantity(item.quantity); setQuantity(item.quantity);
setIsSubmitting(false);
}, [item.id, item.quantity]); }, [item.id, item.quantity]);
const currentIndex = allItems.findIndex((listItem) => listItem.id === item.id); // Find current index and check for prev/next
const currentIndex = allItems.findIndex(i => i.id === item.id);
const hasPrev = currentIndex > 0; const hasPrev = currentIndex > 0;
const hasNext = currentIndex < allItems.length - 1; const hasNext = currentIndex < allItems.length - 1;
const handleIncrement = () => { const handleIncrement = () => {
if (!isSubmitting && quantity < maxQuantity) { if (quantity < maxQuantity) {
setQuantity((prev) => prev + 1); setQuantity(prev => prev + 1);
} }
}; };
const handleDecrement = () => { const handleDecrement = () => {
if (!isSubmitting && quantity > 1) { if (quantity > 1) {
setQuantity((prev) => prev - 1); setQuantity(prev => prev - 1);
} }
}; };
const handleConfirm = async () => { const handleConfirm = () => {
if (isSubmitting) return; onConfirm(quantity);
setIsSubmitting(true);
try {
await onConfirm(quantity);
} finally {
setIsSubmitting(false);
}
}; };
const handlePrev = () => { const handlePrev = () => {
if (isSubmitting) return;
if (hasPrev && onNavigate) { if (hasPrev && onNavigate) {
onNavigate(allItems[currentIndex - 1]); const prevItem = allItems[currentIndex - 1];
onNavigate(prevItem);
} }
}; };
const handleNext = () => { const handleNext = () => {
if (isSubmitting) return;
if (hasNext && onNavigate) { if (hasNext && onNavigate) {
onNavigate(allItems[currentIndex + 1]); const nextItem = allItems[currentIndex + 1];
onNavigate(nextItem);
} }
}; };
@ -65,15 +56,8 @@ export default function ConfirmBuyModal({
: null; : null;
return ( return (
<div <div className="confirm-buy-modal-overlay" onClick={onCancel}>
className="confirm-buy-modal-overlay" <div className="confirm-buy-modal" onClick={(e) => e.stopPropagation()}>
onClick={() => {
if (!isSubmitting) {
onCancel();
}
}}
>
<div className="confirm-buy-modal" onClick={(event) => event.stopPropagation()}>
<div className="confirm-buy-header"> <div className="confirm-buy-header">
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>} {item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
<h2 className="confirm-buy-item-name">{item.item_name}</h2> <h2 className="confirm-buy-item-name">{item.item_name}</h2>
@ -83,27 +67,27 @@ export default function ConfirmBuyModal({
<button <button
className="confirm-buy-nav-btn confirm-buy-nav-prev" className="confirm-buy-nav-btn confirm-buy-nav-prev"
onClick={handlePrev} onClick={handlePrev}
style={{ visibility: hasPrev ? "visible" : "hidden" }} style={{ visibility: hasPrev ? 'visible' : 'hidden' }}
disabled={!hasPrev || isSubmitting} disabled={!hasPrev}
> >
{"<"}
</button> </button>
<div className="confirm-buy-image-container"> <div className="confirm-buy-image-container">
{imageUrl ? ( {imageUrl ? (
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" /> <img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
) : ( ) : (
<div className="confirm-buy-image-placeholder">[ ]</div> <div className="confirm-buy-image-placeholder">📦</div>
)} )}
</div> </div>
<button <button
className="confirm-buy-nav-btn confirm-buy-nav-next" className="confirm-buy-nav-btn confirm-buy-nav-next"
onClick={handleNext} onClick={handleNext}
style={{ visibility: hasNext ? "visible" : "hidden" }} style={{ visibility: hasNext ? 'visible' : 'hidden' }}
disabled={!hasNext || isSubmitting} disabled={!hasNext}
> >
{">"}
</button> </button>
</div> </div>
@ -112,9 +96,9 @@ export default function ConfirmBuyModal({
<button <button
onClick={handleDecrement} onClick={handleDecrement}
className="confirm-buy-counter-btn" className="confirm-buy-counter-btn"
disabled={quantity <= 1 || isSubmitting} disabled={quantity <= 1}
> >
-
</button> </button>
<input <input
type="number" type="number"
@ -125,7 +109,7 @@ export default function ConfirmBuyModal({
<button <button
onClick={handleIncrement} onClick={handleIncrement}
className="confirm-buy-counter-btn" className="confirm-buy-counter-btn"
disabled={quantity >= maxQuantity || isSubmitting} disabled={quantity >= maxQuantity}
> >
+ +
</button> </button>
@ -133,11 +117,11 @@ export default function ConfirmBuyModal({
</div> </div>
<div className="confirm-buy-actions"> <div className="confirm-buy-actions">
<button onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}> <button onClick={onCancel} className="confirm-buy-cancel">
Cancel Cancel
</button> </button>
<button onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}> <button onClick={handleConfirm} className="confirm-buy-confirm">
{isSubmitting ? "Saving..." : "Mark as Bought"} Mark as Bought
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications"; import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
import useActionToast from "../../hooks/useActionToast"; import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/EditItemModal.css"; import "../../styles/components/EditItemModal.css";
import AddImageModal from "./AddImageModal"; import AddImageModal from "./AddImageModal";
@ -14,54 +15,50 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showImageModal, setShowImageModal] = useState(false); const [showImageModal, setShowImageModal] = useState(false);
// Load existing classification
useEffect(() => { useEffect(() => {
if (item.classification) { if (item.classification) {
setItemType(item.classification.item_type || ""); setItemType(item.classification.item_type || "");
setItemGroup(item.classification.item_group || ""); setItemGroup(item.classification.item_group || "");
setZone(item.classification.zone || ""); setZone(item.classification.zone || "");
return;
} }
setItemType("");
setItemGroup("");
setZone("");
}, [item]); }, [item]);
const handleItemTypeChange = (newType) => { const handleItemTypeChange = (newType) => {
setItemType(newType); setItemType(newType);
setItemGroup(""); setItemGroup(""); // Reset group when type changes
}; };
const handleSave = async () => { const handleSave = async () => {
if (!itemName.trim()) { if (!itemName.trim()) {
toast.error("Save item failed", "Save item failed: Item name is required"); alert("Item name is required");
return; return;
} }
if (quantity < 1) { if (quantity < 1) {
toast.error("Save item failed", "Save item failed: Quantity must be at least 1"); alert("Quantity must be at least 1");
return; return;
} }
// If classification fields are filled, validate them
if (itemType && !itemGroup) { if (itemType && !itemGroup) {
toast.error("Save item failed", `Save item failed: Select an item group for ${itemName}`); alert("Please select an item group");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const hasClassificationDetails = Boolean(itemType || itemGroup || zone); const classification = itemType ? {
const classification = hasClassificationDetails item_type: itemType,
? { item_group: itemGroup,
item_type: itemType, zone: zone || null
item_group: itemGroup || null, } : null;
zone: zone || null
}
: null;
await onSave(item.id, itemName, quantity, classification); await onSave(item.id, itemName, quantity, classification);
} catch (error) { } catch (error) {
console.error("Failed to save:", error); console.error("Failed to save:", error);
const message = getApiErrorMessage(error, "Failed to save changes");
toast.error("Save item failed", `Save item failed: ${message}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -74,18 +71,18 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
setShowImageModal(false); setShowImageModal(false);
} catch (error) { } catch (error) {
console.error("Failed to upload image:", error); console.error("Failed to upload image:", error);
const message = error?.response?.data?.error?.message || error?.response?.data?.message || "Failed to upload image"; const message = getApiErrorMessage(error, "Failed to upload image");
toast.error("Upload image failed", `Upload image failed: ${message}`); toast.error("Upload image failed", `Upload image failed: ${message}`);
} }
} }
}; };
const incrementQuantity = () => { const incrementQuantity = () => {
setQuantity((prev) => prev + 1); setQuantity(prev => prev + 1);
}; };
const decrementQuantity = () => { const decrementQuantity = () => {
setQuantity((prev) => Math.max(1, prev - 1)); setQuantity(prev => Math.max(1, prev - 1));
}; };
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : []; const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
@ -95,6 +92,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}> <div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
<h2 className="edit-modal-title">Edit Item</h2> <h2 className="edit-modal-title">Edit Item</h2>
{/* Item Name - no label */}
<input <input
type="text" type="text"
value={itemName} value={itemName}
@ -103,6 +101,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
placeholder="Item name" placeholder="Item name"
/> />
{/* Quantity Control - like AddItemForm */}
<div className="edit-modal-quantity-control"> <div className="edit-modal-quantity-control">
<button <button
type="button" type="button"
@ -110,7 +109,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
onClick={decrementQuantity} onClick={decrementQuantity}
disabled={quantity <= 1} disabled={quantity <= 1}
> >
-
</button> </button>
<input <input
type="number" type="number"
@ -130,6 +129,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
<div className="edit-modal-divider" /> <div className="edit-modal-divider" />
{/* Inline Classification Fields */}
<div className="edit-modal-inline-field"> <div className="edit-modal-inline-field">
<label>Type</label> <label>Type</label>
<select <select
@ -172,9 +172,9 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
className="edit-modal-select" className="edit-modal-select"
> >
<option value="">-- Select Zone --</option> <option value="">-- Select Zone --</option>
{getZoneValues().map((candidateZone) => ( {getZoneValues().map((z) => (
<option key={candidateZone} value={candidateZone}> <option key={z} value={z}>
{candidateZone} {z}
</option> </option>
))} ))}
</select> </select>
@ -188,7 +188,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
disabled={loading} disabled={loading}
type="button" type="button"
> >
{item.item_image ? "Change Image" : "Set Image"} {item.item_image ? "🖼️ Change Image" : "📷 Set Image"}
</button> </button>
<div className="edit-modal-actions"> <div className="edit-modal-actions">

View File

@ -15,12 +15,12 @@ import SortDropdown from "../components/common/SortDropdown";
import AddItemForm from "../components/forms/AddItemForm"; import AddItemForm from "../components/forms/AddItemForm";
import GroceryListItem from "../components/items/GroceryListItem"; import GroceryListItem from "../components/items/GroceryListItem";
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal"; import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
import ConfirmBuyModal from "../components/modals/ConfirmBuyModal";
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal"; import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
import EditItemModal from "../components/modals/EditItemModal"; import EditItemModal from "../components/modals/EditItemModal";
import SimilarItemModal from "../components/modals/SimilarItemModal"; import SimilarItemModal from "../components/modals/SimilarItemModal";
import StoreTabs from "../components/store/StoreTabs"; import StoreTabs from "../components/store/StoreTabs";
import { ZONE_FLOW } from "../constants/classifications"; import { ZONE_FLOW } from "../constants/classifications";
import { ROLES } from "../constants/roles";
import { AuthContext } from "../context/AuthContext"; import { AuthContext } from "../context/AuthContext";
import { HouseholdContext } from "../context/HouseholdContext"; import { HouseholdContext } from "../context/HouseholdContext";
import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext"; import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext";
@ -32,50 +32,6 @@ import getApiErrorMessage from "../lib/getApiErrorMessage";
import "../styles/pages/GroceryList.css"; import "../styles/pages/GroceryList.css";
import { findSimilarItems } from "../utils/stringSimilarity"; import { findSimilarItems } from "../utils/stringSimilarity";
function sortItemsForMode(items, sortMode) {
const sorted = [...items];
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity);
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
if (sortMode === "zone") {
sorted.sort((a, b) => {
if (!a.zone && b.zone) return 1;
if (a.zone && !b.zone) return -1;
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
const aZoneIndex = ZONE_FLOW.indexOf(a.zone);
const bZoneIndex = ZONE_FLOW.indexOf(b.zone);
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
const zoneCompare = aIndex - bIndex;
if (zoneCompare !== 0) return zoneCompare;
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
if (typeCompare !== 0) return typeCompare;
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
if (groupCompare !== 0) return groupCompare;
return a.item_name.localeCompare(b.item_name);
});
}
return sorted;
}
function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId);
if (remainingItems.length === 0) {
return null;
}
return remainingItems[currentIndex] || remainingItems[0];
}
export default function GroceryList() { export default function GroceryList() {
const pageTitle = "Grocery List"; const pageTitle = "Grocery List";
@ -90,7 +46,6 @@ export default function GroceryList() {
// Get household role for permissions // Get household role for permissions
const householdRole = activeHousehold?.role; const householdRole = activeHousehold?.role;
const isHouseholdAdmin = ["owner", "admin"].includes(householdRole); const isHouseholdAdmin = ["owner", "admin"].includes(householdRole);
const canEditList = Boolean(householdRole && householdRole !== "viewer");
// === State === // // === State === //
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
@ -111,7 +66,6 @@ export default function GroceryList() {
const [collapsedZones, setCollapsedZones] = useState({}); const [collapsedZones, setCollapsedZones] = useState({});
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false); const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
const [confirmAddExistingData, setConfirmAddExistingData] = useState(null); const [confirmAddExistingData, setConfirmAddExistingData] = useState(null);
const [buyModalState, setBuyModalState] = useState(null);
// === Data Loading === // === Data Loading ===
@ -152,10 +106,6 @@ export default function GroceryList() {
loadRecentlyBought(); loadRecentlyBought();
}, [activeHousehold?.id, activeStore?.id]); }, [activeHousehold?.id, activeStore?.id]);
useEffect(() => {
setBuyModalState(null);
}, [activeHousehold?.id, activeStore?.id]);
useEffect(() => { useEffect(() => {
const loadHouseholdMembers = async () => { const loadHouseholdMembers = async () => {
if (!activeHousehold?.id) { if (!activeHousehold?.id) {
@ -234,37 +184,46 @@ export default function GroceryList() {
// === Sorted Items Computation === // === Sorted Items Computation ===
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
return sortItemsForMode(items, sortMode); const sorted = [...items];
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity);
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
if (sortMode === "zone") {
sorted.sort((a, b) => {
// Items without classification go to the end
if (!a.zone && b.zone) return 1;
if (a.zone && !b.zone) return -1;
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
// Sort by ZONE_FLOW order
const aZoneIndex = ZONE_FLOW.indexOf(a.zone);
const bZoneIndex = ZONE_FLOW.indexOf(b.zone);
// If zone not in ZONE_FLOW, put at end
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
const zoneCompare = aIndex - bIndex;
if (zoneCompare !== 0) return zoneCompare;
// Then by item_type
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
if (typeCompare !== 0) return typeCompare;
// Then by item_group
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
if (groupCompare !== 0) return groupCompare;
// Finally by name
return a.item_name.localeCompare(b.item_name);
});
}
return sorted;
}, [items, sortMode]); }, [items, sortMode]);
const visibleRecentlyBoughtItems = useMemo(
() => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount),
[recentlyBoughtItems, recentlyBoughtDisplayCount]
);
const buyModalItems = useMemo(() => {
if (!buyModalState) return [];
return buyModalState.source === "active"
? sortedItems
: visibleRecentlyBoughtItems;
}, [buyModalState, sortedItems, visibleRecentlyBoughtItems]);
useEffect(() => {
if (!buyModalState) return;
const refreshedItem = buyModalItems.find((item) => item.id === buyModalState.item.id);
if (!refreshedItem || refreshedItem === buyModalState.item) return;
setBuyModalState((prev) => {
if (!prev || prev.item.id !== refreshedItem.id || prev.source !== buyModalState.source) {
return prev;
}
return { ...prev, item: refreshedItem };
});
}, [buyModalItems, buyModalState]);
// === Suggestion Handler === // === Suggestion Handler ===
const handleSuggest = async (text) => { const handleSuggest = async (text) => {
@ -577,90 +536,35 @@ export default function GroceryList() {
// === Item Action Handlers === // === Item Action Handlers ===
const handleBought = useCallback(async (quantity) => { const handleBought = useCallback(async (id, quantity) => {
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
if (!buyModalState || buyModalState.source !== "active") {
setBuyModalState(null);
return;
}
const item = items.find((listItem) => listItem.id === buyModalState.item.id) || buyModalState.item; const item = items.find(i => i.id === id);
if (!item) return; if (!item) return;
try { try {
const currentIndex = sortedItems.findIndex((listItem) => listItem.id === item.id);
const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true); await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
let nextItems = items; // If buying full quantity, remove from list
if (quantity >= item.quantity) { if (quantity >= item.quantity) {
nextItems = items.filter((existingItem) => existingItem.id !== item.id); setItems(prevItems => prevItems.filter((existingItem) => existingItem.id !== id));
} else { } else {
// If partial, fetch updated item
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name); const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
const updatedItem = response.data; const updatedItem = response.data;
nextItems = items.map((existingItem) => setItems((prevItems) =>
existingItem.id === item.id ? updatedItem : existingItem prevItems.map((existingItem) => (existingItem.id === id ? updatedItem : existingItem))
); );
} }
setItems(nextItems);
const nextSortedItems = sortItemsForMode(nextItems, sortMode);
const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id);
setBuyModalState(
nextModalItem
? {
item: nextModalItem,
source: "active",
canConfirm: true,
}
: null
);
toast.success("Marked item bought", `Marked item ${item.item_name} as bought`); toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
loadRecentlyBought(); loadRecentlyBought();
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to mark item as bought"); const message = getApiErrorMessage(error, "Failed to mark item as bought");
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`); toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
} }
}, [activeHousehold?.id, activeStore?.id, buyModalState, items, sortMode, sortedItems, toast]); }, [activeHousehold?.id, activeStore?.id, items, toast]);
const openActiveBuyModal = useCallback((item) => {
setBuyModalState({
item,
source: "active",
canConfirm: canEditList,
});
}, [canEditList]);
const openRecentBuyModal = useCallback((item) => {
setBuyModalState({
item,
source: "recent",
canConfirm: false,
});
}, []);
const handleBuyModalCancel = useCallback(() => {
setBuyModalState(null);
}, []);
const handleBuyModalNavigate = useCallback((item) => {
setBuyModalState((prev) => (prev ? { ...prev, item } : prev));
}, []);
const handleBuyModalConfirm = useCallback(async (quantity) => {
if (!buyModalState?.canConfirm) {
setBuyModalState(null);
return;
}
await handleBought(quantity);
}, [buyModalState?.canConfirm, handleBought]);
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => { const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => {
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
@ -693,10 +597,10 @@ export default function GroceryList() {
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
try { try {
const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.item_name); const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.id);
setEditingItem({ setEditingItem({
...item, ...item,
classification: classificationResponse.data?.classification || null classification: classificationResponse.data
}); });
setShowEditModal(true); setShowEditModal(true);
} catch (error) { } catch (error) {
@ -850,7 +754,7 @@ export default function GroceryList() {
<StoreTabs /> <StoreTabs />
{canEditList && ( {householdRole && householdRole !== 'viewer' && (
<AddItemForm <AddItemForm
onAdd={handleAdd} onAdd={handleAdd}
onSuggest={handleSuggest} onSuggest={handleSuggest}
@ -889,14 +793,16 @@ export default function GroceryList() {
<GroceryListItem <GroceryListItem
key={item.id} key={item.id}
item={item} item={item}
allItems={sortedItems}
compact={settings.compactView} compact={settings.compactView}
onClick={canEditList ? openActiveBuyModal : null} onClick={(id, quantity) =>
onOpenBuyModal={openActiveBuyModal} householdRole && householdRole !== 'viewer' && handleBought(id, quantity)
}
onImageAdded={ onImageAdded={
canEditList ? handleImageAdded : null householdRole && householdRole !== 'viewer' ? handleImageAdded : null
} }
onLongPress={ onLongPress={
canEditList ? handleLongPress : null householdRole && householdRole !== 'viewer' ? handleLongPress : null
} }
/> />
))} ))}
@ -912,14 +818,16 @@ export default function GroceryList() {
<GroceryListItem <GroceryListItem
key={item.id} key={item.id}
item={item} item={item}
allItems={sortedItems}
compact={settings.compactView} compact={settings.compactView}
onClick={canEditList ? openActiveBuyModal : null} onClick={(id, quantity) =>
onOpenBuyModal={openActiveBuyModal} [ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
}
onImageAdded={ onImageAdded={
canEditList ? handleImageAdded : null [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
} }
onLongPress={ onLongPress={
canEditList ? handleLongPress : null [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
} }
/> />
))} ))}
@ -941,18 +849,18 @@ export default function GroceryList() {
{!recentlyBoughtCollapsed && ( {!recentlyBoughtCollapsed && (
<> <>
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}> <ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
{visibleRecentlyBoughtItems.map((item) => ( {recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
<GroceryListItem <GroceryListItem
key={item.id} key={item.id}
item={item} item={item}
allItems={recentlyBoughtItems}
compact={settings.compactView} compact={settings.compactView}
onClick={null} onClick={null}
onOpenBuyModal={openRecentBuyModal}
onImageAdded={ onImageAdded={
canEditList ? handleImageAdded : null householdRole && householdRole !== 'viewer' ? handleImageAdded : null
} }
onLongPress={ onLongPress={
canEditList ? handleLongPress : null householdRole && householdRole !== 'viewer' ? handleLongPress : null
} }
/> />
))} ))}
@ -1001,16 +909,6 @@ export default function GroceryList() {
/> />
)} )}
{buyModalState && (
<ConfirmBuyModal
item={buyModalState.item}
onConfirm={handleBuyModalConfirm}
onCancel={handleBuyModalCancel}
allItems={buyModalItems}
onNavigate={handleBuyModalNavigate}
/>
)}
{showConfirmAddExisting && confirmAddExistingData && ( {showConfirmAddExisting && confirmAddExistingData && (
<ConfirmAddExistingModal <ConfirmAddExistingModal
itemName={confirmAddExistingData.itemName} itemName={confirmAddExistingData.itemName}

View File

@ -61,7 +61,7 @@
.add-item-details-image-options { .add-item-details-image-options {
display: flex; display: flex;
gap: var(--spacing-sm); gap: 0.8em;
flex-wrap: wrap; flex-wrap: wrap;
} }
@ -69,7 +69,7 @@
flex: 1; flex: 1;
min-width: 140px; min-width: 140px;
padding: var(--button-padding-y) var(--button-padding-x); padding: var(--button-padding-y) var(--button-padding-x);
font-size: var(--font-size-base); font-size: 0.95em;
border: var(--border-width-medium) solid var(--color-primary); border: var(--border-width-medium) solid var(--color-primary);
background: var(--color-bg-surface); background: var(--color-bg-surface);
color: var(--color-primary); color: var(--color-primary);
@ -101,99 +101,97 @@
.add-item-details-remove-image { .add-item-details-remove-image {
position: absolute; position: absolute;
top: var(--spacing-sm); top: 0.5em;
right: var(--spacing-sm); right: 0.5em;
background: var(--color-danger); background: rgba(220, 53, 69, 0.9);
color: var(--color-text-inverse); color: white;
border: none; border: none;
border-radius: var(--border-radius-md); border-radius: 6px;
padding: var(--spacing-sm) var(--spacing-md); padding: 0.4em 0.8em;
cursor: pointer; cursor: pointer;
font-weight: var(--font-weight-semibold); font-weight: 600;
font-size: var(--font-size-sm); font-size: 0.9em;
transition: var(--transition-base); transition: background 0.2s;
} }
.add-item-details-remove-image:hover { .add-item-details-remove-image:hover {
background: var(--color-danger-hover); background: rgba(220, 53, 69, 1);
} }
/* Classification Section */ /* Classification Section */
.add-item-details-field { .add-item-details-field {
margin-bottom: var(--spacing-md); margin-bottom: 1em;
} }
.add-item-details-field label { .add-item-details-field label {
display: block; display: block;
margin-bottom: var(--spacing-sm); margin-bottom: 0.4em;
font-weight: var(--font-weight-semibold); font-weight: 600;
color: var(--color-text-primary); color: #333;
font-size: var(--font-size-sm); font-size: 0.9em;
} }
.add-item-details-select { .add-item-details-select {
width: 100%; width: 100%;
padding: var(--input-padding-y) var(--input-padding-x); padding: 0.6em;
font-size: var(--font-size-base); font-size: 1em;
border: var(--border-width-thin) solid var(--input-border-color); border: 1px solid #ccc;
border-radius: var(--input-border-radius); border-radius: 6px;
box-sizing: border-box; box-sizing: border-box;
transition: var(--transition-base); transition: border-color 0.2s;
background: var(--color-bg-surface); background: white;
color: var(--color-text-primary);
} }
.add-item-details-select:focus { .add-item-details-select:focus {
outline: none; outline: none;
border-color: var(--input-focus-border-color); border-color: #007bff;
box-shadow: var(--input-focus-shadow);
} }
/* Actions */ /* Actions */
.add-item-details-actions { .add-item-details-actions {
display: flex; display: flex;
gap: var(--spacing-sm); gap: 0.6em;
margin-top: var(--spacing-lg); margin-top: 1.5em;
padding-top: var(--spacing-md); padding-top: 1em;
border-top: var(--border-width-thin) solid var(--color-border-light); border-top: 1px solid #e0e0e0;
} }
.add-item-details-btn { .add-item-details-btn {
flex: 1; flex: 1;
padding: var(--button-padding-y) var(--button-padding-x); padding: 0.7em;
font-size: var(--font-size-base); font-size: 1em;
border: none; border: none;
border-radius: var(--button-border-radius); border-radius: 6px;
cursor: pointer; cursor: pointer;
font-weight: var(--button-font-weight); font-weight: 600;
transition: var(--transition-base); transition: all 0.2s;
} }
.add-item-details-btn.cancel { .add-item-details-btn.cancel {
background: var(--color-secondary); background: #6c757d;
color: var(--color-text-inverse); color: white;
} }
.add-item-details-btn.cancel:hover { .add-item-details-btn.cancel:hover {
background: var(--color-secondary-hover); background: #5a6268;
} }
.add-item-details-btn.skip { .add-item-details-btn.skip {
background: var(--color-warning); background: #ffc107;
color: var(--color-text-primary); color: #333;
} }
.add-item-details-btn.skip:hover { .add-item-details-btn.skip:hover {
background: var(--color-warning-hover); background: #e0a800;
} }
.add-item-details-btn.confirm { .add-item-details-btn.confirm {
background: var(--color-primary); background: #007bff;
color: var(--color-text-inverse); color: white;
} }
.add-item-details-btn.confirm:hover { .add-item-details-btn.confirm:hover {
background: var(--color-primary-hover); background: #0056b3;
} }
/* Mobile responsiveness */ /* Mobile responsiveness */
@ -209,7 +207,7 @@
} }
.add-item-details-title { .add-item-details-title {
font-size: var(--font-size-xl); font-size: 1.3em;
} }
.add-item-details-select { .add-item-details-select {
@ -238,20 +236,20 @@
@media (max-width: 480px) { @media (max-width: 480px) {
.add-item-details-modal { .add-item-details-modal {
padding: var(--spacing-md); padding: 1rem;
border-radius: var(--border-radius-lg); border-radius: 8px;
} }
.add-item-details-title { .add-item-details-title {
font-size: var(--font-size-lg); font-size: 1.15em;
} }
.add-item-details-subtitle { .add-item-details-subtitle {
font-size: var(--font-size-sm); font-size: 0.85em;
} }
.add-item-details-section-title { .add-item-details-section-title {
font-size: var(--font-size-base); font-size: 1em;
} }
.add-item-details-image-options { .add-item-details-image-options {
@ -260,10 +258,10 @@
.add-item-details-image-btn { .add-item-details-image-btn {
min-width: 100%; min-width: 100%;
font-size: var(--font-size-sm); font-size: 0.9em;
} }
.add-item-details-field label { .add-item-details-field label {
font-size: var(--font-size-sm); font-size: 0.85em;
} }
} }

View File

@ -2,7 +2,6 @@
width: min(420px, calc(100vw - (2 * var(--spacing-md)))); width: min(420px, calc(100vw - (2 * var(--spacing-md))));
max-width: 420px; max-width: 420px;
overflow-x: hidden; overflow-x: hidden;
overflow-y: visible;
} }
.assign-item-for-modal-field { .assign-item-for-modal-field {
@ -55,10 +54,13 @@
} }
.assign-item-for-dropdown-menu { .assign-item-for-dropdown-menu {
position: fixed; position: absolute;
z-index: var(--z-tooltip); top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 3;
max-height: 180px;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
background: var(--color-bg-surface); background: var(--color-bg-surface);
border: var(--border-width-thin) solid var(--input-border-color); border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);

View File

@ -1,109 +0,0 @@
.available-item-editor-overlay {
position: fixed;
inset: 0;
background: var(--modal-backdrop-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
padding: var(--spacing-md);
}
.available-item-editor-modal {
width: min(560px, 100%);
max-height: 90vh;
overflow-y: auto;
background: var(--modal-bg);
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-xl);
padding: var(--spacing-lg);
}
.available-item-editor-title {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-xl);
}
.available-item-editor-subtitle {
margin: var(--spacing-xs) 0 var(--spacing-lg);
color: var(--color-text-secondary);
}
.available-item-editor-section {
margin-top: var(--spacing-lg);
}
.available-item-editor-field {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-md);
}
.available-item-editor-field label {
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
}
.available-item-editor-input,
.available-item-editor-select {
width: 100%;
box-sizing: border-box;
padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius);
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
.available-item-editor-input:focus,
.available-item-editor-select:focus {
outline: none;
border-color: var(--input-focus-border-color);
box-shadow: var(--input-focus-shadow);
}
.available-item-editor-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
.available-item-editor-btn {
flex: 1;
min-height: 42px;
border: none;
border-radius: var(--button-border-radius);
font-weight: var(--button-font-weight);
cursor: pointer;
transition: var(--transition-base);
}
.available-item-editor-btn-cancel {
background: var(--color-secondary);
color: var(--color-text-inverse);
}
.available-item-editor-btn-cancel:hover:not(:disabled) {
background: var(--color-secondary-hover);
}
.available-item-editor-btn-save {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.available-item-editor-btn-save:hover:not(:disabled) {
background: var(--color-primary-hover);
}
@media (max-width: 640px) {
.available-item-editor-modal {
padding: var(--spacing-md);
}
.available-item-editor-actions {
flex-direction: column;
}
}

View File

@ -25,20 +25,6 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.manage-stores-help {
margin: -0.25rem 0 1rem;
color: var(--text-secondary);
}
.manage-stores-note {
margin: -0.25rem 0 1rem;
padding: 0.875rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
color: var(--text-secondary);
}
/* Stores List */ /* Stores List */
.stores-list { .stores-list {
display: grid; display: grid;

View File

@ -1,233 +0,0 @@
.store-available-items-trigger {
width: 100%;
}
.store-items-modal-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md);
background: var(--modal-backdrop-bg);
}
.store-items-modal {
width: min(960px, 100%);
max-height: min(80vh, 760px);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
padding: var(--spacing-lg);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-xl);
background: var(--modal-bg);
box-shadow: var(--shadow-xl);
}
.store-items-modal-header {
display: flex;
justify-content: space-between;
gap: var(--spacing-md);
align-items: flex-start;
}
.store-items-modal-header h3 {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-xl);
}
.store-items-modal-header p {
margin: var(--spacing-xs) 0 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.store-items-modal-close {
width: 40px;
height: 40px;
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: 50%;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: var(--font-size-lg);
line-height: 1;
}
.store-items-modal-toolbar {
position: sticky;
top: 0;
z-index: 1;
background: var(--modal-bg);
}
.store-available-items-search {
width: 100%;
padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius);
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
.store-available-items-notice {
margin: 0;
padding: var(--spacing-sm) var(--spacing-md);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-md);
background: var(--color-bg-surface);
color: var(--color-text-secondary);
}
.store-items-modal-body {
min-height: 0;
overflow-y: auto;
}
.store-items-table {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.store-items-table-head,
.store-items-table-row {
display: grid;
grid-template-columns: minmax(220px, 2fr) minmax(180px, 2fr) minmax(170px, 1fr);
gap: var(--spacing-md);
align-items: center;
}
.store-items-table-head {
position: sticky;
top: 0;
padding: 0 var(--spacing-sm) var(--spacing-xs);
background: var(--modal-bg);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.store-items-table-body {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.store-items-table-row {
padding: var(--spacing-sm);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-md);
background: var(--color-bg-surface);
}
.store-items-table-cell {
min-width: 0;
}
.store-items-table-item {
min-width: 0;
}
.store-available-items-summary {
display: flex;
align-items: center;
gap: var(--spacing-sm);
min-width: 0;
}
.store-available-items-thumb {
width: 48px;
height: 48px;
border-radius: var(--border-radius-md);
object-fit: cover;
background: var(--color-bg-muted);
flex-shrink: 0;
}
.store-available-items-thumb-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-weight: var(--font-weight-semibold);
}
.store-available-items-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.store-available-items-copy strong {
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-items-defaults-text {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.store-items-table-actions {
justify-self: end;
}
.store-available-items-actions {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
justify-content: flex-end;
}
.store-items-mobile-label {
display: none;
}
@media (max-width: 720px) {
.store-items-modal {
max-height: min(88vh, 900px);
padding: var(--spacing-md);
}
.store-items-table-head {
display: none;
}
.store-items-table-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-sm);
}
.store-items-mobile-label {
display: block;
margin-bottom: 4px;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.store-items-table-actions {
justify-self: stretch;
}
.store-available-items-actions {
width: 100%;
justify-content: stretch;
}
.store-available-items-actions button {
flex: 1 1 0;
}
}

View File

@ -1,242 +0,0 @@
import { expect, test } from "@playwright/test";
function seedAuthStorage(page: import("@playwright/test").Page) {
return page.addInitScript(() => {
localStorage.setItem("token", "test-token");
localStorage.setItem("userId", "1");
localStorage.setItem("role", "admin");
localStorage.setItem("username", "catalog-user");
});
}
async function mockConfig(page: import("@playwright/test").Page) {
await page.route("**/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
maxFileSizeMB: 20,
maxImageDimension: 800,
imageQuality: 85,
}),
});
});
}
async function mockHouseholdAndStoreShell(page: import("@playwright/test").Page) {
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, name: "Catalog House", role: "admin", invite_code: "ABCD1234" },
]),
});
});
await page.route("**/stores/household/1", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
]),
});
});
}
test("manage stores opens a modal to edit and delete household store items", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await mockHouseholdAndStoreShell(page);
let availableItems = [
{
item_id: 501,
item_name: "milk",
item_image: null,
image_mime_type: null,
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
has_managed_settings: true,
},
{
item_id: 777,
item_name: "apples",
item_image: null,
image_mime_type: null,
item_type: null,
item_group: null,
zone: null,
has_managed_settings: false,
},
];
await page.route("**/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 10, name: "Costco" }]),
});
});
await page.route("**/households/1/stores/10/available-items*", async (route) => {
const request = route.request();
const url = new URL(request.url());
const query = (url.searchParams.get("query") || "").toLowerCase();
if (request.method() === "GET") {
const filteredItems = availableItems.filter((item) => item.item_name.includes(query));
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: filteredItems, catalog_ready: true }),
});
return;
}
await route.fulfill({ status: 500 });
});
await page.route("**/households/1/stores/10/available-items/777", async (route) => {
if (route.request().method() === "PATCH") {
availableItems = availableItems.map((item) =>
item.item_id === 777
? {
...item,
item_type: "produce",
item_group: "Fruits",
zone: "Produce & Fresh Vegetables",
has_managed_settings: true,
}
: item
);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Available item updated",
item: availableItems.find((item) => item.item_id === 777),
}),
});
return;
}
await route.fulfill({ status: 500 });
});
await page.route("**/households/1/stores/10/available-items/501", async (route) => {
if (route.request().method() === "DELETE") {
availableItems = availableItems.filter((item) => item.item_id !== 501);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "Store item deleted" }),
});
return;
}
await route.fulfill({ status: 500 });
});
await page.goto("/manage?tab=stores");
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
await expect(storeCard).toBeVisible();
await expect(storeCard.getByRole("button", { name: "Manage Items" })).toBeVisible();
await storeCard.getByRole("button", { name: "Manage Items" }).click();
const managerModal = page.locator(".store-items-modal");
await expect(managerModal).toBeVisible();
await expect(managerModal.getByText("milk")).toBeVisible();
await expect(managerModal.getByText("apples")).toBeVisible();
await managerModal.locator(".store-items-table-row").filter({ hasText: "apples" }).getByRole("button", { name: "Edit Settings" }).click();
const editorModal = page.locator(".available-item-editor-modal");
await expect(editorModal).toBeVisible();
await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
await editorModal.locator(".available-item-editor-select").nth(0).selectOption("produce");
await editorModal.locator(".available-item-editor-select").nth(1).selectOption("Fruits");
await editorModal.locator(".available-item-editor-select").nth(2).selectOption("Produce & Fresh Vegetables");
await editorModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible();
await managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" }).click();
const confirmModal = page.locator(".confirm-slide-modal");
await expect(confirmModal).toBeVisible();
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
const slider = confirmModal.locator(".confirm-slide-handle");
const track = confirmModal.locator(".confirm-slide-track");
const sliderBox = await slider.boundingBox();
const trackBox = await track.boundingBox();
if (!sliderBox || !trackBox) {
throw new Error("Confirm slide control was not measurable");
}
await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2);
await page.mouse.down();
await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, { steps: 8 });
await page.mouse.up();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Deleted store item");
await expect(managerModal.getByText("milk")).toHaveCount(0);
});
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await mockHouseholdAndStoreShell(page);
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
]),
});
});
await page.route("**/households/1/stores/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/item**", async (route) => {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ message: "Item not found" }),
});
});
await page.route("**/households/1/stores/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
});
await page.goto("/");
await expect(page.getByRole("button", { name: "Store Items" })).toHaveCount(0);
await expect(page.locator(".available-items-picker-modal")).toHaveCount(0);
});

View File

@ -1,279 +0,0 @@
import { expect, test } from "@playwright/test";
type MockItem = {
id: number;
item_id: number;
item_name: string;
quantity: number;
bought: boolean;
item_image: string | null;
image_mime_type: string | null;
added_by_users: string[];
last_added_on: string;
item_type: string | null;
item_group: string | null;
zone: string | null;
};
function seedAuthStorage(page: import("@playwright/test").Page) {
return page.addInitScript(() => {
localStorage.setItem("token", "test-token");
localStorage.setItem("userId", "1");
localStorage.setItem("role", "admin");
localStorage.setItem("username", "buy-modal-user");
});
}
async function mockConfig(page: import("@playwright/test").Page) {
await page.route("**/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
maxFileSizeMB: 20,
maxImageDimension: 800,
imageQuality: 85,
}),
});
});
}
function makeItem(
id: number,
itemName: string,
quantity: number,
overrides: Partial<MockItem> = {}
): MockItem {
return {
id,
item_id: id + 500,
item_name: itemName,
quantity,
bought: false,
item_image: null,
image_mime_type: null,
added_by_users: ["Owner User"],
last_added_on: "2026-03-28T12:00:00.000Z",
item_type: null,
item_group: null,
zone: "Produce",
...overrides,
};
}
async function setupBuyModalRoutes(
page: import("@playwright/test").Page,
initialItems: MockItem[]
) {
let activeItems = initialItems.map((item) => ({ ...item }));
let recentItems: MockItem[] = [];
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, name: "Auto Advance House", role: "admin", invite_code: "ABCD1234" },
]),
});
});
await page.route("**/stores/household/1", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
]),
});
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
]),
});
});
await page.route("**/households/1/stores/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(recentItems),
});
});
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ classification: null }),
});
});
await page.route("**/households/1/stores/10/list/item", async (route) => {
const request = route.request();
if (request.method() === "PATCH") {
const body = request.postDataJSON() as {
item_name?: string;
quantity_bought?: number | null;
};
const itemName = String(body.item_name || "").toLowerCase();
const quantityBought = Number(body.quantity_bought ?? 0);
const currentItem = activeItems.find((item) => item.item_name === itemName);
if (!currentItem) {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: { message: "Item not found" } }),
});
return;
}
const remainingQuantity = currentItem.quantity - quantityBought;
recentItems = [
{
...currentItem,
quantity: quantityBought,
bought: true,
},
...recentItems,
];
if (remainingQuantity <= 0) {
activeItems = activeItems.filter((item) => item.id !== currentItem.id);
} else {
activeItems = activeItems.map((item) =>
item.id === currentItem.id
? { ...item, quantity: remainingQuantity }
: item
);
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Item updated",
item: {
id: currentItem.id,
item_name: currentItem.item_name,
quantity: Math.max(remainingQuantity, 0),
bought: remainingQuantity <= 0,
},
}),
});
return;
}
const url = new URL(request.url());
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
const item = activeItems.find((entry) => entry.item_name === itemName);
await route.fulfill({
status: item ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(item || { message: "Item not found" }),
});
});
await page.route("**/households/1/stores/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: activeItems,
}),
});
});
}
async function openBuyModal(page: import("@playwright/test").Page, itemName: string) {
const row = page.locator(".glist-li").filter({ hasText: itemName });
await row.click();
await expect(page.locator(".confirm-buy-modal")).toBeVisible();
}
test("buying an item advances to the next one in the current sort order", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await setupBuyModalRoutes(page, [
makeItem(1, "milk", 2),
makeItem(2, "bread", 5),
makeItem(3, "apples", 3),
]);
await page.goto("/");
await page.locator(".glist-sort").selectOption("qty-high");
await openBuyModal(page, "bread");
await page.getByRole("button", { name: "Mark as Bought" }).click();
await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
});
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await setupBuyModalRoutes(page, [
makeItem(1, "apples", 3),
makeItem(2, "bread", 5),
makeItem(3, "milk", 2),
]);
await page.goto("/");
await page.locator(".glist-sort").selectOption("az");
await openBuyModal(page, "milk");
await page.getByRole("button", { name: "Mark as Bought" }).click();
await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
});
test("partial buy keeps the item on the list and advances past it", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await setupBuyModalRoutes(page, [
makeItem(1, "alpha", 1),
makeItem(2, "bravo", 3),
makeItem(3, "charlie", 5),
]);
await page.goto("/");
await page.locator(".glist-sort").selectOption("qty-low");
await openBuyModal(page, "bravo");
await page.locator(".confirm-buy-counter-btn").nth(0).click();
await page.getByRole("button", { name: "Mark as Bought" }).click();
await expect(page.locator(".confirm-buy-item-name")).toHaveText("charlie");
await expect(page.locator(".glist-li").filter({ hasText: "bravo" })).toContainText("x2");
});
test("buying the only remaining item closes the modal", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await setupBuyModalRoutes(page, [
makeItem(1, "solo", 1),
]);
await page.goto("/");
await openBuyModal(page, "solo");
await page.getByRole("button", { name: "Mark as Bought" }).click();
await expect(page.locator(".confirm-buy-modal")).toBeHidden();
});

View File

@ -1,318 +0,0 @@
import { expect, test } from "@playwright/test";
function seedAuthStorage(page: import("@playwright/test").Page) {
return page.addInitScript(() => {
localStorage.setItem("token", "test-token");
localStorage.setItem("userId", "1");
localStorage.setItem("role", "admin");
localStorage.setItem("username", "classification-user");
});
}
async function mockConfig(page: import("@playwright/test").Page) {
await page.route("**/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
maxFileSizeMB: 20,
maxImageDimension: 800,
imageQuality: 85,
}),
});
});
}
async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
let currentItem: {
id: number;
item_id: number;
item_name: string;
quantity: number;
bought: boolean;
item_image: string | null;
image_mime_type: string | null;
added_by_users: string[];
last_added_on: string;
item_type: string | null;
item_group: string | null;
zone: string | null;
} | null = null;
let currentClassification: {
item_type: string | null;
item_group: string | null;
zone: string | null;
} | null = null;
let classificationRequestMode: "success" | "error" = "success";
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, name: "Classification House", role: "admin", invite_code: "ABCD1234" },
]),
});
});
await page.route("**/stores/household/1", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
]),
});
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
]),
});
});
await page.route("**/households/1/stores/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ classification: currentClassification }),
});
return;
}
const body = request.postDataJSON() as {
classification?: string | { item_type?: string | null; item_group?: string | null; zone?: string | null };
};
if (classificationRequestMode === "error") {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({
error: { message: "Invalid zone" },
}),
});
return;
}
const payload = typeof body.classification === "string"
? { item_type: body.classification, item_group: null, zone: null }
: {
item_type: body.classification?.item_type ?? null,
item_group: body.classification?.item_group ?? null,
zone: body.classification?.zone ?? null,
};
currentClassification = payload;
if (currentItem) {
currentItem = {
...currentItem,
item_type: payload.item_type,
item_group: payload.item_group,
zone: payload.zone,
};
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Classification set",
classification: payload,
}),
});
});
await page.route("**/households/1/stores/10/list/item**", async (route) => {
const request = route.request();
if (request.method() === "PUT") {
const body = request.postDataJSON() as { item_name?: string; quantity?: number };
if (currentItem) {
currentItem = {
...currentItem,
item_name: String(body.item_name || currentItem.item_name).toLowerCase(),
quantity: Number(body.quantity || currentItem.quantity),
};
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Item updated",
item: {
id: currentItem?.id || 201,
item_name: currentItem?.item_name || "yogurt",
quantity: currentItem?.quantity || 1,
},
}),
});
return;
}
const url = new URL(request.url());
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
const itemMatches = currentItem && currentItem.item_name === itemName;
await route.fulfill({
status: itemMatches ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(itemMatches ? currentItem : { message: "Item not found" }),
});
});
await page.route("**/households/1/stores/10/list/add", async (route) => {
currentItem = {
id: 201,
item_id: 501,
item_name: "yogurt",
quantity: 1,
bought: false,
item_image: null,
image_mime_type: null,
added_by_users: ["Owner User"],
last_added_on: "2026-03-28T12:00:00.000Z",
item_type: currentClassification?.item_type ?? null,
item_group: currentClassification?.item_group ?? null,
zone: currentClassification?.zone ?? null,
};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Item added",
item: {
id: 201,
item_name: "yogurt",
quantity: 1,
bought: false,
},
}),
});
});
await page.route("**/households/1/stores/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: currentItem ? [currentItem] : [],
}),
});
});
return {
setClassificationRequestMode(mode: "success" | "error") {
classificationRequestMode = mode;
},
};
}
async function openEditModal(itemRow: ReturnType<import("@playwright/test").Page["locator"]>, page: import("@playwright/test").Page) {
await itemRow.dispatchEvent("mousedown");
await page.waitForTimeout(650);
await itemRow.dispatchEvent("mouseup");
await expect(page.locator(".edit-modal-content")).toBeVisible();
}
test("add-details modal validates with toasts and persists classification details", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await setupGroceryListRoutes(page);
let dialogSeen = false;
page.on("dialog", async (dialog) => {
dialogSeen = true;
await dialog.dismiss();
});
await page.goto("/");
await page.getByPlaceholder("Enter item name").fill("yogurt");
await page.getByRole("button", { name: "Create + Add" }).click();
const addDetailsModal = page.locator(".add-item-details-modal");
await expect(addDetailsModal).toBeVisible();
await addDetailsModal.locator(".add-item-details-select").nth(0).selectOption("dairy");
await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Select an item group");
expect(dialogSeen).toBe(false);
await addDetailsModal.locator(".add-item-details-select").nth(1).selectOption("Milk");
await addDetailsModal.locator(".add-item-details-select").nth(2).selectOption("Dairy & Refrigerated");
await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
await expect(yogurtRow).toBeVisible();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added item");
await openEditModal(yogurtRow, page);
const editModal = page.locator(".edit-modal-content");
await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("dairy");
await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Milk");
await expect(editModal.locator(".edit-modal-select").nth(2)).toHaveValue("Dairy & Refrigerated");
});
test("edit modal supports zone-only updates and shows API error toasts", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
const routes = await setupGroceryListRoutes(page);
await page.goto("/");
await page.getByPlaceholder("Enter item name").fill("yogurt");
await page.getByRole("button", { name: "Create + Add" }).click();
await page.locator(".add-item-details-modal").getByRole("button", { name: "Skip All" }).click();
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
await expect(yogurtRow).toBeVisible();
await openEditModal(yogurtRow, page);
let editModal = page.locator(".edit-modal-content");
await editModal.locator(".edit-modal-select").nth(0).selectOption("");
await editModal.locator(".edit-modal-select").nth(1).selectOption("Checkout Area");
await editModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated item");
await expect(editModal).toBeHidden();
await openEditModal(yogurtRow, page);
editModal = page.locator(".edit-modal-content");
await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("");
await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Checkout Area");
routes.setClassificationRequestMode("error");
await editModal.locator(".edit-modal-select").nth(1).selectOption("Bakery");
await editModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid zone");
});

View File

@ -1,233 +0,0 @@
import { expect, test } from "@playwright/test";
function seedAuthStorage(page: import("@playwright/test").Page) {
return page.addInitScript(() => {
localStorage.setItem("token", "test-token");
localStorage.setItem("userId", "1");
localStorage.setItem("role", "admin");
localStorage.setItem("username", "assignment-user");
});
}
async function mockConfig(page: import("@playwright/test").Page) {
await page.route("**/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
maxFileSizeMB: 20,
maxImageDimension: 800,
imageQuality: 85,
}),
});
});
}
test("assigned items render selected users and keep the picker menu outside the modal", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
const members = [
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
{ id: 2, username: "casey", name: "Casey Client", display_name: "Casey Client", role: "member" },
{ id: 3, username: "jordan", name: "Jordan Client", display_name: "Jordan Client", role: "member" },
{ id: 4, username: "alex", name: "Alex Member", display_name: "Alex Member", role: "member" },
{ id: 5, username: "morgan", name: "Morgan Member", display_name: "Morgan Member", role: "member" },
{ id: 6, username: "sam", name: "Sam Member", display_name: "Sam Member", role: "member" },
{ id: 7, username: "jamie", name: "Jamie Member", display_name: "Jamie Member", role: "member" },
{ id: 8, username: "pat", name: "Pat Member", display_name: "Pat Member", role: "member" },
{ id: 9, username: "drew", name: "Drew Member", display_name: "Drew Member", role: "member" },
{ id: 10, username: "kai", name: "Kai Member", display_name: "Kai Member", role: "member" },
{ id: 11, username: "blair", name: "Blair Member", display_name: "Blair Member", role: "member" },
{ id: 12, username: "quinn", name: "Quinn Member", display_name: "Quinn Member", role: "member" },
{ id: 13, username: "rowan", name: "Rowan Member", display_name: "Rowan Member", role: "member" },
{ id: 14, username: "sage", name: "Sage Member", display_name: "Sage Member", role: "member" },
{ id: 15, username: "taylor", name: "Taylor Member", display_name: "Taylor Member", role: "member" },
{ id: 16, username: "river", name: "River Member", display_name: "River Member", role: "member" },
];
let listItems: Array<{
id: number;
item_id: number;
item_name: string;
quantity: number;
bought: boolean;
item_image: string | null;
image_mime_type: string | null;
added_by_users: string[];
last_added_on: string;
item_type: string | null;
item_group: string | null;
zone: string | null;
}> = [];
let addCallCount = 0;
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, name: "Assignment House", role: "admin", invite_code: "ABCD1234" },
]),
});
});
await page.route("**/stores/household/1", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
]),
});
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(members),
});
});
await page.route("**/households/1/stores/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/item**", async (route) => {
const url = new URL(route.request().url());
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
const item = listItems.find((candidate) => candidate.item_name === itemName);
await route.fulfill({
status: item ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(item || { message: "Item not found" }),
});
});
await page.route("**/households/1/stores/10/list/add", async (route) => {
addCallCount += 1;
if (addCallCount === 1) {
listItems = [
{
id: 201,
item_id: 501,
item_name: "bananas",
quantity: 1,
bought: false,
item_image: null,
image_mime_type: null,
added_by_users: ["Casey Client"],
last_added_on: "2026-03-28T12:00:00.000Z",
item_type: null,
item_group: null,
zone: null,
},
];
} else {
listItems = [
{
id: 201,
item_id: 501,
item_name: "bananas",
quantity: 2,
bought: false,
item_image: null,
image_mime_type: null,
added_by_users: ["Casey Client", "Jordan Client"],
last_added_on: "2026-03-28T12:05:00.000Z",
item_type: null,
item_group: null,
zone: null,
},
];
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: addCallCount === 1 ? "Item added" : "Item updated",
item: {
id: 201,
item_name: "bananas",
quantity: addCallCount === 1 ? 1 : 2,
bought: false,
},
}),
});
});
await page.route("**/households/1/stores/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: listItems }),
});
});
await page.goto("/");
await expect(page.getByRole("heading", { name: "Grocery List" })).toBeVisible();
await page.getByPlaceholder("Enter item name").fill("bananas");
await page.getByRole("button", { name: "Others" }).click();
const assignModal = page.locator(".assign-item-for-modal");
await expect(assignModal).toBeVisible();
await assignModal.getByRole("button", { name: "Select member" }).click();
const portalMenu = page.locator("body > .assign-item-for-dropdown-menu");
await expect(portalMenu).toBeVisible();
await expect(page.locator(".assign-item-for-modal .assign-item-for-dropdown-menu")).toHaveCount(0);
const dropdownMetrics = await portalMenu.evaluate((element) => {
const menu = element as HTMLDivElement;
return {
position: window.getComputedStyle(menu).position,
scrollable: menu.scrollHeight > menu.clientHeight,
};
});
expect(dropdownMetrics.position).toBe("fixed");
expect(dropdownMetrics.scrollable).toBe(true);
await portalMenu.getByRole("option", { name: "Casey Client" }).click();
await assignModal.getByRole("button", { name: "Confirm" }).click();
await expect(page.getByText("Adding for: Casey Client")).toBeVisible();
await page.getByRole("button", { name: "Create + Add" }).click();
await page.getByRole("button", { name: "Skip All" }).click();
const bananasRow = page.locator(".glist-li").filter({ hasText: "bananas" });
await expect(bananasRow).toContainText("Casey Client");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added item");
await page.getByPlaceholder("Enter item name").fill("bananas");
await page.getByRole("button", { name: "Others" }).click();
await assignModal.getByRole("button", { name: "Select member" }).click();
await portalMenu.getByRole("option", { name: "Jordan Client" }).click();
await assignModal.getByRole("button", { name: "Confirm" }).click();
await expect(page.getByText("Adding for: Jordan Client")).toBeVisible();
await page.getByRole("button", { name: "Create + Add" }).click();
await page.getByRole("button", { name: "Update Quantity" }).click();
await expect(bananasRow).toContainText("Casey Client");
await expect(bananasRow).toContainText("Jordan Client");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated item quantity");
});

View File

@ -1,24 +0,0 @@
BEGIN;
CREATE TABLE IF NOT EXISTS household_store_available_items (
id SERIAL PRIMARY KEY,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
custom_image BYTEA,
custom_image_mime_type VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, store_id, item_id)
);
CREATE INDEX IF NOT EXISTS idx_available_items_household_store
ON household_store_available_items(household_id, store_id);
CREATE INDEX IF NOT EXISTS idx_available_items_item
ON household_store_available_items(item_id);
COMMENT ON TABLE household_store_available_items IS 'Curated household-store item catalogs';
COMMENT ON COLUMN household_store_available_items.custom_image IS 'Optional store-specific image override';
COMMIT;

View File

@ -1,199 +0,0 @@
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;

View File

@ -1,23 +0,0 @@
BEGIN;
WITH ranked_classifications AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY household_id, store_id, household_store_item_id
ORDER BY updated_at DESC NULLS LAST, id DESC
) AS row_rank
FROM household_item_classifications
WHERE household_store_item_id IS NOT NULL
)
DELETE FROM household_item_classifications hic
USING ranked_classifications ranked
WHERE hic.id = ranked.id
AND ranked.row_rank > 1;
DROP INDEX IF EXISTS idx_household_item_classifications_household_store_item;
CREATE UNIQUE INDEX idx_household_item_classifications_household_store_item
ON household_item_classifications(household_id, store_id, household_store_item_id);
COMMIT;