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:
co-authored by
Claude Opus 5
parent
1d84003e0a
commit
8807ff41c5
+247
-16
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user