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>
203 lines
6.8 KiB
TypeScript
203 lines
6.8 KiB
TypeScript
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 });
|
|
}
|
|
}
|