UNPKG

@ideal-photography/shared

Version:

Shared MongoDB and utility logic for Ideal Photography PWAs: users, products, services, bookings, orders/cart, galleries, reviews, notifications, campaigns, settings, audit logs, minimart items/orders, and push notification subscriptions.

220 lines (203 loc) 9.03 kB
import { mongoose, models } from "../mongoDB/index.js"; /** * Central Stock & Availability Service * ------------------------------------ * All stock mutations MUST go through this module. * Functions run in MongoDB transactions to avoid race conditions. * * NOTE: This MVP uses simple `availableUnits / stockQuantity` fields. * A future iteration will introduce `reservedUnits` for safer two-phase commits. */ const PRODUCT_TYPES = { EQUIPMENT: ["equipment", "equipment_rental"], MINIMART: ["minimart", "minimart_item"], }; function isEquipment(type) { return PRODUCT_TYPES.EQUIPMENT.includes(type); } function isMinimart(type) { return PRODUCT_TYPES.MINIMART.includes(type); } /** * Check if MongoDB instance supports transactions (replica set) * @returns {Promise<boolean>} */ async function checkTransactionSupport() { try { const admin = mongoose.connection.db.admin(); const serverStatus = await admin.serverStatus(); return serverStatus.repl && serverStatus.repl.ismaster; } catch (error) { console.log('⚠️ Transaction support check failed, assuming standalone mode:', error.message); return false; } } /** * Validate and check availability for a list of items. * @param {Array<{productId:string, productType:string, quantity:number}>} items * @returns {Promise<{available:boolean, details:Array}>} */ async function checkAvailability(items) { const details = []; let allAvailable = true; for (const item of items) { const { productId, productType, quantity } = item; if (isEquipment(productType)) { const equipment = await models.Equipment.findById(productId).lean(); const available = equipment && equipment.inventory?.availableUnits >= quantity; details.push({ productId, productType, requested: quantity, available, availableUnits: equipment?.inventory?.availableUnits ?? 0 }); if (!available) allAvailable = false; } else if (isMinimart(productType)) { const minimart = await models.MinimartItem.findById(productId).lean(); const available = minimart && minimart.inventory?.stockQuantity >= quantity; details.push({ productId, productType, requested: quantity, available, availableUnits: minimart?.inventory?.stockQuantity ?? 0 }); if (!available) allAvailable = false; } else { // Services – always available in stock context details.push({ productId, productType, requested: quantity, available: true }); } } return { available: allAvailable, details }; } /** * Reserve stock for an ORDER in status "checkout". * Mutates the inventory counts immediately. * @param {string} orderId * @param {Array<{productId, productType, quantity}>} items */ async function reserveItems(orderId, items) { // Check if transactions are supported (replica set) const supportsTransactions = await checkTransactionSupport(); if (supportsTransactions) { // Use transaction for production safety const session = await mongoose.startSession(); try { await session.withTransaction(async () => { for (const item of items) { const { productId, productType, quantity } = item; if (isEquipment(productType)) { await models.Equipment.updateOne( { _id: productId, "inventory.availableUnits": { $gte: quantity } }, { $inc: { "inventory.availableUnits": -quantity } }, { session } ); } else if (isMinimart(productType)) { await models.MinimartItem.updateOne( { _id: productId, "inventory.stockQuantity": { $gte: quantity } }, { $inc: { "inventory.stockQuantity": -quantity } }, { session } ); } // services skipped } // flag order as reserved await models.Order.updateOne({ _id: orderId }, { $set: { "metadata.stockReserved": true } }, { session }); }); } finally { await session.endSession(); } } else { // Fallback for local development without replica set console.log('⚠️ Using non-transactional stock reservation (local development mode)'); for (const item of items) { const { productId, productType, quantity } = item; if (isEquipment(productType)) { const result = await models.Equipment.updateOne( { _id: productId, "inventory.availableUnits": { $gte: quantity } }, { $inc: { "inventory.availableUnits": -quantity } } ); if (result.matchedCount === 0) { throw new Error(`Insufficient stock for equipment ${productId}`); } } else if (isMinimart(productType)) { const result = await models.MinimartItem.updateOne( { _id: productId, "inventory.stockQuantity": { $gte: quantity } }, { $inc: { "inventory.stockQuantity": -quantity } } ); if (result.matchedCount === 0) { throw new Error(`Insufficient stock for minimart item ${productId}`); } } // services skipped } // flag order as reserved await models.Order.updateOne({ _id: orderId }, { $set: { "metadata.stockReserved": true } }); } } /** * Commit reservation after PAYMENT success – nothing to do for simple decrement model * but we flag metadata for clarity. */ async function commitReservation(orderId) { await models.Order.findByIdAndUpdate(orderId, { $set: { "metadata.stockCommitted": true, "metadata.stockCommittedAt": new Date() }, }); } /** * Release reservation – restore stock on cancellation/refund/return. * @param {string} orderId * @param {Array} items Same structure as reserveItems */ async function releaseReservation(orderId, items) { // Check if transactions are supported (replica set) const supportsTransactions = await checkTransactionSupport(); if (supportsTransactions) { // Use transaction for production safety const session = await mongoose.startSession(); try { await session.withTransaction(async () => { for (const item of items) { const { productId, productType, quantity } = item; if (isEquipment(productType)) { await models.Equipment.updateOne( { _id: productId }, { $inc: { "inventory.availableUnits": quantity } }, { session } ); } else if (isMinimart(productType)) { await models.MinimartItem.updateOne( { _id: productId }, { $inc: { "inventory.stockQuantity": quantity } }, { session } ); } } await models.Order.updateOne( { _id: orderId }, { $set: { "metadata.stockReleased": true, "metadata.stockReleasedAt": new Date() } }, { session } ); }); } finally { await session.endSession(); } } else { // Fallback for local development without replica set console.log('⚠️ Using non-transactional stock release (local development mode)'); for (const item of items) { const { productId, productType, quantity } = item; if (isEquipment(productType)) { await models.Equipment.updateOne( { _id: productId }, { $inc: { "inventory.availableUnits": quantity } } ); } else if (isMinimart(productType)) { await models.MinimartItem.updateOne( { _id: productId }, { $inc: { "inventory.stockQuantity": quantity } } ); } } await models.Order.updateOne( { _id: orderId }, { $set: { "metadata.stockReleased": true, "metadata.stockReleasedAt": new Date() } } ); } } export const StockService = { checkAvailability, reserveItems, commitReservation, releaseReservation, }; export default StockService;