575 lines
16 KiB
JavaScript
575 lines
16 KiB
JavaScript
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;
|
|
};
|