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
+247 -16
View File
@@ -1,6 +1,8 @@
import { requireAuth } from "@/lib/jwt";
import { sql, query } from "@/lib/db";
import { isServiceId, requireDriverProfile } from "@/lib/driver";
import { DRIVER_BUSY_ARRAY } from "@/lib/ride-lifecycle";
import { deleteUpload, isStoredUploadName } from "@/lib/uploads";
import { SERVICES, type ServiceId } from "@/constants/services";
// GET — the signed-in user's own driver profile, or 403 (code: ONBOARD) when
@@ -12,23 +14,54 @@ export async function GET(req: Request) {
const { auth, driverId } = result;
const rows = await sql`
SELECT id, first_name, last_name, profile_image_url, car_image_url,
car_seats, rating, service, online, car_model, user_id
car_seats, rating, rating_count, service, online, car_model, user_id,
approval_status, rejection_reason, submitted_at, reviewed_at,
license_number, license_expiry, plate_number,
license_image_url, id_image_url, vehicle_reg_image_url
FROM drivers WHERE id = ${driverId}
`;
return Response.json({ data: rows[0], userId: auth.userId });
}
// Credentials collected at onboarding. The numbers are typed by the driver —
// usually prefilled from a scan by /(api)/driver/scan, but a scan is only ever
// a suggestion, so they are validated here exactly as if they had been typed
// from scratch. The scans themselves are stored alongside so the reviewer
// checks the numbers against the document rather than taking them on trust.
const trimmed = (v: unknown, max: number): string | null => {
if (typeof v !== "string") return null;
const value = v.trim();
return value.length > 0 && value.length <= max ? value : null;
};
// Expiry is a plain YYYY-MM-DD date and has to still be in the future — an
// expired licence is exactly what vetting exists to catch.
const futureDate = (v: unknown): string | null => {
if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return null;
const date = new Date(`${v}T00:00:00Z`);
if (Number.isNaN(date.getTime()) || date.getTime() <= Date.now()) return null;
return v;
};
// Scans and profile photos are both referenced by the opaque name their
// upload route handed back, and only names in that shape are accepted. A client
// cannot invent one, so it cannot point its profile row at a file it never
// uploaded — and since the name is all that is stored, there is no path here
// for the filesystem to interpret.
const storedName = (v: unknown): string | null =>
isStoredUploadName(v) ? v : null;
// POST — onboarding. A driver-role user creates their one linked drivers row.
// The user must carry role='driver' (set on sign-up / role.tsx) so a rider
// can't silently become a driver by hitting this endpoint.
// The user must carry role='driver' (set on sign-up / role.tsx), and the row is
// created 'pending': it is not matched, not shown to riders, and cannot go
// online until an owner approves it. Role alone has never been a credential.
export async function POST(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const body = await req.json();
const { car_model, car_seats, service, profile_image_url, car_image_url } =
body;
const { car_model, car_seats, service, car_image_url } = body;
// The user must be flagged a driver to onboard a driver profile.
const users = await sql<{ role: string | null; name: string | null }>`
@@ -43,7 +76,9 @@ export async function POST(req: Request) {
if (!isServiceId(service)) {
return Response.json(
{ error: `service must be one of: ${SERVICES.map((s) => s.id).join(", ")}.` },
{
error: `service must be one of: ${SERVICES.map((s) => s.id).join(", ")}.`,
},
{ status: 400 },
);
}
@@ -56,6 +91,66 @@ export async function POST(req: Request) {
);
}
const licenseNumber = trimmed(body.license_number, 60);
const nationalId = trimmed(body.national_id, 60);
const plateNumber = trimmed(body.plate_number, 20);
const licenseExpiry = futureDate(body.license_expiry);
if (!licenseNumber || !nationalId || !plateNumber) {
return Response.json(
{
error:
"Driving licence number, national ID and plate number are required.",
code: "CREDENTIALS_REQUIRED",
},
{ status: 400 },
);
}
if (!licenseExpiry) {
return Response.json(
{
error: "Licence expiry must be a future date (YYYY-MM-DD).",
code: "LICENSE_EXPIRED",
},
{ status: 400 },
);
}
const licenseDocument = storedName(body.license_document);
const idDocument = storedName(body.id_document);
const vehicleRegDocument = storedName(body.vehicle_reg_document);
const profilePhoto = storedName(body.profile_photo);
// The licence scan is the one document review cannot do without: it is
// what the reviewer checks the typed licence number and expiry against.
// The ID card and vehicle registration help but are not required, so a
// driver whose registration is with the car's owner can still onboard.
if (!licenseDocument) {
return Response.json(
{
error: "Scan your driving licence before submitting.",
code: "LICENSE_SCAN_REQUIRED",
},
{ status: 400 },
);
}
// The profile photo is what a rider sees next to a driver's name when
// choosing between offers, and it is how they check that the person who
// pulls up is the person the app sent. A driver with no photo would be an
// anonymous row in that list, so it is collected up front rather than left
// as a profile nicety somebody gets round to.
if (!profilePhoto) {
return Response.json(
{
error: "Add a profile photo before submitting.",
code: "PHOTO_REQUIRED",
},
{ status: 400 },
);
}
const [firstName, ...rest] = (users[0].name ?? "").split(" ");
// One profile per driver user. The partial unique index on user_id
@@ -64,20 +159,32 @@ export async function POST(req: Request) {
const rows = await sql`
INSERT INTO drivers (
user_id, first_name, last_name, profile_image_url, car_image_url,
car_seats, rating, service, car_model, online
car_seats, rating, service, car_model, online,
approval_status, license_number, license_expiry, national_id,
plate_number, submitted_at,
license_image_url, id_image_url, vehicle_reg_image_url
) VALUES (
${auth.userId},
${firstName || "Driver"},
${rest.join(" ") || ""},
${profile_image_url ?? null},
${profilePhoto},
${car_image_url ?? null},
${seats},
5.0,
${service as ServiceId},
${car_model ?? null},
FALSE
FALSE,
'pending',
${licenseNumber},
${licenseExpiry},
${nationalId},
${plateNumber},
CURRENT_TIMESTAMP,
${licenseDocument},
${idDocument},
${vehicleRegDocument}
)
RETURNING id, service, online
RETURNING id, service, online, approval_status
`;
return Response.json({ data: rows[0] }, { status: 201 });
} catch (error) {
@@ -112,8 +219,130 @@ export async function PATCH(req: Request) {
values.push(value);
};
// A profile that hasn't been cleared cannot go online, and therefore can
// never be matched. This is the gate the whole vetting flow rests on —
// everything else (dispatch filters, the rider map) is defence in depth.
if (online === true && result.approvalStatus !== "approved") {
return Response.json(
{
error: "Your driver account is not approved yet.",
code: "NOT_APPROVED",
approval_status: result.approvalStatus,
},
{ status: 403 },
);
}
// A rejected driver may fix their details and resubmit, which puts them
// back in the review queue rather than silently leaving them stuck. A
// rejection is often about the scan rather than the numbers ("the photo is
// unreadable"), so a fresh scan on its own counts as a resubmission.
const resubmitted =
result.approvalStatus === "rejected" &&
(body.license_number !== undefined ||
body.national_id !== undefined ||
body.plate_number !== undefined ||
body.license_expiry !== undefined ||
body.license_document !== undefined ||
body.id_document !== undefined ||
body.vehicle_reg_document !== undefined);
// Scans replaced by this resubmission, deleted once the row actually
// points at the new ones — an orphaned file is tidier than a row pointing
// at a document that is no longer on disk.
const superseded: string[] = [];
if (resubmitted) {
const licenseNumber = trimmed(body.license_number, 60);
const nationalId = trimmed(body.national_id, 60);
const plateNumber = trimmed(body.plate_number, 20);
const licenseExpiry = futureDate(body.license_expiry);
if (!licenseNumber || !nationalId || !plateNumber || !licenseExpiry) {
return Response.json(
{
error:
"Licence number, expiry (future date), national ID and plate number are all required to resubmit.",
code: "CREDENTIALS_REQUIRED",
},
{ status: 400 },
);
}
// Only documents the driver re-scanned are sent; anything omitted keeps
// the scan already on file.
const replacements: Record<string, string | null> = {
license_image_url: storedName(body.license_document),
id_image_url: storedName(body.id_document),
vehicle_reg_image_url: storedName(body.vehicle_reg_document),
};
const existing = await sql<{
license_image_url: string | null;
id_image_url: string | null;
vehicle_reg_image_url: string | null;
}>`
SELECT license_image_url, id_image_url, vehicle_reg_image_url
FROM drivers WHERE id = ${result.driverId}
`;
// Same rule as onboarding, applied to the state the row will be left in:
// a driver may resubmit without re-scanning, but not end up with no
// licence scan at all.
if (!(replacements.license_image_url ?? existing[0]?.license_image_url)) {
return Response.json(
{
error: "Scan your driving licence before resubmitting.",
code: "LICENSE_SCAN_REQUIRED",
},
{ status: 400 },
);
}
for (const [column, name] of Object.entries(replacements)) {
if (!name) continue;
const previous = existing[0]?.[column as keyof (typeof existing)[0]];
if (previous && previous !== name) superseded.push(previous);
push(column, name);
}
push("license_number", licenseNumber);
push("license_expiry", licenseExpiry);
push("national_id", nationalId);
push("plate_number", plateNumber);
push("approval_status", "pending");
push("rejection_reason", null);
updates.push(`submitted_at = CURRENT_TIMESTAMP`);
}
// Going offline mid-ride would strand the rider: dispatch stops seeing the
// driver, the location heartbeat stops, and the rider's map freezes on a
// car that never arrives — with no way to re-dispatch, since the ride is
// already assigned. Finish or cancel the ride first.
if (online === false) {
const active = await sql<{ ride_id: number }>`
SELECT ride_id FROM rides
WHERE driver_id = ${result.driverId}
AND status = ANY(${DRIVER_BUSY_ARRAY}::text[])
LIMIT 1
`;
if (active[0]) {
return Response.json(
{
error: "Finish or cancel your current ride before going offline.",
code: "RIDE_IN_PROGRESS",
ride_id: active[0].ride_id,
},
{ status: 409 },
);
}
}
if (typeof online === "boolean") push("online", online);
if (typeof car_model === "string" || car_model === null) push("car_model", car_model);
if (typeof car_model === "string" || car_model === null)
push("car_model", car_model);
if (car_seats !== undefined) {
const seats = Number(car_seats);
if (!Number.isInteger(seats) || seats < 1 || seats > 8) {
@@ -126,10 +355,7 @@ export async function PATCH(req: Request) {
}
if (service !== undefined) {
if (!isServiceId(service)) {
return Response.json(
{ error: "Invalid service." },
{ status: 400 },
);
return Response.json({ error: "Invalid service." }, { status: 400 });
}
push("service", service as string);
}
@@ -143,9 +369,14 @@ export async function PATCH(req: Request) {
`UPDATE drivers SET ${updates.join(", ")} WHERE id = $${idx} RETURNING *`,
values,
);
// Nothing references the old scans now, and they are identity documents —
// don't keep them around a moment longer than the row does.
await Promise.all(superseded.map((name) => deleteUpload(name, "document")));
return Response.json({ data: rows[0] });
} catch (error) {
console.error("[DRIVER_PROFILE_PATCH]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
}