const pool = require("../db/pool"); const storeModel = require("./store.model"); const { buildStarterObjectsFromZones, getStarterMapSize, } = require("../services/location-map-layout.service"); const DEFAULT_MAP_WIDTH = 1000; const DEFAULT_MAP_HEIGHT = 700; const DEFAULT_MAP_NAME = "Store Map"; const MAP_OBJECT_TYPES = new Set([ "zone", "aisle", "entrance", "checkout", "shelf", "wall", "department", "anchor", "other", ]); function toNumber(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function toPositiveNumber(value, fallback) { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function toPositiveInteger(value, fallback) { const parsed = Number.parseInt(String(value), 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } function normalizeMapRow(row) { if (!row) return null; return { ...row, width: toPositiveInteger(row.width, DEFAULT_MAP_WIDTH), height: toPositiveInteger(row.height, DEFAULT_MAP_HEIGHT), version: toPositiveInteger(row.version, 1), }; } function normalizeObjectRow(row) { if (!row) return null; return { ...row, x: toNumber(row.x), y: toNumber(row.y), width: toPositiveNumber(row.width, 160), height: toPositiveNumber(row.height, 100), rotation: toNumber(row.rotation), locked: Boolean(row.locked), visible: row.visible !== false, }; } function sanitizeMapPayload(payload = {}) { return { name: typeof payload.name === "string" && payload.name.trim() ? payload.name.trim().slice(0, 120) : DEFAULT_MAP_NAME, width: toPositiveInteger(payload.width, DEFAULT_MAP_WIDTH), height: toPositiveInteger(payload.height, DEFAULT_MAP_HEIGHT), }; } function sanitizeObjectPayload(object = {}, index = 0) { const type = MAP_OBJECT_TYPES.has(object.type) ? object.type : "zone"; const rawZoneId = object.zone_id ?? object.zoneId; const zoneId = rawZoneId === null || rawZoneId === "" || rawZoneId === undefined ? null : toPositiveInteger(rawZoneId, null); return { zone_id: zoneId, type, label: typeof object.label === "string" && object.label.trim() ? object.label.trim().slice(0, 160) : "", x: toNumber(object.x), y: toNumber(object.y), width: toPositiveNumber(object.width, 160), height: toPositiveNumber(object.height, 100), rotation: toNumber(object.rotation), locked: Boolean(object.locked), visible: object.visible !== false, sort_order: toPositiveInteger(object.sort_order ?? object.sortOrder, index + 1), metadata_json: object.metadata_json && typeof object.metadata_json === "object" && !Array.isArray(object.metadata_json) ? object.metadata_json : {}, }; } async function getMaps(db, householdId, locationId, includeDraft) { const statuses = includeDraft ? ["draft", "published"] : ["published"]; const result = await db.query( `SELECT id, household_id, store_location_id, name, width, height, status, version, created_by, updated_by, published_at, created_at, updated_at FROM location_maps WHERE household_id = $1 AND store_location_id = $2 AND status = ANY($3::text[]) ORDER BY CASE status WHEN 'draft' THEN 0 WHEN 'published' THEN 1 ELSE 2 END, updated_at DESC`, [householdId, locationId, statuses] ); return result.rows.map(normalizeMapRow); } async function getObjects(db, mapId) { if (!mapId) return []; const result = await db.query( `SELECT lmo.id, lmo.location_map_id, lmo.zone_id, lmo.type, lmo.label, lmo.x, lmo.y, lmo.width, lmo.height, lmo.rotation, lmo.locked, lmo.visible, lmo.sort_order, lmo.metadata_json, slz.name AS zone_name, lmo.created_at, lmo.updated_at FROM location_map_objects lmo LEFT JOIN store_location_zones slz ON slz.id = lmo.zone_id WHERE lmo.location_map_id = $1 ORDER BY lmo.sort_order ASC, lmo.id ASC`, [mapId] ); return result.rows.map(normalizeObjectRow); } async function getZoneSummaries(db, householdId, locationId) { const result = await db.query( `WITH map_items AS ( SELECT hl.id, resolved_zone.id AS zone_id FROM household_lists hl JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id LEFT JOIN household_item_classifications hic ON hic.household_id = hl.household_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 resolved_zone ON resolved_zone.household_id = hl.household_id AND resolved_zone.store_location_id = hl.store_location_id AND resolved_zone.is_active = TRUE AND ( resolved_zone.id = hic.zone_id OR ( hic.zone_id IS NULL AND hic.zone IS NOT NULL AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone)) ) ) WHERE hl.household_id = $1 AND hl.store_location_id = $2 AND hl.bought = FALSE ), item_counts AS ( SELECT zone_id, COUNT(*)::int AS item_count FROM map_items WHERE zone_id IS NOT NULL GROUP BY zone_id ) SELECT slz.id, slz.name, slz.sort_order, slz.color, slz.map_metadata, slz.is_active, COALESCE(item_counts.item_count, 0)::int AS item_count FROM store_location_zones slz LEFT JOIN item_counts ON item_counts.zone_id = slz.id WHERE slz.household_id = $1 AND slz.store_location_id = $2 AND slz.is_active = TRUE ORDER BY slz.sort_order ASC, slz.name ASC`, [householdId, locationId] ); return result.rows; } async function getMapItems(db, householdId, locationId) { const result = await db.query( `SELECT hl.id, hl.household_store_item_id AS item_id, hsi.name AS item_name, hl.quantity, hl.bought, resolved_zone.id AS zone_id, COALESCE(resolved_zone.name, hic.zone) AS zone, ( 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 FROM household_lists hl JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id LEFT JOIN household_item_classifications hic ON hic.household_id = hl.household_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 resolved_zone ON resolved_zone.household_id = hl.household_id AND resolved_zone.store_location_id = hl.store_location_id AND resolved_zone.is_active = TRUE AND ( resolved_zone.id = hic.zone_id OR ( hic.zone_id IS NULL AND hic.zone IS NOT NULL AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone)) ) ) WHERE hl.household_id = $1 AND hl.store_location_id = $2 AND hl.bought = FALSE ORDER BY resolved_zone.sort_order ASC NULLS LAST, hsi.name ASC`, [householdId, locationId] ); return result.rows.map((row) => ({ ...row, added_by_users: Array.isArray(row.added_by_users) ? row.added_by_users : [], })); } async function insertObjects(db, mapId, objects) { for (let index = 0; index < objects.length; index += 1) { const object = sanitizeObjectPayload(objects[index], index); await db.query( `INSERT INTO location_map_objects ( location_map_id, zone_id, type, label, x, y, width, height, rotation, locked, visible, sort_order, metadata_json ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)`, [ mapId, object.zone_id, object.type, object.label, object.x, object.y, object.width, object.height, object.rotation, object.locked, object.visible, object.sort_order, JSON.stringify(object.metadata_json), ] ); } } async function upsertDraftMap(db, householdId, locationId, userId, mapPayload) { const map = sanitizeMapPayload(mapPayload); const result = await db.query( `INSERT INTO location_maps (household_id, store_location_id, name, width, height, status, version, created_by, updated_by, updated_at) VALUES ($1, $2, $3, $4, $5, 'draft', 1, $6, $6, NOW()) ON CONFLICT (store_location_id) WHERE status = 'draft' DO UPDATE SET name = EXCLUDED.name, width = EXCLUDED.width, height = EXCLUDED.height, updated_by = EXCLUDED.updated_by, updated_at = NOW() RETURNING id, household_id, store_location_id, name, width, height, status, version, created_by, updated_by, published_at, created_at, updated_at`, [householdId, locationId, map.name, map.width, map.height, userId] ); return normalizeMapRow(result.rows[0]); } exports.getMapState = async (householdId, locationId, includeDraft = false) => { const location = await storeModel.getLocationById(householdId, locationId); if (!location) return null; const maps = await getMaps(pool, householdId, locationId, includeDraft); const draftMap = includeDraft ? maps.find((map) => map.status === "draft") || null : null; const publishedMap = maps.find((map) => map.status === "published") || null; const activeMap = draftMap || publishedMap; const [draftObjects, publishedObjects, zones, items] = await Promise.all([ getObjects(pool, draftMap?.id), getObjects(pool, publishedMap?.id), getZoneSummaries(pool, householdId, locationId), getMapItems(pool, householdId, locationId), ]); const objects = draftMap ? draftObjects : publishedObjects; return { location, map: activeMap || null, draft_map: draftMap, published_map: publishedMap, objects, draft_objects: draftObjects, published_objects: publishedObjects, zones, items, unmapped_count: items.filter((item) => !item.zone_id).length, }; }; exports.saveBlankDraft = async (householdId, locationId, userId, mapPayload = {}) => { const client = await pool.connect(); try { await client.query("BEGIN"); const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload); await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]); await client.query("COMMIT"); return exports.getMapState(householdId, locationId, true); } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } }; exports.createDraftFromZones = async (householdId, locationId, userId, mapPayload = {}) => { const client = await pool.connect(); try { await client.query("BEGIN"); const zones = await getZoneSummaries(client, householdId, locationId); const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload); await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]); const starterSize = getStarterMapSize(zones, map); if (starterSize.width !== map.width || starterSize.height !== map.height) { await client.query( `UPDATE location_maps SET width = $1, height = $2, updated_at = NOW(), updated_by = $3 WHERE id = $4`, [starterSize.width, starterSize.height, userId, map.id] ); map.width = starterSize.width; map.height = starterSize.height; } await insertObjects( client, map.id, buildStarterObjectsFromZones(zones, map.width, map.height) ); await client.query("COMMIT"); return exports.getMapState(householdId, locationId, true); } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } }; exports.saveDraft = async (householdId, locationId, userId, payload = {}) => { const client = await pool.connect(); try { await client.query("BEGIN"); const map = await upsertDraftMap(client, householdId, locationId, userId, payload.map || {}); await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]); await insertObjects(client, map.id, Array.isArray(payload.objects) ? payload.objects : []); await client.query("COMMIT"); return exports.getMapState(householdId, locationId, true); } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } }; exports.publishDraft = async (householdId, locationId, userId) => { const client = await pool.connect(); try { await client.query("BEGIN"); const draftResult = await client.query( `SELECT id FROM location_maps WHERE household_id = $1 AND store_location_id = $2 AND status = 'draft' FOR UPDATE`, [householdId, locationId] ); if (draftResult.rowCount === 0) { await client.query("ROLLBACK"); return null; } const versionResult = await client.query( `SELECT COALESCE(MAX(version), 0)::int AS version FROM location_maps WHERE household_id = $1 AND store_location_id = $2`, [householdId, locationId] ); const nextVersion = (versionResult.rows[0]?.version || 0) + 1; await client.query( `UPDATE location_maps SET status = 'archived', updated_by = $3, updated_at = NOW() WHERE household_id = $1 AND store_location_id = $2 AND status = 'published'`, [householdId, locationId, userId] ); await client.query( `UPDATE location_maps SET status = 'published', version = $3, published_at = NOW(), updated_by = $4, updated_at = NOW() WHERE household_id = $1 AND store_location_id = $2 AND id = $5`, [householdId, locationId, nextVersion, userId, draftResult.rows[0].id] ); await client.query("COMMIT"); return exports.getMapState(householdId, locationId, true); } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } };