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 = { 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 }); } }