@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
JavaScript
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;