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>
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
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
|
|
// they haven't onboarded yet. The client uses the code to show the form.
|
|
export async function GET(req: Request) {
|
|
const result = await requireDriverProfile(req);
|
|
if ("error" in result) return result.error;
|
|
|
|
const { auth, driverId } = result;
|
|
const rows = await sql`
|
|
SELECT id, first_name, last_name, profile_image_url, car_image_url,
|
|
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), 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, 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 }>`
|
|
SELECT role, name FROM users WHERE id = ${auth.userId}
|
|
`;
|
|
if (!users[0] || users[0].role !== "driver") {
|
|
return Response.json(
|
|
{ error: "Only driver accounts can onboard a driver profile." },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
|
|
if (!isServiceId(service)) {
|
|
return Response.json(
|
|
{
|
|
error: `service must be one of: ${SERVICES.map((s) => s.id).join(", ")}.`,
|
|
},
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const seats = Number(car_seats);
|
|
if (!Number.isInteger(seats) || seats < 1 || seats > 8) {
|
|
return Response.json(
|
|
{ error: "car_seats must be a whole number between 1 and 8." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
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
|
|
// guarantees this at the DB level; surface a clean 409 on collision.
|
|
try {
|
|
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,
|
|
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(" ") || ""},
|
|
${profilePhoto},
|
|
${car_image_url ?? null},
|
|
${seats},
|
|
5.0,
|
|
${service as ServiceId},
|
|
${car_model ?? null},
|
|
FALSE,
|
|
'pending',
|
|
${licenseNumber},
|
|
${licenseExpiry},
|
|
${nationalId},
|
|
${plateNumber},
|
|
CURRENT_TIMESTAMP,
|
|
${licenseDocument},
|
|
${idDocument},
|
|
${vehicleRegDocument}
|
|
)
|
|
RETURNING id, service, online, approval_status
|
|
`;
|
|
return Response.json({ data: rows[0] }, { status: 201 });
|
|
} catch (error) {
|
|
if ((error as { code?: string }).code === "23505") {
|
|
return Response.json(
|
|
{ error: "Driver profile already exists." },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
} catch (error) {
|
|
console.error("[DRIVER_PROFILE_POST]: ", error);
|
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// PATCH — update mutable profile fields, most importantly the online toggle.
|
|
export async function PATCH(req: Request) {
|
|
const result = await requireDriverProfile(req);
|
|
if ("error" in result) return result.error;
|
|
|
|
try {
|
|
const body = await req.json();
|
|
const { online, car_model, car_seats, service } = body;
|
|
|
|
const updates: string[] = [];
|
|
const values: (string | number | boolean | null)[] = [];
|
|
let idx = 1;
|
|
const push = (col: string, value: string | number | boolean | null) => {
|
|
updates.push(`${col} = $${idx++}`);
|
|
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 (car_seats !== undefined) {
|
|
const seats = Number(car_seats);
|
|
if (!Number.isInteger(seats) || seats < 1 || seats > 8) {
|
|
return Response.json(
|
|
{ error: "car_seats must be a whole number between 1 and 8." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
push("car_seats", seats);
|
|
}
|
|
if (service !== undefined) {
|
|
if (!isServiceId(service)) {
|
|
return Response.json({ error: "Invalid service." }, { status: 400 });
|
|
}
|
|
push("service", service as string);
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
return Response.json({ error: "No fields to update." }, { status: 400 });
|
|
}
|
|
|
|
values.push(result.driverId);
|
|
const rows = await query(
|
|
`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 });
|
|
}
|
|
}
|