grocery-app/backend/models/store.model.js
2026-05-31 21:21:12 -07:00

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;
};