diff --git a/backend/controllers/available-items.controller.js b/backend/controllers/available-items.controller.js index 74617a1..bdb3d73 100644 --- a/backend/controllers/available-items.controller.js +++ b/backend/controllers/available-items.controller.js @@ -1,6 +1,6 @@ const AvailableItems = require("../models/available-item.model"); const List = require("../models/list.model.v2"); -const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); +const { isValidItemType, isValidItemGroup } = require("../constants/classifications"); const { sendError } = require("../utils/http"); const { logError } = require("../utils/logger"); @@ -13,6 +13,10 @@ function parseBoolean(value) { return value === true || value === "true" || value === "1"; } +function getStoreLocationId(req) { + return req.params.locationId || req.params.storeId; +} + function isCatalogTableMissing(error) { return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || ""); } @@ -84,7 +88,7 @@ function normalizeClassificationPayload(classification) { return { item_type, item_group, zone }; } -function validateClassification(res, classification) { +async function validateClassification(res, householdId, storeLocationId, classification) { if (!classification) { return false; } @@ -106,9 +110,12 @@ function validateClassification(res, classification) { return true; } - if (zone && !isValidZone(zone)) { - sendError(res, 400, "Invalid zone"); - return true; + if (zone) { + const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone); + if (!zoneRecord) { + sendError(res, 400, "Invalid zone"); + return true; + } } return false; @@ -121,8 +128,13 @@ function parseItemId(value) { exports.getAvailableItems = async (req, res) => { try { - const { householdId, storeId } = req.params; - const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || ""); + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); + const items = await AvailableItems.listAvailableItems( + householdId, + storeLocationId, + req.query.query || "" + ); res.json({ items, catalog_ready: true }); } catch (error) { if (isCatalogTableMissing(error)) { @@ -139,7 +151,8 @@ exports.getAvailableItems = async (req, res) => { exports.createAvailableItem = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name } = req.body; if (!item_name || item_name.trim() === "") { @@ -152,7 +165,7 @@ exports.createAvailableItem = async (req, res) => { } const normalizedClassification = normalizeClassificationPayload(parsedClassification); - if (validateClassification(res, normalizedClassification)) { + if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) { return; } @@ -161,21 +174,37 @@ exports.createAvailableItem = async (req, res) => { const item = await AvailableItems.createAvailableItem( householdId, - storeId, + storeLocationId, item_name, imageBuffer, - mimeType + mimeType, + req.user.id ); if (normalizedClassification) { - await List.upsertClassification(householdId, storeId, item.item_id, { + await List.upsertClassification(householdId, storeLocationId, item.item_id, { ...normalizedClassification, confidence: 1.0, source: "user", }); + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: item.item_id, + actorUserId: req.user.id, + eventType: "ITEM_CLASSIFICATION_CHANGED", + metadata: { + item_name, + ...normalizedClassification, + }, + }); } - const refreshedItem = await AvailableItems.getAvailableItemById(householdId, storeId, item.item_id); + const refreshedItem = await AvailableItems.getAvailableItemById( + householdId, + storeLocationId, + item.item_id + ); res.status(201).json({ message: "Available item added", @@ -199,7 +228,8 @@ exports.createAvailableItem = async (req, res) => { exports.updateAvailableItem = async (req, res) => { try { - const { householdId, storeId, itemId: rawItemId } = req.params; + const { householdId, itemId: rawItemId } = req.params; + const storeLocationId = getStoreLocationId(req); const itemId = parseItemId(rawItemId); if (!itemId) { @@ -214,15 +244,19 @@ exports.updateAvailableItem = async (req, res) => { } const normalizedClassification = normalizeClassificationPayload(parsedClassification); - if (normalizedClassification && validateClassification(res, normalizedClassification)) { + if ( + normalizedClassification && + (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) + ) { return; } - const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeId, itemId, { + const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeLocationId, itemId, { itemName: req.body.item_name, imageBuffer: req.processedImage?.buffer || null, mimeType: req.processedImage?.mimeType || null, removeImage: parseBoolean(req.body.remove_image), + userId: req.user.id, }); if (!updatedItem) { @@ -231,19 +265,30 @@ exports.updateAvailableItem = async (req, res) => { if (hasClassificationField) { if (normalizedClassification) { - await List.upsertClassification(householdId, storeId, updatedItem.item_id, { + await List.upsertClassification(householdId, storeLocationId, updatedItem.item_id, { ...normalizedClassification, confidence: 1.0, source: "user", }); + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: updatedItem.item_id, + actorUserId: req.user.id, + eventType: "ITEM_CLASSIFICATION_CHANGED", + metadata: { + item_name: updatedItem.item_name, + ...normalizedClassification, + }, + }); } else { - await List.deleteClassification(householdId, storeId, updatedItem.item_id); + await List.deleteClassification(householdId, storeLocationId, updatedItem.item_id); } } const refreshedItem = await AvailableItems.getAvailableItemById( householdId, - storeId, + storeLocationId, updatedItem.item_id ); @@ -269,14 +314,20 @@ exports.updateAvailableItem = async (req, res) => { exports.deleteAvailableItem = async (req, res) => { try { - const { householdId, storeId, itemId: rawItemId } = req.params; + const { householdId, itemId: rawItemId } = req.params; + const storeLocationId = getStoreLocationId(req); 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); + const deleted = await AvailableItems.deleteAvailableItem( + householdId, + storeLocationId, + itemId, + req.user.id + ); if (!deleted) { return sendError(res, 404, "Store item not found"); @@ -298,8 +349,9 @@ exports.deleteAvailableItem = async (req, res) => { exports.importCurrentItems = async (req, res) => { try { - const { householdId, storeId } = req.params; - const importedCount = await AvailableItems.importCurrentListItems(householdId, storeId); + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); + const importedCount = await AvailableItems.importCurrentListItems(householdId, storeLocationId); res.json({ message: importedCount > 0 ? "Imported current list items" : "No current list items to import", diff --git a/backend/controllers/lists.controller.v2.js b/backend/controllers/lists.controller.v2.js index 8295fdb..d09eda9 100644 --- a/backend/controllers/lists.controller.v2.js +++ b/backend/controllers/lists.controller.v2.js @@ -1,6 +1,6 @@ const List = require("../models/list.model.v2"); const householdModel = require("../models/household.model"); -const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); +const { isValidItemType, isValidItemGroup } = require("../constants/classifications"); const { sendError } = require("../utils/http"); const { logError } = require("../utils/logger"); @@ -9,6 +9,10 @@ const LEGACY_ITEM_TYPE_MAP = { snacks: "snack", }; +function getStoreLocationId(req) { + return req.params.locationId || req.params.storeId; +} + function normalizeClassificationPayload(classification) { if (typeof classification === "string") { const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification; @@ -43,14 +47,40 @@ function normalizeClassificationPayload(classification) { return { item_type, item_group, zone }; } -/** - * Get list items for household and store - * GET /households/:householdId/stores/:storeId/list - */ +async function validateClassification(res, householdId, storeLocationId, classification) { + 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) { + const zoneRecord = await List.getZoneByName(householdId, storeLocationId, zone); + if (!zoneRecord) { + sendError(res, 400, "Invalid zone"); + return true; + } + } + + return false; +} + exports.getList = async (req, res) => { try { - const { householdId, storeId } = req.params; - const items = await List.getHouseholdStoreList(householdId, storeId); + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); + const items = await List.getHouseholdStoreList(householdId, storeLocationId); res.json({ items }); } catch (error) { logError(req, "listsV2.getList", error); @@ -58,20 +88,17 @@ exports.getList = async (req, res) => { } }; -/** - * Get specific item by name - * GET /households/:householdId/stores/:storeId/list/item - */ exports.getItemByName = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name } = req.query; if (!item_name) { return sendError(res, 400, "Item name is required"); } - const item = await List.getItemByName(householdId, storeId, item_name); + const item = await List.getItemByName(householdId, storeLocationId, item_name); if (!item) { return sendError(res, 404, "Item not found"); } @@ -83,13 +110,10 @@ exports.getItemByName = async (req, res) => { } }; -/** - * Add or update item in household store list - * POST /households/:householdId/stores/:storeId/list/add - */ exports.addItem = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name, quantity, notes, added_for_user_id } = req.body; const userId = req.user.id; let historyUserId = userId; @@ -118,13 +142,12 @@ exports.addItem = async (req, res) => { historyUserId = parsedUserId; } - // Get processed image if uploaded const imageBuffer = req.processedImage?.buffer || null; const mimeType = req.processedImage?.mimeType || null; const result = await List.addOrUpdateItem( householdId, - storeId, + storeLocationId, item_name, quantity || "1", userId, @@ -133,17 +156,38 @@ exports.addItem = async (req, res) => { notes ); - // Add history record - await List.addHistoryRecord(result.listId, result.householdStoreItemId, quantity || "1", historyUserId); + await List.addHistoryRecord( + result.listId, + result.householdStoreItemId, + result.historyQuantity ?? quantity ?? "1", + historyUserId, + storeLocationId + ); + + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: result.householdStoreItemId, + householdListId: result.listId, + actorUserId: historyUserId, + eventType: "ITEM_ADDED", + quantityDelta: result.historyQuantity ?? Number.parseInt(quantity || "1", 10), + quantityAfter: result.quantity, + metadata: { + item_name: result.itemName, + is_new_list_item: result.isNew, + added_by_request_user_id: userId, + }, + }); res.json({ message: result.isNew ? "Item added" : "Item updated", item: { id: result.listId, item_name: result.itemName, - quantity: quantity || "1", - bought: false - } + quantity: result.quantity ?? quantity ?? "1", + bought: false, + }, }); } catch (error) { logError(req, "listsV2.addItem", error); @@ -151,23 +195,35 @@ exports.addItem = async (req, res) => { } }; -/** - * Mark item as bought or unbought - * PATCH /households/:householdId/stores/:storeId/list/item - */ exports.markBought = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name, bought, quantity_bought } = req.body; if (!item_name) return sendError(res, 400, "Item name is required"); - const item = await List.getItemByName(householdId, storeId, item_name); + const item = await List.getItemByName(householdId, storeLocationId, item_name); if (!item) return sendError(res, 404, "Item not found"); + const eventDetails = await List.setBought(item.id, bought, quantity_bought); - // Update bought status (with optional partial purchase) - await List.setBought(item.id, bought, quantity_bought); + if (eventDetails) { + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: item.household_store_item_id, + householdListId: item.id, + actorUserId: req.user.id, + eventType: eventDetails.eventType, + quantityDelta: eventDetails.quantityDelta, + quantityAfter: eventDetails.quantityAfter, + metadata: { + item_name, + requested_quantity: quantity_bought || null, + }, + }); + } res.json({ message: bought ? "Item marked as bought" : "Item unmarked" }); } catch (error) { @@ -176,27 +232,42 @@ exports.markBought = async (req, res) => { } }; -/** - * Update item details (quantity, notes) - * PUT /households/:householdId/stores/:storeId/list/item - */ exports.updateItem = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name, quantity, notes } = req.body; if (!item_name) { return sendError(res, 400, "Item name is required"); } - // Get the list item - const item = await List.getItemByName(householdId, storeId, item_name); + const item = await List.getItemByName(householdId, storeLocationId, item_name); if (!item) { return sendError(res, 404, "Item not found"); } - // Update item - await List.updateItem(item.id, item_name, quantity, notes); + const updateResult = await List.updateItem(item.id, item_name, quantity, notes); + if (!updateResult) { + return sendError(res, 404, "Item not found"); + } + + if (quantity !== undefined && Number(quantity) !== Number(updateResult.previous.quantity)) { + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: item.household_store_item_id, + householdListId: item.id, + actorUserId: req.user.id, + eventType: "ITEM_QUANTITY_CHANGED", + quantityDelta: Number(quantity) - Number(updateResult.previous.quantity), + quantityAfter: Number(quantity), + metadata: { + item_name, + previous_quantity: updateResult.previous.quantity, + }, + }); + } res.json({ message: "Item updated", @@ -204,8 +275,8 @@ exports.updateItem = async (req, res) => { id: item.id, item_name, quantity, - notes - } + notes, + }, }); } catch (error) { logError(req, "listsV2.updateItem", error); @@ -213,26 +284,36 @@ exports.updateItem = async (req, res) => { } }; -/** - * Delete item from list - * DELETE /households/:householdId/stores/:storeId/list/item - */ exports.deleteItem = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name } = req.body; if (!item_name) { return sendError(res, 400, "Item name is required"); } - // Get the list item - const item = await List.getItemByName(householdId, storeId, item_name); + const item = await List.getItemByName(householdId, storeLocationId, item_name); if (!item) { return sendError(res, 404, "Item not found"); } - await List.deleteItem(item.id); + const deleted = await List.deleteItem(item.id); + + if (deleted) { + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: item.household_store_item_id, + householdListId: item.id, + actorUserId: req.user.id, + eventType: "ITEM_DELETED", + quantityDelta: -Number(item.quantity || 0), + quantityAfter: 0, + metadata: { item_name }, + }); + } res.json({ message: "Item deleted" }); } catch (error) { @@ -241,16 +322,13 @@ exports.deleteItem = async (req, res) => { } }; -/** - * Get item suggestions based on query - * GET /households/:householdId/stores/:storeId/list/suggestions - */ exports.getSuggestions = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { query } = req.query; - const suggestions = await List.getSuggestions(query || "", householdId, storeId); + const suggestions = await List.getSuggestions(query || "", householdId, storeLocationId); res.json(suggestions); } catch (error) { logError(req, "listsV2.getSuggestions", error); @@ -258,14 +336,11 @@ exports.getSuggestions = async (req, res) => { } }; -/** - * Get recently bought items - * GET /households/:householdId/stores/:storeId/list/recent - */ exports.getRecentlyBought = async (req, res) => { try { - const { householdId, storeId } = req.params; - const items = await List.getRecentlyBoughtItems(householdId, storeId); + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); + const items = await List.getRecentlyBoughtItems(householdId, storeLocationId); res.json(items); } catch (error) { logError(req, "listsV2.getRecentlyBought", error); @@ -273,26 +348,26 @@ exports.getRecentlyBought = async (req, res) => { } }; -/** - * Get item classification - * GET /households/:householdId/stores/:storeId/list/classification - */ exports.getClassification = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name } = req.query; if (!item_name) { return sendError(res, 400, "Item name is required"); } - // Get item ID from name - const item = await List.getItemByName(householdId, storeId, item_name); + const item = await List.getItemByName(householdId, storeLocationId, item_name); if (!item) { return res.json({ classification: null }); } - const classification = await List.getClassification(householdId, storeId, item.item_id); + const classification = await List.getClassification( + householdId, + storeLocationId, + item.item_id + ); res.json({ classification }); } catch (error) { logError(req, "listsV2.getClassification", error); @@ -300,13 +375,10 @@ exports.getClassification = async (req, res) => { } }; -/** - * Set/update item classification - * POST /households/:householdId/stores/:storeId/list/classification - */ exports.setClassification = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name, classification } = req.body; if (!item_name) { @@ -318,33 +390,17 @@ exports.setClassification = async (req, res) => { return sendError(res, 400, "Classification is required"); } - const { item_type, item_group, zone } = normalizedClassification; - - if (item_type && !isValidItemType(item_type)) { - return sendError(res, 400, "Invalid item_type"); + if (await validateClassification(res, householdId, storeLocationId, normalizedClassification)) { + return; } - 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 - const item = await List.getItemByName(householdId, storeId, item_name); + const item = await List.getItemByName(householdId, storeLocationId, item_name); let itemId; if (!item) { - // Item doesn't exist in list, need to get from items table or create const itemResult = await List.ensureHouseholdStoreItem( householdId, - storeId, + storeLocationId, item_name ); itemId = itemResult.id; @@ -352,14 +408,43 @@ exports.setClassification = async (req, res) => { itemId = item.item_id; } - await List.upsertClassification(householdId, storeId, itemId, { - item_type, - item_group, - zone, + const updated = await List.upsertClassification(householdId, storeLocationId, itemId, { + ...normalizedClassification, confidence: 1.0, source: "user", }); + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: itemId, + householdListId: item?.id || null, + actorUserId: req.user.id, + eventType: "ITEM_CLASSIFICATION_CHANGED", + metadata: { + item_name, + item_type: normalizedClassification.item_type, + item_group: normalizedClassification.item_group, + zone: normalizedClassification.zone, + }, + }); + + if (normalizedClassification.zone) { + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: itemId, + householdListId: item?.id || null, + actorUserId: req.user.id, + eventType: "ITEM_ZONE_CHANGED", + metadata: { + item_name, + zone: normalizedClassification.zone, + zone_id: updated.zone_id || null, + }, + }); + } + res.json({ message: "Classification set", classification: normalizedClassification }); } catch (error) { logError(req, "listsV2.setClassification", error); @@ -367,17 +452,13 @@ exports.setClassification = async (req, res) => { } }; -/** - * Update item image - * POST /households/:householdId/stores/:storeId/list/update-image - */ exports.updateItemImage = async (req, res) => { try { - const { householdId, storeId } = req.params; + const { householdId } = req.params; + const storeLocationId = getStoreLocationId(req); const { item_name, quantity } = req.body; const userId = req.user.id; - // Get processed image const imageBuffer = req.processedImage?.buffer || null; const mimeType = req.processedImage?.mimeType || null; @@ -385,8 +466,15 @@ exports.updateItemImage = async (req, res) => { return sendError(res, 400, "No image provided"); } - // Update the item with new image - await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType); + await List.addOrUpdateItem( + householdId, + storeLocationId, + item_name, + quantity, + userId, + imageBuffer, + mimeType + ); res.json({ message: "Image updated successfully" }); } catch (error) { diff --git a/backend/controllers/stores.controller.js b/backend/controllers/stores.controller.js index 6e4adee..d4a086e 100644 --- a/backend/controllers/stores.controller.js +++ b/backend/controllers/stores.controller.js @@ -2,7 +2,20 @@ const storeModel = require("../models/store.model"); const { sendError } = require("../utils/http"); const { logError } = require("../utils/logger"); -// Get all available stores +function parsePositiveInteger(value) { + const parsed = Number.parseInt(String(value), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function getHouseholdId(req) { + return req.params.householdId || req.household?.id; +} + +function getLocationId(req) { + return req.params.locationId || req.params.storeId; +} + +// Legacy global store catalog. Kept for system-admin compatibility. exports.getAllStores = async (req, res) => { try { const stores = await storeModel.getAllStores(); @@ -13,78 +26,6 @@ exports.getAllStores = async (req, res) => { } }; -// Get stores for household -exports.getHouseholdStores = async (req, res) => { - try { - const stores = await storeModel.getHouseholdStores(req.params.householdId); - res.json(stores); - } catch (error) { - logError(req, "stores.getHouseholdStores", error); - sendError(res, 500, "Failed to fetch household stores"); - } -}; - -// Add store to household -exports.addStoreToHousehold = async (req, res) => { - try { - const { storeId, isDefault } = req.body; - // console.log("Adding store to household:", { householdId: req.params.householdId, storeId, isDefault }); - if (!storeId) { - return sendError(res, 400, "Store ID is required"); - } - - const store = await storeModel.getStoreById(storeId); - if (!store) return sendError(res, 404, "Store not found"); - const foundStores = await storeModel.getHouseholdStores(req.params.householdId); - // if (foundStores.length == 0) isDefault = 'true'; - - await storeModel.addStoreToHousehold( - req.params.householdId, - storeId, - foundStores.length == 0 ? true : isDefault || false - ); - - res.status(201).json({ - message: "Store added to household successfully", - store - }); - } catch (error) { - logError(req, "stores.addStoreToHousehold", error); - sendError(res, 500, "Failed to add store to household"); - } -}; - -// Remove store from household -exports.removeStoreFromHousehold = async (req, res) => { - try { - await storeModel.removeStoreFromHousehold( - req.params.householdId, - req.params.storeId - ); - - res.json({ message: "Store removed from household successfully" }); - } catch (error) { - logError(req, "stores.removeStoreFromHousehold", error); - sendError(res, 500, "Failed to remove store from household"); - } -}; - -// Set default store -exports.setDefaultStore = async (req, res) => { - try { - await storeModel.setDefaultStore( - req.params.householdId, - req.params.storeId - ); - - res.json({ message: "Default store updated successfully" }); - } catch (error) { - logError(req, "stores.setDefaultStore", error); - sendError(res, 500, "Failed to set default store"); - } -}; - -// Create store (system admin only) exports.createStore = async (req, res) => { try { const { name, default_zones } = req.body; @@ -97,25 +38,24 @@ exports.createStore = async (req, res) => { res.status(201).json({ message: "Store created successfully", - store + store, }); } catch (error) { logError(req, "stores.createStore", error); - if (error.code === '23505') { // Unique violation + if (error.code === "23505") { return sendError(res, 400, "Store with this name already exists"); } sendError(res, 500, "Failed to create store"); } }; -// Update store (system admin only) exports.updateStore = async (req, res) => { try { const { name, default_zones } = req.body; const store = await storeModel.updateStore(req.params.storeId, { name: name?.trim(), - default_zones + default_zones, }); if (!store) { @@ -124,7 +64,7 @@ exports.updateStore = async (req, res) => { res.json({ message: "Store updated successfully", - store + store, }); } catch (error) { logError(req, "stores.updateStore", error); @@ -132,16 +72,341 @@ exports.updateStore = async (req, res) => { } }; -// Delete store (system admin only) exports.deleteStore = async (req, res) => { try { await storeModel.deleteStore(req.params.storeId); res.json({ message: "Store deleted successfully" }); } catch (error) { logError(req, "stores.deleteStore", error); - if (error.message.includes('in use')) { + if (error.message.includes("in use")) { return sendError(res, 400, error.message); } sendError(res, 500, "Failed to delete store"); } }; + +// Household-owned store/location management. +exports.getHouseholdStores = async (req, res) => { + try { + const stores = await storeModel.getHouseholdStores(getHouseholdId(req)); + res.json(stores); + } catch (error) { + logError(req, "stores.getHouseholdStores", error); + sendError(res, 500, "Failed to fetch household stores"); + } +}; + +exports.createHouseholdStore = async (req, res) => { + try { + const householdId = getHouseholdId(req); + const { name, location_name, address } = req.body; + + if (!name || name.trim().length === 0) { + return sendError(res, 400, "Store name is required"); + } + + const store = await storeModel.createHouseholdStore( + householdId, + name, + location_name || "Default Location", + address || null, + req.user.id + ); + + res.status(201).json({ + message: "Store location created successfully", + store, + }); + } catch (error) { + logError(req, "stores.createHouseholdStore", error); + if (error.code === "23505") { + return sendError(res, 400, "Store or location already exists for this household"); + } + sendError(res, 500, "Failed to create store location"); + } +}; + +exports.updateHouseholdStore = async (req, res) => { + try { + const { name } = req.body; + const householdStoreId = parsePositiveInteger(req.params.householdStoreId); + + if (!householdStoreId) { + return sendError(res, 400, "Store ID must be a positive integer"); + } + + if (!name || name.trim().length === 0) { + return sendError(res, 400, "Store name is required"); + } + + const store = await storeModel.updateHouseholdStore(getHouseholdId(req), householdStoreId, { + name, + }); + + if (!store) { + return sendError(res, 404, "Store not found"); + } + + res.json({ message: "Store updated successfully", store }); + } catch (error) { + logError(req, "stores.updateHouseholdStore", error); + sendError(res, 500, "Failed to update store"); + } +}; + +exports.deleteHouseholdStore = async (req, res) => { + try { + const householdStoreId = parsePositiveInteger(req.params.householdStoreId); + if (!householdStoreId) { + return sendError(res, 400, "Store ID must be a positive integer"); + } + + const deleted = await storeModel.deleteHouseholdStore(getHouseholdId(req), householdStoreId); + if (!deleted) { + return sendError(res, 404, "Store not found"); + } + + res.json({ message: "Store deleted successfully" }); + } catch (error) { + logError(req, "stores.deleteHouseholdStore", error); + if (error.message.includes("last store location")) { + return sendError(res, 400, error.message); + } + sendError(res, 500, "Failed to delete store"); + } +}; + +exports.addLocationToStore = async (req, res) => { + try { + const householdStoreId = parsePositiveInteger(req.params.householdStoreId); + const { name, address } = req.body; + + if (!householdStoreId) { + return sendError(res, 400, "Store ID must be a positive integer"); + } + + if (!name || name.trim().length === 0) { + return sendError(res, 400, "Location name is required"); + } + + const location = await storeModel.addLocationToStore( + getHouseholdId(req), + householdStoreId, + name, + address || null, + req.user.id + ); + + if (!location) { + return sendError(res, 404, "Store not found"); + } + + res.status(201).json({ + message: "Location added successfully", + store: location, + }); + } catch (error) { + logError(req, "stores.addLocationToStore", error); + if (error.code === "23505") { + return sendError(res, 400, "Location already exists for this store"); + } + sendError(res, 500, "Failed to add location"); + } +}; + +exports.updateLocation = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + const { name, address, map_data } = req.body; + + if (!locationId) { + return sendError(res, 400, "Location ID must be a positive integer"); + } + + const location = await storeModel.updateLocation(getHouseholdId(req), locationId, { + name, + address, + map_data, + }); + + if (!location) { + return sendError(res, 404, "Location not found"); + } + + res.json({ message: "Location updated successfully", store: location }); + } catch (error) { + logError(req, "stores.updateLocation", error); + sendError(res, 500, "Failed to update location"); + } +}; + +exports.deleteLocation = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + if (!locationId) { + return sendError(res, 400, "Location ID must be a positive integer"); + } + + const deleted = await storeModel.deleteLocation(getHouseholdId(req), locationId); + if (!deleted) { + return sendError(res, 404, "Location not found"); + } + + res.json({ message: "Location removed successfully" }); + } catch (error) { + logError(req, "stores.deleteLocation", error); + if (error.message.includes("last store location")) { + return sendError(res, 400, error.message); + } + sendError(res, 500, "Failed to remove location"); + } +}; + +exports.setDefaultLocation = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + if (!locationId) { + return sendError(res, 400, "Location ID must be a positive integer"); + } + + await storeModel.setDefaultLocation(getHouseholdId(req), locationId); + res.json({ message: "Default location updated successfully" }); + } catch (error) { + logError(req, "stores.setDefaultLocation", error); + sendError(res, 500, "Failed to set default location"); + } +}; + +exports.getLocationZones = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + if (!locationId) { + return sendError(res, 400, "Location ID must be a positive integer"); + } + + const zones = await storeModel.listLocationZones(getHouseholdId(req), locationId); + res.json({ zones }); + } catch (error) { + logError(req, "stores.getLocationZones", error); + sendError(res, 500, "Failed to load zones"); + } +}; + +exports.createZone = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + const { name, sort_order, color, map_metadata } = req.body; + + if (!locationId) { + return sendError(res, 400, "Location ID must be a positive integer"); + } + + if (!name || name.trim().length === 0) { + return sendError(res, 400, "Zone name is required"); + } + + const zone = await storeModel.createZone(getHouseholdId(req), locationId, { + name, + sort_order: Number.isInteger(sort_order) ? sort_order : Number.parseInt(sort_order, 10), + color, + map_metadata, + }); + + res.status(201).json({ message: "Zone created successfully", zone }); + } catch (error) { + logError(req, "stores.createZone", error); + if (error.code === "23505") { + return sendError(res, 400, "Zone already exists for this location"); + } + sendError(res, 500, "Failed to create zone"); + } +}; + +exports.updateZone = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + const zoneId = parsePositiveInteger(req.params.zoneId); + + if (!locationId || !zoneId) { + return sendError(res, 400, "Location ID and zone ID must be positive integers"); + } + + const sortOrder = req.body.sort_order; + const zone = await storeModel.updateZone(getHouseholdId(req), locationId, zoneId, { + name: req.body.name, + sort_order: + sortOrder === undefined + ? undefined + : Number.isInteger(sortOrder) + ? sortOrder + : Number.parseInt(sortOrder, 10), + color: req.body.color, + map_metadata: req.body.map_metadata, + is_active: req.body.is_active, + }); + + if (!zone) { + return sendError(res, 404, "Zone not found"); + } + + res.json({ message: "Zone updated successfully", zone }); + } catch (error) { + logError(req, "stores.updateZone", error); + sendError(res, 500, "Failed to update zone"); + } +}; + +exports.deleteZone = async (req, res) => { + try { + const locationId = parsePositiveInteger(getLocationId(req)); + const zoneId = parsePositiveInteger(req.params.zoneId); + + if (!locationId || !zoneId) { + return sendError(res, 400, "Location ID and zone ID must be positive integers"); + } + + const deleted = await storeModel.deleteZone(getHouseholdId(req), locationId, zoneId); + if (!deleted) { + return sendError(res, 404, "Zone not found"); + } + + res.json({ message: "Zone removed successfully" }); + } catch (error) { + logError(req, "stores.deleteZone", error); + sendError(res, 500, "Failed to remove zone"); + } +}; + +// Backward-compatible handlers for the old /stores/household routes. +exports.addStoreToHousehold = async (req, res) => { + try { + const { storeId } = req.body; + if (!storeId) { + return sendError(res, 400, "Store ID is required"); + } + + const legacyStore = await storeModel.getStoreById(storeId); + if (!legacyStore) { + return sendError(res, 404, "Store not found"); + } + + const store = await storeModel.createHouseholdStore( + getHouseholdId(req), + legacyStore.name, + "Default Location", + null, + req.user.id + ); + + res.status(201).json({ + message: "Store added to household successfully", + store, + }); + } catch (error) { + logError(req, "stores.addStoreToHousehold", error); + sendError(res, 500, "Failed to add store to household"); + } +}; + +exports.removeStoreFromHousehold = exports.deleteLocation; +exports.setDefaultStore = exports.setDefaultLocation; diff --git a/backend/middleware/household.js b/backend/middleware/household.js index 8a2ece0..4ce5fff 100644 --- a/backend/middleware/household.js +++ b/backend/middleware/household.js @@ -90,6 +90,43 @@ exports.storeAccess = async (req, res, next) => { } }; +// Middleware to check location access (household must own the store location) +exports.locationAccess = async (req, res, next) => { + try { + const locationId = parseInt(req.params.locationId || req.params.storeId); + + if (!locationId) { + return sendError(res, 400, "Location ID required"); + } + + if (!req.household) { + return sendError(res, 500, "Household context not set. Use householdAccess middleware first."); + } + + const storeModel = require("../models/store.model"); + const hasLocation = await storeModel.householdHasLocation(req.household.id, locationId); + + if (!hasLocation) { + return sendError(res, 403, "This household does not have access to this store location."); + } + + req.storeLocation = { + id: locationId + }; + + // Keep req.store populated so older controller code and tests can continue + // to refer to the active shopping scope as a store. + req.store = { + id: locationId + }; + + next(); + } catch (error) { + logError(req, "middleware.locationAccess", error); + sendError(res, 500, "Server error checking location access"); + } +}; + // Middleware to require system admin role exports.requireSystemAdmin = (req, res, next) => { if (!req.user) { diff --git a/backend/models/available-item.model.js b/backend/models/available-item.model.js index 0d4c7d6..e6d6571 100644 --- a/backend/models/available-item.model.js +++ b/backend/models/available-item.model.js @@ -1,81 +1,97 @@ const pool = require("../db/pool"); +const List = require("./list.model.v2"); function normalizeItemName(itemName) { return String(itemName || "").trim().toLowerCase(); } -async function getHouseholdStoreItemRecord(householdId, storeId, itemId) { +async function getHouseholdStoreItemRecord(householdId, storeLocationId, itemId) { const result = await pool.query( `WITH latest_list_items AS ( SELECT DISTINCT ON (hl.household_store_item_id) hl.household_store_item_id, + hl.image_id, hl.custom_image, hl.custom_image_mime_type, hl.modified_on, hl.id FROM household_lists hl WHERE hl.household_id = $1 - AND hl.store_id = $2 + AND hl.store_location_id = $2 ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC ) SELECT hsi.id AS item_id, hsi.name AS item_name, - ENCODE(COALESCE(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, + ENCODE( + COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image), + 'base64' + ) AS item_image, + COALESCE( + catalog_img.mime_type, + hsi.custom_image_mime_type, + list_img.mime_type, + lli.custom_image_mime_type + ) AS image_mime_type, hic.item_type, hic.item_group, - hic.zone + COALESCE(slz.name, hic.zone) AS zone, + slz.sort_order AS zone_sort_order FROM household_store_items hsi + LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id + LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id LEFT JOIN household_item_classifications hic ON hic.household_id = hsi.household_id - AND hic.store_id = hsi.store_id + AND hic.store_location_id = hsi.store_location_id AND hic.household_store_item_id = hsi.id + LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id WHERE hsi.household_id = $1 - AND hsi.store_id = $2 + AND hsi.store_location_id = $2 AND hsi.id = $3`, - [householdId, storeId, itemId] + [householdId, storeLocationId, itemId] ); return result.rows[0] || null; } -async function findOrCreateHouseholdStoreItem(householdId, storeId, itemName) { +async function findOrCreateHouseholdStoreItem(householdId, storeLocationId, itemName) { const normalizedName = normalizeItemName(itemName); const existing = await pool.query( `SELECT id, name FROM household_store_items WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 AND normalized_name = $3`, - [householdId, storeId, normalizedName] + [householdId, storeLocationId, normalizedName] ); if (existing.rowCount > 0) { return { itemId: existing.rows[0].id, itemName: existing.rows[0].name, + isNew: false, }; } const created = await pool.query( `INSERT INTO household_store_items - (household_id, store_id, name, normalized_name, updated_at) + (household_id, store_location_id, name, normalized_name, updated_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING id, name`, - [householdId, storeId, normalizedName, normalizedName] + [householdId, storeLocationId, normalizedName, normalizedName] ); return { itemId: created.rows[0].id, itemName: created.rows[0].name, + isNew: true, }; } -exports.listAvailableItems = async (householdId, storeId, query = "") => { +exports.listAvailableItems = async (householdId, storeLocationId, query = "") => { const trimmedQuery = String(query || "").trim(); - const values = [householdId, storeId]; + const values = [householdId, storeLocationId]; let filterClause = ""; if (trimmedQuery) { @@ -87,35 +103,49 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => { `WITH latest_list_items AS ( SELECT DISTINCT ON (hl.household_store_item_id) hl.household_store_item_id, + hl.image_id, hl.custom_image, hl.custom_image_mime_type, hl.modified_on, hl.id FROM household_lists hl WHERE hl.household_id = $1 - AND hl.store_id = $2 + AND hl.store_location_id = $2 ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC ) SELECT hsi.id AS item_id, hsi.name AS item_name, - ENCODE(COALESCE(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, + ENCODE( + COALESCE(catalog_img.image, hsi.custom_image, list_img.image, lli.custom_image), + 'base64' + ) AS item_image, + COALESCE( + catalog_img.mime_type, + hsi.custom_image_mime_type, + list_img.mime_type, + lli.custom_image_mime_type + ) AS image_mime_type, hic.item_type, hic.item_group, - hic.zone, + COALESCE(slz.name, hic.zone) AS zone, + slz.sort_order AS zone_sort_order, ( - hsi.custom_image IS NOT NULL + hsi.image_id IS NOT NULL + OR hsi.custom_image IS NOT NULL OR hic.household_store_item_id IS NOT NULL ) AS has_managed_settings FROM household_store_items hsi + LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id + LEFT JOIN household_item_images list_img ON list_img.id = lli.image_id LEFT JOIN household_item_classifications hic ON hic.household_id = hsi.household_id - AND hic.store_id = hsi.store_id + AND hic.store_location_id = hsi.store_location_id AND hic.household_store_item_id = hsi.id + LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id WHERE hsi.household_id = $1 - AND hsi.store_id = $2 + AND hsi.store_location_id = $2 ${filterClause} ORDER BY hsi.name ASC LIMIT 100`, @@ -125,22 +155,23 @@ exports.listAvailableItems = async (householdId, storeId, query = "") => { return result.rows; }; -exports.getAvailableItemById = async (householdId, storeId, itemId) => - getHouseholdStoreItemRecord(householdId, storeId, itemId); +exports.getAvailableItemById = async (householdId, storeLocationId, itemId) => + getHouseholdStoreItemRecord(householdId, storeLocationId, itemId); -exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => { +exports.getAvailableItemImageByName = async (householdId, storeLocationId, 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] + hsi.id AS item_id, + hsi.name AS item_name, + COALESCE(img.image, hsi.custom_image) AS custom_image, + COALESCE(img.mime_type, hsi.custom_image_mime_type) AS custom_image_mime_type + FROM household_store_items hsi + LEFT JOIN household_item_images img ON img.id = hsi.image_id + WHERE hsi.household_id = $1 + AND hsi.store_location_id = $2 + AND hsi.normalized_name = $3`, + [householdId, storeLocationId, normalizedName] ); return result.rows[0] || null; @@ -148,39 +179,54 @@ exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => exports.createAvailableItem = async ( householdId, - storeId, + storeLocationId, itemName, imageBuffer = null, - mimeType = null + mimeType = null, + userId = null ) => { - const { itemId } = await findOrCreateHouseholdStoreItem(householdId, storeId, itemName); + const { itemId, isNew } = await findOrCreateHouseholdStoreItem( + householdId, + storeLocationId, + 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] + await List.setCatalogItemImage( + householdId, + storeLocationId, + itemId, + imageBuffer, + mimeType, + userId ); } - return getHouseholdStoreItemRecord(householdId, storeId, itemId); + if (isNew) { + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: itemId, + actorUserId: userId, + eventType: "ITEM_ADDED", + metadata: { source: "catalog" }, + }); + } + + return getHouseholdStoreItemRecord(householdId, storeLocationId, itemId); }; -exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => { +exports.updateAvailableItem = async (householdId, storeLocationId, itemId, updates = {}) => { const { itemName, imageBuffer, mimeType, removeImage = false, + userId = null, } = updates; const assignments = ["updated_at = NOW()"]; - const values = [householdId, storeId, itemId]; + const values = [householdId, storeLocationId, itemId]; let parameterIndex = values.length; if (itemName !== undefined && String(itemName).trim() !== "") { @@ -195,22 +241,14 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) } 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); + assignments.push("image_id = NULL", "custom_image = NULL", "custom_image_mime_type = NULL"); } const result = await pool.query( `UPDATE household_store_items SET ${assignments.join(", ")} WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 AND id = $3 RETURNING id`, values @@ -220,53 +258,75 @@ exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) return null; } - return getHouseholdStoreItemRecord(householdId, storeId, result.rows[0].id); + if (!removeImage && imageBuffer && mimeType) { + await List.setCatalogItemImage( + householdId, + storeLocationId, + result.rows[0].id, + imageBuffer, + mimeType, + userId + ); + } + + return getHouseholdStoreItemRecord(householdId, storeLocationId, result.rows[0].id); }; -exports.deleteAvailableItem = async (householdId, storeId, itemId) => { +exports.deleteAvailableItem = async (householdId, storeLocationId, itemId, userId = null) => { + const item = await getHouseholdStoreItemRecord(householdId, storeLocationId, itemId); const result = await pool.query( `DELETE FROM household_store_items WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 AND id = $3`, - [householdId, storeId, itemId] + [householdId, storeLocationId, itemId] ); + if (result.rowCount > 0) { + await List.recordItemEvent({ + householdId, + storeLocationId, + householdStoreItemId: itemId, + actorUserId: userId, + eventType: "ITEM_DELETED", + metadata: { item_name: item?.item_name || null }, + }); + } + return result.rowCount > 0; }; -exports.importCurrentListItems = async (householdId, storeId) => { +exports.importCurrentListItems = async (householdId, storeLocationId) => { 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) + (household_id, store_location_id, name, normalized_name, image_id, updated_at) SELECT DISTINCT ON (hl.household_store_item_id) hl.household_id, - hl.store_id, + hl.store_location_id, hsi.name, hsi.normalized_name, - hsi.custom_image, - hsi.custom_image_mime_type, + hsi.image_id, NOW() FROM household_lists hl JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id WHERE hl.household_id = $1 - AND hl.store_id = $2 - ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING + AND hl.store_location_id = $2 + ON CONFLICT (household_id, store_location_id, normalized_name) DO NOTHING RETURNING id`, - [householdId, storeId] + [householdId, storeLocationId] ); return result.rowCount; }; -exports.hasAvailableItems = async (householdId, storeId) => { +exports.hasAvailableItems = async (householdId, storeLocationId) => { const result = await pool.query( `SELECT 1 FROM household_store_items WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 LIMIT 1`, - [householdId, storeId] + [householdId, storeLocationId] ); return result.rowCount > 0; diff --git a/backend/models/household.model.js b/backend/models/household.model.js index 528ce89..db146d5 100644 --- a/backend/models/household.model.js +++ b/backend/models/household.model.js @@ -169,18 +169,6 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse try { await client.query("BEGIN"); - const promoteResult = await client.query( - `UPDATE household_members - SET role = 'owner' - WHERE household_id = $1 AND user_id = $2 - RETURNING user_id, role`, - [householdId, nextOwnerUserId] - ); - - if (promoteResult.rows.length === 0) { - throw new Error("TARGET_MEMBER_NOT_FOUND"); - } - const demoteResult = await client.query( `UPDATE household_members SET role = 'admin' @@ -193,6 +181,18 @@ exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUse throw new Error("CURRENT_OWNER_NOT_FOUND"); } + const promoteResult = await client.query( + `UPDATE household_members + SET role = 'owner' + WHERE household_id = $1 AND user_id = $2 + RETURNING user_id, role`, + [householdId, nextOwnerUserId] + ); + + if (promoteResult.rows.length === 0) { + throw new Error("TARGET_MEMBER_NOT_FOUND"); + } + await client.query("COMMIT"); return promoteResult.rows[0]; } catch (error) { diff --git a/backend/models/list.model.v2.js b/backend/models/list.model.v2.js index 7f513bb..9993104 100644 --- a/backend/models/list.model.v2.js +++ b/backend/models/list.model.v2.js @@ -4,22 +4,104 @@ function normalizeItemName(itemName) { return String(itemName || "").trim().toLowerCase(); } -async function getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName) { +function toPositiveInteger(value, fallback = 1) { + const numberValue = Number(value); + return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : fallback; +} + +const ACTIVE_ADDED_BY_USERS_SQL = ` + ( + SELECT ARRAY_AGG( + active_added_by_users.user_label + ORDER BY active_added_by_users.last_added_on DESC, active_added_by_users.user_label + ) + FROM ( + SELECT + COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label, + MAX(active_history.added_on) AS last_added_on + FROM ( + SELECT + hlh.*, + COALESCE( + SUM(hlh.quantity) OVER ( + PARTITION BY hlh.household_list_id + ORDER BY hlh.added_on DESC, hlh.id DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), + 0 + ) AS newer_quantity + FROM household_list_history hlh + WHERE hlh.household_list_id = hl.id + ) active_history + JOIN users u ON active_history.added_by = u.id + WHERE active_history.newer_quantity < GREATEST(hl.quantity, 0) + GROUP BY user_label + ) active_added_by_users + ) AS added_by_users`; + +async function getHouseholdStoreItemByNormalizedName(householdId, storeLocationId, normalizedName) { const result = await pool.query( - `SELECT id, name, normalized_name, custom_image, custom_image_mime_type + `SELECT id, name, normalized_name, image_id FROM household_store_items WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 AND normalized_name = $3`, - [householdId, storeId, normalizedName] + [householdId, storeLocationId, normalizedName] ); return result.rows[0] || null; } -exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => { +async function createItemImage({ + householdId, + storeLocationId, + householdStoreItemId, + householdListId = null, + imageScope, + imageBuffer, + mimeType, + userId = null, +}) { + if (!imageBuffer || !mimeType) { + return null; + } + + const result = await pool.query( + `INSERT INTO household_item_images + ( + household_id, + store_location_id, + household_store_item_id, + household_list_id, + image_scope, + image, + mime_type, + created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [ + householdId, + storeLocationId, + householdStoreItemId, + householdListId, + imageScope, + imageBuffer, + mimeType, + userId, + ] + ); + + return result.rows[0].id; +} + +exports.ensureHouseholdStoreItem = async (householdId, storeLocationId, itemName) => { const normalizedName = normalizeItemName(itemName); - let item = await getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName); + let item = await getHouseholdStoreItemByNormalizedName( + householdId, + storeLocationId, + normalizedName + ); if (item) { return item; @@ -27,23 +109,16 @@ exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => { const result = await pool.query( `INSERT INTO household_store_items - (household_id, store_id, name, normalized_name, updated_at) + (household_id, store_location_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] + RETURNING id, name, normalized_name, image_id`, + [householdId, storeLocationId, normalizedName, normalizedName] ); return result.rows[0]; }; -/** - * Get list items for a specific household and store - * @param {number} householdId - Household ID - * @param {number} storeId - Store ID - * @param {boolean} includeHistory - Include purchase history - * @returns {Promise} List of items - */ -exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => { +exports.getHouseholdStoreList = async (householdId, storeLocationId, includeHistory = true) => { const result = await pool.query( `SELECT hl.id, @@ -52,130 +127,141 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr hsi.name AS item_name, hl.quantity, hl.bought, - ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, - COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, - ${includeHistory ? ` - ( - SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) - FROM ( - SELECT DISTINCT - COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label - FROM household_list_history hlh - JOIN users u ON hlh.added_by = u.id - WHERE hlh.household_list_id = hl.id - ) added_by_labels - ) AS added_by_users, - ` : "NULL AS added_by_users,"} + hl.notes, + ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image, + COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type, + ${includeHistory ? `${ACTIVE_ADDED_BY_USERS_SQL},` : "NULL AS added_by_users,"} hl.modified_on AS last_added_on, hic.item_type, hic.item_group, - hic.zone + COALESCE(slz.name, hic.zone) AS zone, + slz.sort_order AS zone_sort_order FROM household_lists hl JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id + LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id LEFT JOIN household_item_classifications hic ON hic.household_id = hl.household_id - AND hic.store_id = hl.store_id + AND hic.store_location_id = hl.store_location_id AND hic.household_store_item_id = hl.household_store_item_id + LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id WHERE hl.household_id = $1 - AND hl.store_id = $2 + AND hl.store_location_id = $2 AND hl.bought = FALSE - ORDER BY hl.id ASC`, - [householdId, storeId] + ORDER BY slz.sort_order ASC NULLS LAST, hsi.name ASC`, + [householdId, storeLocationId] ); return result.rows; }; -/** - * Get a specific item from household list by name - * @param {number} householdId - Household ID - * @param {number} storeId - Store ID - * @param {string} itemName - Item name to search for - * @returns {Promise} Item or null - */ -exports.getItemByName = async (householdId, storeId, itemName) => { +exports.getItemByName = async (householdId, storeLocationId, itemName) => { const normalizedName = normalizeItemName(itemName); const result = await pool.query( `SELECT hl.id, + hl.household_id, + hl.store_location_id, hl.household_store_item_id AS item_id, hl.household_store_item_id, hsi.name AS item_name, hl.quantity, hl.bought, - ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, - COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, - ( - SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) - FROM ( - SELECT DISTINCT - COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label - FROM household_list_history hlh - JOIN users u ON hlh.added_by = u.id - WHERE hlh.household_list_id = hl.id - ) added_by_labels - ) AS added_by_users, + hl.notes, + ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image, + COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type, + ${ACTIVE_ADDED_BY_USERS_SQL}, hl.modified_on AS last_added_on, hic.item_type, hic.item_group, - hic.zone + COALESCE(slz.name, hic.zone) AS zone, + slz.sort_order AS zone_sort_order FROM household_lists hl JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id + LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id LEFT JOIN household_item_classifications hic ON hic.household_id = hl.household_id - AND hic.store_id = hl.store_id + AND hic.store_location_id = hl.store_location_id AND hic.household_store_item_id = hl.household_store_item_id + LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id WHERE hl.household_id = $1 - AND hl.store_id = $2 + AND hl.store_location_id = $2 AND hsi.normalized_name = $3`, - [householdId, storeId, normalizedName] + [householdId, storeLocationId, normalizedName] ); return result.rows[0] || null; }; -/** - * Add or update an item in household list - * @returns {Promise<{listId:number,itemId:number,householdStoreItemId:number,itemName:string,isNew:boolean}>} - */ exports.addOrUpdateItem = async ( householdId, - storeId, + storeLocationId, itemName, quantity, userId, imageBuffer = null, - mimeType = null + mimeType = null, + notes = undefined ) => { - const householdStoreItem = await exports.ensureHouseholdStoreItem(householdId, storeId, itemName); + const nextQuantity = toPositiveInteger(quantity); + const householdStoreItem = await exports.ensureHouseholdStoreItem( + householdId, + storeLocationId, + itemName + ); const listResult = await pool.query( - `SELECT id, bought + `SELECT id, bought, quantity FROM household_lists WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 AND household_store_item_id = $3`, - [householdId, storeId, householdStoreItem.id] + [householdId, storeLocationId, householdStoreItem.id] ); if (listResult.rowCount > 0) { const listId = listResult.rows[0].id; + const previousQuantity = toPositiveInteger(listResult.rows[0].quantity, 0); + const wasBought = Boolean(listResult.rows[0].bought); + const historyQuantity = + !wasBought && nextQuantity > previousQuantity + ? nextQuantity - previousQuantity + : nextQuantity; + + let imageId = null; if (imageBuffer && mimeType) { + imageId = await createItemImage({ + householdId, + storeLocationId, + householdStoreItemId: householdStoreItem.id, + householdListId: listId, + imageScope: "list", + imageBuffer, + mimeType, + userId, + }); + } + + if (imageId) { await pool.query( `UPDATE household_lists SET quantity = $1, bought = FALSE, - custom_image = $2, - custom_image_mime_type = $3, + image_id = $2, + custom_image = NULL, + custom_image_mime_type = NULL, + notes = COALESCE($3, notes), modified_on = NOW() WHERE id = $4`, - [quantity, imageBuffer, mimeType, listId] + [nextQuantity, imageId, notes, listId] ); } else { await pool.query( `UPDATE household_lists SET quantity = $1, bought = FALSE, + notes = COALESCE($2, notes), modified_on = NOW() - WHERE id = $2`, - [quantity, listId] + WHERE id = $3`, + [nextQuantity, notes, listId] ); } @@ -184,46 +270,87 @@ exports.addOrUpdateItem = async ( itemId: householdStoreItem.id, householdStoreItemId: householdStoreItem.id, itemName: householdStoreItem.name, + quantity: nextQuantity, + previousQuantity, + historyQuantity, + wasBought, isNew: false, }; } 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) + (household_id, store_location_id, household_store_item_id, quantity, added_by, notes) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, - [householdId, storeId, householdStoreItem.id, quantity, imageBuffer, mimeType, userId] + [householdId, storeLocationId, householdStoreItem.id, nextQuantity, userId, notes || null] ); + if (imageBuffer && mimeType) { + const imageId = await createItemImage({ + householdId, + storeLocationId, + householdStoreItemId: householdStoreItem.id, + householdListId: insert.rows[0].id, + imageScope: "list", + imageBuffer, + mimeType, + userId, + }); + + await pool.query( + `UPDATE household_lists + SET image_id = $1, + custom_image = NULL, + custom_image_mime_type = NULL + WHERE id = $2`, + [imageId, insert.rows[0].id] + ); + } + return { listId: insert.rows[0].id, itemId: householdStoreItem.id, householdStoreItemId: householdStoreItem.id, itemName: householdStoreItem.name, + quantity: nextQuantity, + previousQuantity: 0, + historyQuantity: nextQuantity, + wasBought: false, isNew: true, }; }; exports.setBought = async (listId, bought, quantityBought = null) => { + const item = await pool.query( + `SELECT id, household_id, store_location_id, household_store_item_id, quantity, bought + FROM household_lists + WHERE id = $1`, + [listId] + ); + + if (!item.rows[0]) return null; + + const current = item.rows[0]; + const currentQuantity = toPositiveInteger(current.quantity, 0); + if (bought === false) { await pool.query( "UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1", [listId] ); - return; + return { + ...current, + eventType: "ITEM_UNBOUGHT", + quantityDelta: null, + quantityAfter: currentQuantity, + }; } - if (quantityBought && quantityBought > 0) { - const item = await pool.query( - "SELECT quantity FROM household_lists WHERE id = $1", - [listId] - ); - - if (!item.rows[0]) return; - - const currentQuantity = item.rows[0].quantity; - const remainingQuantity = currentQuantity - quantityBought; + const requestedQuantity = toPositiveInteger(quantityBought, 0); + if (requestedQuantity > 0) { + const boughtQuantity = Math.min(requestedQuantity, currentQuantity); + const remainingQuantity = currentQuantity - boughtQuantity; if (remainingQuantity <= 0) { await pool.query( @@ -236,23 +363,90 @@ exports.setBought = async (listId, bought, quantityBought = null) => { [remainingQuantity, listId] ); } - } else { - await pool.query( - "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", - [listId] - ); + + return { + ...current, + eventType: "ITEM_BOUGHT", + quantityDelta: -boughtQuantity, + quantityAfter: Math.max(remainingQuantity, 0), + }; } + + await pool.query( + "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", + [listId] + ); + + return { + ...current, + eventType: "ITEM_BOUGHT", + quantityDelta: -currentQuantity, + quantityAfter: 0, + }; }; -exports.addHistoryRecord = async (listId, householdStoreItemId, quantity, userId) => { +exports.addHistoryRecord = async ( + listId, + householdStoreItemId, + quantity, + userId, + storeLocationId = null +) => { await pool.query( - `INSERT INTO household_list_history (household_list_id, household_store_item_id, quantity, added_by, added_on) - VALUES ($1, $2, $3, $4, NOW())`, - [listId, householdStoreItemId, quantity, userId] + `INSERT INTO household_list_history + (household_list_id, store_location_id, household_store_item_id, quantity, added_by, added_on) + VALUES ( + $1, + COALESCE($5, (SELECT store_location_id FROM household_lists WHERE id = $1)), + $2, + $3, + $4, + NOW() + )`, + [listId, householdStoreItemId, quantity, userId, storeLocationId] ); }; -exports.getSuggestions = async (query, householdId, storeId) => { +exports.recordItemEvent = async ({ + householdId, + storeLocationId, + householdStoreItemId, + householdListId = null, + actorUserId = null, + eventType, + quantityDelta = null, + quantityAfter = null, + metadata = {}, +}) => { + await pool.query( + `INSERT INTO household_item_events + ( + household_id, + store_location_id, + household_store_item_id, + household_list_id, + actor_user_id, + event_type, + quantity_delta, + quantity_after, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`, + [ + householdId, + storeLocationId, + householdStoreItemId, + householdListId, + actorUserId, + eventType, + quantityDelta, + quantityAfter, + JSON.stringify(metadata || {}), + ] + ); +}; + +exports.getSuggestions = async (query, householdId, storeLocationId) => { const result = await pool.query( `SELECT DISTINCT hsi.name AS item_name, @@ -261,18 +455,18 @@ exports.getSuggestions = async (query, householdId, storeId) => { LEFT JOIN household_lists hl ON hl.household_store_item_id = hsi.id AND hl.household_id = $2 - AND hl.store_id = $3 + AND hl.store_location_id = $3 WHERE hsi.household_id = $2 - AND hsi.store_id = $3 + AND hsi.store_location_id = $3 AND hsi.name ILIKE $1 ORDER BY sort_order, hsi.name LIMIT 10`, - [`%${query}%`, householdId, storeId] + [`%${query}%`, householdId, storeLocationId] ); return result.rows; }; -exports.getRecentlyBoughtItems = async (householdId, storeId) => { +exports.getRecentlyBoughtItems = async (householdId, storeLocationId) => { const result = await pool.query( `SELECT hl.id, @@ -281,73 +475,121 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => { hsi.name AS item_name, hl.quantity, hl.bought, - ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, - COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, - ( - SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) - FROM ( - SELECT DISTINCT - COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label - FROM household_list_history hlh - JOIN users u ON hlh.added_by = u.id - WHERE hlh.household_list_id = hl.id - ) added_by_labels - ) AS added_by_users, + ENCODE(COALESCE(list_img.image, hl.custom_image, catalog_img.image, hsi.custom_image), 'base64') AS item_image, + COALESCE(list_img.mime_type, hl.custom_image_mime_type, catalog_img.mime_type, hsi.custom_image_mime_type) AS image_mime_type, + ${ACTIVE_ADDED_BY_USERS_SQL}, hl.modified_on AS last_added_on FROM household_lists hl JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_images list_img ON list_img.id = hl.image_id + LEFT JOIN household_item_images catalog_img ON catalog_img.id = hsi.image_id WHERE hl.household_id = $1 - AND hl.store_id = $2 + AND hl.store_location_id = $2 AND hl.bought = TRUE AND hl.modified_on >= NOW() - INTERVAL '24 hours' ORDER BY hl.modified_on DESC`, - [householdId, storeId] + [householdId, storeLocationId] ); return result.rows; }; -exports.getClassification = async (householdId, storeId, itemId) => { +exports.getZoneByName = async (householdId, storeLocationId, zoneName) => { const result = await pool.query( - `SELECT item_type, item_group, zone, confidence, source - FROM household_item_classifications - WHERE household_id = $1 AND store_id = $2 AND household_store_item_id = $3`, - [householdId, storeId, itemId] + `SELECT id, name, sort_order + FROM store_location_zones + WHERE household_id = $1 + AND store_location_id = $2 + AND normalized_name = $3 + AND is_active = TRUE`, + [householdId, storeLocationId, normalizeItemName(zoneName)] ); return result.rows[0] || null; }; -exports.upsertClassification = async (householdId, storeId, itemId, classification) => { +exports.getClassification = async (householdId, storeLocationId, itemId) => { + const result = await pool.query( + `SELECT + hic.item_type, + hic.item_group, + COALESCE(slz.name, hic.zone) AS zone, + hic.confidence, + hic.source + FROM household_item_classifications hic + LEFT JOIN store_location_zones slz ON slz.id = hic.zone_id + WHERE hic.household_id = $1 + AND hic.store_location_id = $2 + AND hic.household_store_item_id = $3`, + [householdId, storeLocationId, itemId] + ); + return result.rows[0] || null; +}; + +exports.upsertClassification = async (householdId, storeLocationId, itemId, classification) => { const { item_type, item_group, zone, confidence, source } = classification; + const zoneRecord = zone ? await exports.getZoneByName(householdId, storeLocationId, zone) : null; const result = await pool.query( `INSERT INTO household_item_classifications - (household_id, store_id, household_store_item_id, item_type, item_group, zone, confidence, source) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (household_id, store_id, household_store_item_id) + ( + household_id, + store_location_id, + household_store_item_id, + item_type, + item_group, + zone, + zone_id, + confidence, + source + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (household_id, store_location_id, household_store_item_id) DO UPDATE SET item_type = EXCLUDED.item_type, item_group = EXCLUDED.item_group, zone = EXCLUDED.zone, + zone_id = EXCLUDED.zone_id, confidence = EXCLUDED.confidence, - source = EXCLUDED.source + source = EXCLUDED.source, + updated_at = NOW() RETURNING *`, - [householdId, storeId, itemId, item_type, item_group, zone, confidence, source] + [ + householdId, + storeLocationId, + itemId, + item_type, + item_group, + zone, + zoneRecord?.id || null, + confidence, + source, + ] ); return result.rows[0]; }; -exports.deleteClassification = async (householdId, storeId, itemId) => { +exports.deleteClassification = async (householdId, storeLocationId, itemId) => { const result = await pool.query( `DELETE FROM household_item_classifications WHERE household_id = $1 - AND store_id = $2 + AND store_location_id = $2 AND household_store_item_id = $3`, - [householdId, storeId, itemId] + [householdId, storeLocationId, itemId] ); return result.rowCount > 0; }; exports.updateItem = async (listId, itemName, quantity, notes) => { + const existing = await pool.query( + `SELECT id, household_id, store_location_id, household_store_item_id, quantity, notes + FROM household_lists + WHERE id = $1`, + [listId] + ); + + if (existing.rowCount === 0) { + return null; + } + const updates = []; const values = [listId]; let paramCount = 1; @@ -366,22 +608,56 @@ exports.updateItem = async (listId, itemName, quantity, notes) => { updates.push("modified_on = NOW()"); - if (updates.length === 1) { - const result = await pool.query( - "UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *", - [listId] - ); - return result.rows[0]; - } - const result = await pool.query( `UPDATE household_lists SET ${updates.join(", ")} WHERE id = $1 RETURNING *`, values ); - return result.rows[0]; + return { + previous: existing.rows[0], + updated: result.rows[0], + }; }; exports.deleteItem = async (listId) => { - await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]); + const result = await pool.query( + `DELETE FROM household_lists + WHERE id = $1 + RETURNING id, household_id, store_location_id, household_store_item_id, quantity`, + [listId] + ); + return result.rows[0] || null; +}; + +exports.setCatalogItemImage = async ( + householdId, + storeLocationId, + householdStoreItemId, + imageBuffer, + mimeType, + userId = null +) => { + const imageId = await createItemImage({ + householdId, + storeLocationId, + householdStoreItemId, + imageScope: "catalog", + imageBuffer, + mimeType, + userId, + }); + + await pool.query( + `UPDATE household_store_items + SET image_id = $1, + custom_image = NULL, + custom_image_mime_type = NULL, + updated_at = NOW() + WHERE household_id = $2 + AND store_location_id = $3 + AND id = $4`, + [imageId, householdId, storeLocationId, householdStoreItemId] + ); + + return imageId; }; diff --git a/backend/models/store.model.js b/backend/models/store.model.js index f838d10..64bdb68 100644 --- a/backend/models/store.model.js +++ b/backend/models/store.model.js @@ -1,6 +1,70 @@ const pool = require("../db/pool"); +const { ZONE_FLOW } = require("../constants/classifications"); -// Get all available stores +const DEFAULT_LOCATION_NAME = "Default Location"; + +function normalizeName(value) { + return String(value || "").trim().toLowerCase(); +} + +function displayLocationName(storeName, locationName) { + if (!locationName || locationName === DEFAULT_LOCATION_NAME) { + return storeName; + } + return `${storeName} - ${locationName}`; +} + +function mapLocationRow(row) { + if (!row) return null; + return { + ...row, + id: row.location_id, + display_name: row.display_name || displayLocationName(row.name, row.location_name), + }; +} + +async function queryLocationById(db, householdId, locationId) { + const result = await db.query( + `SELECT + sl.id AS location_id, + sl.id, + sl.household_id, + sl.household_store_id, + hcs.name, + sl.name AS location_name, + sl.address, + sl.is_default, + sl.map_data, + sl.created_at, + sl.updated_at, + CASE + WHEN sl.name = $3 THEN hcs.name + ELSE hcs.name || ' - ' || sl.name + END AS display_name + FROM store_locations sl + JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id + WHERE sl.household_id = $1 + AND sl.id = $2`, + [householdId, locationId, DEFAULT_LOCATION_NAME] + ); + + return mapLocationRow(result.rows[0]); +} + +async function seedDefaultZones(db, householdId, locationId) { + for (let index = 0; index < ZONE_FLOW.length; index += 1) { + const zoneName = ZONE_FLOW[index]; + await db.query( + `INSERT INTO store_location_zones + (household_id, store_location_id, name, normalized_name, sort_order) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (store_location_id, normalized_name) DO NOTHING`, + [householdId, locationId, zoneName, normalizeName(zoneName), (index + 1) * 10] + ); + } +} + +// Legacy global store catalog. Kept for system-admin compatibility only. exports.getAllStores = async () => { const result = await pool.query( `SELECT id, name, default_zones, created_at @@ -10,7 +74,6 @@ exports.getAllStores = async () => { return result.rows; }; -// Get store by ID exports.getStoreById = async (storeId) => { const result = await pool.query( `SELECT id, name, default_zones, created_at @@ -21,77 +84,6 @@ exports.getStoreById = async (storeId) => { return result.rows[0]; }; -// Get stores for a specific household -exports.getHouseholdStores = async (householdId) => { - const result = await pool.query( - `SELECT - s.id, - s.name, - s.default_zones, - hs.is_default, - hs.added_at - FROM stores s - JOIN household_stores hs ON s.id = hs.store_id - WHERE hs.household_id = $1 - ORDER BY hs.is_default DESC, s.name ASC`, - [householdId] - ); - return result.rows; -}; - -// Add store to household -exports.addStoreToHousehold = async (householdId, storeId, isDefault = false) => { - // If setting as default, unset other defaults - if (isDefault) { - await pool.query( - `UPDATE household_stores - SET is_default = FALSE - WHERE household_id = $1`, - [householdId] - ); - } - - const result = await pool.query( - `INSERT INTO household_stores (household_id, store_id, is_default) - VALUES ($1, $2, $3) - ON CONFLICT (household_id, store_id) - DO UPDATE SET is_default = $3 - RETURNING household_id, store_id, is_default`, - [householdId, storeId, isDefault] - ); - - return result.rows[0]; -}; - -// Remove store from household -exports.removeStoreFromHousehold = async (householdId, storeId) => { - await pool.query( - `DELETE FROM household_stores - WHERE household_id = $1 AND store_id = $2`, - [householdId, storeId] - ); -}; - -// Set default store for household -exports.setDefaultStore = async (householdId, storeId) => { - // Unset all defaults - await pool.query( - `UPDATE household_stores - SET is_default = FALSE - WHERE household_id = $1`, - [householdId] - ); - - // Set new default - await pool.query( - `UPDATE household_stores - SET is_default = TRUE - WHERE household_id = $1 AND store_id = $2`, - [householdId, storeId] - ); -}; - -// Create new store (system admin only) exports.createStore = async (name, defaultZones) => { const result = await pool.query( `INSERT INTO stores (name, default_zones) @@ -102,12 +94,11 @@ exports.createStore = async (name, defaultZones) => { return result.rows[0]; }; -// Update store (system admin only) exports.updateStore = async (storeId, updates) => { const { name, default_zones } = updates; const result = await pool.query( - `UPDATE stores - SET + `UPDATE stores + SET name = COALESCE($1, name), default_zones = COALESCE($2, default_zones) WHERE id = $3 @@ -117,27 +108,452 @@ exports.updateStore = async (storeId, updates) => { return result.rows[0]; }; -// Delete store (system admin only, only if not in use) exports.deleteStore = async (storeId) => { - // Check if store is in use const usage = await pool.query( `SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`, [storeId] ); - if (parseInt(usage.rows[0].count) > 0) { - throw new Error('Cannot delete store that is in use by households'); + if (parseInt(usage.rows[0].count, 10) > 0) { + throw new Error("Cannot delete store that is in use by households"); } - await pool.query('DELETE FROM stores WHERE id = $1', [storeId]); + await pool.query("DELETE FROM stores WHERE id = $1", [storeId]); }; -// Check if household has store +// Household-owned store locations. +exports.getHouseholdStores = async (householdId) => { + const result = await pool.query( + `SELECT + sl.id AS location_id, + sl.id, + sl.household_id, + sl.household_store_id, + hcs.name, + sl.name AS location_name, + sl.address, + sl.is_default, + sl.map_data, + sl.created_at, + sl.updated_at, + CASE + WHEN sl.name = $2 THEN hcs.name + ELSE hcs.name || ' - ' || sl.name + END AS display_name + FROM store_locations sl + JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id + WHERE sl.household_id = $1 + ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`, + [householdId, DEFAULT_LOCATION_NAME] + ); + return result.rows.map(mapLocationRow); +}; + +exports.createHouseholdStore = async ( + householdId, + name, + locationName = DEFAULT_LOCATION_NAME, + address = null, + createdBy = null +) => { + const client = await pool.connect(); + const normalizedStoreName = normalizeName(name); + const normalizedLocationName = normalizeName(locationName || DEFAULT_LOCATION_NAME); + + try { + await client.query("BEGIN"); + + const storeResult = await client.query( + `INSERT INTO household_custom_stores + (household_id, name, normalized_name, created_by, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (household_id, normalized_name) + DO UPDATE SET name = EXCLUDED.name, updated_at = NOW() + RETURNING id, name`, + [householdId, name.trim(), normalizedStoreName, createdBy] + ); + + const hasDefault = await client.query( + `SELECT 1 FROM store_locations + WHERE household_id = $1 AND is_default = TRUE + LIMIT 1`, + [householdId] + ); + + const locationResult = await client.query( + `INSERT INTO store_locations + (household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (household_store_id, normalized_name) + DO UPDATE SET + name = EXCLUDED.name, + address = COALESCE(EXCLUDED.address, store_locations.address), + updated_at = NOW() + RETURNING id`, + [ + householdId, + storeResult.rows[0].id, + (locationName || DEFAULT_LOCATION_NAME).trim(), + normalizedLocationName, + address || null, + hasDefault.rowCount === 0, + createdBy, + ] + ); + + await seedDefaultZones(client, householdId, locationResult.rows[0].id); + + const location = await queryLocationById(client, householdId, locationResult.rows[0].id); + await client.query("COMMIT"); + return location; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +exports.updateHouseholdStore = async (householdId, householdStoreId, updates = {}) => { + const { name } = updates; + const result = await pool.query( + `UPDATE household_custom_stores + SET name = COALESCE($1, name), + normalized_name = COALESCE($2, normalized_name), + updated_at = NOW() + WHERE household_id = $3 + AND id = $4 + RETURNING id, household_id, name, created_at, updated_at`, + [ + name?.trim() || null, + name ? normalizeName(name) : null, + householdId, + householdStoreId, + ] + ); + return result.rows[0] || null; +}; + +exports.deleteHouseholdStore = async (householdId, householdStoreId) => { + const countResult = await pool.query( + `SELECT COUNT(*)::int AS count + FROM store_locations + WHERE household_id = $1`, + [householdId] + ); + + const storeLocationCount = countResult.rows[0]?.count || 0; + const targetLocations = await pool.query( + `SELECT COUNT(*)::int AS count + FROM store_locations + WHERE household_id = $1 + AND household_store_id = $2`, + [householdId, householdStoreId] + ); + + if (storeLocationCount <= targetLocations.rows[0]?.count) { + throw new Error("Cannot remove the last store location for a household"); + } + + const result = await pool.query( + `DELETE FROM household_custom_stores + WHERE household_id = $1 + AND id = $2`, + [householdId, householdStoreId] + ); + + return result.rowCount > 0; +}; + +exports.addLocationToStore = async ( + householdId, + householdStoreId, + name, + address = null, + createdBy = null +) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const storeResult = await client.query( + `SELECT id FROM household_custom_stores + WHERE household_id = $1 + AND id = $2`, + [householdId, householdStoreId] + ); + + if (storeResult.rowCount === 0) { + await client.query("ROLLBACK"); + return null; + } + + const hasDefault = await client.query( + `SELECT 1 FROM store_locations + WHERE household_id = $1 AND is_default = TRUE + LIMIT 1`, + [householdId] + ); + + const locationResult = await client.query( + `INSERT INTO store_locations + (household_id, household_store_id, name, normalized_name, address, is_default, created_by, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + RETURNING id`, + [ + householdId, + householdStoreId, + name.trim(), + normalizeName(name), + address || null, + hasDefault.rowCount === 0, + createdBy, + ] + ); + + await seedDefaultZones(client, householdId, locationResult.rows[0].id); + const location = await queryLocationById(client, householdId, locationResult.rows[0].id); + await client.query("COMMIT"); + return location; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +exports.updateLocation = async (householdId, locationId, updates = {}) => { + const { name, address, map_data } = updates; + const result = await pool.query( + `UPDATE store_locations + SET name = COALESCE($1, name), + normalized_name = COALESCE($2, normalized_name), + address = COALESCE($3, address), + map_data = COALESCE($4::jsonb, map_data), + updated_at = NOW() + WHERE household_id = $5 + AND id = $6 + RETURNING id`, + [ + name?.trim() || null, + name ? normalizeName(name) : null, + address === undefined ? null : address, + map_data ? JSON.stringify(map_data) : null, + householdId, + locationId, + ] + ); + + if (result.rowCount === 0) return null; + return queryLocationById(pool, householdId, locationId); +}; + +exports.deleteLocation = async (householdId, locationId) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const countResult = await client.query( + `SELECT COUNT(*)::int AS count + FROM store_locations + WHERE household_id = $1`, + [householdId] + ); + + if ((countResult.rows[0]?.count || 0) <= 1) { + throw new Error("Cannot remove the last store location for a household"); + } + + const deleted = await client.query( + `DELETE FROM store_locations + WHERE household_id = $1 + AND id = $2 + RETURNING is_default`, + [householdId, locationId] + ); + + if (deleted.rowCount === 0) { + await client.query("COMMIT"); + return false; + } + + if (deleted.rows[0].is_default) { + await client.query( + `UPDATE store_locations + SET is_default = TRUE, updated_at = NOW() + WHERE id = ( + SELECT id + FROM store_locations + WHERE household_id = $1 + ORDER BY created_at ASC, id ASC + LIMIT 1 + )`, + [householdId] + ); + } + + await client.query("COMMIT"); + return true; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +exports.setDefaultLocation = async (householdId, locationId) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query( + `UPDATE store_locations + SET is_default = FALSE, updated_at = NOW() + WHERE household_id = $1`, + [householdId] + ); + + const result = await client.query( + `UPDATE store_locations + SET is_default = TRUE, updated_at = NOW() + WHERE household_id = $1 + AND id = $2 + RETURNING id`, + [householdId, locationId] + ); + + if (result.rowCount === 0) { + throw new Error("Location not found"); + } + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +exports.householdHasLocation = async (householdId, locationId) => { + const result = await pool.query( + `SELECT 1 + FROM store_locations + WHERE household_id = $1 + AND id = $2`, + [householdId, locationId] + ); + return result.rowCount > 0; +}; + +exports.getLocationById = async (householdId, locationId) => + queryLocationById(pool, householdId, locationId); + +exports.listLocationZones = async (householdId, locationId, includeInactive = false) => { + const values = [householdId, locationId]; + const inactiveClause = includeInactive ? "" : "AND is_active = TRUE"; + const result = await pool.query( + `SELECT id, name, sort_order, color, map_metadata, is_active, created_at, updated_at + FROM store_location_zones + WHERE household_id = $1 + AND store_location_id = $2 + ${inactiveClause} + ORDER BY sort_order ASC, name ASC`, + values + ); + return result.rows; +}; + +exports.getZoneByName = async (householdId, locationId, zoneName) => { + const result = await pool.query( + `SELECT id, name, sort_order, color, is_active + FROM store_location_zones + WHERE household_id = $1 + AND store_location_id = $2 + AND normalized_name = $3 + AND is_active = TRUE`, + [householdId, locationId, normalizeName(zoneName)] + ); + return result.rows[0] || null; +}; + +exports.createZone = async (householdId, locationId, zone) => { + const { name, sort_order, color, map_metadata } = zone; + const result = await pool.query( + `INSERT INTO store_location_zones + (household_id, store_location_id, name, normalized_name, sort_order, color, map_metadata) + VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7::jsonb, '{}'::jsonb)) + ON CONFLICT (store_location_id, normalized_name) + DO UPDATE SET + name = EXCLUDED.name, + sort_order = EXCLUDED.sort_order, + color = EXCLUDED.color, + map_metadata = EXCLUDED.map_metadata, + is_active = TRUE, + updated_at = NOW() + RETURNING id, name, sort_order, color, map_metadata, is_active`, + [ + householdId, + locationId, + name.trim(), + normalizeName(name), + Number.isInteger(sort_order) ? sort_order : 0, + color || null, + map_metadata ? JSON.stringify(map_metadata) : null, + ] + ); + return result.rows[0]; +}; + +exports.updateZone = async (householdId, locationId, zoneId, updates = {}) => { + const { name, sort_order, color, map_metadata, is_active } = updates; + const result = await pool.query( + `UPDATE store_location_zones + SET name = COALESCE($1, name), + normalized_name = COALESCE($2, normalized_name), + sort_order = COALESCE($3, sort_order), + color = COALESCE($4, color), + map_metadata = COALESCE($5::jsonb, map_metadata), + is_active = COALESCE($6, is_active), + updated_at = NOW() + WHERE household_id = $7 + AND store_location_id = $8 + AND id = $9 + RETURNING id, name, sort_order, color, map_metadata, is_active`, + [ + name?.trim() || null, + name ? normalizeName(name) : null, + Number.isInteger(sort_order) ? sort_order : null, + color === undefined ? null : color, + map_metadata ? JSON.stringify(map_metadata) : null, + typeof is_active === "boolean" ? is_active : null, + householdId, + locationId, + zoneId, + ] + ); + return result.rows[0] || null; +}; + +exports.deleteZone = async (householdId, locationId, zoneId) => { + const result = await pool.query( + `UPDATE store_location_zones + SET is_active = FALSE, updated_at = NOW() + WHERE household_id = $1 + AND store_location_id = $2 + AND id = $3`, + [householdId, locationId, zoneId] + ); + return result.rowCount > 0; +}; + +// Backward-compatible check for legacy routes. Prefer householdHasLocation. exports.householdHasStore = async (householdId, storeId) => { const result = await pool.query( - `SELECT 1 FROM household_stores + `SELECT 1 FROM household_stores WHERE household_id = $1 AND store_id = $2`, [householdId, storeId] ); - return result.rows.length > 0; + return result.rowCount > 0; }; diff --git a/backend/routes/households.routes.js b/backend/routes/households.routes.js index 20aef5a..1dd1a73 100644 --- a/backend/routes/households.routes.js +++ b/backend/routes/households.routes.js @@ -3,9 +3,11 @@ const router = express.Router(); const controller = require("../controllers/households.controller"); const listsController = require("../controllers/lists.controller.v2"); const availableItemsController = require("../controllers/available-items.controller"); +const storesController = require("../controllers/stores.controller"); const auth = require("../middleware/auth"); const { householdAccess, + locationAccess, requireHouseholdAdmin, storeAccess, } = require("../middleware/household"); @@ -40,6 +42,139 @@ router.post( controller.refreshInviteCode ); +// Household-owned stores and locations +router.get( + "/:householdId/stores", + auth, + householdAccess, + storesController.getHouseholdStores +); +router.post( + "/:householdId/stores", + auth, + householdAccess, + requireHouseholdAdmin, + storesController.createHouseholdStore +); +router.patch( + "/:householdId/stores/:householdStoreId", + auth, + householdAccess, + requireHouseholdAdmin, + storesController.updateHouseholdStore +); +router.delete( + "/:householdId/stores/:householdStoreId", + auth, + householdAccess, + requireHouseholdAdmin, + storesController.deleteHouseholdStore +); +router.post( + "/:householdId/stores/:householdStoreId/locations", + auth, + householdAccess, + requireHouseholdAdmin, + storesController.addLocationToStore +); +router.patch( + "/:householdId/locations/:locationId", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.updateLocation +); +router.delete( + "/:householdId/locations/:locationId", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.deleteLocation +); +router.patch( + "/:householdId/locations/:locationId/default", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.setDefaultLocation +); +router.get( + "/:householdId/locations/:locationId/zones", + auth, + householdAccess, + locationAccess, + storesController.getLocationZones +); +router.post( + "/:householdId/locations/:locationId/zones", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.createZone +); +router.patch( + "/:householdId/locations/:locationId/zones/:zoneId", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.updateZone +); +router.delete( + "/:householdId/locations/:locationId/zones/:zoneId", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + storesController.deleteZone +); + +router.get( + "/:householdId/locations/:locationId/available-items", + auth, + householdAccess, + locationAccess, + availableItemsController.getAvailableItems +); +router.post( + "/:householdId/locations/:locationId/available-items", + auth, + householdAccess, + locationAccess, + upload.single("image"), + processImage, + availableItemsController.createAvailableItem +); +router.patch( + "/:householdId/locations/:locationId/available-items/:itemId", + auth, + householdAccess, + locationAccess, + upload.single("image"), + processImage, + availableItemsController.updateAvailableItem +); +router.delete( + "/:householdId/locations/:locationId/available-items/:itemId", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + availableItemsController.deleteAvailableItem +); +router.post( + "/:householdId/locations/:locationId/available-items/import-current", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + availableItemsController.importCurrentItems +); + router.get( "/:householdId/stores/:storeId/available-items", auth, @@ -109,6 +244,88 @@ router.delete( // All list routes require household access AND store access // Get grocery list +router.get( + "/:householdId/locations/:locationId/list", + auth, + householdAccess, + locationAccess, + listsController.getList +); +router.get( + "/:householdId/locations/:locationId/list/item", + auth, + householdAccess, + locationAccess, + listsController.getItemByName +); +router.post( + "/:householdId/locations/:locationId/list/add", + auth, + householdAccess, + locationAccess, + upload.single("image"), + processImage, + listsController.addItem +); +router.patch( + "/:householdId/locations/:locationId/list/item", + auth, + householdAccess, + locationAccess, + listsController.markBought +); +router.put( + "/:householdId/locations/:locationId/list/item", + auth, + householdAccess, + locationAccess, + listsController.updateItem +); +router.delete( + "/:householdId/locations/:locationId/list/item", + auth, + householdAccess, + locationAccess, + listsController.deleteItem +); +router.get( + "/:householdId/locations/:locationId/list/suggestions", + auth, + householdAccess, + locationAccess, + listsController.getSuggestions +); +router.get( + "/:householdId/locations/:locationId/list/recent", + auth, + householdAccess, + locationAccess, + listsController.getRecentlyBought +); +router.get( + "/:householdId/locations/:locationId/list/classification", + auth, + householdAccess, + locationAccess, + listsController.getClassification +); +router.post( + "/:householdId/locations/:locationId/list/classification", + auth, + householdAccess, + locationAccess, + listsController.setClassification +); +router.post( + "/:householdId/locations/:locationId/list/update-image", + auth, + householdAccess, + locationAccess, + upload.single("image"), + processImage, + listsController.updateItemImage +); + router.get( "/:householdId/stores/:storeId/list", auth, diff --git a/docs/guides/api-documentation.md b/docs/guides/api-documentation.md index 2c53c4b..0f8eafb 100644 --- a/docs/guides/api-documentation.md +++ b/docs/guides/api-documentation.md @@ -4,6 +4,7 @@ Base URL: `http://localhost:5000/api` ## Table of Contents - [Authentication](#authentication) +- [Current Household Location Scope](#current-household-location-scope) - [Grocery List Management](#grocery-list-management) - [User Management](#user-management) - [Admin Operations](#admin-operations) @@ -12,6 +13,23 @@ Base URL: `http://localhost:5000/api` --- +## Current Household Location Scope + +The active grocery flow is scoped by household-owned store locations, not the legacy global store catalog. + +- List household store locations: `GET /households/:householdId/stores` +- Create a household-owned store with an initial location: `POST /households/:householdId/stores` +- Add a location to a household store: `POST /households/:householdId/stores/:householdStoreId/locations` +- Set the default shopping location: `PATCH /households/:householdId/locations/:locationId/default` +- Manage ordered zones for a location: `GET|POST /households/:householdId/locations/:locationId/zones` +- Update/remove a zone: `PATCH|DELETE /households/:householdId/locations/:locationId/zones/:zoneId` +- Location-scoped list APIs: `/households/:householdId/locations/:locationId/list...` +- Location-scoped item catalog APIs: `/households/:householdId/locations/:locationId/available-items...` + +Owners/admins manage stores, locations, zones, and catalog deletion. Members can add/update list items and catalog item details. + +--- + ## Authentication All authenticated endpoints require a JWT token in the `Authorization` header: @@ -129,7 +147,7 @@ Retrieve all unbought grocery items. - `bought` - Purchase status (always false for this endpoint) - `item_image` - Base64 encoded image (nullable) - `image_mime_type` - MIME type of image (nullable) -- `added_by_users` - Array of user names who added/modified this item +- `added_by_users` - Array of user names whose additions account for the current listed quantity - `modified_on` - Last modification timestamp - `item_type` - Classification type (nullable) - `item_group` - Classification group (nullable) diff --git a/packages/db/migrations/20260526_010000_custom_store_locations.sql b/packages/db/migrations/20260526_010000_custom_store_locations.sql new file mode 100644 index 0000000..ad0d725 --- /dev/null +++ b/packages/db/migrations/20260526_010000_custom_store_locations.sql @@ -0,0 +1,465 @@ +BEGIN; + +-- Household-owned store brands. The legacy public.stores table remains for +-- historical data and system-admin compatibility, but the household shopping +-- flow should use these records plus store_locations. +CREATE TABLE IF NOT EXISTS household_custom_stores ( + id SERIAL PRIMARY KEY, + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + name VARCHAR(120) NOT NULL, + normalized_name VARCHAR(120) NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(household_id, normalized_name) +); + +CREATE INDEX IF NOT EXISTS idx_household_custom_stores_household + ON household_custom_stores(household_id); + +CREATE TABLE IF NOT EXISTS store_locations ( + id SERIAL PRIMARY KEY, + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + household_store_id INTEGER NOT NULL REFERENCES household_custom_stores(id) ON DELETE CASCADE, + name VARCHAR(160) NOT NULL, + normalized_name VARCHAR(160) NOT NULL, + address TEXT, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + map_data JSONB NOT NULL DEFAULT '{}'::jsonb, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(household_store_id, normalized_name) +); + +CREATE INDEX IF NOT EXISTS idx_store_locations_household + ON store_locations(household_id); + +CREATE INDEX IF NOT EXISTS idx_store_locations_store + ON store_locations(household_store_id); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_store_locations_default_per_household + ON store_locations(household_id) + WHERE is_default; + +CREATE TABLE IF NOT EXISTS store_location_zones ( + id SERIAL PRIMARY KEY, + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE, + name VARCHAR(120) NOT NULL, + normalized_name VARCHAR(120) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + color VARCHAR(32), + map_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(store_location_id, normalized_name) +); + +CREATE INDEX IF NOT EXISTS idx_store_location_zones_location_order + ON store_location_zones(store_location_id, is_active, sort_order, name); + +CREATE TABLE IF NOT EXISTS household_item_images ( + id BIGSERIAL PRIMARY KEY, + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + store_location_id INTEGER REFERENCES store_locations(id) ON DELETE CASCADE, + household_store_item_id INTEGER REFERENCES household_store_items(id) ON DELETE CASCADE, + household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE, + image_scope VARCHAR(20) NOT NULL CHECK (image_scope IN ('catalog', 'list')), + image BYTEA NOT NULL, + mime_type VARCHAR(50) NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_household_item_images_item + ON household_item_images(household_store_item_id, image_scope, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_household_item_images_location + ON household_item_images(household_id, store_location_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS household_item_events ( + id BIGSERIAL PRIMARY KEY, + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE, + household_store_item_id INTEGER REFERENCES household_store_items(id) ON DELETE SET NULL, + household_list_id INTEGER REFERENCES household_lists(id) ON DELETE SET NULL, + actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + event_type VARCHAR(50) NOT NULL CHECK ( + event_type IN ( + 'ITEM_ADDED', + 'ITEM_BOUGHT', + 'ITEM_UNBOUGHT', + 'ITEM_QUANTITY_CHANGED', + 'ITEM_DELETED', + 'ITEM_CLASSIFICATION_CHANGED', + 'ITEM_ZONE_CHANGED' + ) + ), + quantity_delta INTEGER, + quantity_after INTEGER, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_household_item_events_location_time + ON household_item_events(household_id, store_location_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_household_item_events_item_time + ON household_item_events(household_store_item_id, created_at DESC); + +-- Allow new location-scoped rows to be independent of the legacy global +-- stores/items catalog. +ALTER TABLE household_store_items + ALTER COLUMN store_id DROP NOT NULL; + +ALTER TABLE household_lists + ADD COLUMN IF NOT EXISTS store_location_id INTEGER; + +ALTER TABLE household_store_items + ADD COLUMN IF NOT EXISTS store_location_id INTEGER, + ADD COLUMN IF NOT EXISTS image_id BIGINT REFERENCES household_item_images(id) ON DELETE SET NULL; + +ALTER TABLE household_item_classifications + ADD COLUMN IF NOT EXISTS store_location_id INTEGER, + ADD COLUMN IF NOT EXISTS zone_id INTEGER REFERENCES store_location_zones(id) ON DELETE SET NULL; + +ALTER TABLE household_list_history + ADD COLUMN IF NOT EXISTS store_location_id INTEGER; + +ALTER TABLE household_lists + ADD COLUMN IF NOT EXISTS image_id BIGINT REFERENCES household_item_images(id) ON DELETE SET NULL; + +-- One owner per household, with defensive cleanup for older data. +WITH ranked_owners AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY household_id + ORDER BY joined_at ASC, id ASC + ) AS owner_rank + FROM household_members + WHERE role = 'owner' +) +UPDATE household_members hm +SET role = 'admin' +FROM ranked_owners ro +WHERE hm.id = ro.id + AND ro.owner_rank > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_household_members_one_owner + ON household_members(household_id) + WHERE role = 'owner'; + +-- Backfill custom stores from current household/global-store links. +INSERT INTO household_custom_stores ( + household_id, + name, + normalized_name, + created_at, + updated_at +) +SELECT + hs.household_id, + s.name, + LOWER(TRIM(s.name)), + COALESCE(MIN(hs.added_at), NOW()), + NOW() +FROM household_stores hs +JOIN stores s ON s.id = hs.store_id +GROUP BY hs.household_id, s.name +ON CONFLICT (household_id, normalized_name) DO NOTHING; + +WITH ranked_links AS ( + SELECT + hs.household_id, + hcs.id AS household_store_id, + ROW_NUMBER() OVER ( + PARTITION BY hs.household_id + ORDER BY hs.is_default DESC, hs.added_at ASC, hs.id ASC + ) AS household_rank + FROM household_stores hs + JOIN stores s ON s.id = hs.store_id + JOIN household_custom_stores hcs + ON hcs.household_id = hs.household_id + AND hcs.normalized_name = LOWER(TRIM(s.name)) +) +INSERT INTO store_locations ( + household_id, + household_store_id, + name, + normalized_name, + is_default, + created_at, + updated_at +) +SELECT + household_id, + household_store_id, + 'Default Location', + 'default location', + household_rank = 1, + NOW(), + NOW() +FROM ranked_links +ON CONFLICT (household_store_id, normalized_name) DO NOTHING; + +-- Backfill location ids onto existing records. +UPDATE household_store_items hsi +SET store_location_id = sl.id +FROM stores s +JOIN household_custom_stores hcs + ON hcs.normalized_name = LOWER(TRIM(s.name)) +JOIN store_locations sl + ON sl.household_store_id = hcs.id + AND sl.normalized_name = 'default location' +WHERE hsi.store_id = s.id + AND hcs.household_id = hsi.household_id + AND hsi.store_location_id IS NULL; + +UPDATE household_lists hl +SET store_location_id = hsi.store_location_id +FROM household_store_items hsi +WHERE hl.household_store_item_id = hsi.id + AND hl.store_location_id IS NULL; + +UPDATE household_item_classifications hic +SET store_location_id = hsi.store_location_id +FROM household_store_items hsi +WHERE hic.household_store_item_id = hsi.id + AND hic.store_location_id IS NULL; + +UPDATE household_list_history hlh +SET store_location_id = hl.store_location_id +FROM household_lists hl +WHERE hlh.household_list_id = hl.id + AND hlh.store_location_id IS NULL; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM household_store_items WHERE store_location_id IS NULL) THEN + RAISE EXCEPTION 'Failed to backfill household_store_items.store_location_id'; + END IF; + + IF EXISTS (SELECT 1 FROM household_lists WHERE store_location_id IS NULL) THEN + RAISE EXCEPTION 'Failed to backfill household_lists.store_location_id'; + END IF; + + IF EXISTS (SELECT 1 FROM household_item_classifications WHERE store_location_id IS NULL) THEN + RAISE EXCEPTION 'Failed to backfill household_item_classifications.store_location_id'; + END IF; +END $$; + +ALTER TABLE household_store_items + ALTER COLUMN store_location_id SET NOT NULL, + ADD CONSTRAINT household_store_items_store_location_id_fkey + FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE; + +ALTER TABLE household_lists + ALTER COLUMN store_location_id SET NOT NULL, + ADD CONSTRAINT household_lists_store_location_id_fkey + FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE; + +ALTER TABLE household_item_classifications + ALTER COLUMN store_location_id SET NOT NULL, + ADD CONSTRAINT household_item_classifications_store_location_id_fkey + FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE; + +ALTER TABLE household_list_history + ADD CONSTRAINT household_list_history_store_location_id_fkey + FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE; + +-- Seed v1 zones for every migrated/default location. +WITH default_zones(name, sort_order, color) AS ( + VALUES + ('Entrance & Seasonal', 10, '#64748b'), + ('Produce & Fresh Vegetables', 20, '#16a34a'), + ('Meat & Seafood Counter', 30, '#dc2626'), + ('Deli & Prepared Foods', 40, '#ea580c'), + ('Bakery', 50, '#ca8a04'), + ('Dairy & Refrigerated', 60, '#0284c7'), + ('Frozen Foods', 70, '#2563eb'), + ('Center Aisles (Dry Goods)', 80, '#7c3aed'), + ('Beverages & Water', 90, '#0891b2'), + ('Snacks & Candy', 100, '#db2777'), + ('Household & Cleaning', 110, '#475569'), + ('Health & Beauty', 120, '#9333ea'), + ('Checkout Area', 130, '#0f172a') +) +INSERT INTO store_location_zones ( + household_id, + store_location_id, + name, + normalized_name, + sort_order, + color +) +SELECT + sl.household_id, + sl.id, + dz.name, + LOWER(TRIM(dz.name)), + dz.sort_order, + dz.color +FROM store_locations sl +CROSS JOIN default_zones dz +ON CONFLICT (store_location_id, normalized_name) DO NOTHING; + +WITH existing_zone_names AS ( + SELECT DISTINCT + hic.household_id, + hic.store_location_id, + TRIM(hic.zone) AS name + FROM household_item_classifications hic + WHERE hic.zone IS NOT NULL + AND TRIM(hic.zone) <> '' +), +ranked_existing_zones AS ( + SELECT + ezn.*, + 1000 + ROW_NUMBER() OVER ( + PARTITION BY ezn.store_location_id + ORDER BY ezn.name + ) AS sort_order + FROM existing_zone_names ezn +) +INSERT INTO store_location_zones ( + household_id, + store_location_id, + name, + normalized_name, + sort_order +) +SELECT + household_id, + store_location_id, + name, + LOWER(TRIM(name)), + sort_order +FROM ranked_existing_zones +ON CONFLICT (store_location_id, normalized_name) DO NOTHING; + +UPDATE household_item_classifications hic +SET zone_id = slz.id +FROM store_location_zones slz +WHERE slz.store_location_id = hic.store_location_id + AND slz.normalized_name = LOWER(TRIM(hic.zone)) + AND hic.zone_id IS NULL + AND hic.zone IS NOT NULL + AND TRIM(hic.zone) <> ''; + +-- Backfill image references while retaining old BYTEA columns for rollback and +-- old-code compatibility. +INSERT INTO household_item_images ( + household_id, + store_location_id, + household_store_item_id, + image_scope, + image, + mime_type, + created_at +) +SELECT + hsi.household_id, + hsi.store_location_id, + hsi.id, + 'catalog', + hsi.custom_image, + COALESCE(hsi.custom_image_mime_type, 'image/jpeg'), + COALESCE(hsi.updated_at, NOW()) +FROM household_store_items hsi +WHERE hsi.custom_image IS NOT NULL + AND hsi.image_id IS NULL; + +UPDATE household_store_items hsi +SET image_id = img.id +FROM household_item_images img +WHERE img.household_store_item_id = hsi.id + AND img.image_scope = 'catalog' + AND hsi.image_id IS NULL; + +INSERT INTO household_item_images ( + household_id, + store_location_id, + household_store_item_id, + household_list_id, + image_scope, + image, + mime_type, + created_by, + created_at +) +SELECT + hl.household_id, + hl.store_location_id, + hl.household_store_item_id, + hl.id, + 'list', + hl.custom_image, + COALESCE(hl.custom_image_mime_type, 'image/jpeg'), + hl.added_by, + COALESCE(hl.modified_on, NOW()) +FROM household_lists hl +WHERE hl.custom_image IS NOT NULL + AND hl.image_id IS NULL; + +UPDATE household_lists hl +SET image_id = img.id +FROM household_item_images img +WHERE img.household_list_id = hl.id + AND img.image_scope = 'list' + AND hl.image_id IS NULL; + +-- Backfill known add history into the new event log. +INSERT INTO household_item_events ( + household_id, + store_location_id, + household_store_item_id, + household_list_id, + actor_user_id, + event_type, + quantity_delta, + metadata, + created_at +) +SELECT + hl.household_id, + hl.store_location_id, + hlh.household_store_item_id, + hlh.household_list_id, + hlh.added_by, + 'ITEM_ADDED', + hlh.quantity, + jsonb_build_object('source', 'household_list_history'), + hlh.added_on +FROM household_list_history hlh +JOIN household_lists hl ON hl.id = hlh.household_list_id +WHERE NOT EXISTS ( + SELECT 1 + FROM household_item_events hie + WHERE hie.household_list_id = hlh.household_list_id + AND hie.household_store_item_id = hlh.household_store_item_id + AND hie.event_type = 'ITEM_ADDED' + AND hie.created_at = hlh.added_on +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_household_store_items_location_name + ON household_store_items(household_id, store_location_id, normalized_name); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_household_lists_location_item + ON household_lists(household_id, store_location_id, household_store_item_id); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_household_item_classifications_location_item + ON household_item_classifications(household_id, store_location_id, household_store_item_id); + +CREATE INDEX IF NOT EXISTS idx_household_lists_location_bought + ON household_lists(household_id, store_location_id, bought); + +CREATE INDEX IF NOT EXISTS idx_household_classifications_location_zone + ON household_item_classifications(household_id, store_location_id, zone_id); + +CREATE INDEX IF NOT EXISTS idx_household_list_history_location_item + ON household_list_history(store_location_id, household_store_item_id, added_on DESC); + +COMMIT;