order status notifications

This commit is contained in:
2ManyProjects 2025-04-26 22:51:53 -05:00
parent f138152678
commit c2a5eea983
7 changed files with 479 additions and 39 deletions

View file

@ -1,11 +1,25 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const nodemailer = require('nodemailer');
const config = require('../config');
// Helper function to create email transporter
const createTransporter = () => {
return nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
auth: {
user: config.email.user,
pass: config.email.pass
}
});
};
module.exports = (pool, query, authMiddleware) => { module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes // Apply authentication middleware to all routes
router.use(authMiddleware); router.use(authMiddleware);
// Get all orders // Get all orders (admin only)
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
// Check if user is admin // Check if user is admin
@ -97,7 +111,7 @@ module.exports = (pool, query, authMiddleware) => {
} }
}); });
// Update order status // Update order status (simple version)
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
@ -144,5 +158,225 @@ module.exports = (pool, query, authMiddleware) => {
} }
}); });
// Update order status with shipping information and send notification
router.patch('/:id/shipping', async (req, res, next) => {
try {
const { id } = req.params;
const { status, shippingData, sendNotification } = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate status
const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
if (!validStatuses.includes(status)) {
return res.status(400).json({
error: true,
message: `Invalid status. Must be one of: ${validStatuses.join(', ')}`
});
}
// Get order with customer information before updating
const orderResult = await query(`
SELECT o.*, u.email, u.first_name, u.last_name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.id = $1
`, [id]);
if (orderResult.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Order not found'
});
}
const order = orderResult.rows[0];
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Store shipping information as JSON in the database
const updatedOrder = await client.query(`
UPDATE orders
SET
status = $1,
updated_at = NOW(),
shipping_info = $2,
shipping_date = $3
WHERE id = $4
RETURNING *
`, [
status,
JSON.stringify(shippingData),
new Date(),
id
]);
// If status is 'shipped' and notification requested, send email
if (status === 'shipped' && sendNotification) {
// Get order items for the email
const itemsResult = await client.query(`
SELECT oi.*, p.name as product_name, p.price as original_price
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = $1
`, [id]);
const orderItems = itemsResult.rows;
// Send email notification
await sendShippingNotification(
order,
orderItems,
shippingData
);
// Log the notification in the database
await client.query(`
INSERT INTO notification_logs (order_id, notification_type, sent_at)
VALUES ($1, $2, NOW())
`, [id, 'shipping_notification']);
}
await client.query('COMMIT');
res.json({
message: 'Order status updated successfully',
order: updatedOrder.rows[0]
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
console.error('Error updating order status with shipping:', error);
next(error);
}
});
// Helper function to send shipping notification email
async function sendShippingNotification(order, orderItems, shippingData) {
try {
const transporter = createTransporter();
// Calculate order total
const orderTotal = orderItems.reduce((sum, item) => {
return sum + (parseFloat(item.price_at_purchase) * item.quantity);
}, 0);
// Format shipping date
const shippedDate = new Date(shippingData.shippedDate || new Date()).toLocaleDateString();
// Generate items HTML table
const itemsHtml = orderItems.map(item => `
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.product_name}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.quantity}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${parseFloat(item.price_at_purchase).toFixed(2)}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)}</td>
</tr>
`).join('');
// Generate carrier tracking link
let trackingLink = '#';
const shipper = shippingData.shipper?.toLowerCase() || '';
const trackingNumber = shippingData.trackingNumber;
if (trackingNumber) {
if (shipper.includes('usps')) {
trackingLink = `https://tools.usps.com/go/TrackConfirmAction?tLabels=${trackingNumber}`;
} else if (shipper.includes('ups')) {
trackingLink = `https://www.ups.com/track?tracknum=${trackingNumber}`;
} else if (shipper.includes('fedex')) {
trackingLink = `https://www.fedex.com/apps/fedextrack/?tracknumbers=${trackingNumber}`;
} else if (shipper.includes('dhl')) {
trackingLink = `https://www.dhl.com/global-en/home/tracking.html?tracking-id=${trackingNumber}`;
}
}
// Build email HTML
const emailHtml = `
<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: #333;">Your Order Has Shipped!</h1>
<p style="font-size: 16px;">Order #${order.id.substring(0, 8)}</p>
</div>
<div style="padding: 20px;">
<p>Hello ${order.first_name},</p>
<p>Good news! Your order has been shipped and is on its way to you.</p>
${shippingData.customerMessage ? `<p><strong>Message from our team:</strong> ${shippingData.customerMessage}</p>` : ''}
<div style="background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #4caf50;">
<h3 style="margin-top: 0;">Shipping Details</h3>
<p><strong>Carrier:</strong> ${shippingData.shipper || 'Standard Shipping'}</p>
<p><strong>Tracking Number:</strong> <a href="${trackingLink}" target="_blank">${shippingData.trackingNumber}</a></p>
<p><strong>Shipped On:</strong> ${shippedDate}</p>
${shippingData.estimatedDelivery ? `<p><strong>Estimated Delivery:</strong> ${shippingData.estimatedDelivery}</p>` : ''}
</div>
<div style="margin-top: 30px;">
<h3>Order Summary</h3>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background-color: #f2f2f2;">
<th style="padding: 10px; text-align: left;">Item</th>
<th style="padding: 10px; text-align: left;">Qty</th>
<th style="padding: 10px; text-align: left;">Price</th>
<th style="padding: 10px; text-align: left;">Total</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
<tfoot>
<tr>
<td colspan="3" style="padding: 10px; text-align: right;"><strong>Total:</strong></td>
<td style="padding: 10px;"><strong>$${orderTotal.toFixed(2)}</strong></td>
</tr>
</tfoot>
</table>
</div>
<div style="margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;">
<p>Thank you for your purchase! If you have any questions, please contact our customer service.</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>
`;
// Send the email
await transporter.sendMail({
from: config.email.reply,
to: order.email,
subject: `Your Order #${order.id.substring(0, 8)} Has Shipped!`,
html: emailHtml
});
console.log(`Shipping notification email sent to ${order.email}`);
return true;
} catch (error) {
console.error('Error sending shipping notification:', error);
throw error;
}
}
return router; return router;
}; };

View file

@ -6,6 +6,8 @@ const config = require('../config');
module.exports = (pool, query, authMiddleware) => { module.exports = (pool, query, authMiddleware) => {
// Webhook to handle events from Stripe // Webhook to handle events from Stripe
// before authmiddleware since stripe
// and json processing to prevent express from processing buffer as json obj
router.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => { router.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
// This needs to be called with raw body data // This needs to be called with raw body data
const payload = req.body; const payload = req.body;

View file

@ -0,0 +1,16 @@
-- Add shipping related columns to the orders table
ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_info JSONB;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_date TIMESTAMP;
-- Create a notification logs table to track emails sent
CREATE TABLE IF NOT EXISTS notification_logs (
id SERIAL PRIMARY KEY,
order_id UUID NOT NULL REFERENCES orders(id),
notification_type VARCHAR(50) NOT NULL,
sent_at TIMESTAMP NOT NULL DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'success',
error_message TEXT
);
-- Create an index on order_id for faster lookups
CREATE INDEX IF NOT EXISTS idx_notification_logs_order_id ON notification_logs(order_id);

View file

@ -0,0 +1,185 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
CircularProgress,
Grid,
Typography,
Divider,
Box
} from '@mui/material';
import { useUpdateOrderStatus } from '@hooks/adminHooks';
const OrderStatusDialog = ({ open, onClose, order }) => {
const [status, setStatus] = useState(order ? order.status : 'pending');
const [shippingData, setShippingData] = useState({
shipper: '',
trackingNumber: '',
shipmentId: '',
estimatedDelivery: '',
customerMessage: ''
});
const updateOrderStatus = useUpdateOrderStatus();
const handleStatusChange = (e) => {
setStatus(e.target.value);
};
const handleShippingDataChange = (e) => {
const { name, value } = e.target;
setShippingData(prev => ({
...prev,
[name]: value
}));
};
const handleSave = () => {
// For shipped status, require tracking number
if (status === 'shipped' && !shippingData.trackingNumber) {
alert('Please enter a tracking number');
return;
}
updateOrderStatus.mutate({
orderId: order.id,
status,
...(status === 'shipped' && {
shippingData: {
...shippingData,
// Add current date as shipped date if not provided
shippedDate: new Date().toISOString().split('T')[0]
},
sendNotification: true
})
}, {
onSuccess: () => {
onClose();
}
});
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Update Order Status</DialogTitle>
<DialogContent dividers>
<Grid container spacing={3}>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel id="status-select-label">Status</InputLabel>
<Select
labelId="status-select-label"
value={status}
label="Status"
onChange={handleStatusChange}
>
<MenuItem value="pending">Pending</MenuItem>
<MenuItem value="processing">Processing</MenuItem>
<MenuItem value="shipped">Shipped</MenuItem>
<MenuItem value="delivered">Delivered</MenuItem>
<MenuItem value="cancelled">Cancelled</MenuItem>
<MenuItem value="refunded">Refunded</MenuItem>
</Select>
</FormControl>
</Grid>
{status === 'shipped' && (
<>
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
<Typography variant="subtitle1" gutterBottom>
Shipping Information
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
This information will be sent to the customer via email.
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Shipping Carrier"
name="shipper"
value={shippingData.shipper}
onChange={handleShippingDataChange}
placeholder="USPS, FedEx, UPS, etc."
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
required
label="Tracking Number"
name="trackingNumber"
value={shippingData.trackingNumber}
onChange={handleShippingDataChange}
placeholder="Enter tracking number"
error={status === 'shipped' && !shippingData.trackingNumber}
helperText={status === 'shipped' && !shippingData.trackingNumber ? 'Tracking number is required' : ''}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Shipment ID"
name="shipmentId"
value={shippingData.shipmentId}
onChange={handleShippingDataChange}
placeholder="Optional internal reference"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Estimated Delivery"
name="estimatedDelivery"
value={shippingData.estimatedDelivery}
onChange={handleShippingDataChange}
placeholder="e.g., 3-5 business days"
/>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
<TextField
fullWidth
multiline
rows={4}
label="Message to Customer"
name="customerMessage"
value={shippingData.customerMessage}
onChange={handleShippingDataChange}
placeholder="Add a personal message to the shipping notification email (optional)"
/>
</Grid>
</>
)}
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
onClick={handleSave}
variant="contained"
color="primary"
disabled={updateOrderStatus.isLoading}
>
{updateOrderStatus.isLoading ? <CircularProgress size={24} /> : 'Save'}
</Button>
</DialogActions>
</Dialog>
);
};
export default OrderStatusDialog;

View file

@ -104,7 +104,19 @@ export const useUpdateOrderStatus = () => {
const notification = useNotification(); const notification = useNotification();
return useMutation({ return useMutation({
mutationFn: ({ orderId, status }) => adminOrderService.updateOrderStatus(orderId, status), mutationFn: ({ orderId, status, shippingData, sendNotification }) => {
// If shipping data is provided, include it in the request
if (shippingData && sendNotification) {
return adminOrderService.updateOrderStatusWithShipping(
orderId,
status,
shippingData,
sendNotification
);
}
// Otherwise just update the status
return adminOrderService.updateOrderStatus(orderId, status);
},
onSuccess: (data) => { onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-orders'] }); queryClient.invalidateQueries({ queryKey: ['admin-orders'] });
queryClient.invalidateQueries({ queryKey: ['admin-order', data.order.id] }); queryClient.invalidateQueries({ queryKey: ['admin-order', data.order.id] });

View file

@ -41,9 +41,10 @@ import {
LocalShipping as ShippingIcon LocalShipping as ShippingIcon
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../../services/api'; import apiClient from '@services/api';
import { format } from 'date-fns'; import { format } from 'date-fns';
import ProductImage from '../../components/ProductImage'; import ProductImage from '@components/ProductImage';
import OrderStatusDialog from '@components/OrderStatusDialog';
const AdminOrdersPage = () => { const AdminOrdersPage = () => {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@ -169,7 +170,6 @@ const AdminOrdersPage = () => {
// Handle opening status change dialog // Handle opening status change dialog
const handleOpenStatusDialog = (order) => { const handleOpenStatusDialog = (order) => {
setSelectedOrder(order); setSelectedOrder(order);
setNewStatus(order.status);
setStatusDialogOpen(true); setStatusDialogOpen(true);
}; };
@ -520,41 +520,11 @@ const AdminOrdersPage = () => {
</Dialog> </Dialog>
{/* Change Status Dialog */} {/* Change Status Dialog */}
<Dialog <OrderStatusDialog
open={statusDialogOpen} open={statusDialogOpen}
onClose={() => setStatusDialogOpen(false)} onClose={() => setStatusDialogOpen(false)}
> order={selectedOrder}
<DialogTitle>Change Order Status</DialogTitle> />
<DialogContent>
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel id="status-select-label">Status</InputLabel>
<Select
labelId="status-select-label"
value={newStatus}
label="Status"
onChange={handleStatusChange}
>
<MenuItem value="pending">Pending</MenuItem>
<MenuItem value="processing">Processing</MenuItem>
<MenuItem value="shipped">Shipped</MenuItem>
<MenuItem value="delivered">Delivered</MenuItem>
<MenuItem value="cancelled">Cancelled</MenuItem>
<MenuItem value="refunded">Refunded</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setStatusDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleSaveStatus}
variant="contained"
color="primary"
disabled={updateOrderStatus.isLoading}
>
{updateOrderStatus.isLoading ? <CircularProgress size={24} /> : 'Save'}
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
}; };

View file

@ -102,5 +102,26 @@ export const adminOrderService = {
} catch (error) { } catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' }; throw error.response?.data || { message: 'An unknown error occurred' };
} }
},
/**
* Update an order's status with shipping information (admin only)
* @param {string} id - Order ID
* @param {string} status - New order status
* @param {Object} shippingData - Shipping information
* @param {boolean} sendNotification - Whether to send email notification
* @returns {Promise} Promise with the API response
*/
updateOrderStatusWithShipping: async (id, status, shippingData, sendNotification = true) => {
try {
const response = await apiClient.patch(`/admin/orders/${id}/shipping`, {
status,
shippingData,
sendNotification
});
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
} }
}; };