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>
101 lines
3.4 KiB
TypeScript
101 lines
3.4 KiB
TypeScript
import { requireAuth } from "@/lib/jwt";
|
|
import { requireDriverProfile } from "@/lib/driver";
|
|
import { sql } from "@/lib/db";
|
|
import { CONNECTED_STATUS_ARRAY } from "@/lib/ride-lifecycle";
|
|
|
|
// GET — the Chat tab's default view. Returns the caller's currently-active
|
|
// ride that has the other party assigned (so a conversation can open), or
|
|
// null when there's nothing to chat about. The caller is auto-detected: a
|
|
// rider by default, or a driver when ?role=driver is passed (the driver app
|
|
// hits this with role=driver since the same account could in principle be a
|
|
// rider elsewhere).
|
|
//
|
|
// We try the rider path first. If the signed-in user owns an active ride
|
|
// with a driver assigned, that's their conversation. Otherwise, if they have
|
|
// a driver profile, we look for a ride they're assigned to. Either way the
|
|
// response carries the caller's `role` and a `peer` summary for the header.
|
|
|
|
type ActiveRideRow = {
|
|
ride_id: number;
|
|
status: string;
|
|
role: "rider" | "driver";
|
|
peer_name: string;
|
|
peer_avatar: string | null;
|
|
peer_service: string | null;
|
|
peer_car_model: string | null;
|
|
};
|
|
|
|
// The client (chat.tsx, call.tsx) expects `peer` nested per the ChatActiveRide
|
|
// type, not the flat peer_* columns the query returns.
|
|
const toActiveRide = (row: ActiveRideRow) => ({
|
|
ride_id: row.ride_id,
|
|
status: row.status,
|
|
role: row.role,
|
|
peer: {
|
|
name: row.peer_name,
|
|
avatar: row.peer_avatar,
|
|
service: row.peer_service,
|
|
car_model: row.peer_car_model,
|
|
},
|
|
});
|
|
|
|
export async function GET(req: Request) {
|
|
const auth = requireAuth(req);
|
|
if ("error" in auth) return auth.error;
|
|
|
|
const wantsDriver = new URL(req.url).searchParams.get("role") === "driver";
|
|
|
|
try {
|
|
// Rider path: a ride this user owns that's active and has a driver.
|
|
if (!wantsDriver) {
|
|
const riderRides = await sql<ActiveRideRow>`
|
|
SELECT
|
|
r.ride_id,
|
|
r.status,
|
|
'rider' AS role,
|
|
CONCAT_WS(' ', d.first_name, d.last_name) AS peer_name,
|
|
d.profile_image_url AS peer_avatar,
|
|
d.service AS peer_service,
|
|
d.car_model AS peer_car_model
|
|
FROM rides r
|
|
JOIN drivers d ON d.id = r.driver_id
|
|
WHERE r.user_id = ${auth.userId}
|
|
AND r.status = ANY(${CONNECTED_STATUS_ARRAY}::text[])
|
|
AND r.driver_id IS NOT NULL
|
|
ORDER BY r.created_at DESC
|
|
LIMIT 1
|
|
`;
|
|
if (riderRides[0])
|
|
return Response.json({ data: toActiveRide(riderRides[0]) });
|
|
}
|
|
|
|
// Driver path: a ride this user (as a driver) is assigned to and is active.
|
|
const driver = await requireDriverProfile(req);
|
|
if (!("error" in driver)) {
|
|
const driverRides = await sql<ActiveRideRow>`
|
|
SELECT
|
|
r.ride_id,
|
|
r.status,
|
|
'driver' AS role,
|
|
u.name AS peer_name,
|
|
NULL::text AS peer_avatar,
|
|
r.service AS peer_service,
|
|
NULL::text AS peer_car_model
|
|
FROM rides r
|
|
JOIN users u ON u.id = r.user_id
|
|
WHERE r.driver_id = ${driver.driverId}
|
|
AND r.status = ANY(${CONNECTED_STATUS_ARRAY}::text[])
|
|
ORDER BY r.created_at DESC
|
|
LIMIT 1
|
|
`;
|
|
if (driverRides[0])
|
|
return Response.json({ data: toActiveRide(driverRides[0]) });
|
|
}
|
|
|
|
return Response.json({ data: null });
|
|
} catch (error) {
|
|
console.error("[GET_ACTIVE_CHAT]: ", error);
|
|
return Response.json({ error: "Internal Server Error." }, { status: 500 });
|
|
}
|
|
}
|