Files
waseel/app/(api)/driver/rides+api.ts
KrikoriosandClaude Opus 5 8807ff41c5 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>
2026-08-26 02:17:55 +03:00

298 lines
12 KiB
TypeScript

import { requireDriverProfile } from "@/lib/driver";
import { sql } from "@/lib/db";
import { DRIVER_BUSY_ARRAY, expireStaleRequests } from "@/lib/ride-lifecycle";
import { boundingBox, haversine } from "@/lib/utils";
import { BROADCAST_RADIUS_M, REQUEST_TTL_SECONDS } from "@/constants/dispatch";
import { splitFare } from "@/lib/pricing";
// GET — the driver's world in one poll:
// requests: open ride requests broadcast near this driver, each carrying
// how far the pickup is, what the driver would earn, and whether
// they have already offered on it.
// active : the ride this driver is currently on (accepted -> en_route).
// recent : rides completed today, for the earnings summary.
//
// Requests are found by distance from the driver's own last position, using
// the same radius lib/dispatch broadcasts over — the two questions ("who
// should be told about this request?" and "what is open near me?") have to
// agree, or a driver gets pushed a job their dashboard then hides.
export async function GET(req: Request) {
const result = await requireDriverProfile(req);
if ("error" in result) return result.error;
try {
const { driverId } = result;
// This poll is one of the lazy paths that stands in for a background
// worker, so it also buries requests nobody was picked for. Awaited: the
// list read below should not include a request that just died.
await expireStaleRequests();
// The driver's own position and state. A driver with no fix yet can't be
// told what's near them, and one who is offline shouldn't be shown work.
const [me] = await sql<{
latitude: number | null;
longitude: number | null;
service: string;
online: boolean;
}>`
SELECT latitude, longitude, service, online
FROM drivers WHERE id = ${driverId}
`;
const canSeeRequests =
me?.online === true && me.latitude !== null && me.longitude !== null;
// Coarse box in the index, great-circle pass afterwards — the same
// two-step every other proximity query in this codebase uses.
const box = canSeeRequests
? boundingBox(me.latitude!, me.longitude!, BROADCAST_RADIUS_M)
: null;
const openRequests = box
? await sql<OpenRequestRow>`
SELECT
r.ride_id, r.origin_address, r.destination_address,
r.origin_latitude, r.origin_longitude,
r.destination_latitude, r.destination_longitude,
r.ride_time, r.fare_price, r.service, r.created_at,
u.name AS rider_name, u.rating AS rider_rating,
mine.id AS my_offer_id,
(SELECT COUNT(*)::int FROM ride_offers ro
WHERE ro.ride_id = r.ride_id AND ro.status = 'offered')
AS offer_count
FROM rides r
LEFT JOIN users u ON u.id = r.user_id
LEFT JOIN ride_offers mine
ON mine.ride_id = r.ride_id
AND mine.driver_id = ${driverId}
AND mine.status = 'offered'
WHERE r.status = 'requested'
AND r.service = ${me.service}
AND r.created_at > CURRENT_TIMESTAMP - make_interval(secs => ${REQUEST_TTL_SECONDS})
AND r.origin_latitude BETWEEN ${box.minLat} AND ${box.maxLat}
AND r.origin_longitude BETWEEN ${box.minLng} AND ${box.maxLng}
ORDER BY r.created_at DESC
`
: [];
// Distance is computed here rather than in SQL so the filter and the
// number the driver reads on the card are the same calculation.
const requests = (openRequests as unknown as OpenRequestRow[])
.map((row) => ({
...row,
pickup_distance_m: Math.round(
haversine(
me.latitude!,
me.longitude!,
Number(row.origin_latitude),
Number(row.origin_longitude),
),
),
}))
.filter((row) => row.pickup_distance_m <= BROADCAST_RADIUS_M)
.sort((a, b) => a.pickup_distance_m - b.pickup_distance_m);
// Note: pickup_code is deliberately NOT selected here. The whole point of
// the code is that the driver has to get it from the rider at the car.
//
// The rider's phone number isn't selected either. It used to be shipped to
// the driver client and never rendered — personal data in transit for
// nothing. Driver↔rider contact goes through the in-app chat and WebRTC
// call, which is this app's equivalent of a masked number.
const active = await sql`
SELECT
r.ride_id, r.status, r.service, r.payment_status,
r.origin_address, r.destination_address,
r.origin_latitude, r.origin_longitude,
r.destination_latitude, r.destination_longitude,
r.ride_time, r.fare_price, r.created_at, r.arrived_at,
u.name AS rider_name, u.rating AS rider_rating
FROM rides r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.driver_id = ${driverId}
AND r.status = ANY(${DRIVER_BUSY_ARRAY}::text[])
ORDER BY r.created_at DESC
LIMIT 1
`;
// driver_payout_cents is what the driver actually keeps; fare_price is
// what the rider paid. Everything the driver sees is the payout — COALESCE
// covers rides completed before the split existed.
const recent = await sql<RecentRow>`
SELECT ride_id, fare_price, service, payment_status, completed_at,
COALESCE(driver_payout_cents, fare_price) AS payout_cents,
COALESCE(platform_fee_cents, 0) AS fee_cents
FROM rides
WHERE driver_id = ${driverId} AND status = 'completed'
AND completed_at >= CURRENT_DATE
ORDER BY completed_at DESC
`;
// The driver's running balance with the company, across all time rather
// than just today — an unremitted commission doesn't stop mattering at
// midnight. Two directions: cash commission they're holding for us, and
// card payouts we still owe them.
const [balance] = await sql<{
owes_company_cents: number;
owed_to_driver_cents: number;
}>`
SELECT
COALESCE(SUM(platform_fee_cents)
FILTER (WHERE platform_fee_settled_at IS NULL), 0)::int
AS owes_company_cents,
COALESCE(SUM(driver_payout_cents)
FILTER (WHERE driver_payout_settled_at IS NULL), 0)::int
AS owed_to_driver_cents
FROM rides
WHERE driver_id = ${driverId}
AND status = 'completed'
AND payment_status IN ('paid','cash_collected')
`;
// A ride the driver finished recently and hasn't rated. Surfaced as a
// prompt on the dashboard so the rating survives the driver immediately
// accepting their next trip.
const pendingRating = await sql`
SELECT r.ride_id, u.name AS rider_name
FROM rides r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.driver_id = ${driverId}
AND r.status = 'completed'
AND r.completed_at > CURRENT_TIMESTAMP - INTERVAL '1 day'
AND NOT EXISTS (
SELECT 1 FROM ride_ratings rr
WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'driver'
)
ORDER BY r.completed_at DESC
LIMIT 1
`;
const settled = (r: RecentRow) =>
r.payment_status === "paid" || r.payment_status === "cash_collected";
const sumPayout = (rows: typeof recent) =>
rows.reduce((sum, r) => sum + Number(r.payout_cents), 0);
const sumFares = (rows: typeof recent) =>
rows.reduce((sum, r) => sum + Number(r.fare_price), 0);
// Earnings count settled money only, and count the driver's share of it.
// A cash ride the driver marked "not collected" is still an unpaid trip
// and used to land in this headline anyway, so the figure a driver saw and
// the figure they'd be paid against disagreed from day one.
const earnings = sumPayout(recent.filter(settled));
// The platform's cut of the same rides, so the number above is explainable
// rather than mysteriously smaller than the fares they remember charging.
const platformFees = recent
.filter(settled)
.reduce((sum, r) => sum + Number(r.fee_cents), 0);
// Cash the driver has taken in hand today — the full fare, because that's
// the physical money in their pocket, not their share of it. This is the
// figure they'll be reconciled against, and the platform's cut of it is
// owed back.
const cashCollected = sumFares(
recent.filter((r) => r.payment_status === "cash_collected"),
);
// Fares that were never collected. Surfaced rather than hidden so an
// unpaid trip is visible to the driver on the day it happened.
const cashOwed = sumFares(recent.filter((r) => r.payment_status === "cash"));
// A driver deciding whether to take a ride cares what they'll be paid, not
// what the rider is charged. The split isn't stored until completion, so
// it's computed here from the same helper that stamps it later — the two
// can't disagree, and the driver is never shown a number they won't get.
const withPayout = <T extends { fare_price: number }>(row: T) => ({
...row,
payout_cents: splitFare(Number(row.fare_price)).driverPayoutCents,
});
return Response.json({
data: {
// The server's clock, so the client can draw a request countdown that
// matches the TTL dispatch actually enforces. Without it a phone whose
// clock is a few seconds out shows a timer that expires early or late.
now: new Date().toISOString(),
requests: requests.map(withPayout),
active: active[0]
? withPayout(active[0] as unknown as ActiveRide)
: null,
recent,
earnings,
platform_fees: platformFees,
cash_collected: cashCollected,
cash_owed: cashOwed,
owes_company: Number(balance?.owes_company_cents ?? 0),
owed_to_driver: Number(balance?.owed_to_driver_cents ?? 0),
pending_rating:
(pendingRating[0] as unknown as PendingRatingRow | undefined) ?? null,
},
});
} catch (error) {
console.error("[DRIVER_RIDES]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
type OpenRequestRow = {
ride_id: number;
origin_address: string;
destination_address: string;
origin_latitude: number;
origin_longitude: number;
destination_latitude: number;
destination_longitude: number;
ride_time: number;
fare_price: number;
service: string;
created_at: string;
rider_name: string | null;
rider_rating: number | null;
/** The id of this driver's live offer on the request, or null. */
my_offer_id: number | null;
/** How many drivers are competing for it, this one included. */
offer_count: number;
/** Metres from the driver's last position to the pickup. */
pickup_distance_m?: number;
/** The driver's share of the fare, computed per request. */
payout_cents?: number;
};
type ActiveRide = {
/** The driver's share of the fare, computed per request. */
payout_cents?: number;
ride_id: number;
status: string;
service: string;
payment_status: string;
origin_address: string;
destination_address: string;
origin_latitude: number;
origin_longitude: number;
destination_latitude: number;
destination_longitude: number;
ride_time: number;
fare_price: number;
created_at: string;
arrived_at: string | null;
rider_name: string | null;
rider_rating: number | null;
};
type RecentRow = {
ride_id: number;
fare_price: number;
payout_cents: number;
fee_cents: number;
service: string;
payment_status: string;
completed_at: string;
};
type PendingRatingRow = {
ride_id: number;
rider_name: string | null;
};