From 119e9adfec09864c5ffcc9744e9080740afce4ff Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Mon, 28 Apr 2025 10:26:52 -0500 Subject: [PATCH] Low inventory notification support --- backend/src/index.js | 27 ++- backend/src/routes/cart.js | 30 +++ backend/src/routes/productAdmin.js | 51 +++++ backend/src/services/notificationService.js | 174 ++++++++++++++++ db/init/14-product-notifications.sql | 52 +++++ fileStructure.txt | 69 ++++--- frontend/src/pages/Admin/ProductEditPage.jsx | 206 ++++++++++++++++++- 7 files changed, 565 insertions(+), 44 deletions(-) create mode 100644 backend/src/services/notificationService.js create mode 100644 db/init/14-product-notifications.sql diff --git a/backend/src/index.js b/backend/src/index.js index 17cabe8..38d7f87 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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 diff --git a/backend/src/routes/cart.js b/backend/src/routes/cart.js index b8f3f96..e2605c6 100644 --- a/backend/src/routes/cart.js +++ b/backend/src/routes/cart.js @@ -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', diff --git a/backend/src/routes/productAdmin.js b/backend/src/routes/productAdmin.js index 7886803..87b6a69 100644 --- a/backend/src/routes/productAdmin.js +++ b/backend/src/routes/productAdmin.js @@ -151,6 +151,57 @@ module.exports = (pool, query, authMiddleware) => { next(error); } }); + + 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) => { diff --git a/backend/src/services/notificationService.js b/backend/src/services/notificationService.js new file mode 100644 index 0000000..aa0d567 --- /dev/null +++ b/backend/src/services/notificationService.js @@ -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 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 ` +
+
+

Low Stock Alert

+
+ +
+

Hello,

+ +

This is an automated notification to inform you that the following product is running low on stock:

+ +
+

${product.name}

+

Current Stock: ${product.stock_quantity}

+

Threshold: ${threshold}

+
+ +

You might want to restock this item soon to avoid running out of inventory.

+ +
+

This is an automated notification. You received this because you set up stock notifications for this product.

+
+
+ +
+

© ${new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.

+
+
+ `; + } +}; + +module.exports = notificationService; \ No newline at end of file diff --git a/db/init/14-product-notifications.sql b/db/init/14-product-notifications.sql new file mode 100644 index 0000000..0fd95c7 --- /dev/null +++ b/db/init/14-product-notifications.sql @@ -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; \ No newline at end of file diff --git a/fileStructure.txt b/fileStructure.txt index 3a6458b..2bea297 100644 --- a/fileStructure.txt +++ b/fileStructure.txt @@ -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 \ No newline at end of file diff --git a/frontend/src/pages/Admin/ProductEditPage.jsx b/frontend/src/pages/Admin/ProductEditPage.jsx index 50af92f..f23f853 100644 --- a/frontend/src/pages/Admin/ProductEditPage.jsx +++ b/frontend/src/pages/Admin/ProductEditPage.jsx @@ -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; @@ -154,17 +186,38 @@ const ProductEditPage = () => { setErrors(prev => ({ ...prev, [name]: null })); } }; - + // Handle tags change const handleTagsChange = (event, newTags) => { setFormData(prev => ({ ...prev, tags: newTags })); }; - + // Handle images change const handleImagesChange = (newImages) => { 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 = {}; @@ -204,11 +257,32 @@ const ProductEditPage = () => { if (formData.heightCm && (isNaN(formData.heightCm) || parseFloat(formData.heightCm) <= 0)) { 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; }; - + // Handle form submission const handleSubmit = (e) => { e.preventDefault(); @@ -234,18 +308,43 @@ 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 + }); + } } }; - + // Handle notification close const handleNotificationClose = () => { setNotification(prev => ({ ...prev, open: false })); }; - + // Load product data when available useEffect(() => { if (product && !isNewProduct) { @@ -270,9 +369,22 @@ const ProductEditPage = () => { displayOrder: img.displayOrder || img.display_order || 0 })) : [] }); - } - }, [product, isNewProduct]); + // 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, userData]); + // Format tag objects for Autocomplete component const formatTags = () => { if (!allTags) return []; @@ -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 = () => { /> + {/* Stock Notification Section */} + {!isNewProduct && ( + <> + + + + Stock Level Notifications + + + + + + + + + + Get notified when stock is running low + + + + + } + label="Enable stock level notifications" + /> + + {notificationEnabled && ( + + + + + + + + + )} + + + + + )} +