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
+202
View File
@@ -0,0 +1,202 @@
import { requireAuth } from "@/lib/jwt";
import { transaction } from "@/lib/db";
import { getOrder, consumeOrderForRide } from "@/lib/payment-orders";
import { sendPushToDriver } from "@/lib/push";
import { DRIVER_BUSY_ARRAY, generatePickupCode } from "@/lib/ride-lifecycle";
// POST — the rider picks one of the drivers who offered, and pays.
//
// { offer_id, payment_method: 'cash' }
// { offer_id, payment_method: 'card', payment_order_id }
//
// This is the single moment a ride is assigned. Everything that has to be true
// at once — the request is still open, this offer is still live, the driver is
// still free, and (for card) a paid order of the right amount exists and has
// not been spent — is checked inside one transaction, so a rider and a
// disappearing driver can't half-complete it.
//
// The card order is consumed here rather than earlier for the same reason: if
// the pick fails because the driver just took another job, the transaction
// rolls back with the order still 'paid', and the rider can pick a different
// driver with the money they already put down instead of paying twice.
export async function POST(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
let body: {
offer_id?: number;
payment_method?: string;
payment_order_id?: string;
};
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const offerId = Number(body.offer_id);
if (!Number.isInteger(offerId)) {
return Response.json({ error: "offer_id is required." }, { status: 400 });
}
const method = body.payment_method;
if (method !== "cash" && method !== "card") {
return Response.json({ error: "Invalid payment method." }, { status: 400 });
}
try {
// Card: everything about the order is verified before the transaction
// opens, so the only thing left to do inside it is spend it.
if (method === "card") {
if (!body.payment_order_id) {
return Response.json(
{ error: "Missing payment order id." },
{ status: 400 },
);
}
const order = await getOrder(body.payment_order_id);
if (!order)
return Response.json(
{ error: "Payment order not found." },
{ status: 404 },
);
if (order.user_id !== auth.userId)
return Response.json({ error: "Unauthorized." }, { status: 403 });
if (order.status !== "paid")
return Response.json(
{ error: "Payment not verified." },
{ status: 400 },
);
}
const picked = await transaction<
| { driverId: number; fare: number }
| "gone"
| "amount_mismatch"
| "order_spent"
>(async (tx) => {
// Lock the request. A second tap on a second driver serialises behind
// this and finds the ride already assigned.
const rides = await tx<{
status: string;
fare_price: number;
origin_address: string;
}>`
SELECT status, fare_price, origin_address
FROM rides
WHERE ride_id = ${rideId} AND user_id = ${auth.userId}
FOR UPDATE
`;
const ride = rides[0];
if (!ride || ride.status !== "requested") return "gone";
const offers = await tx<{ driver_id: number }>`
SELECT driver_id FROM ride_offers
WHERE id = ${offerId} AND ride_id = ${rideId} AND status = 'offered'
`;
const offer = offers[0];
if (!offer) return "gone";
// The driver may have been picked by somebody else in the seconds the
// rider spent deciding. Their other ride is the authority, not the offer.
const busy = await tx<{ n: number }>`
SELECT COUNT(*)::int AS n FROM rides
WHERE driver_id = ${offer.driver_id}
AND status = ANY(${DRIVER_BUSY_ARRAY}::text[])
`;
if ((busy[0]?.n ?? 0) > 0) return "gone";
let paymentStatus = "cash";
let orderId: string | null = null;
if (method === "card") {
const order = await getOrder(body.payment_order_id!);
if (!order) return "gone";
// Re-checked against the row we just locked: the fare is authoritative
// here, not the number the client did its arithmetic with.
if (order.amount_cents !== Number(ride.fare_price))
return "amount_mismatch";
const consumed = await consumeOrderForRide(
body.payment_order_id!,
auth.userId,
tx,
);
if (!consumed) return "order_spent";
paymentStatus = "paid";
orderId = body.payment_order_id!;
}
// Assign. The status='requested' guard is what stops a double-submit
// from reassigning a ride that already has a driver.
const assigned = await tx<{ ride_id: number }>`
UPDATE rides
SET status = 'accepted',
driver_id = ${offer.driver_id},
accepted_at = CURRENT_TIMESTAMP,
payment_status = ${paymentStatus},
payment_order_id = COALESCE(${orderId}, payment_order_id),
pickup_code = COALESCE(pickup_code, ${generatePickupCode()})
WHERE ride_id = ${rideId} AND status = 'requested'
RETURNING ride_id
`;
if (!assigned[0]) return "gone";
await tx`
UPDATE ride_offers
SET status = 'accepted', responded_at = CURRENT_TIMESTAMP
WHERE id = ${offerId}
`;
// Everyone else who volunteered is released in the same breath, so no
// driver is left with a card for a job that is already someone else's.
await tx`
UPDATE ride_offers
SET status = 'passed', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND id <> ${offerId} AND status = 'offered'
`;
return { driverId: offer.driver_id, fare: Number(ride.fare_price) };
});
if (picked === "amount_mismatch") {
return Response.json(
{ error: "Payment does not match this ride." },
{ status: 400 },
);
}
if (picked === "order_spent") {
return Response.json(
{ error: "That payment has already been used." },
{ status: 409 },
);
}
if (picked === "gone") {
return Response.json(
{
error: "That driver is no longer available.",
code: "OFFER_UNAVAILABLE",
},
{ status: 409 },
);
}
void sendPushToDriver(picked.driverId, {
title: "You got the ride",
body: "The rider picked you. Head to the pickup point.",
data: { type: "ride_assigned", rideId },
});
return Response.json({ data: { status: "accepted" } });
} catch (error) {
console.error("[RIDE_SELECT]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}