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
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user