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:
@@ -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). */
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user