diff --git a/backend/src/routes/orders.js b/backend/src/routes/orders.js index b12a912..faf3c70 100644 --- a/backend/src/routes/orders.js +++ b/backend/src/routes/orders.js @@ -154,6 +154,12 @@ router.post('/bulk-import', wrapAsync(async (req, res) => { res.status(200).send(payload); })); + +router.put('/:id/status', wrapAsync(async (req, res) => { + const payload = await OrdersService.changeStatus(req.params.id, req.body.data || {}, req.currentUser); + res.status(200).send(payload); +})); + /** * @swagger * /api/orders/{id}: diff --git a/backend/src/services/orders.js b/backend/src/services/orders.js index 2cac58d..d347c01 100644 --- a/backend/src/services/orders.js +++ b/backend/src/services/orders.js @@ -3,11 +3,60 @@ const OrdersDBApi = require('../db/api/orders'); const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); +const ORDER_STATUS_TRANSITIONS = { + pending: ['assigned', 'cancelled'], + assigned: ['picked_up'], + picked_up: ['out_for_delivery'], + out_for_delivery: ['delivered', 'failed_attempt'], + failed_attempt: ['rescheduled'], + rescheduled: ['out_for_delivery'], + delivered: [], + cancelled: [], + returned: [], +}; + +const STATUS_ROLE_RULES = { + assigned: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor'], + picked_up: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Driver User'], + out_for_delivery: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Driver User'], + delivered: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Driver User'], + failed_attempt: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Driver User', 'Customer Support Agent'], + rescheduled: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Customer Support Agent'], + cancelled: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Customer Support Agent'], +}; + +function getRoleName(currentUser) { + return currentUser?.app_role?.name || currentUser?.role || 'Unknown'; +} + +function validateStatusTransition(order, nextStatus, currentUser) { + if (!nextStatus || !ORDER_STATUS_TRANSITIONS[nextStatus]) { + throw new ValidationError('orders.invalidStatus', `Invalid order status '${nextStatus}'.`); + } + + const currentStatus = order.current_status || 'pending'; + const allowedNext = ORDER_STATUS_TRANSITIONS[currentStatus] || []; + if (!allowedNext.includes(nextStatus)) { + throw new ValidationError( + 'orders.invalidTransition', + `Cannot transition order from '${currentStatus}' to '${nextStatus}'.`, + ); + } + + const roleName = getRoleName(currentUser); + const allowedRoles = STATUS_ROLE_RULES[nextStatus] || []; + if (!allowedRoles.includes(roleName)) { + throw new ValidationError( + 'orders.statusRoleForbidden', + `Role '${roleName}' cannot change an order to '${nextStatus}'.`, + ); + } +} + + @@ -28,9 +77,9 @@ module.exports = class OrdersService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -65,10 +114,71 @@ module.exports = class OrdersService { } } + + static async changeStatus(id, data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + const order = await db.orders.findByPk(id, { transaction }); + + if (!order) { + throw new ValidationError('ordersNotFound'); + } + + const nextStatus = data.current_status; + validateStatusTransition(order, nextStatus, currentUser); + + if (nextStatus === 'assigned' && !data.assigned_driver && !order.assigned_driverId) { + throw new ValidationError('orders.driverRequired', 'Assigned status requires a driver.'); + } + + const previousStatus = order.current_status || 'pending'; + const updatePayload = { + previous_status: previousStatus, + current_status: nextStatus, + last_status_at: new Date(), + updatedById: currentUser?.id || null, + }; + + if (data.assigned_driver !== undefined) { + updatePayload.assigned_driverId = data.assigned_driver || null; + } + + if (data.delivery_lat !== undefined) { + updatePayload.delivery_lat = data.delivery_lat || null; + } + + if (data.delivery_lng !== undefined) { + updatePayload.delivery_lng = data.delivery_lng || null; + } + + await order.update(updatePayload, { transaction }); + + await db.order_status_logs.create( + { + orderId: id, + from_status: previousStatus, + to_status: nextStatus, + comment: data.comment || null, + changed_at: new Date(), + changed_by_userId: currentUser?.id || null, + createdById: currentUser?.id || null, + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + + await transaction.commit(); + return OrdersDBApi.findBy({ id }); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); try { - let orders = await OrdersDBApi.findBy( + const orders = await OrdersDBApi.findBy( {id}, {transaction}, ); @@ -95,7 +205,7 @@ module.exports = class OrdersService { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); diff --git a/frontend/src/components/AsideMenu.tsx b/frontend/src/components/AsideMenu.tsx index 442dfac..433d4af 100644 --- a/frontend/src/components/AsideMenu.tsx +++ b/frontend/src/components/AsideMenu.tsx @@ -19,7 +19,7 @@ export default function AsideMenu({ <>
-
+
Parcel Ops RTL diff --git a/frontend/src/components/FooterBar.tsx b/frontend/src/components/FooterBar.tsx index 0acc9c5..9f07b58 100644 --- a/frontend/src/components/FooterBar.tsx +++ b/frontend/src/components/FooterBar.tsx @@ -12,7 +12,7 @@ export default function FooterBar({ children }: Props) { return (