order status notifications
This commit is contained in:
parent
f138152678
commit
c2a5eea983
7 changed files with 479 additions and 39 deletions
|
|
@ -1,11 +1,25 @@
|
|||
const express = require('express');
|
||||
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) => {
|
||||
// Apply authentication middleware to all routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Get all orders
|
||||
// Get all orders (admin only)
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
// 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) => {
|
||||
try {
|
||||
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>© ${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;
|
||||
};
|
||||
|
|
@ -6,6 +6,8 @@ const config = require('../config');
|
|||
module.exports = (pool, query, authMiddleware) => {
|
||||
|
||||
// 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) => {
|
||||
// This needs to be called with raw body data
|
||||
const payload = req.body;
|
||||
|
|
|
|||
16
db/init/11-notifications.sql
Normal file
16
db/init/11-notifications.sql
Normal 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);
|
||||
185
frontend/src/components/OrderStatusDialog.jsx
Normal file
185
frontend/src/components/OrderStatusDialog.jsx
Normal 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;
|
||||
|
|
@ -104,7 +104,19 @@ export const useUpdateOrderStatus = () => {
|
|||
const notification = useNotification();
|
||||
|
||||
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) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-order', data.order.id] });
|
||||
|
|
|
|||
|
|
@ -41,9 +41,10 @@ import {
|
|||
LocalShipping as ShippingIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../../services/api';
|
||||
import apiClient from '@services/api';
|
||||
import { format } from 'date-fns';
|
||||
import ProductImage from '../../components/ProductImage';
|
||||
import ProductImage from '@components/ProductImage';
|
||||
import OrderStatusDialog from '@components/OrderStatusDialog';
|
||||
|
||||
const AdminOrdersPage = () => {
|
||||
const [page, setPage] = useState(0);
|
||||
|
|
@ -169,7 +170,6 @@ const AdminOrdersPage = () => {
|
|||
// Handle opening status change dialog
|
||||
const handleOpenStatusDialog = (order) => {
|
||||
setSelectedOrder(order);
|
||||
setNewStatus(order.status);
|
||||
setStatusDialogOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -520,41 +520,11 @@ const AdminOrdersPage = () => {
|
|||
</Dialog>
|
||||
|
||||
{/* Change Status Dialog */}
|
||||
<Dialog
|
||||
<OrderStatusDialog
|
||||
open={statusDialogOpen}
|
||||
onClose={() => setStatusDialogOpen(false)}
|
||||
>
|
||||
<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>
|
||||
order={selectedOrder}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -102,5 +102,26 @@ export const adminOrderService = {
|
|||
} catch (error) {
|
||||
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' };
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
Reference in a new issue