Fix the empty rider dropdown and notify riders on assign

The assign dropdown was empty on open. Two faults compounded: riders were
queried by tenantid, and the source list defaulted to a tab that could
never match.

/partners/getriders returns nothing for any tenant, because a rider record
leaves app_users.tenantid unset — riders belong to a partner and an
app-location. Scoping by applocationid returns them, so both Orders and
Deliveries now take the app-location from the rows they are showing and
fall back to the caller's.

The second fault was the "Store Fleet" default, which filtered for
partnerid === 0. Every on-duty rider has a partnerid, so the default tab
was always empty even once the query returned rows. It is now On Duty /
This Partner, the latter enabled only when the orders name a partner.

Dropped the getallusers?roleid=5 "own fleet" list it merged in. There is no
rider role: app_roles defines 1-6 as Super admin / Operations / Admin /
Manager per configid, and riders are identified by configid=6 inside
getriders. roleid=5 matched a single user with two deliveries in the
platform's history, while the users actually driving deliveries carry
roleid 0.

/partners/getriders is already a presence query rather than a roster — it
requires status Active, onduty=1 and a riderlog dated today with
logstatus=0 — so the list is riders working right now, and it carries the
userfcmtoken needed to reach them. Added a refetch so someone logging off
mid-shift drops out of the list.

Riders are now notified. The push runs after the write and is reported
separately: the deliveries are committed by then, so a failed push must not
read as a failed assignment, but it must still be visible because a rider
who was never told has work sitting unseen. A missing token is reported as
a rider with no device registered rather than as a transport failure, since
the remedy is different.

Deliveries gains the rider actions its placeholder promised: change rider,
notify, and send-cancellation carrying data.type=cancel. Change-rider is
offered only while a delivery is pending, accepted or arrived, because
reassigning resets orderstatus to pending and would otherwise rewind a
journey already completed.

reassignDeliveries was posting to /riders/reassigndeliveries, which is not
registered on the backend and answers 404. It had no callers, so the
failure had never been observed. It now goes through updatedelivery, one
call per delivery, tolerating partial failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 12:48:57 +05:30
parent cc08f2f6c5
commit 8deaf8513b
4 changed files with 427 additions and 73 deletions

View File

@@ -18,8 +18,8 @@ import { createPortal } from 'react-dom';
import {
Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike,
} from 'lucide-react';
import { useFiestaDeliverySummary, useFiestaDeliveries, useFiestaRiders, useFiestaOrderDetails } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { useFiestaDeliverySummary, useFiestaDeliveries, useFiestaRiders, useFiestaOrderDetails, useFiestaChangeRider, useFiestaNotifyRider } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
import {
@@ -27,7 +27,7 @@ import {
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
} from './consoleUi';
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; date?: string; }
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; applocationid?: number; date?: string; }
type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled';
const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [
@@ -57,7 +57,7 @@ function inBatch(r: Row, b: BatchId): boolean {
return h >= 16 && h < 19;
}
export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, date }: DeliveriesViewProps) {
export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, applocationid = FIESTA_APPLOCATION_ID, date }: DeliveriesViewProps) {
const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
@@ -89,7 +89,15 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
// the whole day (status='all', large pagesize); status/search filter client-side.
const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate, todate, locationid });
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate, todate, locationid, status: 'all', pagesize: 200 });
const ridersQ = useFiestaRiders({ tenantid: tenantId });
// Scoped by app-location, not tenant: rider records leave app_users.tenantid
// unset, so a tenant-scoped call returns an empty list and the change-rider
// dropdown would have nothing in it. Prefer the app-location the visible
// deliveries name, falling back to the caller's.
const rowApplocationId = useMemo(
() => fnum((deliveriesQ.data ?? []).find((r) => fnum(r.applocationid))?.applocationid),
[deliveriesQ.data],
);
const ridersQ = useFiestaRiders({ applocationid: rowApplocationId || applocationid || undefined });
const allRows = deliveriesQ.data ?? [];
const summary = summaryQ.data;
@@ -243,13 +251,142 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
</div>
</div>
{detailRow && <DeliveryDetailModal row={detailRow} onClose={() => setDetailRow(null)} />}
{detailRow && <DeliveryDetailModal row={detailRow} riders={ridersQ.data ?? []} onClose={() => setDetailRow(null)} />}
</div>
);
}
/**
* Change-rider / notify controls for one delivery.
*
* Change-rider is offered only while the delivery is still pending, accepted or
* arrived — reassigning resets orderstatus to 'pending', so allowing it once the
* parcel is picked up or delivered would rewind a completed journey.
*
* Notify and reassign are reported separately on purpose. The reassign is
* committed the moment it returns; a push that then fails leaves a rider
* holding work nobody told them about, which is worth saying out loud rather
* than folding into a single "done".
*/
function RiderActions({ row, riders }: { row: Row; riders: Row[] }) {
const st = fstr(row.orderstatus).toLowerCase();
const deliveryid = fnum(row.deliveryid);
const orderheaderid = fnum(row.orderheaderid);
const canChange = ['pending', 'accepted', 'arrived'].includes(st) && deliveryid > 0;
const canNotify = st !== 'delivered' && st !== 'cancelled';
const [picked, setPicked] = useState(0);
const [msg, setMsg] = useState('');
const changeMut = useFiestaChangeRider();
const notifyMut = useFiestaNotifyRider();
const options = useMemo(
() =>
riders
.map((r) => ({
id: fnum(r.userid),
label: (fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`).trim(),
token: fstr(r.userfcmtoken),
}))
.filter((o) => o.id > 0 && o.label),
[riders],
);
// The rider currently on the delivery. Deliveries carry the token on the row
// itself, so notifying does not depend on them still being in the on-duty list.
const currentToken = fstr(row.userfcmtoken);
const push = async (token: string, body: string, data?: Record<string, string>, okLabel = 'Rider notified') => {
try {
await notifyMut.mutateAsync({ token, body, data });
setMsg(okLabel);
} catch (err) {
setMsg(
err instanceof RiderNotReachableError
? 'Not sent — this rider has no device registered'
: 'Not sent — the push failed, tell them another way',
);
}
};
const handleChange = async () => {
if (!picked) return;
const option = options.find((o) => o.id === picked);
try {
await changeMut.mutateAsync({ deliveryid, orderheaderid, userid: picked });
setMsg(`Moved to ${option?.label ?? 'rider'} · notifying…`);
await push(option?.token ?? '', RIDER_MESSAGES.reassigned, undefined, `Moved to ${option?.label ?? 'rider'} · notified`);
} catch {
setMsg('Could not change the rider — please retry.');
}
};
const busy = changeMut.isPending || notifyMut.isPending;
return (
<div className="rounded-xl p-3" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<p className="text-[10px] font-extrabold uppercase tracking-wider mb-2" style={{ color: TEXT_2 }}>Rider actions</p>
{canChange ? (
<div className="flex flex-wrap items-center gap-2">
<select
value={picked}
onChange={(e) => setPicked(Number(e.target.value))}
disabled={busy}
className="rounded-full font-bold text-[11px] outline-none cursor-pointer disabled:opacity-50"
style={{ padding: '6px 10px', border: `1px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 200 }}
>
<option value={0}>{options.length ? 'Change rider…' : 'No riders on duty'}</option>
{options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<button
onClick={handleChange}
disabled={!picked || busy}
className="rounded-full font-extrabold cursor-pointer text-white disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '6px 14px', fontSize: 11, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}
>
{changeMut.isPending ? 'Moving…' : 'Change'}
</button>
</div>
) : (
<p className="text-[11px] font-medium" style={{ color: TEXT_3 }}>
{st === 'delivered' || st === 'cancelled'
? `This delivery is ${st} — the rider can no longer be changed.`
: 'The rider can only be changed while a delivery is pending, accepted or arrived.'}
</p>
)}
{canNotify && (
<div className="flex flex-wrap items-center gap-2 mt-2 pt-2" style={{ borderTop: `1px solid ${DIVIDER}` }}>
<button
onClick={() => push(currentToken, RIDER_MESSAGES.reminder)}
disabled={busy}
className="rounded-full font-bold cursor-pointer disabled:opacity-40"
style={{ padding: '5px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}
>
Notify rider
</button>
<button
onClick={() =>
push(currentToken, RIDER_MESSAGES.cancelled(fstr(row.orderid) || `DLV-${deliveryid}`), { type: 'cancel' }, 'Cancellation sent')
}
disabled={busy}
title="Tells the rider app to drop this delivery"
className="rounded-full font-bold cursor-pointer disabled:opacity-40"
style={{ padding: '5px 12px', fontSize: 11, color: '#b91c1c', background: '#fef2f2', border: '1px solid #fecaca' }}
>
Send cancellation
</button>
</div>
)}
{msg && <p className="text-[11px] font-semibold mt-2" style={{ color: TEXT_2 }}>{msg}</p>}
</div>
);
}
// ── Delivery details modal ──────────────────────────────────────────────────────
function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }) {
function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]; onClose: () => void }) {
const orderheaderid = row.orderheaderid ?? row.orderid;
const detailsQ = useFiestaOrderDetails(orderheaderid as number | string);
const lines = (detailsQ.data ?? []).map((d) => {
@@ -312,7 +449,7 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
))}
</div>
</div>
<AwaitingApi label="Reassign · Cancel · Notify rider" api="dispatch backend" compact />
<RiderActions row={row} riders={riders} />
</div>
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>Close</button>

View File

@@ -14,8 +14,11 @@
import React, { useMemo, useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react';
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaUsers } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaNotifyRider } from '../services/fiestaQueries';
import {
FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError,
num as fnum, str as fstr, ymd, type Row,
} from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers';
import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
@@ -27,6 +30,12 @@ interface OrdersViewProps {
locationid?: number;
/** Merchant tenant to scope to; defaults to the shared constant. */
tenantId?: number;
/**
* App-location to source assignable riders from. Riders are scoped by
* app-location rather than tenant, so without one the rider list falls back
* to whatever the visible orders name. Defaults to the platform constant.
*/
applocationid?: number;
date?: string;
}
@@ -40,7 +49,13 @@ const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
];
const PAGE_SIZE = 25;
export default function OrdersView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, date }: OrdersViewProps) {
export default function OrdersView({
searchQuery = '',
locationid,
tenantId = FIESTA_TENANT_ID,
applocationid = FIESTA_APPLOCATION_ID,
date,
}: OrdersViewProps) {
const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const [fromdate, setFromdate] = useState<string>(date || ymd(today));
@@ -81,6 +96,7 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
const [assignMsg, setAssignMsg] = useState('');
const [showSelected, setShowSelected] = useState(false); // full-page review of selection
const assignMut = useFiestaAssignRider();
const notifyMut = useFiestaNotifyRider();
// Ctrl/Cmd+K focuses search; Escape blurs it (parity with the ops console).
const searchRef = useRef<HTMLInputElement>(null);
@@ -105,7 +121,10 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
setShowSelected(false);
}, [fromdate, todate, status, branch, pageno, locationid]);
const [riderSource, setRiderSource] = useState<'own' | 'partner'>('own');
// 'all' lists every rider on duty at this app-location; 'partner' narrows to
// the partner already carrying the selected orders. Defaults to 'all' — the
// old default filtered to riders with no partnerid, which no on-duty rider has.
const [riderSource, setRiderSource] = useState<'all' | 'partner'>('all');
// Scope to the user's store when a locationid is supplied (server-side per the
// backend's getordersummary/getorders locationid param); tenant-wide otherwise.
@@ -114,51 +133,54 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
const summary = summaryQ.data;
const rawRows = ordersQ.data ?? [];
// Riders must share the orders' tenant + partner to be assignable (the backend
// rejects cross-tenant/partner riders), so derive the partner/app-location from
// the live order rows and scope the rider list to them. An out-of-tenant rider
// simply won't appear — the intended guard.
// Riders are scoped by app-location, NOT by tenant. A rider record carries a
// partnerid and an applocationid but leaves app_users.tenantid unset, so
// /partners/getriders?tenantid=… returns an empty list for every tenant
// which is what the assign dropdown used to show. The app-location is taken
// from the live order rows, falling back to the signed-in user's own.
const orderPartnerId = useMemo(() => fnum(rawRows.find((r) => fnum(r.partnerid))?.partnerid), [rawRows]);
const orderApplocationId = useMemo(() => fnum(rawRows.find((r) => fnum(r.applocationid))?.applocationid), [rawRows]);
const riderApplocationId = orderApplocationId || applocationid || 0;
// /partners/getriders is already a live-presence query, not a roster: the
// backend filters on status='Active', onduty=1 and a riderlog dated today
// with logstatus=0, joined to each rider's most recent GPS ping. So this
// returns riders who are on shift and logged in right now, and it carries the
// userfcmtoken needed to notify them.
const ridersQ = useFiestaRiders({
tenantid: tenantId,
applocationid: orderApplocationId || undefined,
// We omit partnerid here to fetch all partner riders for the location at once.
});
const internalRidersQ = useFiestaUsers({
tenantid: tenantId,
roleid: 5, // 5 = Rider role
pagesize: 500
applocationid: riderApplocationId || undefined,
});
// The previous build also merged in getallusers?roleid=5 as an "own fleet".
// There is no rider role: app_roles only defines 1-6 as Super admin /
// Operations / Admin / Manager per configid, and riders are identified by
// configid=6 inside getriders. roleid=5 matched a single user with two
// deliveries in the platform's history, while the 29 users who actually drive
// the bulk of deliveries carry roleid 0. Every on-duty rider also has
// partnerid > 0, so the "own fleet" tab — the default — filtered that list
// down to nothing and the dropdown was empty on open.
const riderOptions = useMemo(
() => {
const externalRiders = ridersQ.data ?? [];
const internalRiders = internalRidersQ.data ?? [];
const allRiders = [...externalRiders, ...internalRiders];
const filtered = allRiders.filter((r) => {
() =>
(ridersQ.data ?? [])
.filter((r) => {
if (riderSource === 'all') return true;
// Restrict to the partner already carrying these orders, when the
// rows name one; otherwise there is nothing to narrow to.
const pId = fnum(r.partnerid);
if (riderSource === 'own') {
// Store fleet riders are internal users (they have no partner id)
return !pId || pId === 0;
} else {
// Partner riders belong to a 3rd party (partnerid > 0)
// If the order already has a specific partnerid, we only show riders from that partner.
return pId > 0 && (!orderPartnerId || pId === orderPartnerId);
}
});
return filtered
return !orderPartnerId || pId === orderPartnerId;
})
.map((r) => ({
id: fnum(r.userid),
label: `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() + (fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''),
label:
(fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`).trim() +
(fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''),
// Carried so the assign can notify the rider, and so a rider with no
// registered device can be called out rather than silently skipped.
token: fstr(r.userfcmtoken),
vehicle: fstr(r.vehiclename),
}))
.filter((o) => o.id > 0 && o.label);
},
[ridersQ.data, internalRidersQ.data, riderSource, orderPartnerId],
.filter((o) => o.id > 0 && o.label),
[ridersQ.data, riderSource, orderPartnerId],
);
// Branches (app-locations) present in the data — drives the branch filter so the
@@ -271,16 +293,42 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
const handleAssign = async () => {
if (!assignRiderId || selected.size === 0) return;
const toAssign = rows.filter((r) => selected.has(rowKey(r)));
const rider = riderOptions.find((o) => o.id === assignRiderId)?.label ?? 'rider';
const option = riderOptions.find((o) => o.id === assignRiderId);
const rider = option?.label ?? 'rider';
try {
const res = await assignMut.mutateAsync({ userid: assignRiderId, orders: toAssign });
setAssignMsg(
const assigned =
res.failed
? `Assigned ${res.ok}/${res.total} to ${rider} · ${res.failed} failed`
: `Assigned ${res.ok} order${res.ok === 1 ? '' : 's'} to ${rider}`,
);
: `Assigned ${res.ok} order${res.ok === 1 ? '' : 's'} to ${rider}`;
setSelected(new Set());
setShowSelected(false); // return to the board with the result shown in the bar
// Notify only for work that actually landed. The push runs after the
// write and is reported separately: the deliveries exist either way, so a
// failed notification must not read as a failed assignment — but it must
// still be visible, because a rider who was never told has work sitting
// unseen.
if (res.ok === 0) {
setAssignMsg(assigned);
return;
}
setAssignMsg(`${assigned} · notifying…`);
try {
await notifyMut.mutateAsync({
token: option?.token ?? '',
body: RIDER_MESSAGES.assigned(res.ok),
});
setAssignMsg(`${assigned} · rider notified`);
} catch (err) {
setAssignMsg(
`${assigned} · NOT notified — ${
err instanceof RiderNotReachableError
? 'this rider has no device registered'
: 'the push failed, tell them another way'
}`,
);
}
} catch {
setAssignMsg('Assignment failed — please retry.');
}
@@ -371,18 +419,21 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
</span>
<div className="flex bg-white rounded-full p-0.5 ml-2 border" style={{ borderColor: edge(BRAND) }}>
<button
onClick={() => setRiderSource('own')}
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'own' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
style={riderSource === 'own' ? { background: tint(BRAND), color: BRAND } : undefined}
onClick={() => setRiderSource('all')}
title="Every rider on duty at this branch right now"
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'all' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
style={riderSource === 'all' ? { background: tint(BRAND), color: BRAND } : undefined}
>
Store Fleet
On Duty
</button>
<button
onClick={() => setRiderSource('partner')}
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'partner' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
disabled={!orderPartnerId}
title={orderPartnerId ? 'Only riders from the partner carrying these orders' : 'These orders name no partner'}
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors disabled:opacity-40 ${riderSource === 'partner' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
style={riderSource === 'partner' ? { background: tint(BRAND), color: BRAND } : undefined}
>
Partners
This Partner
</button>
</div>
<select
@@ -393,7 +444,15 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
className="rounded-full font-bold text-xs outline-none cursor-pointer disabled:opacity-50"
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}
>
<option value={0}>{ridersQ.isLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : `No ${riderSource === 'own' ? 'store' : 'partner'} riders available`}</option>
<option value={0}>
{ridersQ.isLoading
? 'Loading riders…'
: riderOptions.length
? 'Select rider…'
: riderSource === 'partner'
? 'No riders on duty for this partner'
: 'No riders on duty right now'}
</option>
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<button

View File

@@ -530,25 +530,97 @@ export async function assignRiderToOrders(
return { ok, failed, total: orders.length };
}
// ════════════════════════════════════════════════════════════════════════════
// RIDER PUSH NOTIFICATION
// ════════════════════════════════════════════════════════════════════════════
/**
* Thrown when the rider has no registered device. Distinct from a transport
* failure because the remedy is different — the rider must open the app and
* sign in, not retry. Without this the operator sees a generic "notification
* failed" and assumes the network is at fault.
*/
export class RiderNotReachableError extends Error {
constructor(message = 'This rider has no device registered, so they were not notified.') {
super(message);
this.name = 'RiderNotReachableError';
}
}
export interface NotifyRiderInput {
token: string;
title?: string;
body: string;
/** Silent payload the rider app switches on, e.g. `{ type: 'cancel' }`. */
data?: Record<string, string>;
}
/**
* POST /utils/notifyuser — relays an FCM push to a rider through the backend,
* which holds the Firebase service account.
*
* Fire-and-forget by design: there is no delivery receipt and no retry. The
* delivery row is already committed by the time this runs, so a failure here
* means the rider has work they have not been told about — which is why it is
* surfaced to the operator rather than swallowed.
*/
export async function notifyRider(input: NotifyRiderInput): Promise<Row> {
const token = (input.token ?? '').trim();
// Checked before the request: posting an empty token returns a generic FCM
// "invalid argument", which reads as a server fault rather than a rider who
// has never opened the app.
if (!token) throw new RiderNotReachableError();
return fiestaSend<Row>('utils/notifyuser', 'POST', {
token,
notification: {
title: input.title ?? 'NearleXpress',
body: input.body,
sound: 'ring',
image: '',
},
...(input.data ? { data: input.data } : {}),
});
}
/** Standard message bodies, kept together so the wording stays consistent. */
export const RIDER_MESSAGES = {
assigned: (count: number) =>
count === 1
? 'An order has been assigned to you. Kindly accept and process the delivery.'
: `${count} orders have been assigned to you. Kindly accept and process the deliveries.`,
reassigned: 'A delivery has been assigned to you. Kindly accept and process it.',
reminder: 'You have deliveries waiting. Kindly accept and process them.',
cancelled: (orderid: string) => `${orderid} has been cancelled.`,
} as const;
// ════════════════════════════════════════════════════════════════════════════
// PARTNERS / RIDERS
// ════════════════════════════════════════════════════════════════════════════
/**
* /partners/getriders?applocationid=&tenantid=&partnerid= — active rider fleet.
* Scoped by tenant AND partner: a rider belongs to one tenant/partner, so an
* order can only be assigned to a rider sharing its partnerid. Passing the
* order's partnerid keeps the assignable list correct (an out-of-tenant rider
* simply won't appear, which is the intended guard).
* /partners/getriders?applocationid=&partnerid=&tenantid= — riders on duty NOW.
*
* Despite the name this is a presence query, not a roster. The backend requires
* status='Active', onduty=1, and a riderlog dated today with logstatus=0, then
* joins each rider's most recent GPS ping. So it answers "who is working right
* now", and the rows carry userfcmtoken for notifying them.
*
* Scope by applocationid or partnerid. NOT by tenantid: a rider record leaves
* app_users.tenantid unset (riders belong to a partner and an app-location), so
* a tenant-scoped call returns an empty list for every tenant. The backend
* checks applocationid first, then partnerid, then tenantid, so passing an
* app-location alongside anything else wins.
*/
export async function getRiders(opts: {
applocationid?: number;
tenantid: number;
tenantid?: number;
partnerid?: number;
}): Promise<Row[]> {
const scoped = opts.applocationid || opts.partnerid || opts.tenantid;
return toRows(
await fiestaGet('partners/getriders', {
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
applocationid: scoped ? opts.applocationid : FIESTA_APPLOCATION_ID,
tenantid: opts.tenantid,
partnerid: opts.partnerid,
}),
@@ -1210,12 +1282,48 @@ export async function updateDelivery(deliveryid: number, updates: Row): Promise<
});
}
/** POST /riders/reassigndeliveries — Batch-reassign multiple deliveries to a new rider. */
/**
* Move one delivery to a different rider.
*
* Goes through PUT /deliveries/updatedelivery. There is no batch reassign
* endpoint: /riders/reassigndeliveries, which this used to POST to, is not
* registered on the backend and answers 404 — it had no callers, so the failure
* was never observed.
*
* Re-assigning resets orderstatus to 'pending', discarding any accepted/arrived
* progress the previous rider had made. That is the backend's existing
* behaviour and callers should gate the action accordingly.
*/
export async function changeDeliveryRider(opts: {
deliveryid: number;
orderheaderid: number;
userid: number;
}): Promise<Row> {
return fiestaSend<Row>('deliveries/updatedelivery', 'PUT', {
deliveryid: opts.deliveryid,
orderheaderid: opts.orderheaderid,
userid: opts.userid,
orderstatus: 'pending',
assigntime: nowStamp(),
});
}
/** Reassign several deliveries to one rider, one call each. Tolerates partial
* failure and reports it, the same contract as assignRiderToOrders. */
export async function reassignDeliveries(opts: {
userid: number;
deliveryids: number[];
}): Promise<Row> {
return fiestaSend<Row>('riders/reassigndeliveries', 'POST', opts);
deliveries: { deliveryid: number; orderheaderid: number }[];
}): Promise<{ ok: number; failed: number; total: number }> {
const results = await Promise.allSettled(
opts.deliveries.map((d) =>
changeDeliveryRider({ deliveryid: d.deliveryid, orderheaderid: d.orderheaderid, userid: opts.userid }),
),
);
return {
ok: results.filter((r) => r.status === 'fulfilled').length,
failed: results.filter((r) => r.status === 'rejected').length,
total: opts.deliveries.length,
};
}
/** POST /v1/web/tenants/createlocation — Create a new tenant location (outlet). */

View File

@@ -56,6 +56,9 @@ import {
updateUser,
setUserPassword,
assignRiderToOrders,
changeDeliveryRider,
notifyRider,
NotifyRiderInput,
CreateUserInput,
createTenantUser,
createTenantLocation,
@@ -350,12 +353,53 @@ export function useFiestaAssignRider() {
});
}
/**
* Move a delivery to a different rider. Refreshes the deliveries board and its
* KPI cards, plus the orders list, since the order's rider is shown there too.
*/
export function useFiestaChangeRider() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { deliveryid: number; orderheaderid: number; userid: number }) =>
changeDeliveryRider(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['fiesta', 'deliveries'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'deliverySummary'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'orders'] });
},
});
}
/**
* Push a message to a rider's device.
*
* Deliberately NOT chained into the assign mutation's onSuccess: the delivery
* is already committed by then, so a failed push must not read as a failed
* assignment. Callers fire this after the write and report the two outcomes
* separately.
*/
export function useFiestaNotifyRider() {
return useMutation({
mutationFn: (input: NotifyRiderInput) => notifyRider(input),
});
}
// ── Partners / Riders ─────────────────────────────────────────────────────────
export function useFiestaRiders(opts: { applocationid?: number; tenantid: number; partnerid?: number }) {
/**
* Riders on duty right now — see getRiders for why this is presence, not a
* roster. Enabled on ANY scope: gating on tenantid alone kept the query off for
* callers that legitimately scope by app-location, which is the only scope that
* actually returns riders.
*/
export function useFiestaRiders(opts: { applocationid?: number; tenantid?: number; partnerid?: number }) {
return useQuery({
queryKey: fiestaKeys.riders(opts),
queryFn: () => getRiders(opts),
enabled: Boolean(opts.tenantid),
enabled: Boolean(opts.applocationid || opts.partnerid || opts.tenantid),
// Presence goes stale quickly — a rider logging off mid-shift should drop
// out of the assign list rather than linger for the whole session.
staleTime: 60_000,
refetchInterval: 120_000,
});
}
@@ -425,14 +469,20 @@ export function useFiestaUpdateDelivery() {
});
}
/**
* Move several deliveries to one rider. Each needs its orderheaderid as well as
* its deliveryid — updatedelivery keys on both — so this takes delivery rows
* rather than the bare id list it used to.
*/
export function useFiestaReassignDeliveries() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { userid: number; deliveryids: number[] }) =>
mutationFn: (input: { userid: number; deliveries: { deliveryid: number; orderheaderid: number }[] }) =>
reassignDeliveries(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['fiesta', 'deliveries'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'deliverySummary'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'orders'] });
},
});
}