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
+117
View File
@@ -0,0 +1,117 @@
import { sql } from "@/lib/db";
import { requireRideParticipant } from "@/lib/ride-participants";
import { refreshDriverRating, refreshRiderRating } from "@/lib/ride-lifecycle";
// Two-way rating on a finished ride: the rider rates the driver, the driver
// rates the rider. Either party may only rate once (the UNIQUE (ride_id,
// rater_type) constraint makes the write an idempotent upsert, so a re-submit
// corrects a mis-tap instead of double-counting), and only after the ride is
// completed — a cancelled ride has nothing to rate.
// GET — both sides' ratings for this ride, so a client can show "you rated
// this ride 5" and (once the other party has rated) what they said.
export async function GET(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const participant = await requireRideParticipant(req, rideId);
if ("error" in participant) return participant.error;
try {
const rows = await sql<{
rater_type: "rider" | "driver";
rating: number;
comment: string | null;
created_at: string;
}>`
SELECT rater_type, rating, comment, created_at
FROM ride_ratings WHERE ride_id = ${rideId}
`;
const mine = rows.find((r) => r.rater_type === participant.role) ?? null;
const theirs = rows.find((r) => r.rater_type !== participant.role) ?? null;
return Response.json({ data: { mine, theirs } });
} catch (error) {
console.error("[GET_RIDE_RATING]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// POST — submit (or correct) this party's rating. Body: { rating: 1..5,
// comment?: string }.
export async function POST(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const participant = await requireRideParticipant(req, rideId);
if ("error" in participant) return participant.error;
let body: { rating?: unknown; comment?: unknown };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const rating = Number(body.rating);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return Response.json(
{ error: "rating must be a whole number from 1 to 5." },
{ status: 400 },
);
}
// Comments are optional and capped — they're shown verbatim in the admin
// portal's ride detail, so an unbounded field is a liability.
const rawComment =
typeof body.comment === "string" ? body.comment.trim() : "";
const comment = rawComment ? rawComment.slice(0, 500) : null;
try {
const rides = await sql<{
status: string;
driver_id: number | null;
user_id: string;
}>`
SELECT status, driver_id, user_id FROM rides WHERE ride_id = ${rideId}
`;
const ride = rides[0];
if (!ride) {
return Response.json({ error: "Ride not found." }, { status: 404 });
}
if (ride.status !== "completed") {
return Response.json(
{ error: "Only a completed ride can be rated." },
{ status: 409 },
);
}
const rows = await sql<{ rating: number; comment: string | null }>`
INSERT INTO ride_ratings (ride_id, rater_type, rating, comment)
VALUES (${rideId}, ${participant.role}, ${rating}, ${comment})
ON CONFLICT (ride_id, rater_type) DO UPDATE
SET rating = EXCLUDED.rating,
comment = EXCLUDED.comment,
updated_at = CURRENT_TIMESTAMP
RETURNING rating, comment
`;
// Fold the new score into the rated party's headline average. Awaited
// rather than fire-and-forget so the client's next read sees it.
if (participant.role === "rider" && ride.driver_id !== null) {
await refreshDriverRating(ride.driver_id);
} else if (participant.role === "driver") {
await refreshRiderRating(ride.user_id);
}
return Response.json({ data: rows[0] }, { status: 201 });
} catch (error) {
console.error("[RATE_RIDE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}