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>
227 lines
8.0 KiB
TypeScript
227 lines
8.0 KiB
TypeScript
// 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);
|
|
}
|
|
};
|