import { getMilers, assignMilerToBooking } from 'pages/api/doormileApi'; import { buildMilerLookup, notifyRider } from 'pages/api/api'; // ==============================|| Doormile AI — assigning a rider ||============================== // // // Fourth write capability. Two endpoints, and which one is correct depends // entirely on how many orders are being assigned: // // ONE order → POST /admin/bookings/:id/assign-miler // MANY orders → POST /hub/bookings/batch-assign // // These are NOT interchangeable. doormile-flow.md §4: batch-assign is "the only // place stops get ordered" — after assigning it sends each affected rider's // whole active set to the route optimiser and writes step, per-leg distance and // ETA back onto `bookingassignments`. Assigning ten orders with ten single // calls leaves every route unsequenced and riders choosing their own order. // // ---- The two rider IDs ----------------------------------------------------- // // `assign-miler` takes a **mileruserid** in its body. `/admin/milers/:id/notify` // keys off a **milerprofileid**. Different identity spaces on adjacent // endpoints — doormile-flow.md calls this out by name. Getting it wrong fails // quietly in both directions: the assign 404s, or the rider is never told. // `buildMilerLookup` is the bridge, and it is the SAME one orders.js already // uses for exactly this translation. Don't grow a second lookup here. // // ---- Assignment is normally automatic -------------------------------------- // // Creating a booking publishes `booking.assignment_requested`; a worker finds a // rider within 10km via Redis GEO, scores them, and commits — retrying 5 times, // 2 minutes apart (doormile-flow.md §3). So everything here is an OVERRIDE of a // decision the backend is already making, which is why the flow re-reads the // booking's current assignee before offering to change it. export const ASSIGN_TRIGGER = /\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it|this|that|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i; // Riders, plus the id bridge, in one call. export const loadRiders = async () => { const milers = (await getMilers()) || []; return { milers, lookup: buildMilerLookup(milers) }; }; const riderName = (m) => m.displayname || m.authname || m.name || `Rider #${m.userid}`; // Available riders first — an operator picking by hand wants the ones who can // actually take it at the top. Beyond that, alphabetical: any other ordering // (nearest, least loaded) would need position data this list doesn't carry, and // a proximity label nobody can stand behind is worse than none. const isAvailable = (m) => /avail|active|online|free/i.test(String(m.availabilitystatus || '')); export const riderOptions = (milers) => [...(milers || [])] .sort((a, b) => { const byAvail = Number(isAvailable(b)) - Number(isAvailable(a)); return byAvail || riderName(a).localeCompare(riderName(b)); }) .map((m) => ({ value: String(m.userid), label: [riderName(m), m.phone, m.defaultvehicletype, m.availabilitystatus || 'availability unknown'].filter(Boolean).join(' · '), record: m })); // Who currently holds this booking, resolved to a person rather than an id. export const currentAssignee = (booking, lookup) => booking?.assignedmileruserid ? lookup?.byUserId?.get(String(booking.assignedmileruserid)) || null : null; export const describeRider = (m) => (m ? [riderName(m), m.phone].filter(Boolean).join(' · ') : null); // ---- one order -------------------------------------------------------------- export const executeAssign = async (booking, rider) => { const started = Date.now(); const bookingLabel = booking?.bookingno || `#${booking?.bookingid}`; const call = { name: 'assignMilerToBooking', target: `POST /admin/bookings/${booking?.bookingid}/assign-miler`, stats: `${bookingLabel} → ${riderName(rider)}` }; try { // mileruserid, NOT milerprofileid. See the note at the top of this file. const res = await assignMilerToBooking(booking.bookingid, { mileruserid: Number(rider.userid) }); const duration = `${Date.now() - started}ms`; if (res && res.success === false) { return { ok: false, message: res.message || 'The server refused the assignment.', sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }] }; } const sourceCalls = [{ ...call, duration, status: 'complete' }]; // CLAUDE.md §9: any mutation that affects a rider is followed by a push. // It is deliberately NOT allowed to fail the assignment — the order IS // assigned at this point, and reporting otherwise would be a lie. // Whether the push ACTUALLY went out, not whether it could have been // attempted. This used to be reported as `Boolean(rider.milerprofileid)` // — i.e. "this rider has an id, so assume they were told" — which is a // different claim entirely. Caught live: notify returned 400 and the // assistant still said "The rider has been notified." // // The assignment itself is unaffected; it had already landed. But a // dispatcher who believes a rider was pinged does not follow up, and this // bot's whole contract is that it never states something it has not // confirmed. let notified = false; if (rider.milerprofileid) { try { await notifyRider(rider.milerprofileid); notified = true; sourceCalls.push({ name: 'notifyRider', target: `POST /admin/milers/${rider.milerprofileid}/notify`, status: 'complete', stats: 'rider notified' }); } catch (err) { sourceCalls.push({ name: 'notifyRider', target: `POST /admin/milers/${rider.milerprofileid}/notify`, status: 'error', errorMessage: err.message || 'notification failed' }); } } else { // No profile id means no push is possible — say so rather than letting // the operator assume the rider's phone buzzed. sourceCalls.push({ name: 'notifyRider', target: '/admin/milers/:id/notify', status: 'error', errorMessage: 'this rider has no milerprofileid, so no notification could be sent' }); } return { ok: true, rider, bookingLabel, notified, sourceCalls }; } catch (err) { // doormileAxios rejects with the response BODY; the status rides on // err.httpStatus. const status = err.httpStatus; const message = status ? `POST /admin/bookings/${booking?.bookingid}/assign-miler returned ${status}${ err.message ? ` — ${err.message}` : '' }. Nothing changed.` : `${err.message || 'The request failed'} — nothing changed.`; return { ok: false, message, sourceCalls: [{ ...call, duration: `${Date.now() - started}ms`, status: 'error', errorMessage: message }] }; } }; // ---- repeat a run, keeping each order with the rider who ran it last -------- // // A repeated run is the SAME drops to the SAME doors. The rider who did them // yesterday already knows the buzzer, the gate code and which side of the // building to park on, so re-deriving an assignment from scratch throws away // the one piece of routing knowledge the previous day produced. // // Deliberately built on /admin/bookings/:id/assign-miler, one call per order, // rather than the hub batch endpoint: batch-assign lets the BACKEND choose // riders, which is the opposite of the intent here, and it is refused to every // non-hub login anyway (403, confirmed live). // // The trade-off this accepts: assigning individually does not sequence a // rider's stops. Yesterday's run was already sequenced for these same drops, // so the ordering is not arbitrary — but it is not recomputed either, and the // caller states that rather than implying a fresh optimisation. export const executeRepeatAssign = async (createdPairs, rows) => { const started = Date.now(); // Only rows whose source order actually had a rider. A blank one is not a // failure — yesterday's copy was never assigned either. const targets = (createdPairs || []) .map(({ index, bookingid }) => ({ bookingid, mileruserid: rows?.[index]?.__previousMilerUserId ?? null })) .filter((t) => t.mileruserid != null); if (!targets.length) { return { ok: true, assigned: 0, skipped: (createdPairs || []).length, failures: [], notified: 0, sourceCalls: [] }; } const { lookup } = await loadRiders().catch(() => ({ lookup: null })); const assignedRiders = new Set(); const failures = []; let assigned = 0; // Sequential on purpose. These are writes against real dispatch records, and // firing a burst of them concurrently makes a partial failure much harder to // report accurately — which order did not land, and to whom. // eslint-disable-next-line no-restricted-syntax for (const t of targets) { try { // eslint-disable-next-line no-await-in-loop await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) }); assigned += 1; assignedRiders.add(String(t.mileruserid)); } catch (err) { failures.push({ bookingid: t.bookingid, reason: err.message || `HTTP ${err.httpStatus || '?'}` }); } } const sourceCalls = [ { name: 'assignMilerToBooking', target: 'POST /admin/bookings/:id/assign-miler', duration: `${Date.now() - started}ms`, status: failures.length ? 'error' : 'complete', stats: `${assigned} of ${targets.length} re-assigned to yesterday's rider`, errorMessage: failures.length ? `${failures.length} could not be assigned` : undefined } ]; // One push per rider, not per order — a rider getting ten of yesterday's // drops back should feel one buzz, not ten. let notified = 0; if (lookup) { // eslint-disable-next-line no-restricted-syntax for (const userid of assignedRiders) { const rider = lookup.byUserId.get(userid); if (rider?.milerprofileid) { try { // eslint-disable-next-line no-await-in-loop await notifyRider(rider.milerprofileid); notified += 1; } catch { // Notification failure never fails the assignment — the order IS // assigned by this point. It is reported, not swallowed. } } } sourceCalls.push({ name: 'notifyRider', target: 'POST /admin/milers/:id/notify', status: notified === assignedRiders.size ? 'complete' : 'error', stats: `${notified} of ${assignedRiders.size} rider${assignedRiders.size === 1 ? '' : 's'} notified` }); } return { ok: assigned > 0, assigned, skipped: (createdPairs || []).length - targets.length, failures, notified, riders: assignedRiders.size, sourceCalls }; };