537 lines
16 KiB
JavaScript
537 lines
16 KiB
JavaScript
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
|
|
OR hl.modified_on >= NOW() - INTERVAL '24 hours'
|
|
)
|
|
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 getActiveZoneIds(db, householdId, locationId) {
|
|
const result = await db.query(
|
|
`SELECT id
|
|
FROM store_location_zones
|
|
WHERE household_id = $1
|
|
AND store_location_id = $2
|
|
AND is_active = TRUE`,
|
|
[householdId, locationId]
|
|
);
|
|
|
|
return new Set(result.rows.map((row) => Number(row.id)));
|
|
}
|
|
|
|
async function insertObjects(db, mapId, objects, validZoneIds = null) {
|
|
for (let index = 0; index < objects.length; index += 1) {
|
|
const object = sanitizeObjectPayload(objects[index], index);
|
|
const scopedZoneId =
|
|
object.zone_id && validZoneIds && !validZoneIds.has(Number(object.zone_id))
|
|
? null
|
|
: object.zone_id;
|
|
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,
|
|
scopedZoneId,
|
|
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 || {});
|
|
const validZoneIds = await getActiveZoneIds(client, householdId, locationId);
|
|
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 : [], validZoneIds);
|
|
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();
|
|
}
|
|
};
|