Low inventory notification support

This commit is contained in:
2ManyProjects 2025-04-28 10:26:52 -05:00
parent d9953baa19
commit 119e9adfec
7 changed files with 565 additions and 44 deletions

View file

@ -10,6 +10,10 @@ const adminAuthMiddleware = require('./middleware/adminAuth');
const settingsAdminRoutes = require('./routes/settingsAdmin');
const SystemSettings = require('./models/SystemSettings');
const fs = require('fs');
// services
const notificationService = require('./services/notificationService');
// routes
const stripePaymentRoutes = require('./routes/stripePayment');
@ -96,7 +100,28 @@ pool.connect()
console.error('Error loading settings from database:', error);
}
})
.catch(err => console.error('Database connection error:', err));
.catch(err => console.error('Database connection error:', err))
.finally((() => {
let timeInterval = 60 * 1000;
if (config.environment === 'prod') {
console.log('Setting up scheduled tasks for production environment, 30 mins');
timeInterval = 30 * 60 * 1000;
}else {
console.log('Setting up scheduled tasks for development environment, 2 mins');
timeInterval = 2 * 60 * 1000;
}
// Process stock notifications every 30 minutes
setInterval(async () => {
try {
console.log('Processing low stock notifications...');
const processedCount = await notificationService.processLowStockNotifications(pool, query);
console.log(`Processed ${processedCount} low stock notifications`);
} catch (error) {
console.error('Error processing low stock notifications:', error);
}
}, timeInterval);
}));
// Handle SSL proxy headers

View file

@ -811,6 +811,36 @@ module.exports = (pool, query, authMiddleware) => {
await client.query('COMMIT');
for (const item of cartItemsResult.rows) {
// Get product details including notification settings
const productWithNotification = await client.query(
`SELECT stock_quantity, stock_notification
FROM products
WHERE id = $1 AND stock_notification IS NOT NULL`,
[item.product_id]
);
if (productWithNotification.rows.length > 0) {
const product = productWithNotification.rows[0];
// Check if notification is enabled and stock is below threshold
if (product.stock_notification &&
product.stock_notification.enabled === true &&
product.stock_notification.threshold &&
product.stock_quantity <= product.stock_notification.threshold) {
// Log the notification - it will be picked up by the notification service
await client.query(
`INSERT INTO notification_logs
(order_id, notification_type, sent_at, status)
VALUES ($1, $2, NOW(), $3)`,
[orderId, 'low_stock_alert', 'pending']
);
console.log(`Low stock notification queued for product ${item.product_id}`);
}
}
}
res.status(200).json({
success: true,
message: 'Order completed successfully',

View file

@ -152,6 +152,57 @@ module.exports = (pool, query, authMiddleware) => {
}
});
router.post('/:id/stock-notification', async (req, res, next) => {
try {
const { id } = req.params;
const { enabled, email, threshold } = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Check if product exists
const productCheck = await query(
'SELECT * FROM products WHERE id = $1',
[id]
);
if (productCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Product not found'
});
}
// Store notification settings as JSONB
const notificationSettings = {
enabled,
email: email || null,
threshold: threshold || 0
};
// Update product with notification settings
const result = await query(
`UPDATE products
SET stock_notification = $1
WHERE id = $2
RETURNING *`,
[JSON.stringify(notificationSettings), id]
);
res.json({
message: 'Stock notification settings updated successfully',
product: result.rows[0]
});
} catch (error) {
next(error);
}
});
// Update an existing product
router.put('/:id', async (req, res, next) => {
try {

View file

@ -0,0 +1,174 @@
// Create a new file: src/services/notificationService.js
const nodemailer = require('nodemailer');
const config = require('../config');
/**
* Service for handling notifications including stock alerts
*/
const notificationService = {
/**
* Create email transporter
* @returns {Object} Configured nodemailer transporter
*/
createTransporter() {
return nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
auth: {
user: config.email.user,
pass: config.email.pass
}
});
},
/**
* Process pending low stock notifications
* @param {Object} pool - Database connection pool
* @param {Function} query - Database query function
* @returns {Promise<number>} Number of notifications processed
*/
async processLowStockNotifications(pool, query) {
const client = await pool.connect();
let processedCount = 0;
try {
await client.query('BEGIN');
// Get pending low stock notifications
const pendingNotifications = await client.query(`
SELECT id
FROM notification_logs
WHERE notification_type = 'low_stock_alert'
AND status = 'pending'
LIMIT 50
`);
if (pendingNotifications.rows.length === 0) {
await client.query('COMMIT');
return 0;
}
// Get products with current stock below threshold
const lowStockProducts = await client.query(`
SELECT
p.id,
p.name,
p.stock_quantity,
p.stock_notification
FROM products p
WHERE
p.stock_notification IS NOT NULL
AND p.stock_notification->>'enabled' = 'true'
AND p.stock_notification->>'email' IS NOT NULL
AND p.stock_quantity <= (p.stock_notification->>'threshold')::int
`);
if (lowStockProducts.rows.length === 0) {
// Mark notifications as processed with no action
const notificationIds = pendingNotifications.rows.map(n => n.id);
await client.query(`
UPDATE notification_logs
SET status = 'completed', error_message = 'No eligible products found'
WHERE id = ANY($1)
`, [notificationIds]);
await client.query('COMMIT');
return 0;
}
// Initialize email transporter
const transporter = this.createTransporter();
// Send notifications for each low stock product
for (const product of lowStockProducts.rows) {
const notification = JSON.parse(product.stock_notification);
try {
// Send email notification
await transporter.sendMail({
from: config.email.reply,
to: notification.email,
subject: `Low Stock Alert: ${product.name}`,
html: this.generateLowStockEmailTemplate(product)
});
// Mark one notification as processed
if (pendingNotifications.rows[processedCount]) {
await client.query(`
UPDATE notification_logs
SET status = 'success'
WHERE id = $1
`, [pendingNotifications.rows[processedCount].id]);
processedCount++;
}
} catch (error) {
console.error(`Error sending low stock notification for product ${product.id}:`, error);
// Mark notification as failed
if (pendingNotifications.rows[processedCount]) {
await client.query(`
UPDATE notification_logs
SET status = 'failed', error_message = $2
WHERE id = $1
`, [pendingNotifications.rows[processedCount].id, error.message]);
processedCount++;
}
}
}
await client.query('COMMIT');
return processedCount;
} catch (error) {
await client.query('ROLLBACK');
console.error('Error processing low stock notifications:', error);
throw error;
} finally {
client.release();
}
},
/**
* Generate email template for low stock notification
* @param {Object} product - Product with low stock
* @returns {string} HTML email template
*/
generateLowStockEmailTemplate(product) {
const stockNotification = JSON.parse(product.stock_notification);
const threshold = stockNotification.threshold || 0;
return `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #f8f8f8; padding: 20px; text-align: center;">
<h1 style="color: #ff6b6b;">Low Stock Alert</h1>
</div>
<div style="padding: 20px;">
<p>Hello,</p>
<p>This is an automated notification to inform you that the following product is running low on stock:</p>
<div style="background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #ff6b6b;">
<h3 style="margin-top: 0;">${product.name}</h3>
<p><strong>Current Stock:</strong> ${product.stock_quantity}</p>
<p><strong>Threshold:</strong> ${threshold}</p>
</div>
<p>You might want to restock this item soon to avoid running out of inventory.</p>
<div style="margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;">
<p>This is an automated notification. You received this because you set up stock notifications for this product.</p>
</div>
</div>
<div style="background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;">
<p>&copy; ${new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.</p>
</div>
</div>
`;
}
};
module.exports = notificationService;

View file

@ -0,0 +1,52 @@
ALTER TABLE products ADD COLUMN IF NOT EXISTS stock_notification JSONB;
-- Create a function to send email notifications when stock drops below threshold
CREATE OR REPLACE FUNCTION notify_low_stock()
RETURNS TRIGGER AS $$
BEGIN
-- Check if notification is enabled and new stock is below threshold
IF (NEW.stock_notification IS NOT NULL AND
NEW.stock_notification->>'enabled' = 'true' AND
NEW.stock_notification->>'email' IS NOT NULL AND
(NEW.stock_notification->>'threshold')::int > 0 AND
NEW.stock_quantity <= (NEW.stock_notification->>'threshold')::int AND
(OLD.stock_quantity IS NULL OR OLD.stock_quantity > (NEW.stock_notification->>'threshold')::int)) THEN
-- Insert notification record into a notification log table
INSERT INTO notification_logs (
order_id, -- Using NULL as this isn't tied to a specific order
notification_type,
sent_at,
status
) VALUES (
NULL,
'low_stock_alert',
NOW(),
'pending'
);
-- Note: The actual email sending will be handled by a backend process
-- that periodically checks for pending notifications
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger to check stock level on update
CREATE TRIGGER check_stock_level_on_update
AFTER UPDATE OF stock_quantity ON products
FOR EACH ROW
EXECUTE FUNCTION notify_low_stock();
-- Create trigger to check stock level on insert (though less common)
CREATE TRIGGER check_stock_level_on_insert
AFTER INSERT ON products
FOR EACH ROW
EXECUTE FUNCTION notify_low_stock();
-- Add stock_notification column to products table
ALTER TABLE products ADD COLUMN IF NOT EXISTS stock_notification JSONB;
-- Create index for faster lookups of products with notifications
CREATE INDEX IF NOT EXISTS idx_products_stock_notification ON products ((stock_notification IS NOT NULL))
WHERE stock_notification IS NOT NULL;

View file

@ -1,15 +1,16 @@
ROCKS/
Rocks/
├── backend/
│ ├── src/
│ │ ├── services/
│ │ │ ├── shippingService.js
│ │ │ └── notificationService.js
│ │ │ └── shippingService.js
│ │ ├── routes/
│ │ │ ├── cart.js
│ │ │ ├── stripePayment.js
│ │ │ ├── shipping.js
│ │ │ ├── settingsAdmin.js
│ │ │ ├── cart.js
│ │ │ ├── userOrders.js
│ │ │ ├── orderAdmin.js
│ │ │ ├── stripePayment.js
│ │ │ ├── auth.js
│ │ │ ├── userAdmin.js
│ │ │ ├── products.js
@ -24,16 +25,16 @@ ROCKS/
│ │ │ ├── auth.js
│ │ │ └── adminAuth.js
│ │ └── db/
│ │ ── index.js
│ ├── index.js
── config.js
│ │ ── index.js
├── index.js
│ └── config.js
│ ├── node_modules/
│ ├── public/
│ │ └── uploads/
│ │ └── products/
│ ├── .env
│ ├── package.json
│ ├── package-lock.json
│ ├── .env
│ ├── Dockerfile
│ ├── README.md
│ └── .gitignore
@ -109,32 +110,34 @@ ROCKS/
│ │ ├── App.jsx
│ │ ├── config.js
│ │ └── main.jsx
── public/
├── favicon.svg
│ └── index.html
│ ├── package-lock.json
├── package.json
├── vite.config.js
├── Dockerfile
├── nginx.conf
│ ├── README.md
│ ├── .env
│ └── setup-frontend.sh
── public/
├── favicon.svg
├── package-lock.json
├── package.json
├── vite.config.js
├── Dockerfile
├── nginx.conf
├── index.html
├── README.md
├── .env
└── setup-frontend.sh
├── db/
│ └── init/
│ ├── 12-shipping-orders.sql
│ ├── 09-system-settings.sql
│ ├── 11-notifications.sql
│ ├── 10-payment.sql
│ ├── 08-create-email.sql
│ ├── 07-user-keys.sql
│ ├── 06-product-categories.sql
│ ├── 05-admin-role.sql
│ ├── 02-seed.sql
│ ├── 04-product-images.sql
│ ├── 03-api-key.sql
│ └── 01-schema.sql
├── test/
│ ├── init/
│ │ ├── 14-product-notifications.sql
│ │ ├── 13-cart-metadata.sql
│ │ ├── 12-shipping-orders.sql
│ │ ├── 09-system-settings.sql
│ │ ├── 11-notifications.sql
│ │ ├── 10-payment.sql
│ │ ├── 08-create-email.sql
│ │ ├── 07-user-keys.sql
│ │ ├── 06-product-categories.sql
│ │ ├── 05-admin-role.sql
│ │ ├── 02-seed.sql
│ │ ├── 04-product-images.sql
│ │ ├── 03-api-key.sql
│ │ └── 01-schema.sql
│ └── test/
├── fileStructure.txt
├── docker-compose.yml
└── .gitignore

View file

@ -17,12 +17,17 @@ import {
CircularProgress,
Alert,
Snackbar,
Autocomplete
Autocomplete,
FormControlLabel,
Checkbox,
Card,
CardContent,
} from '@mui/material';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import ImageUploader from '@components/ImageUploader';
import apiClient from '@services/api';
import { useAuth } from '@hooks/reduxHooks';
@ -32,8 +37,9 @@ const ProductEditPage = () => {
const id = pathname.split('/').pop();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { apiKey } = useAuth();
const { apiKey, userData } = useAuth();
const isNewProduct = id === 'new';
// Form state
const [formData, setFormData] = useState({
name: '',
@ -53,6 +59,11 @@ const ProductEditPage = () => {
images: []
});
// Notification state
const [notificationEnabled, setNotificationEnabled] = useState(false);
const [notificationEmail, setNotificationEmail] = useState('');
const [stockThreshold, setStockThreshold] = useState(1);
// Validation state
const [errors, setErrors] = useState({});
@ -144,6 +155,27 @@ const ProductEditPage = () => {
}
});
// Save stock notification settings
const saveStockNotification = useMutation({
mutationFn: async (notificationData) => {
return await apiClient.post(`/admin/products/${id}/stock-notification`, notificationData);
},
onSuccess: () => {
setNotification({
open: true,
message: 'Stock notification settings saved!',
severity: 'success'
});
},
onError: (error) => {
setNotification({
open: true,
message: `Failed to save notification settings: ${error.message}`,
severity: 'error'
});
}
});
// Handle form changes
const handleChange = (e) => {
const { name, value } = e.target;
@ -165,6 +197,27 @@ const ProductEditPage = () => {
setFormData(prev => ({ ...prev, images: newImages }));
};
// Handle notification checkbox change
const handleNotificationToggle = (event) => {
setNotificationEnabled(event.target.checked);
};
// Handle notification email change
const handleNotificationEmailChange = (e) => {
setNotificationEmail(e.target.value);
};
// Handle stock threshold change
const handleStockThresholdChange = (e) => {
let value = parseInt(e.target.value);
if (isNaN(value) || value < 1) {
value = 1;
} else if (value > parseInt(formData.stockQuantity)) {
value = parseInt(formData.stockQuantity);
}
setStockThreshold(value);
};
// Validate form
const validateForm = () => {
const newErrors = {};
@ -205,6 +258,27 @@ const ProductEditPage = () => {
newErrors.heightCm = 'Height must be a positive number';
}
// Validate notification settings if enabled
if (notificationEnabled) {
if (!notificationEmail) {
newErrors.notificationEmail = 'Email is required for notifications';
} else {
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(notificationEmail)) {
newErrors.notificationEmail = 'Please enter a valid email address';
}
}
if (!stockThreshold) {
newErrors.stockThreshold = 'Threshold is required for notifications';
} else if (isNaN(stockThreshold) || stockThreshold < 1) {
newErrors.stockThreshold = 'Threshold must be a positive number';
} else if (formData.stockQuantity && stockThreshold > parseInt(formData.stockQuantity)) {
newErrors.stockThreshold = `Threshold cannot exceed current stock (${formData.stockQuantity})`;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@ -234,10 +308,35 @@ const ProductEditPage = () => {
tags: formData.tags.map(tag => typeof tag === 'string' ? tag : tag.name)
};
// Add notification data if enabled
if (notificationEnabled && !isNewProduct) {
productData.stockNotification = {
enabled: true,
email: notificationEmail,
threshold: stockThreshold
};
}
if (isNewProduct) {
createProduct.mutate(productData);
} else {
updateProduct.mutate({ id, productData });
// Save notification settings separately
if (notificationEnabled) {
saveStockNotification.mutate({
enabled: true,
email: notificationEmail,
threshold: stockThreshold
});
} else {
// Disable notifications if checkbox is unchecked
saveStockNotification.mutate({
enabled: false,
email: '',
threshold: 0
});
}
}
};
@ -270,8 +369,21 @@ const ProductEditPage = () => {
displayOrder: img.displayOrder || img.display_order || 0
})) : []
});
// Load notification settings if available
if (product.stock_notification) {
setNotificationEnabled(product.stock_notification.enabled);
setNotificationEmail(product.stock_notification.email || userData?.email || '');
setStockThreshold(product.stock_notification.threshold || 1);
} else {
// Default to admin's email
setNotificationEmail(userData?.email || '');
}
} else {
// For new products, set default notification email to admin's email
setNotificationEmail(userData?.email || '');
}
}, [product, isNewProduct]);
}, [product, isNewProduct, userData]);
// Format tag objects for Autocomplete component
const formatTags = () => {
@ -280,7 +392,7 @@ const ProductEditPage = () => {
};
const isLoading = categoriesLoading || tagsLoading || (productLoading && !isNewProduct);
const isSaving = createProduct.isLoading || updateProduct.isLoading;
const isSaving = createProduct.isLoading || updateProduct.isLoading || saveStockNotification.isLoading;
if (isLoading) {
return (
@ -403,6 +515,80 @@ const ProductEditPage = () => {
/>
</Grid>
{/* Stock Notification Section */}
{!isNewProduct && (
<>
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="h6" gutterBottom>
Stock Level Notifications
</Typography>
</Grid>
<Grid item xs={12}>
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<NotificationsActiveIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="subtitle1">
Get notified when stock is running low
</Typography>
</Box>
<FormControlLabel
control={
<Checkbox
checked={notificationEnabled}
onChange={handleNotificationToggle}
name="notificationEnabled"
color="primary"
/>
}
label="Enable stock level notifications"
/>
{notificationEnabled && (
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Notification Email"
name="notificationEmail"
type="email"
value={notificationEmail}
onChange={handleNotificationEmailChange}
error={!!errors.notificationEmail}
helperText={errors.notificationEmail}
required
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Stock Threshold"
name="stockThreshold"
type="number"
value={stockThreshold}
onChange={handleStockThresholdChange}
error={!!errors.stockThreshold}
helperText={errors.stockThreshold || "You'll be notified when stock falls below this number"}
required
InputProps={{
inputProps: {
min: 1,
max: formData.stockQuantity ? parseInt(formData.stockQuantity) : 999
}
}}
/>
</Grid>
</Grid>
)}
</CardContent>
</Card>
</Grid>
</>
)}
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
</Grid>