Waseel: driver capture, chat/calls, dispatch, and session fixes
Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.
Camera permission on Android:
- Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
own plugin never declares CAMERA, and Android denies a request for an
undeclared permission instantly and silently — no dialog is ever shown,
which is indistinguishable from the app not asking at all.
- Handle canAskAgain: once Android stops showing the dialog, repeating why
we need it is a dead end, so offer Open Settings instead (lib/capture-
permission.ts), matching what the location flow already did.
Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.
Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1d84003e0a
commit
8807ff41c5
@@ -0,0 +1,226 @@
|
||||
// The ride state machine, shared by every endpoint that touches it.
|
||||
//
|
||||
// requested ──rider picks an offer──▶ accepted ──arrive──▶ arrived
|
||||
// │ │ │ pickup code
|
||||
// │ │ ▼
|
||||
// │ │ en_route ──▶ completed
|
||||
// │ │ │ │
|
||||
// │ └─────────────┴────────────┴──── cancel ──▶ cancelled
|
||||
// └── nobody offered in time ──▶ expired
|
||||
//
|
||||
// Dispatch is a broadcast, not a hand-off: a new request is put in front of
|
||||
// every eligible driver near the pickup at once, each of them can volunteer
|
||||
// for it (a row in ride_offers), and the rider chooses between whoever did.
|
||||
// So a ride has exactly one moment of assignment — the rider's pick — rather
|
||||
// than a driver claiming it and the rider being told after the fact.
|
||||
//
|
||||
// Keeping the status sets here (rather than inlining string arrays in each
|
||||
// route) is what stops a new state like 'arrived' from being handled in one
|
||||
// query and silently ignored in the next.
|
||||
|
||||
import { query, sql, type SqlValue } from "@/lib/db";
|
||||
import { REQUEST_TTL_SECONDS } from "@/constants/dispatch";
|
||||
|
||||
export const RIDE_STATUSES = [
|
||||
"requested",
|
||||
"accepted",
|
||||
"arrived",
|
||||
"en_route",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"expired",
|
||||
] as const;
|
||||
|
||||
export type RideStatus = (typeof RIDE_STATUSES)[number];
|
||||
|
||||
/** Ride is in flight for the rider: they should be on the tracking screen. */
|
||||
export const ACTIVE_RIDE_STATUSES = [
|
||||
"requested",
|
||||
"accepted",
|
||||
"arrived",
|
||||
"en_route",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Driver is committed to a ride and must not be shown new requests. Offering
|
||||
* on a request costs a driver nothing and can be withdrawn, so it is the
|
||||
* assignment — not the offer — that takes them off the board.
|
||||
*/
|
||||
export const DRIVER_BUSY_STATUSES = [
|
||||
"accepted",
|
||||
"arrived",
|
||||
"en_route",
|
||||
] as const;
|
||||
|
||||
/** Nothing more will happen to the ride. */
|
||||
export const TERMINAL_RIDE_STATUSES = [
|
||||
"completed",
|
||||
"cancelled",
|
||||
"expired",
|
||||
] as const;
|
||||
|
||||
/** Chat and calls are open between the two parties in these states. */
|
||||
export const CONNECTED_RIDE_STATUSES = [
|
||||
"accepted",
|
||||
"arrived",
|
||||
"en_route",
|
||||
] as const;
|
||||
|
||||
export const isTerminal = (status: string): boolean =>
|
||||
(TERMINAL_RIDE_STATUSES as readonly string[]).includes(status);
|
||||
|
||||
// Postgres array literals for the sets above. Passed as a bound parameter and
|
||||
// cast in the query — `status = ANY(${ACTIVE_STATUS_ARRAY}::text[])` — so a
|
||||
// status list is defined once here instead of being retyped inline in every
|
||||
// route, where adding a state means remembering every literal that needs it.
|
||||
const pgArray = (v: readonly string[]): string => `{${v.join(",")}}`;
|
||||
|
||||
/** The rider may still call it off in these states — nobody is moving yet. */
|
||||
export const RIDER_CANCELLABLE_STATUSES = [
|
||||
"requested",
|
||||
"accepted",
|
||||
"arrived",
|
||||
] as const;
|
||||
|
||||
/** The assigned driver's cancellation window: from being picked to the pickup. */
|
||||
export const DRIVER_CANCELLABLE_STATUSES = [
|
||||
"accepted",
|
||||
"arrived",
|
||||
] as const;
|
||||
|
||||
export const ACTIVE_STATUS_ARRAY = pgArray(ACTIVE_RIDE_STATUSES);
|
||||
export const DRIVER_BUSY_ARRAY = pgArray(DRIVER_BUSY_STATUSES);
|
||||
export const CONNECTED_STATUS_ARRAY = pgArray(CONNECTED_RIDE_STATUSES);
|
||||
export const TERMINAL_STATUS_ARRAY = pgArray(TERMINAL_RIDE_STATUSES);
|
||||
export const RIDER_CANCELLABLE_ARRAY = pgArray(RIDER_CANCELLABLE_STATUSES);
|
||||
export const DRIVER_CANCELLABLE_ARRAY = pgArray(DRIVER_CANCELLABLE_STATUSES);
|
||||
|
||||
// Cancellation reasons the clients may send. Free text is rejected: these
|
||||
// codes are what makes cancellations countable in the admin portal, and an
|
||||
// open text field would turn that into an unqueryable mess.
|
||||
export const CANCELLATION_REASONS = [
|
||||
"changed_mind",
|
||||
"wait_too_long",
|
||||
"wrong_address",
|
||||
"driver_no_show",
|
||||
"rider_no_show",
|
||||
"unreachable",
|
||||
"vehicle_issue",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type CancellationReason = (typeof CANCELLATION_REASONS)[number];
|
||||
|
||||
export const isCancellationReason = (v: unknown): v is CancellationReason =>
|
||||
typeof v === "string" &&
|
||||
(CANCELLATION_REASONS as readonly string[]).includes(v);
|
||||
|
||||
// 4-digit pickup code. Not a secret worth hardening — it only has to be hard
|
||||
// to guess on the first try in a parking lot, and the driver can only try it
|
||||
// against a ride already assigned to them.
|
||||
export const generatePickupCode = (): string =>
|
||||
String(Math.floor(1000 + Math.random() * 9000));
|
||||
|
||||
/**
|
||||
* Give up on `requested` rides nobody was picked for within
|
||||
* REQUEST_TTL_SECONDS, and close whatever offers were sitting on them.
|
||||
*
|
||||
* Called from the lazy paths that stand in for a background worker — the
|
||||
* rider's status poll, the driver's dashboard poll — so there is no daemon to
|
||||
* keep alive.
|
||||
*
|
||||
* Unlike the old hand-off dispatch, a request with offers on it is expired
|
||||
* like any other. Offers are volunteers, not commitments: a rider who never
|
||||
* picked one has left three drivers holding a job that is never going to
|
||||
* start, and the honest end of that is to close it and free them.
|
||||
*/
|
||||
export const expireStaleRequests = async (rideId?: number): Promise<number> => {
|
||||
const values: SqlValue[] = [REQUEST_TTL_SECONDS];
|
||||
let scope = "";
|
||||
if (rideId !== undefined) {
|
||||
values.push(rideId);
|
||||
scope = ` AND ride_id = $${values.length}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await query<{ ride_id: number }>(
|
||||
`UPDATE rides
|
||||
SET status = 'expired',
|
||||
cancelled_at = CURRENT_TIMESTAMP,
|
||||
cancelled_by = 'system',
|
||||
cancellation_reason = 'no_drivers_available'
|
||||
WHERE status = 'requested'
|
||||
AND created_at < CURRENT_TIMESTAMP - make_interval(secs => $1)${scope}
|
||||
RETURNING ride_id`,
|
||||
values,
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
// Same sweep, so a driver's "waiting for the rider" card can never
|
||||
// outlive the request it belongs to.
|
||||
// Passed as a Postgres array literal and cast in the query, the same way
|
||||
// the status sets above travel — SqlValue is deliberately scalar-only.
|
||||
await query(
|
||||
`UPDATE ride_offers
|
||||
SET status = 'expired', responded_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'offered'
|
||||
AND ride_id = ANY($1::int[])`,
|
||||
[`{${rows.map((r) => r.ride_id).join(",")}}`],
|
||||
);
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
} catch (error) {
|
||||
console.error("[EXPIRE_STALE_REQUESTS]: ", error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Recompute a driver's headline rating from the ratings riders left them.
|
||||
* Denormalised onto drivers.rating because every driver card and every match
|
||||
* candidate reads it. Drivers with no ratings yet keep the 5.0 they onboard
|
||||
* with, so a new driver isn't shown as unrated-and-therefore-bad.
|
||||
*/
|
||||
export const refreshDriverRating = async (driverId: number): Promise<void> => {
|
||||
try {
|
||||
await sql`
|
||||
UPDATE drivers d
|
||||
SET rating = COALESCE(agg.avg_rating, 5.0),
|
||||
rating_count = COALESCE(agg.n, 0)
|
||||
FROM (
|
||||
SELECT
|
||||
ROUND(AVG(rr.rating)::numeric, 1) AS avg_rating,
|
||||
COUNT(*)::int AS n
|
||||
FROM ride_ratings rr
|
||||
JOIN rides r ON r.ride_id = rr.ride_id
|
||||
WHERE rr.rater_type = 'rider' AND r.driver_id = ${driverId}
|
||||
) AS agg
|
||||
WHERE d.id = ${driverId}
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error("[REFRESH_DRIVER_RATING]: ", error);
|
||||
}
|
||||
};
|
||||
|
||||
/** The mirror of the above: what drivers thought of a rider. */
|
||||
export const refreshRiderRating = async (userId: string): Promise<void> => {
|
||||
try {
|
||||
await sql`
|
||||
UPDATE users u
|
||||
SET rating = agg.avg_rating,
|
||||
rating_count = COALESCE(agg.n, 0)
|
||||
FROM (
|
||||
SELECT
|
||||
ROUND(AVG(rr.rating)::numeric, 1) AS avg_rating,
|
||||
COUNT(*)::int AS n
|
||||
FROM ride_ratings rr
|
||||
JOIN rides r ON r.ride_id = rr.ride_id
|
||||
WHERE rr.rater_type = 'driver' AND r.user_id = ${userId}
|
||||
) AS agg
|
||||
WHERE u.id = ${userId}
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error("[REFRESH_RIDER_RATING]: ", error);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user