// Full item_status enum — must match the `order_items.item_status` // select field on the PB schema. The 2026-07-20-delivery-tracking // migration added the last 3 (`with_driver`, `checked_in`, // `completed`); the others (`ready_invoice`, `invoiced`, // `stock_booked`) were already on the PB schema but missing from // this whitelist, which silently rejected legitimate PATCHes — // fixed in the same change so /deliveries + n8n flows don't // trip a stale validator. const ITEM_STATUS_VALUES = [ "pending", "processed", "po_generated", "collection_pool", "assigned", "collected", "stock_verified", "picking", "ready_delivery", "delivered", "cancelled", "ready_invoice", "invoiced", "stock_booked", "with_driver", "checked_in", "completed", ] as const; const DRIVER_STATUS_VALUES = [ "unassigned", "assigned", "en_route", "collected", "failed", ] as const; export type ItemStatusValue = (typeof ITEM_STATUS_VALUES)[number]; export type DriverStatusValue = (typeof DRIVER_STATUS_VALUES)[number]; /** * Convert a YYYY-MM-DD string to an ISO timestamp at the start of that * day in the business's local timezone. Returns null on parse failure. * * The route layer normalizes YYYY-MM-DD dates from the operator-facing * modal before they hit the normalizer. We don't bake a TZ into the * server-side normalizer because the business timezone is a config * concern (one day this should live in env or PB), and 00:00 in UTC is * close enough for operator scheduling (PO dates are calendar days, not * timestamps the driver app needs second-level precision on). */ export function assignedDateToIso(dateOnly: string): string | null { if (!/^\d{4}-\d{2}-\d{2}$/.test(dateOnly)) return null; const d = new Date(dateOnly + "T00:00:00.000Z"); if (Number.isNaN(d.getTime())) return null; return d.toISOString(); } export function normalizeOrderItemStatusInput(input: { item_status?: string; driver_status?: string; drv_status?: string; notes?: string; problem_notes?: string; /** * Driver user id (relation to `users`). Optional on every status * update — the assignment page writes it; the driver app's * "I'm starting" / "I'm collected" updates leave it unchanged. * Empty string is treated as "clear the assignment" so the * unassign path works without a separate codepath. */ drv_id?: string; /** * ISO timestamp of the most recent assignment. Same rationale as * `drv_id` — only the assignment page writes it; other surfaces * leave it untouched. Empty string clears the assignment time. * * `assignedDate` is the date-only convenience form (YYYY-MM-DD) * used by the operator-facing assign modal. The route layer * converts it to a full ISO at the local business timezone * (00:00:00 UTC) so the operator's chosen day wins over an * auto-stamp. Both fields are independent — sending only * `assigned_at` keeps the legacy behaviour (stamp to now). */ assigned_at?: string; /** YYYY-MM-DD date the operator picked from the date input. */ assignedDate?: string; }) { const patch: Record = {}; if (input.item_status) { if (!ITEM_STATUS_VALUES.includes(input.item_status as ItemStatusValue)) { throw new Error(`Invalid item_status: ${input.item_status}`); } patch.item_status = input.item_status; } const resolvedDriverStatus = input.drv_status ?? input.driver_status; if (resolvedDriverStatus) { if (!DRIVER_STATUS_VALUES.includes(resolvedDriverStatus as DriverStatusValue)) { throw new Error(`Invalid driver status: ${resolvedDriverStatus}`); } patch.drv_status = resolvedDriverStatus; patch.driver_status = resolvedDriverStatus; } if (typeof input.notes === "string") { patch.notes = input.notes; } if (typeof input.problem_notes === "string") { patch.problem_notes = input.problem_notes; } // drv_id + assigned_at: assignment lifecycle fields. Either both // can be omitted (no change), or either can be present alone — // the route handler does not enforce coupling because the // driver app's status-only writes must keep flowing. The // assignment page is the only surface that writes both at once. if (typeof input.drv_id === "string") { patch.drv_id = input.drv_id; } // assignedDate takes precedence over assigned_at. If the operator // picked a date, that wins; otherwise fall through to the legacy // auto-stamp path (caller-supplied or now). if (typeof input.assignedDate === "string") { const iso = assignedDateToIso(input.assignedDate); if (!iso) { throw new Error(`Invalid assignedDate: ${input.assignedDate}`); } patch.assigned_at = iso; } else if (typeof input.assigned_at === "string") { patch.assigned_at = input.assigned_at; } return patch; }