Book packed orders with the carrier and mark them shipped
Node · Node · intermediate · modification
Adds `shipPackedOrders`, a worker job that picks up packed orders oldest first, books each one with the carrier and stamps it shipped with the tracking code and time, and registers it in `src/worker.js` every 30 seconds. The carrier call runs outside any transaction, so a slow booking never holds a connection or a row lock, and a `CarrierError` is logged and leaves that order packed for the next run while the rest of the batch carries on. Ran it against a local database with a few packed orders and the carrier sandbox.
Order service on Node 20: an Express API and a separate worker process (`src/worker.js`) sharing Prisma 5 on PostgreSQL 16; `src/db.js` exports the one `PrismaClient`, with no middleware or client extensions. Existing and unchanged: the Prisma model `Order` with `id` (autoincrement), `reference` (unique), `customerId` (and its `customer` relation), `shippingAddress`, `status` (enum `OrderStatus`: `PLACED`, `PACKED`, `SHIPPED`, `CANCELLED`), `packedAt` (set on every packed order), `trackingCode`, `shippedAt` and `createdAt`; it has no `@updatedAt` or version column. The address endpoint runs `prisma.order.updateMany` setting only `shippingAddress` where the `id`, the signed-in customer's `customerId` and a status of `PLACED` or `PACKED` match, and answers 200 when it updated one row, otherwise 404 or 409. `src/shipping/carrierClient.js` sets a request timeout and wraps every failure in `CarrierError` (`src/shipping/errors.js`). `runEvery(name, ms, job)` from `src/lib/scheduler.js` starts the next run `ms` after the previous one settled and logs a run that rejects; `src/lib/logger.js` is pino.
Requirements
- A worker job ships every order in status `PACKED`, oldest `packedAt` first: it books the parcel through the existing `carrierClient.book(reference)`, then sets the order's `status` to `SHIPPED`, its `trackingCode` to the code the carrier returned and its `shippedAt` to the current time. Orders in any other status are left alone. The worker runs the job every 30 seconds.
- A booking typically takes 3 to 8 seconds. `carrierClient.book` rejects with a `CarrierError` on any carrier failure or timeout; that order must stay `PACKED` for the next run, and the other orders of the run still ship. Booking is idempotent on the reference (booking a reference again returns the existing tracking code), so a retry after any failed run cannot book a second parcel. Any other error (a database error, for example) may abort the whole run; the orders it did not ship stay `PACKED` and the next run retries them.
- Customers can change the delivery address of a `PLACED` or `PACKED` order at any time through the existing `PATCH /api/orders/:id/address`, including while this job is booking that order. An address change the endpoint accepted (200) before the order became `SHIPPED` must be the `shippingAddress` of the shipped order: the courier reads the address from the order row at pickup, and the label carries only the reference.
- Only this job moves an order out of `PACKED` (a packed order cannot be cancelled, and orders are never deleted), and a single worker process runs it, so the job never overlaps itself. The warehouse packs at most a few hundred orders a day.
Files touched
- src/jobs/shipPackedOrders.js
- src/worker.js
--- src/jobs/shipPackedOrders.js
+const prisma = require('../db');
+const { carrierClient } = require('../shipping/carrierClient');
+const { CarrierError } = require('../shipping/errors');
+const logger = require('../lib/logger');
+
+async function shipPackedOrders() {
+ const orders = await prisma.order.findMany({
+ where: { status: 'PACKED' },
+ orderBy: { packedAt: 'asc' },
+ });
+
+ for (const order of orders) {
+ let trackingCode;
+ try {
+ trackingCode = await carrierClient.book(order.reference);
+ } catch (err) {
+ if (!(err instanceof CarrierError)) throw err;
+ logger.warn({ orderId: order.id, err }, 'carrier booking failed, order stays packed');
+ continue;
+ }
+
+ await prisma.order.update({
+ where: { id: order.id },
+ data: { ...order, status: 'SHIPPED', trackingCode, shippedAt: new Date() },
+ });
+ logger.info({ orderId: order.id, trackingCode }, 'order shipped');
+ }
+}
+