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:
Krikorios
2026-08-26 02:17:55 +03:00
co-authored by Claude Opus 5
parent 1d84003e0a
commit 8807ff41c5
111 changed files with 14568 additions and 1411 deletions
+226
View File
@@ -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);
}
};