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
+272
View File
@@ -0,0 +1,272 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { query, sql, type SqlValue } from "@/lib/db";
import { isSettlementSide } from "@/lib/settlement";
// Recording that money actually changed hands.
//
// Two different real-world events, one endpoint:
//
// side='platform_fee' — a driver handed the company its cut of the cash
// fares they collected. Clears what THEY owe US.
// side='driver_payout' — the company paid a driver for the card rides they
// drove. Clears what WE owe THEM.
//
// Settling is deliberately idempotent and one-way: a row already stamped is
// skipped rather than re-stamped, so a double-tap on "mark paid" can't rewrite
// when the money moved. Reversing a mistake is a separate, explicit action
// (`undo: true`) so it can't happen by accident.
export async function OPTIONS(request: Request) {
return preflight(request);
}
type Body = {
side?: string;
/** Settle everything outstanding for this driver. */
driver_id?: number;
/** Or settle these specific rides. */
ride_ids?: number[];
/** Free-text reference: a transfer id, a receipt number, "cash in office". */
note?: string;
/** Reverse a settlement recorded in error. */
undo?: boolean;
};
export async function POST(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(request, auth.error);
let body: Body;
try {
body = (await request.json()) as Body;
} catch {
return withCors(
request,
Response.json({ error: "Invalid JSON body." }, { status: 400 }),
);
}
if (!isSettlementSide(body.side)) {
return withCors(
request,
Response.json(
{ error: "side must be 'platform_fee' or 'driver_payout'." },
{ status: 400 },
),
);
}
const rideIds = Array.isArray(body.ride_ids)
? body.ride_ids.map(Number).filter(Number.isInteger)
: [];
const driverId = Number(body.driver_id);
const hasDriver = Number.isInteger(driverId);
if (!hasDriver && rideIds.length === 0) {
return withCors(
request,
Response.json(
{ error: "Provide either driver_id or a non-empty ride_ids array." },
{ status: 400 },
),
);
}
// Column names come from the validated `side`, never from raw input.
const column =
body.side === "platform_fee"
? "platform_fee_settled_at"
: "driver_payout_settled_at";
const amountColumn =
body.side === "platform_fee" ? "platform_fee_cents" : "driver_payout_cents";
// Only one payment type produces a transfer that a human has to make, and
// it's the opposite one for each side:
//
// platform_fee — owed only on CASH rides. On a card ride the company
// already holds its fee; there is nothing to collect.
// driver_payout — owed only on CARD rides. On a cash ride the driver
// already has their share in hand.
//
// Scoping to that payment type is what keeps an undo honest. Without it,
// reversing one collected cash commission also cleared the automatically
// settled fees on that driver's card rides, and the ledger then told the
// operator to go and collect money the company had never been without.
const payableStatus =
body.side === "platform_fee" ? "cash_collected" : "paid";
const undo = body.undo === true;
const params: SqlValue[] = [payableStatus];
const conds: string[] = [
"status = 'completed'",
// Only money that actually materialised can be settled: an uncollected
// cash fare owes nobody anything and must never appear as settled.
"payment_status = $1",
// Idempotent in both directions — already-settled rows are skipped when
// settling, already-clear rows when undoing.
undo ? `${column} IS NOT NULL` : `${column} IS NULL`,
];
if (hasDriver) {
params.push(driverId);
conds.push(`driver_id = $${params.length}`);
}
if (rideIds.length > 0) {
params.push(`{${rideIds.join(",")}}`);
conds.push(`ride_id = ANY($${params.length}::int[])`);
}
try {
params.push(body.note?.trim() ? body.note.trim().slice(0, 500) : null);
const noteParam = params.length;
const rows = await query<{ ride_id: number; amount: number }>(
`UPDATE rides
SET ${column} = ${undo ? "NULL" : "CURRENT_TIMESTAMP"},
settlement_note = COALESCE($${noteParam}, settlement_note)
WHERE ${conds.join(" AND ")}
RETURNING ride_id, COALESCE(${amountColumn}, 0) AS amount`,
params,
);
const totalCents = rows.reduce((sum, r) => sum + Number(r.amount), 0);
return withCors(
request,
Response.json({
data: {
side: body.side,
undone: undo,
rides: rows.length,
ride_ids: rows.map((r) => r.ride_id),
total_cents: totalCents,
},
}),
);
} catch (error) {
console.error("[ADMIN_SETTLE]: ", error);
return withCors(
request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
}
// GET — the outstanding ledger.
//
// Without arguments: one row per driver, answering the two questions an
// operator has at the end of a shift — which drivers owe us cash commission,
// and which drivers are we behind on paying.
//
// With ?driver_id=N&side=platform_fee: the individual rides making up that
// balance, so a part-payment can be recorded against the exact trips it
// covers. A driver handing over three of yesterday's five fares is a normal
// thing to happen, and settling all five because the UI only offered
// all-or-nothing would put the ledger out of step with the cash.
export async function GET(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(request, auth.error);
try {
const url = new URL(request.url);
const driverParam = Number(url.searchParams.get("driver_id"));
const sideParam = url.searchParams.get("side");
if (Number.isInteger(driverParam) && sideParam !== null) {
if (!isSettlementSide(sideParam)) {
return withCors(
request,
Response.json(
{ error: "side must be 'platform_fee' or 'driver_payout'." },
{ status: 400 },
),
);
}
// Mirrors the POST handler's rules exactly: only the payment type that
// actually leaves a transfer outstanding for this side is listed, so the
// picker can never show a ride that settling would refuse to touch.
const settledColumn =
sideParam === "platform_fee"
? "platform_fee_settled_at"
: "driver_payout_settled_at";
const amountColumn =
sideParam === "platform_fee"
? "platform_fee_cents"
: "driver_payout_cents";
const payableStatus =
sideParam === "platform_fee" ? "cash_collected" : "paid";
const rides = await query<{
ride_id: number;
amount_cents: number;
fare_price: number;
origin_address: string;
destination_address: string;
completed_at: string;
}>(
`SELECT ride_id,
COALESCE(${amountColumn}, 0) AS amount_cents,
fare_price, origin_address, destination_address, completed_at
FROM rides
WHERE driver_id = $1
AND status = 'completed'
AND payment_status = $2
AND ${settledColumn} IS NULL
ORDER BY completed_at DESC`,
[driverParam, payableStatus],
);
return withCors(
request,
Response.json({
data: {
side: sideParam,
driver_id: driverParam,
rides,
total_cents: rides.reduce(
(sum, r) => sum + Number(r.amount_cents),
0,
),
},
}),
);
}
const rows = await sql<{
driver_id: number;
name: string;
owes_company_cents: number;
owed_to_driver_cents: number;
unsettled_rides: number;
}>`
SELECT
d.id AS driver_id,
TRIM(COALESCE(d.first_name,'') || ' ' || COALESCE(d.last_name,'')) AS name,
COALESCE(SUM(r.platform_fee_cents)
FILTER (WHERE r.platform_fee_settled_at IS NULL), 0)::int
AS owes_company_cents,
COALESCE(SUM(r.driver_payout_cents)
FILTER (WHERE r.driver_payout_settled_at IS NULL), 0)::int
AS owed_to_driver_cents,
COUNT(*)::int AS unsettled_rides
FROM drivers d
JOIN rides r ON r.driver_id = d.id
WHERE r.status = 'completed'
AND r.payment_status IN ('paid','cash_collected')
AND (r.platform_fee_settled_at IS NULL
OR r.driver_payout_settled_at IS NULL)
GROUP BY d.id, d.first_name, d.last_name
ORDER BY owes_company_cents DESC, owed_to_driver_cents DESC
`;
return withCors(request, Response.json({ data: rows }));
} catch (error) {
console.error("[ADMIN_SETTLE_GET]: ", error);
return withCors(
request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
}