feat: add safe fresh batch inventory reset

This commit is contained in:
Gemini CLI committed 2026-07-27 16:26:57 +10:00
1 parent 27f3d73d01
commit f27bd572e4
9 files changed
+400 -247

No files matched your search

+231 -160
View File
@@ -11,15 +11,44 @@ const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
const requireAdmin: express.RequestHandler = async (req, res, next) => {
try {
const adminCode = req.get('x-admin-code');
const config = await prisma.config.findUnique({ where: { id: 1 } });
if (!adminCode || !config || adminCode !== config.adminCode) {
res.status(401).json({ error: 'Admin authentication required' });
return;
}
next();
} catch (error) {
console.error('Admin authentication failed:', error);
res.status(500).json({ error: 'Failed to authenticate admin' });
}
};
const parseId = (value: string | string[] | undefined) => {
const id = Array.isArray(value) ? NaN : Number(value);
return Number.isInteger(id) && id > 0 ? id : null;
};
// API Routes
// Fetch all data (Stock, Orders, Config, FAQs)
app.get('/api/data', async (req, res) => {
try {
let stock = await prisma.stockItem.findMany();
const stock = await prisma.stockItem.findMany();
const orders = await prisma.order.findMany();
const config = await prisma.config.findFirst();
const config = await prisma.config.findUnique({ where: { id: 1 } });
const faqs = await prisma.fAQ.findMany({ orderBy: { order: 'asc' } });
const suppliedAdminCode = req.get('x-admin-code');
const isAdmin = Boolean(suppliedAdminCode && config && suppliedAdminCode === config.adminCode);
if (suppliedAdminCode && !isAdmin) {
res.status(401).json({ error: 'Admin session expired' });
return;
}
// Parse order items from string to JSON
const parsedOrders = orders.map(order => ({
@@ -30,42 +59,40 @@ app.get('/api/data', async (req, res) => {
// Tally total ordered kg per stock item across all orders
const orderedQtyById: Record<number, number> = {};
for (const order of parsedOrders) {
if (order.status === 'Cancelled') continue;
for (const item of order.items) {
orderedQtyById[item.id] = (orderedQtyById[item.id] || 0) + (item.qty || 0);
}
}
// Auto-deactivate items that have hit their maxStock limit
for (const item of stock) {
if (item.maxStock !== null && item.maxStock !== undefined) {
const ordered = orderedQtyById[item.id] || 0;
const isOutOfStock = ordered >= item.maxStock;
// Only auto-disable if it's currently marked as available but is actually out of stock
if (isOutOfStock && item.available) {
await prisma.stockItem.update({
where: { id: item.id },
data: { available: false }
});
}
}
}
// Attach computed batch availability without overwriting the admin's availability setting
const stockWithRemaining = stock.map(item => {
const orderedQty = orderedQtyById[item.id] || 0;
const remainingStock = item.maxStock !== null
? Math.max(0, item.maxStock - orderedQty)
: null;
// Re-fetch stock after potential auto-updates
stock = await prisma.stockItem.findMany();
// Attach remaining stock info to each item
const stockWithRemaining = stock.map(item => ({
...item,
orderedQty: orderedQtyById[item.id] || 0,
remainingStock: item.maxStock !== null && item.maxStock !== undefined
? Math.max(0, item.maxStock - (orderedQtyById[item.id] || 0))
: null
}));
return {
...item,
available: item.available && (remainingStock === null || remainingStock > 0),
configuredAvailable: item.available,
orderedQty,
remainingStock
};
});
// Security: Don't send the admin code to the client
const configResponse = config ? { ...config, adminCode: undefined } : null;
const configResponse = config ? {
id: config.id,
finalDepositDate: config.finalDepositDate,
cookDay: config.cookDay,
payIdInfo: config.payIdInfo,
termsOfService: config.termsOfService,
orderingPolicy: config.orderingPolicy,
depositPercentage: config.depositPercentage
} : null;
res.json({ stock: stockWithRemaining, orders: parsedOrders, config: configResponse, faqs });
res.json({ stock: stockWithRemaining, orders: isAdmin ? parsedOrders : [], config: configResponse, faqs });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to fetch data' });
@@ -76,13 +103,10 @@ app.get('/api/data', async (req, res) => {
app.post('/api/login', async (req, res) => {
try {
const { code } = req.body;
console.log(`Login attempt with code: ${code}`);
const config = await prisma.config.findFirst();
const config = await prisma.config.findUnique({ where: { id: 1 } });
if (config && config.adminCode === code) {
console.log('Login successful');
res.json({ success: true });
} else {
console.log(`Login failed. Expected: ${config?.adminCode}, Got: ${code}`);
res.status(401).json({ error: 'Invalid admin code' });
}
} catch (error) {
@@ -94,67 +118,97 @@ app.post('/api/login', async (req, res) => {
// Submit a new order
app.post('/api/orders', async (req, res) => {
try {
const { name, phone, items, totalWeight, estimatedTotal } = req.body;
// 1. Validate Stock Availability
const stockItems = await prisma.stockItem.findMany();
const allOrders = await prisma.order.findMany();
const orderedQtyById: Record<number, number> = {};
for (const order of allOrders) {
const parsedItems = JSON.parse(order.items);
for (const item of parsedItems) {
orderedQtyById[item.id] = (orderedQtyById[item.id] || 0) + (item.qty || 0);
}
const { name, phone, items } = req.body;
if (typeof name !== 'string' || !name.trim() || typeof phone !== 'string' || !phone.trim()) {
throw new Error('Name and phone are required');
}
if (!Array.isArray(items) || items.length === 0) {
throw new Error('At least one item is required');
}
let computedTotalWeight = 0;
let computedEstimatedTotal = 0;
const updatedOrder = await prisma.$transaction(async (tx) => {
const stockItems = await tx.stockItem.findMany();
const allOrders = await tx.order.findMany({
where: { status: { not: 'Cancelled' } },
select: { items: true }
});
const orderedQtyById: Record<number, number> = {};
for (const item of items) {
const dbItem = stockItems.find(s => s.id === item.id);
if (!dbItem) throw new Error(`Item ${item.name} not found`);
if (!dbItem.available) throw new Error(`Sorry, ${dbItem.name} has just sold out!`);
if (dbItem.maxStock !== null && dbItem.maxStock !== undefined) {
const alreadyOrdered = orderedQtyById[dbItem.id] || 0;
if (alreadyOrdered + item.qty > dbItem.maxStock) {
throw new Error(`Not enough stock for ${dbItem.name}. Only ${Math.max(0, dbItem.maxStock - alreadyOrdered)}${dbItem.unit} remaining.`);
for (const order of allOrders) {
const parsedItems = JSON.parse(order.items);
for (const item of parsedItems) {
orderedQtyById[item.id] = (orderedQtyById[item.id] || 0) + (item.qty || 0);
}
}
// Recalculate prices server-side
const getPrice = (stockItem: any, qty: number) => {
if (stockItem.bulk2Threshold > 0 && qty >= stockItem.bulk2Threshold && stockItem.bulk2Price > 0) return stockItem.bulk2Price;
if (stockItem.bulk1Threshold > 0 && qty >= stockItem.bulk1Threshold && stockItem.bulk1Price > 0) return stockItem.bulk1Price;
return stockItem.price;
};
const seenItemIds = new Set<number>();
const validatedItems: Array<{
id: number;
name: string;
qty: number;
unit: string;
finalPricePerUnit: number;
lineTotal: number;
}> = [];
let computedTotalWeight = 0;
let computedEstimatedTotal = 0;
const finalPrice = getPrice(dbItem, item.qty);
item.finalPricePerUnit = finalPrice;
item.lineTotal = finalPrice * item.qty;
computedTotalWeight += item.qty;
computedEstimatedTotal += item.lineTotal;
}
for (const item of items) {
if (!Number.isInteger(item?.id) || typeof item?.qty !== 'number' || !Number.isFinite(item.qty) || item.qty <= 0) {
throw new Error('Each order item must have a valid item ID and quantity greater than zero');
}
if (seenItemIds.has(item.id)) {
throw new Error('Duplicate items are not allowed');
}
seenItemIds.add(item.id);
// 2. Create the order
const order = await prisma.order.create({
data: {
name,
phone,
totalWeight: computedTotalWeight,
estimatedTotal: computedEstimatedTotal,
items: JSON.stringify(items),
status: 'Pending'
const dbItem = stockItems.find(stockItem => stockItem.id === item.id);
if (!dbItem) throw new Error('An item in this order no longer exists');
if (!dbItem.available) throw new Error(`Sorry, ${dbItem.name} has just sold out!`);
if (dbItem.maxStock !== null) {
const alreadyOrdered = orderedQtyById[dbItem.id] || 0;
if (alreadyOrdered + item.qty > dbItem.maxStock) {
throw new Error(`Not enough stock for ${dbItem.name}. Only ${Math.max(0, dbItem.maxStock - alreadyOrdered)}${dbItem.unit} remaining.`);
}
}
let finalPrice = dbItem.price;
if (dbItem.bulk2Threshold > 0 && item.qty >= dbItem.bulk2Threshold && dbItem.bulk2Price > 0) {
finalPrice = dbItem.bulk2Price;
} else if (dbItem.bulk1Threshold > 0 && item.qty >= dbItem.bulk1Threshold && dbItem.bulk1Price > 0) {
finalPrice = dbItem.bulk1Price;
}
const lineTotal = finalPrice * item.qty;
validatedItems.push({
id: dbItem.id,
name: dbItem.name,
qty: item.qty,
unit: dbItem.unit,
finalPricePerUnit: finalPrice,
lineTotal
});
computedTotalWeight += item.qty;
computedEstimatedTotal += lineTotal;
}
});
// 3. Generate and update order number based on ID (to avoid race conditions)
const orderNumber = `#${order.id + 1000}`;
const updatedOrder = await prisma.order.update({
where: { id: order.id },
data: { orderNumber }
const order = await tx.order.create({
data: {
name: name.trim(),
phone: phone.trim(),
totalWeight: computedTotalWeight,
estimatedTotal: computedEstimatedTotal,
items: JSON.stringify(validatedItems),
status: 'Pending'
}
});
return tx.order.update({
where: { id: order.id },
data: { orderNumber: `#${order.id + 1000}` }
});
});
res.json(updatedOrder);
@@ -165,12 +219,22 @@ app.post('/api/orders', async (req, res) => {
});
// Update Order Status
app.patch('/api/orders/:id/status', async (req, res) => {
app.patch('/api/orders/:id/status', requireAdmin, async (req, res) => {
try {
const { id } = req.params;
const { status } = req.body;
const orderId = parseId(id);
const allowedStatuses = ['Pending', 'Deposit Paid', 'Paid', 'Cooked', 'Completed', 'Cancelled'];
if (orderId === null) {
res.status(400).json({ error: 'Invalid order ID' });
return;
}
if (!allowedStatuses.includes(status)) {
res.status(400).json({ error: 'Invalid order status' });
return;
}
const order = await prisma.order.update({
where: { id: parseInt(id) },
where: { id: orderId },
data: { status }
});
res.json(order);
@@ -181,10 +245,15 @@ app.patch('/api/orders/:id/status', async (req, res) => {
});
// Delete an order
app.delete('/api/orders/:id', async (req, res) => {
app.delete('/api/orders/:id', requireAdmin, async (req, res) => {
try {
const { id } = req.params;
await prisma.order.delete({ where: { id: parseInt(id) } });
const orderId = parseId(id);
if (orderId === null) {
res.status(400).json({ error: 'Invalid order ID' });
return;
}
await prisma.order.delete({ where: { id: orderId } });
res.sendStatus(200);
} catch (error) {
console.error(error);
@@ -192,21 +261,34 @@ app.delete('/api/orders/:id', async (req, res) => {
}
});
// Clear all orders
app.delete('/api/orders', async (req, res) => {
// Start a fresh cooking batch while preserving the menu and site settings
app.post('/api/batches/reset', requireAdmin, async (req, res) => {
try {
await prisma.order.deleteMany();
res.sendStatus(200);
const [clearedOrders, reactivatedItems] = await prisma.$transaction([
prisma.order.deleteMany(),
prisma.stockItem.updateMany({
where: { available: false },
data: { available: true }
})
]);
res.json({
clearedOrders: clearedOrders.count,
reactivatedItems: reactivatedItems.count
});
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to clear orders' });
res.status(500).json({ error: 'Failed to start a new cooking batch' });
}
});
// Update or Create Stock Item
app.post('/api/stock', async (req, res) => {
app.post('/api/stock', requireAdmin, async (req, res) => {
try {
const { orderedQty, remainingStock, ...item } = req.body; // strip virtual fields
const { orderedQty, remainingStock, configuredAvailable, ...item } = req.body; // strip virtual fields
if (typeof configuredAvailable === 'boolean') {
item.available = configuredAvailable;
}
if (item.id) {
const updated = await prisma.stockItem.update({
where: { id: item.id },
@@ -227,10 +309,15 @@ app.post('/api/stock', async (req, res) => {
});
// Delete Stock Item
app.delete('/api/stock/:id', async (req, res) => {
app.delete('/api/stock/:id', requireAdmin, async (req, res) => {
try {
const { id } = req.params;
await prisma.stockItem.delete({ where: { id: parseInt(id) } });
const stockItemId = parseId(id);
if (stockItemId === null) {
res.status(400).json({ error: 'Invalid stock item ID' });
return;
}
await prisma.stockItem.delete({ where: { id: stockItemId } });
res.sendStatus(200);
} catch (error) {
console.error(error);
@@ -239,17 +326,34 @@ app.delete('/api/stock/:id', async (req, res) => {
});
// Update Config
app.post('/api/config', async (req, res) => {
app.post('/api/config', requireAdmin, async (req, res) => {
try {
const { adminCode, finalDepositDate, cookDay, payIdInfo, termsOfService, orderingPolicy, depositPercentage } = req.body;
const data: any = {};
if (adminCode !== undefined) data.adminCode = adminCode;
if (adminCode !== undefined) {
if (typeof adminCode !== 'string' || adminCode.length < 4) {
res.status(400).json({ error: 'Admin code must be at least 4 characters' });
return;
}
data.adminCode = adminCode;
}
if (finalDepositDate !== undefined) data.finalDepositDate = finalDepositDate;
if (cookDay !== undefined) data.cookDay = cookDay;
if (payIdInfo !== undefined) data.payIdInfo = payIdInfo;
if (termsOfService !== undefined) data.termsOfService = termsOfService;
if (orderingPolicy !== undefined) data.orderingPolicy = orderingPolicy;
if (depositPercentage !== undefined) data.depositPercentage = parseInt(depositPercentage);
if (depositPercentage !== undefined) {
if (depositPercentage === '') {
res.status(400).json({ error: 'Deposit percentage is required' });
return;
}
const parsedDepositPercentage = Number(depositPercentage);
if (!Number.isInteger(parsedDepositPercentage) || parsedDepositPercentage < 0 || parsedDepositPercentage > 100) {
res.status(400).json({ error: 'Deposit percentage must be a whole number from 0 to 100' });
return;
}
data.depositPercentage = parsedDepositPercentage;
}
// Use upsert to ensure we always use ID 1
await prisma.config.upsert({
@@ -266,7 +370,7 @@ app.post('/api/config', async (req, res) => {
});
// Update or Create FAQ Item
app.post('/api/faqs', async (req, res) => {
app.post('/api/faqs', requireAdmin, async (req, res) => {
try {
const item = req.body;
if (item.id) {
@@ -289,10 +393,15 @@ app.post('/api/faqs', async (req, res) => {
});
// Delete FAQ Item
app.delete('/api/faqs/:id', async (req, res) => {
app.delete('/api/faqs/:id', requireAdmin, async (req, res) => {
try {
const { id } = req.params;
await prisma.fAQ.delete({ where: { id: parseInt(id) } });
const faqId = parseId(id);
if (faqId === null) {
res.status(400).json({ error: 'Invalid FAQ ID' });
return;
}
await prisma.fAQ.delete({ where: { id: faqId } });
res.sendStatus(200);
} catch (error) {
console.error(error);
@@ -300,49 +409,6 @@ app.delete('/api/faqs/:id', async (req, res) => {
}
});
// Reset Database to defaults
app.post('/api/reset', async (req, res) => {
try {
await prisma.order.deleteMany();
await prisma.stockItem.deleteMany();
await prisma.config.deleteMany();
await prisma.fAQ.deleteMany();
// Seed default admin code and limits with ID 1
await prisma.config.create({
data: {
id: 1,
adminCode: '1234',
payIdInfo: 'Your PayID Here',
termsOfService: 'To secure your order, a 30% non-refundable deposit is required upfront before smoking begins. Payments can be made via PayID or Cash. The remaining balance is payable upon pickup.',
orderingPolicy: 'Deposits cover material costs and are final. PayID details provided after ordering.',
depositPercentage: 30
}
});
// Seed some default stock items
await prisma.stockItem.createMany({
data: [
{ name: 'Beef Brisket', category: 'Beef', price: 45, unit: 'kg', description: 'Low and slow smoked brisket', available: true },
{ name: 'Pork Shoulder', category: 'Pork', price: 35, unit: 'kg', description: 'Pulled pork perfection', available: true }
]
});
// Seed some default FAQs
await prisma.fAQ.createMany({
data: [
{ question: 'When is pickup?', answer: 'Pickups are usually scheduled for the afternoon of the Cook Day. We will contact you to confirm the exact time.', order: 1 },
{ question: 'How do I pay the deposit?', answer: 'Once you submit your order, you can pay the 30% deposit via PayID or Cash.', order: 2 }
]
});
res.sendStatus(200);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to reset database' });
}
});
// Serve Static Files in Production
const frontendPath = fs.existsSync(path.join(__dirname, '../frontend-dist'))
? path.join(__dirname, '../frontend-dist')
@@ -354,18 +420,23 @@ app.get(/^\/(?!api).*/, (req, res) => {
res.sendFile(path.join(frontendPath, 'index.html'));
});
app.listen(PORT, async () => {
// Ensure a default config exists
const config = await prisma.config.findFirst();
if (!config) {
await prisma.config.create({
data: {
id: 1,
adminCode: '1234',
payIdInfo: 'Your PayID Here'
}
});
console.log('Default config seeded');
}
console.log(`Server is running on http://localhost:${PORT}`);
const startServer = async () => {
await prisma.config.upsert({
where: { id: 1 },
update: {},
create: {
id: 1,
adminCode: '1234',
payIdInfo: 'Your PayID Here'
}
});
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
};
startServer().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});