const pool = require("../db/pool"); const { ZONE_FLOW } = require("../constants/classifications"); 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 FROM stores ORDER BY name ASC` ); return result.rows; }; exports.getStoreById = async (storeId) => { const result = await pool.query( `SELECT id, name, default_zones, created_at FROM stores WHERE id = $1`, [storeId] ); return result.rows[0]; }; exports.createStore = async (name, defaultZones) => { const result = await pool.query( `INSERT INTO stores (name, default_zones) VALUES ($1, $2) RETURNING id, name, default_zones, created_at`, [name, JSON.stringify(defaultZones)] ); return result.rows[0]; }; exports.updateStore = async (storeId, updates) => { const { name, default_zones } = updates; const result = await pool.query( `UPDATE stores SET name = COALESCE($1, name), default_zones = COALESCE($2, default_zones) WHERE id = $3 RETURNING id, name, default_zones, created_at`, [name, default_zones ? JSON.stringify(default_zones) : null, storeId] ); return result.rows[0]; }; exports.deleteStore = async (storeId) => { const usage = await pool.query( `SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`, [storeId] ); 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]); }; // 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, COALESCE(zone_counts.zone_count, 0)::int AS zone_count, COALESCE(item_counts.item_count, 0)::int AS item_count, 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 LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS zone_count FROM store_location_zones slz WHERE slz.household_id = sl.household_id AND slz.store_location_id = sl.id AND slz.is_active = TRUE ) zone_counts ON TRUE LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS item_count FROM household_store_items hsi WHERE hsi.household_id = sl.household_id AND hsi.store_location_id = sl.id ) item_counts ON TRUE 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 WHERE household_id = $1 AND store_id = $2`, [householdId, storeId] ); return result.rowCount > 0; };