diff --git a/backend/src/routes/orderAdmin.js b/backend/src/routes/orderAdmin.js index c0eaee3..bbb6a56 100644 --- a/backend/src/routes/orderAdmin.js +++ b/backend/src/routes/orderAdmin.js @@ -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 => ` + + ${item.product_name} + ${item.quantity} + $${parseFloat(item.price_at_purchase).toFixed(2)} + $${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)} + + `).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 = ` +
+
+

Your Order Has Shipped!

+

Order #${order.id.substring(0, 8)}

+
+ +
+

Hello ${order.first_name},

+ +

Good news! Your order has been shipped and is on its way to you.

+ + ${shippingData.customerMessage ? `

Message from our team: ${shippingData.customerMessage}

` : ''} + +
+

Shipping Details

+

Carrier: ${shippingData.shipper || 'Standard Shipping'}

+

Tracking Number: ${shippingData.trackingNumber}

+

Shipped On: ${shippedDate}

+ ${shippingData.estimatedDelivery ? `

Estimated Delivery: ${shippingData.estimatedDelivery}

` : ''} +
+ +
+

Order Summary

+ + + + + + + + + + + ${itemsHtml} + + + + + + + +
ItemQtyPriceTotal
Total:$${orderTotal.toFixed(2)}
+
+ +
+

Thank you for your purchase! If you have any questions, please contact our customer service.

+
+
+ +
+

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

+
+
+ `; + + // 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; }; \ No newline at end of file diff --git a/backend/src/routes/stripePayment.js b/backend/src/routes/stripePayment.js index ab2ce7a..7ccc715 100644 --- a/backend/src/routes/stripePayment.js +++ b/backend/src/routes/stripePayment.js @@ -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; diff --git a/db/init/11-notifications.sql b/db/init/11-notifications.sql new file mode 100644 index 0000000..ba40e2a --- /dev/null +++ b/db/init/11-notifications.sql @@ -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); \ No newline at end of file diff --git a/frontend/src/components/OrderStatusDialog.jsx b/frontend/src/components/OrderStatusDialog.jsx new file mode 100644 index 0000000..b22da7e --- /dev/null +++ b/frontend/src/components/OrderStatusDialog.jsx @@ -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 ( + + Update Order Status + + + + + Status + + + + + {status === 'shipped' && ( + <> + + + + Shipping Information + + + This information will be sent to the customer via email. + + + + + + + + + + + + + + + + + + + + + + + + + )} + + + + + + + + ); +}; + +export default OrderStatusDialog; \ No newline at end of file diff --git a/frontend/src/hooks/adminHooks.js b/frontend/src/hooks/adminHooks.js index 8aa609f..088813f 100644 --- a/frontend/src/hooks/adminHooks.js +++ b/frontend/src/hooks/adminHooks.js @@ -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] }); diff --git a/frontend/src/pages/Admin/OrdersPage.jsx b/frontend/src/pages/Admin/OrdersPage.jsx index 7149cb0..75c67ac 100644 --- a/frontend/src/pages/Admin/OrdersPage.jsx +++ b/frontend/src/pages/Admin/OrdersPage.jsx @@ -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 = () => { {/* Change Status Dialog */} - setStatusDialogOpen(false)} - > - Change Order Status - - - Status - - - - - - - - + order={selectedOrder} + /> ); }; diff --git a/frontend/src/services/adminService.js b/frontend/src/services/adminService.js index a511e17..74843d6 100644 --- a/frontend/src/services/adminService.js +++ b/frontend/src/services/adminService.js @@ -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' }; + } } }; \ No newline at end of file