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
+9 -9
View File
@@ -1,13 +1,13 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { sql } from "@/lib/db";
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function GET(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const rows = await sql`
@@ -22,10 +22,10 @@ export async function GET(request: Request) {
ORDER BY d.id
`;
return withCors(Response.json({ data: rows }));
return withCors(request, Response.json({ data: rows }));
} catch (error) {
console.error("[ADMIN_DRIVERS]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
@@ -42,13 +42,13 @@ type DriverBody = {
export async function POST(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const body = (await request.json()) as DriverBody;
if (!body.first_name?.trim() || !body.last_name?.trim()) {
return withCors(
return withCors(request,
Response.json(
{ error: "first_name and last_name are required." },
{ status: 400 },
@@ -69,10 +69,10 @@ export async function POST(request: Request) {
RETURNING *
`;
return withCors(Response.json({ data: driver }, { status: 201 }));
return withCors(request, Response.json({ data: driver }, { status: 201 }));
} catch (error) {
console.error("[ADMIN_DRIVER_CREATE]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+69 -12
View File
@@ -1,8 +1,9 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { sql } from "@/lib/db";
import { isApprovalStatus } from "@/lib/driver";
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
type DriverBody = {
@@ -12,15 +13,53 @@ type DriverBody = {
car_image_url?: string;
car_seats?: number;
rating?: number;
/** Vetting decision: 'approved' | 'rejected' | 'suspended' | 'pending'. */
approval_status?: string;
/** Shown to the driver when the decision is 'rejected'. */
rejection_reason?: string;
};
export async function PATCH(request: Request, { id }: { id: string }) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const body = (await request.json()) as DriverBody;
// Vetting decision. Anything other than 'approved' also forces the driver
// offline in the same statement: a driver who is suspended mid-shift must
// stop receiving offers immediately, not at their next toggle.
let approval: string | null = null;
if (body.approval_status !== undefined) {
if (!isApprovalStatus(body.approval_status)) {
return withCors(
request,
Response.json(
{
error:
"approval_status must be pending, approved, rejected or suspended.",
},
{ status: 400 },
),
);
}
if (body.approval_status === "rejected" && !body.rejection_reason?.trim()) {
return withCors(
request,
Response.json(
{ error: "A rejection needs a reason the driver can act on." },
{ status: 400 },
),
);
}
approval = body.approval_status;
}
const rejectionReason =
approval === "approved" ? null : (body.rejection_reason?.trim() ?? null);
const rows = await sql`
UPDATE drivers SET
first_name = COALESCE(${body.first_name ?? null}, first_name),
@@ -28,21 +67,39 @@ export async function PATCH(request: Request, { id }: { id: string }) {
profile_image_url = COALESCE(${body.profile_image_url ?? null}, profile_image_url),
car_image_url = COALESCE(${body.car_image_url ?? null}, car_image_url),
car_seats = COALESCE(${body.car_seats ?? null}, car_seats),
rating = COALESCE(${body.rating ?? null}, rating)
rating = COALESCE(${body.rating ?? null}, rating),
approval_status = COALESCE(${approval}, approval_status),
rejection_reason = CASE
WHEN ${approval}::text IS NULL THEN rejection_reason
ELSE ${rejectionReason}
END,
reviewed_at = CASE
WHEN ${approval}::text IS NULL THEN reviewed_at
ELSE CURRENT_TIMESTAMP
END,
reviewed_by = CASE
WHEN ${approval}::text IS NULL THEN reviewed_by
ELSE ${auth.userId}::uuid
END,
online = CASE
WHEN ${approval}::text IS NOT NULL AND ${approval}::text <> 'approved'
THEN FALSE
ELSE online
END
WHERE id = ${id}
RETURNING *
`;
if (!rows[0]) {
return withCors(
return withCors(request,
Response.json({ error: "Driver not found." }, { status: 404 }),
);
}
return withCors(Response.json({ data: rows[0] }));
return withCors(request, Response.json({ data: rows[0] }));
} catch (error) {
console.error("[ADMIN_DRIVER_PATCH]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
@@ -50,7 +107,7 @@ export async function PATCH(request: Request, { id }: { id: string }) {
export async function DELETE(request: Request, { id }: { id: string }) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const used = await sql<{ n: number }>`
@@ -58,7 +115,7 @@ export async function DELETE(request: Request, { id }: { id: string }) {
`;
if (used[0].n > 0) {
return withCors(
return withCors(request,
Response.json(
{ error: "Driver has recorded rides and cannot be deleted." },
{ status: 409 },
@@ -71,15 +128,15 @@ export async function DELETE(request: Request, { id }: { id: string }) {
`;
if (!rows[0]) {
return withCors(
return withCors(request,
Response.json({ error: "Driver not found." }, { status: 404 }),
);
}
return withCors(Response.json({ data: rows[0] }));
return withCors(request, Response.json({ data: rows[0] }));
} catch (error) {
console.error("[ADMIN_DRIVER_DELETE]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+40 -10
View File
@@ -1,8 +1,19 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { query, type SqlValue } from "@/lib/db";
import { RIDE_STATUSES as LIFECYCLE_STATUSES } from "@/lib/ride-lifecycle";
const PAGE_SIZE = 25;
// Lowercased for comparison against the `status` query param.
const RIDE_STATUSES: readonly string[] = LIFECYCLE_STATUSES;
// LEFT JOIN on drivers, deliberately.
//
// This was an INNER JOIN, which meant every ride without a driver was missing
// from the admin list entirely — a rider cancelling before a match, or a
// request that expired with nobody available, simply never appeared. Those are
// exactly the rides an operator needs to see: they're the ones that went
// wrong.
const SELECT_RIDES = `
SELECT
r.ride_id,
@@ -11,26 +22,36 @@ const SELECT_RIDES = `
r.ride_time,
r.fare_price,
r.payment_status,
r.status,
r.cancelled_by,
r.cancellation_reason,
r.platform_fee_cents,
r.driver_payout_cents,
r.commission_rate,
r.platform_fee_settled_at,
r.driver_payout_settled_at,
r.settlement_note,
r.created_at,
r.completed_at,
u.id AS user_id,
u.email AS user_email,
json_build_object(
CASE WHEN d.id IS NULL THEN NULL ELSE json_build_object(
'driver_id', d.id,
'name', d.first_name || ' ' || d.last_name,
'rating', d.rating
) AS driver
) END AS driver
FROM rides r
INNER JOIN drivers d ON d.id = r.driver_id
LEFT JOIN drivers d ON d.id = r.driver_id
INNER JOIN users u ON u.id = r.user_id
`;
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function GET(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const url = new URL(request.url);
@@ -41,9 +62,18 @@ export async function GET(request: Request) {
const conds: string[] = [];
const params: SqlValue[] = [];
// `status` filters the ride's own lifecycle state when it names one, and
// falls back to the payment status otherwise — so the existing "paid" /
// "cash" filters keep working while "cancelled" and "completed" become
// filterable too, which is what an operator actually reaches for.
if (status) {
params.push(status);
conds.push(`LOWER(r.payment_status) = $${params.length}`);
const n = params.length;
conds.push(
RIDE_STATUSES.includes(status)
? `LOWER(r.status) = $${n}`
: `LOWER(r.payment_status) = $${n}`,
);
}
if (q) {
@@ -60,7 +90,7 @@ export async function GET(request: Request) {
const [{ count }] = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count
FROM rides r
INNER JOIN drivers d ON d.id = r.driver_id
LEFT JOIN drivers d ON d.id = r.driver_id
INNER JOIN users u ON u.id = r.user_id${where}`,
params,
);
@@ -72,7 +102,7 @@ export async function GET(request: Request) {
[...params, PAGE_SIZE, (page - 1) * PAGE_SIZE],
);
return withCors(
return withCors(request,
Response.json({
data: rows,
total: count,
@@ -83,7 +113,7 @@ export async function GET(request: Request) {
);
} catch (error) {
console.error("[ADMIN_RIDES]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+272
View File
@@ -0,0 +1,272 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { query, sql, type SqlValue } from "@/lib/db";
import { isSettlementSide } from "@/lib/settlement";
// Recording that money actually changed hands.
//
// Two different real-world events, one endpoint:
//
// side='platform_fee' — a driver handed the company its cut of the cash
// fares they collected. Clears what THEY owe US.
// side='driver_payout' — the company paid a driver for the card rides they
// drove. Clears what WE owe THEM.
//
// Settling is deliberately idempotent and one-way: a row already stamped is
// skipped rather than re-stamped, so a double-tap on "mark paid" can't rewrite
// when the money moved. Reversing a mistake is a separate, explicit action
// (`undo: true`) so it can't happen by accident.
export async function OPTIONS(request: Request) {
return preflight(request);
}
type Body = {
side?: string;
/** Settle everything outstanding for this driver. */
driver_id?: number;
/** Or settle these specific rides. */
ride_ids?: number[];
/** Free-text reference: a transfer id, a receipt number, "cash in office". */
note?: string;
/** Reverse a settlement recorded in error. */
undo?: boolean;
};
export async function POST(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(request, auth.error);
let body: Body;
try {
body = (await request.json()) as Body;
} catch {
return withCors(
request,
Response.json({ error: "Invalid JSON body." }, { status: 400 }),
);
}
if (!isSettlementSide(body.side)) {
return withCors(
request,
Response.json(
{ error: "side must be 'platform_fee' or 'driver_payout'." },
{ status: 400 },
),
);
}
const rideIds = Array.isArray(body.ride_ids)
? body.ride_ids.map(Number).filter(Number.isInteger)
: [];
const driverId = Number(body.driver_id);
const hasDriver = Number.isInteger(driverId);
if (!hasDriver && rideIds.length === 0) {
return withCors(
request,
Response.json(
{ error: "Provide either driver_id or a non-empty ride_ids array." },
{ status: 400 },
),
);
}
// Column names come from the validated `side`, never from raw input.
const column =
body.side === "platform_fee"
? "platform_fee_settled_at"
: "driver_payout_settled_at";
const amountColumn =
body.side === "platform_fee" ? "platform_fee_cents" : "driver_payout_cents";
// Only one payment type produces a transfer that a human has to make, and
// it's the opposite one for each side:
//
// platform_fee — owed only on CASH rides. On a card ride the company
// already holds its fee; there is nothing to collect.
// driver_payout — owed only on CARD rides. On a cash ride the driver
// already has their share in hand.
//
// Scoping to that payment type is what keeps an undo honest. Without it,
// reversing one collected cash commission also cleared the automatically
// settled fees on that driver's card rides, and the ledger then told the
// operator to go and collect money the company had never been without.
const payableStatus =
body.side === "platform_fee" ? "cash_collected" : "paid";
const undo = body.undo === true;
const params: SqlValue[] = [payableStatus];
const conds: string[] = [
"status = 'completed'",
// Only money that actually materialised can be settled: an uncollected
// cash fare owes nobody anything and must never appear as settled.
"payment_status = $1",
// Idempotent in both directions — already-settled rows are skipped when
// settling, already-clear rows when undoing.
undo ? `${column} IS NOT NULL` : `${column} IS NULL`,
];
if (hasDriver) {
params.push(driverId);
conds.push(`driver_id = $${params.length}`);
}
if (rideIds.length > 0) {
params.push(`{${rideIds.join(",")}}`);
conds.push(`ride_id = ANY($${params.length}::int[])`);
}
try {
params.push(body.note?.trim() ? body.note.trim().slice(0, 500) : null);
const noteParam = params.length;
const rows = await query<{ ride_id: number; amount: number }>(
`UPDATE rides
SET ${column} = ${undo ? "NULL" : "CURRENT_TIMESTAMP"},
settlement_note = COALESCE($${noteParam}, settlement_note)
WHERE ${conds.join(" AND ")}
RETURNING ride_id, COALESCE(${amountColumn}, 0) AS amount`,
params,
);
const totalCents = rows.reduce((sum, r) => sum + Number(r.amount), 0);
return withCors(
request,
Response.json({
data: {
side: body.side,
undone: undo,
rides: rows.length,
ride_ids: rows.map((r) => r.ride_id),
total_cents: totalCents,
},
}),
);
} catch (error) {
console.error("[ADMIN_SETTLE]: ", error);
return withCors(
request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
}
// GET — the outstanding ledger.
//
// Without arguments: one row per driver, answering the two questions an
// operator has at the end of a shift — which drivers owe us cash commission,
// and which drivers are we behind on paying.
//
// With ?driver_id=N&side=platform_fee: the individual rides making up that
// balance, so a part-payment can be recorded against the exact trips it
// covers. A driver handing over three of yesterday's five fares is a normal
// thing to happen, and settling all five because the UI only offered
// all-or-nothing would put the ledger out of step with the cash.
export async function GET(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(request, auth.error);
try {
const url = new URL(request.url);
const driverParam = Number(url.searchParams.get("driver_id"));
const sideParam = url.searchParams.get("side");
if (Number.isInteger(driverParam) && sideParam !== null) {
if (!isSettlementSide(sideParam)) {
return withCors(
request,
Response.json(
{ error: "side must be 'platform_fee' or 'driver_payout'." },
{ status: 400 },
),
);
}
// Mirrors the POST handler's rules exactly: only the payment type that
// actually leaves a transfer outstanding for this side is listed, so the
// picker can never show a ride that settling would refuse to touch.
const settledColumn =
sideParam === "platform_fee"
? "platform_fee_settled_at"
: "driver_payout_settled_at";
const amountColumn =
sideParam === "platform_fee"
? "platform_fee_cents"
: "driver_payout_cents";
const payableStatus =
sideParam === "platform_fee" ? "cash_collected" : "paid";
const rides = await query<{
ride_id: number;
amount_cents: number;
fare_price: number;
origin_address: string;
destination_address: string;
completed_at: string;
}>(
`SELECT ride_id,
COALESCE(${amountColumn}, 0) AS amount_cents,
fare_price, origin_address, destination_address, completed_at
FROM rides
WHERE driver_id = $1
AND status = 'completed'
AND payment_status = $2
AND ${settledColumn} IS NULL
ORDER BY completed_at DESC`,
[driverParam, payableStatus],
);
return withCors(
request,
Response.json({
data: {
side: sideParam,
driver_id: driverParam,
rides,
total_cents: rides.reduce(
(sum, r) => sum + Number(r.amount_cents),
0,
),
},
}),
);
}
const rows = await sql<{
driver_id: number;
name: string;
owes_company_cents: number;
owed_to_driver_cents: number;
unsettled_rides: number;
}>`
SELECT
d.id AS driver_id,
TRIM(COALESCE(d.first_name,'') || ' ' || COALESCE(d.last_name,'')) AS name,
COALESCE(SUM(r.platform_fee_cents)
FILTER (WHERE r.platform_fee_settled_at IS NULL), 0)::int
AS owes_company_cents,
COALESCE(SUM(r.driver_payout_cents)
FILTER (WHERE r.driver_payout_settled_at IS NULL), 0)::int
AS owed_to_driver_cents,
COUNT(*)::int AS unsettled_rides
FROM drivers d
JOIN rides r ON r.driver_id = d.id
WHERE r.status = 'completed'
AND r.payment_status IN ('paid','cash_collected')
AND (r.platform_fee_settled_at IS NULL
OR r.driver_payout_settled_at IS NULL)
GROUP BY d.id, d.first_name, d.last_name
ORDER BY owes_company_cents DESC, owed_to_driver_cents DESC
`;
return withCors(request, Response.json({ data: rows }));
} catch (error) {
console.error("[ADMIN_SETTLE_GET]: ", error);
return withCors(
request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
}
+100 -16
View File
@@ -1,20 +1,35 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { sql } from "@/lib/db";
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function GET(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
// Money only ever comes from rides that actually happened.
//
// "Pending payment" used to be `payment_status <> 'paid'`, which swept in
// every cancelled and expired ride — a rider who changed their mind before
// a driver was even assigned showed up as outstanding revenue the company
// was owed. Settled/outstanding are now scoped to completed rides, and the
// top line is split three ways: what riders paid, what drivers keep, and
// what the company actually earns.
const [totals] = await sql<{
users: number;
drivers: number;
rides: number;
revenue: number;
completed_rides: number;
cancelled_rides: number;
gross_fares: number;
driver_payouts: number;
company_revenue: number;
company_collected: number;
company_outstanding: number;
driver_outstanding: number;
rides_today: number;
avg_fare: number;
pending_count: number;
@@ -25,19 +40,82 @@ export async function GET(request: Request) {
(SELECT COUNT(*)::int FROM users) AS users,
(SELECT COUNT(*)::int FROM drivers) AS drivers,
(SELECT COUNT(*)::int FROM rides) AS rides,
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides WHERE payment_status = 'paid') AS revenue,
(SELECT COUNT(*)::int FROM rides WHERE status = 'completed') AS completed_rides,
(SELECT COUNT(*)::int FROM rides WHERE status IN ('cancelled','expired')) AS cancelled_rides,
-- What riders were charged, across every completed ride.
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8
FROM rides WHERE status = 'completed') AS gross_fares,
-- Revenue and payouts count rides whose money actually materialised.
--
-- Scoping these to paid rides is what makes the books reconcile:
-- gross_fares = paid fares + pending_revenue
-- paid fares = company_revenue + driver_payouts
-- company_revenue = company_collected + company_outstanding
-- Counting fees on a fare nobody ever paid would show revenue that can
-- never be collected and never be chased — it belongs in the
-- uncollected line below, not the top line.
(SELECT COALESCE(SUM(COALESCE(driver_payout_cents, 0)) / 100.0, 0)::float8
FROM rides WHERE status = 'completed'
AND payment_status IN ('paid','cash_collected')) AS driver_payouts,
(SELECT COALESCE(SUM(COALESCE(platform_fee_cents, 0)) / 100.0, 0)::float8
FROM rides WHERE status = 'completed'
AND payment_status IN ('paid','cash_collected')) AS company_revenue,
-- Earned and actually in hand: card fees, plus cash commission a
-- driver has since remitted.
(SELECT COALESCE(SUM(platform_fee_cents) / 100.0, 0)::float8
FROM rides
WHERE status = 'completed'
AND payment_status IN ('paid','cash_collected')
AND platform_fee_settled_at IS NOT NULL) AS company_collected,
-- Earned but still sitting in a driver's pocket. This is the number an
-- operator chases at the end of a shift.
(SELECT COALESCE(SUM(platform_fee_cents) / 100.0, 0)::float8
FROM rides
WHERE status = 'completed'
AND payment_status IN ('paid','cash_collected')
AND platform_fee_settled_at IS NULL) AS company_outstanding,
-- The mirror: payouts the company still owes its drivers.
(SELECT COALESCE(SUM(driver_payout_cents) / 100.0, 0)::float8
FROM rides
WHERE status = 'completed'
AND payment_status IN ('paid','cash_collected')
AND driver_payout_settled_at IS NULL) AS driver_outstanding,
(SELECT COUNT(*)::int FROM rides WHERE created_at >= CURRENT_DATE) AS rides_today,
(SELECT COALESCE(ROUND(AVG(fare_price) / 100.0, 2), 0)::float8 FROM rides WHERE payment_status = 'paid') AS avg_fare,
(SELECT COUNT(*)::int FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_count,
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_revenue,
(SELECT COALESCE(ROUND(AVG(fare_price) / 100.0, 2), 0)::float8
FROM rides WHERE status = 'completed') AS avg_fare,
-- Completed rides whose money never actually landed: a cash fare the
-- driver didn't collect, or a card ride that never settled.
(SELECT COUNT(*)::int FROM rides
WHERE status = 'completed'
AND payment_status NOT IN ('paid','cash_collected')) AS pending_count,
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides
WHERE status = 'completed'
AND payment_status NOT IN ('paid','cash_collected')) AS pending_revenue,
(SELECT COUNT(*)::int FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days') AS new_users_7d
`;
const trend = await sql<{ day: string; rides: number; revenue: number }>`
const trend = await sql<{
day: string;
rides: number;
revenue: number;
payouts: number;
}>`
SELECT
TO_CHAR(DAY, 'YYYY-MM-DD') AS day,
COUNT(r.ride_id)::int AS rides,
COALESCE(SUM(r.fare_price) FILTER (WHERE r.payment_status = 'paid') / 100.0, 0)::float8 AS revenue
COALESCE(SUM(COALESCE(r.platform_fee_cents, 0))
FILTER (WHERE r.status = 'completed') / 100.0, 0)::float8 AS revenue,
COALESCE(SUM(COALESCE(r.driver_payout_cents, 0))
FILTER (WHERE r.status = 'completed') / 100.0, 0)::float8 AS payouts
FROM generate_series(
CURRENT_DATE - INTERVAL '13 days',
CURRENT_DATE,
@@ -48,28 +126,34 @@ export async function GET(request: Request) {
ORDER BY DAY
`;
// Ranked by what each driver actually earned, not by what their riders
// were charged — and counting only rides that happened.
const topDrivers = await sql<{
driver_id: number;
name: string;
rides: number;
revenue: number;
earnings: number;
company_revenue: number;
}>`
SELECT
d.id AS driver_id,
d.first_name || ' ' || d.last_name AS name,
COUNT(r.ride_id)::int AS rides,
COALESCE(SUM(r.fare_price) FILTER (WHERE r.payment_status = 'paid') / 100.0, 0)::float8 AS revenue
COUNT(r.ride_id) FILTER (WHERE r.status = 'completed')::int AS rides,
COALESCE(SUM(COALESCE(r.driver_payout_cents, 0))
FILTER (WHERE r.status = 'completed') / 100.0, 0)::float8 AS earnings,
COALESCE(SUM(COALESCE(r.platform_fee_cents, 0))
FILTER (WHERE r.status = 'completed') / 100.0, 0)::float8 AS company_revenue
FROM drivers d
LEFT JOIN rides r ON r.driver_id = d.id
GROUP BY d.id, d.first_name, d.last_name
ORDER BY revenue DESC, rides DESC
ORDER BY earnings DESC, rides DESC
LIMIT 5
`;
return withCors(Response.json({ data: { totals, trend, topDrivers } }));
return withCors(request, Response.json({ data: { totals, trend, topDrivers } }));
} catch (error) {
console.error("[ADMIN_STATS]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+5 -5
View File
@@ -1,13 +1,13 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { sql } from "@/lib/db";
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function GET(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const url = new URL(request.url);
@@ -58,10 +58,10 @@ export async function GET(request: Request) {
LIMIT 500
`;
return withCors(Response.json({ data: rows }));
return withCors(request, Response.json({ data: rows }));
} catch (error) {
console.error("[ADMIN_USERS]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+13 -13
View File
@@ -6,13 +6,13 @@ type Body = {
email_verified?: boolean;
};
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function PATCH(request: Request, { id }: { id: string }) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const body = (await request.json()) as Body;
@@ -20,7 +20,7 @@ export async function PATCH(request: Request, { id }: { id: string }) {
if (body.role !== undefined) {
const allowed = ["rider", "driver", "owner", null];
if (!allowed.includes(body.role)) {
return withCors(
return withCors(request,
Response.json(
{ error: "Role must be rider, driver, owner or null." },
{ status: 400 },
@@ -29,7 +29,7 @@ export async function PATCH(request: Request, { id }: { id: string }) {
}
if (id === auth.userId && body.role !== "owner") {
return withCors(
return withCors(request,
Response.json(
{ error: "You cannot remove your own owner role." },
{ status: 400 },
@@ -47,13 +47,13 @@ export async function PATCH(request: Request, { id }: { id: string }) {
`;
if (!rows[0]) {
return withCors(Response.json({ error: "User not found." }, { status: 404 }));
return withCors(request, Response.json({ error: "User not found." }, { status: 404 }));
}
return withCors(Response.json({ data: rows[0] }));
return withCors(request, Response.json({ data: rows[0] }));
} catch (error) {
console.error("[ADMIN_USER_PATCH]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
@@ -61,10 +61,10 @@ export async function PATCH(request: Request, { id }: { id: string }) {
export async function DELETE(request: Request, { id }: { id: string }) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
if (id === auth.userId) {
return withCors(
return withCors(request,
Response.json(
{ error: "You cannot delete your own account." },
{ status: 400 },
@@ -79,15 +79,15 @@ export async function DELETE(request: Request, { id }: { id: string }) {
`;
if (!rows[0]) {
return withCors(
return withCors(request,
Response.json({ error: "User not found." }, { status: 404 }),
);
}
return withCors(Response.json({ data: rows[0] }));
return withCors(request, Response.json({ data: rows[0] }));
} catch (error) {
console.error("[ADMIN_USER_DELETE]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+100
View File
@@ -0,0 +1,100 @@
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 });
}
}
-20
View File
@@ -1,20 +0,0 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
export async function GET(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const response = await sql`
SELECT id, first_name, last_name, profile_image_url, car_image_url, car_seats, rating
FROM drivers
`;
return Response.json({ data: response });
} catch (error) {
console.log("[GET_DRIVERS]: ", error);
return Response.json({ error }, { status: 500 });
}
}
+80
View File
@@ -0,0 +1,80 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
import { SERVICES } from "@/constants/services";
import { boundingBox, haversine } from "@/lib/utils";
import { DRIVER_STALE_SECONDS } from "@/constants/dispatch";
// GET — how many drivers of each service are within reach of a point.
//
// The rider map filters by the selected service, so an empty map is ambiguous:
// it means "nobody at all" and "nobody driving a moto, though three cars are a
// street away" identically. That's the state riders were getting stuck in —
// staring at an empty map with no way to know that switching service would
// fill it. This answers the question the map can't.
//
// Query: ?lat=33.89&lng=35.50&radius=20000
//
// Returns every known service, zeros included, so the client can render the
// full picker without inventing missing keys.
const DEFAULT_RADIUS_M = 20000;
const MAX_RADIUS_M = 20000;
export async function GET(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const url = new URL(req.url);
const lat = Number(url.searchParams.get("lat"));
const lng = Number(url.searchParams.get("lng"));
if (Number.isNaN(lat) || Number.isNaN(lng)) {
return Response.json(
{ error: "lat and lng query params are required numbers." },
{ status: 400 },
);
}
const requested = Number(url.searchParams.get("radius"));
const radius =
Number.isFinite(requested) && requested > 0
? Math.min(requested, MAX_RADIUS_M)
: DEFAULT_RADIUS_M;
const box = boundingBox(lat, lng, radius);
// Same visibility rules as /driver/nearby — vetted, online, fresh, real
// account, positioned. A driver riders can't be matched to must not be
// counted here either, or the hint sends them to an empty service.
const rows = await sql<{
service: string;
latitude: number;
longitude: number;
}>`
SELECT service, latitude, longitude
FROM drivers
WHERE online = TRUE
AND approval_status = 'approved'
AND user_id IS NOT NULL
AND last_seen > CURRENT_TIMESTAMP - make_interval(secs => ${DRIVER_STALE_SECONDS})
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND latitude BETWEEN ${box.minLat} AND ${box.maxLat}
AND longitude BETWEEN ${box.minLng} AND ${box.maxLng}
`;
const counts: Record<string, number> = {};
for (const service of SERVICES) counts[service.id] = 0;
for (const row of rows) {
if (haversine(lat, lng, row.latitude, row.longitude) > radius) continue;
if (counts[row.service] === undefined) continue;
counts[row.service] += 1;
}
return Response.json({ data: { radius, counts } });
} catch (error) {
console.error("[DRIVER_AVAILABILITY]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+81
View File
@@ -0,0 +1,81 @@
import { preflight, withCors } from "@/lib/admin";
import { sql } from "@/lib/db";
import { requireAuth } from "@/lib/jwt";
import { isStoredUploadName, readUpload, uploadMimeType } from "@/lib/uploads";
// GET /(api)/driver/documents?name=… — serve one stored document scan.
//
// These are identity documents, so they are not static files: every read is
// authenticated and authorised here. Exactly two principals may fetch a scan —
// the driver it belongs to, and an owner reviewing that driver. Knowing the
// (unguessable) file name is not itself permission.
//
// The name travels as a query parameter rather than a path segment because it
// ends in .jpg/.png/.webp, and a dotted final segment is exactly what static
// asset middleware tends to claim before the router ever sees it. A query
// parameter cannot be mistaken for a file on disk.
//
// CORS is applied because the admin dashboard is a separate origin; it fetches
// the bytes with its bearer token and renders them from a blob URL, since an
// <img src> cannot carry an Authorization header.
export async function OPTIONS(request: Request) {
return preflight(request);
}
const notFound = (request: Request) =>
withCors(request, Response.json({ error: "Not found." }, { status: 404 }));
export async function GET(request: Request) {
const auth = requireAuth(request);
if ("error" in auth) return withCors(request, auth.error);
const name = new URL(request.url).searchParams.get("name");
// Rejecting the name before it reaches the filesystem is what keeps a
// crafted "../../.env" from ever being joined onto the upload directory.
if (!isStoredUploadName(name)) return notFound(request);
try {
const rows = await sql<{ role: string | null; owns: boolean }>`
SELECT
(SELECT role FROM users WHERE id = ${auth.userId}) AS role,
EXISTS (
SELECT 1 FROM drivers
WHERE user_id = ${auth.userId}
AND ${name} IN (
license_image_url, id_image_url, vehicle_reg_image_url
)
) AS owns
`;
const allowed = rows[0]?.role === "owner" || rows[0]?.owns === true;
// A 404 rather than a 403: a caller who is not entitled to the document
// shouldn't learn whether it exists.
if (!allowed) return notFound(request);
const bytes = await readUpload(name, "document");
if (!bytes) return notFound(request);
return withCors(
request,
new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": uploadMimeType(name),
"Content-Length": String(bytes.length),
// Never let a shared cache hold somebody's ID card.
"Cache-Control": "private, no-store",
"Content-Disposition": `inline; filename="${name}"`,
"X-Content-Type-Options": "nosniff",
},
}),
);
} catch (error) {
console.error("[DRIVER_DOCUMENT_GET]: ", error);
return withCors(
request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
}
+101 -6
View File
@@ -1,5 +1,8 @@
import { requireDriverProfile } from "@/lib/driver";
import { sql } from "@/lib/db";
import { DRIVER_BUSY_ARRAY } from "@/lib/ride-lifecycle";
import { boundingBox, haversine } from "@/lib/utils";
import { BROADCAST_RADIUS_M, REQUEST_TTL_SECONDS } from "@/constants/dispatch";
// POST — driver location heartbeat. Each ping updates lat/lng/last_seen and
// keeps the driver marked online. The client (use-driver-location) fires this
@@ -11,7 +14,7 @@ export async function POST(req: Request) {
try {
const body = await req.json();
const { latitude, longitude } = body;
const { latitude, longitude, heading, speed_kph } = body;
if (
typeof latitude !== "number" ||
@@ -25,20 +28,112 @@ export async function POST(req: Request) {
);
}
// Heading and speed are optional and frequently unavailable — a phone
// sitting still reports heading -1, and a cached fix may carry neither.
// Anything unusable is stored as NULL rather than as a wrong direction,
// because a confidently wrong arrow on a rider's map is worse than none.
const bearing =
typeof heading === "number" && heading >= 0 && heading <= 360
? Math.round(heading) % 360
: null;
const speed =
typeof speed_kph === "number" && speed_kph >= 0 && speed_kph < 300
? Math.round(speed_kph)
: null;
// A ping refreshes position and liveness only. It deliberately does NOT
// set online = TRUE: a ping already in flight when the driver toggles off
// would land afterwards and put them back in the match pool, so they'd
// keep getting requests they thought they'd opted out of. Going online is
// an explicit PATCH to /driver/profile and nothing else.
const { driverId } = result;
const rows = await sql`
UPDATE drivers
SET latitude = ${latitude},
longitude = ${longitude},
last_seen = CURRENT_TIMESTAMP,
online = TRUE
-- COALESCE, not overwrite: a fix without a usable heading (typical
-- at a standstill) shouldn't erase the direction the car was last
-- known to be facing, which is still the best guess for how it's
-- parked. Speed does overwrite, because "not moving" is real
-- information and must be able to reach zero.
heading = COALESCE(${bearing}, heading),
speed_kph = ${speed},
last_seen = CURRENT_TIMESTAMP
WHERE id = ${driverId}
RETURNING id, latitude, longitude, last_seen, online
RETURNING id, latitude, longitude, heading, speed_kph, last_seen, online
`;
return Response.json({ data: rows[0] });
// The nearest open request this driver could take, returned with the
// heartbeat.
//
// While a driver is online this endpoint is hit every few seconds by a
// foreground-service location task that keeps running with the screen
// off — so it is the one request we know is still happening when the
// dashboard poll has stopped. Piggybacking the nearest job here lets the
// app raise a local notification for it without a second round trip, and
// without needing remote push credentials.
//
// Filtered to requests this driver hasn't already offered on, so a driver
// who volunteered and is waiting on the rider isn't buzzed about the same
// job every five seconds.
const box = boundingBox(latitude, longitude, BROADCAST_RADIUS_M);
const driver = rows[0] as { online?: boolean } | undefined;
const nearby = driver?.online
? await sql<{
ride_id: number;
origin_address: string;
fare_price: number;
origin_latitude: number;
origin_longitude: number;
}>`
SELECT r.ride_id, r.origin_address, r.fare_price,
r.origin_latitude, r.origin_longitude
FROM rides r
WHERE r.status = 'requested'
AND r.service = (SELECT service FROM drivers WHERE id = ${driverId})
AND r.created_at > CURRENT_TIMESTAMP - make_interval(secs => ${REQUEST_TTL_SECONDS})
AND r.origin_latitude BETWEEN ${box.minLat} AND ${box.maxLat}
AND r.origin_longitude BETWEEN ${box.minLng} AND ${box.maxLng}
AND NOT EXISTS (
SELECT 1 FROM ride_offers ro
WHERE ro.ride_id = r.ride_id
AND ro.driver_id = ${driverId}
AND ro.status = 'offered'
)
AND NOT EXISTS (
SELECT 1 FROM rides busy
WHERE busy.driver_id = ${driverId}
AND busy.status = ANY(${DRIVER_BUSY_ARRAY}::text[])
)
ORDER BY r.created_at DESC
LIMIT 5
`
: [];
// Same great-circle trim the dashboard applies, so the notification and
// the list the driver opens agree on what counts as nearby.
const pending = nearby
.map((r) => ({
ride_id: r.ride_id,
origin_address: r.origin_address,
fare_price: Number(r.fare_price),
distance: haversine(
latitude,
longitude,
Number(r.origin_latitude),
Number(r.origin_longitude),
),
}))
.filter((r) => r.distance <= BROADCAST_RADIUS_M)
.sort((a, b) => a.distance - b.distance)[0];
return Response.json({
data: { ...rows[0], pending_request: pending ?? null },
});
} catch (error) {
console.error("[DRIVER_LOCATION]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
}
+51 -8
View File
@@ -1,12 +1,30 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
import { boundingBox, haversine } from "@/lib/utils";
import { DRIVER_STALE_SECONDS } from "@/constants/dispatch";
// GET — online drivers of `service` near (lat,lng), for the rider map and the
// "nearest driver ETA" estimate on confirm-ride. Only real, logged-in drivers
// (user_id IS NOT NULL) with a fresh location ping are returned; legacy seed
// rows have no position and are never shown to riders.
// "drivers near you" count on the request screen. Only vetted, logged-in
// drivers (approved + user_id IS NOT NULL) with a fresh location ping are
// returned; legacy seed rows have no position and are never shown to riders.
//
// Query: ?service=car&lat=33.89&lng=35.50&radius=8000
//
// The radius is enforced, not decorative. Returning every online driver in the
// country to any signed-in account turns this endpoint into a live tracker for
// the whole fleet; bounding it means a caller only ever learns about cars they
// could plausibly hail. A coarse bounding box does the work in the index, then
// a great-circle pass trims the corners.
const DEFAULT_RADIUS_M = 8000;
const MAX_RADIUS_M = 20000;
// Drivers are returned at ~11m precision (4 decimal places). That is well
// inside "which street is the car on" for a map pin, and stops the endpoint
// from being a metre-accurate trace of someone's working day.
const COORD_PRECISION = 1e4;
const snap = (value: number): number =>
Math.round(value * COORD_PRECISION) / COORD_PRECISION;
export async function GET(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
@@ -24,22 +42,47 @@ export async function GET(req: Request) {
);
}
const rows = await sql`
const requested = Number(url.searchParams.get("radius"));
const radius =
Number.isFinite(requested) && requested > 0
? Math.min(requested, MAX_RADIUS_M)
: DEFAULT_RADIUS_M;
const box = boundingBox(lat, lng, radius);
const rows = await sql<{
id: number;
latitude: number;
longitude: number;
heading: number | null;
speed_kph: number | null;
}>`
SELECT id, first_name, last_name, profile_image_url, car_image_url,
car_seats, rating, service, car_model, latitude, longitude,
last_seen
heading, speed_kph, last_seen
FROM drivers
WHERE service = ${service}
AND online = TRUE
AND approval_status = 'approved'
AND user_id IS NOT NULL
AND last_seen > CURRENT_TIMESTAMP - INTERVAL '60 seconds'
AND last_seen > CURRENT_TIMESTAMP - make_interval(secs => ${DRIVER_STALE_SECONDS})
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND latitude BETWEEN ${box.minLat} AND ${box.maxLat}
AND longitude BETWEEN ${box.minLng} AND ${box.maxLng}
`;
return Response.json({ data: rows });
const nearby = rows
.filter((d) => haversine(lat, lng, d.latitude, d.longitude) <= radius)
.map((d) => ({
...d,
latitude: snap(d.latitude),
longitude: snap(d.longitude),
}));
return Response.json({ data: nearby });
} catch (error) {
console.error("[DRIVER_NEARBY]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
}
+257
View File
@@ -0,0 +1,257 @@
import { preflight, withCors } from "@/lib/admin";
import { sql } from "@/lib/db";
import { requireAuth } from "@/lib/jwt";
import {
deleteUpload,
isStoredUploadName,
MAX_UPLOAD_BYTES,
pruneOrphanUploads,
readUpload,
sniffImageType,
storeUpload,
uploadMimeType,
} from "@/lib/uploads";
// The driver's profile photo — the face a rider sees beside a driver's name
// when picking between offers, and what they check the arriving car's driver
// against.
//
// POST uploads it (authenticated, driver-role only). GET serves it, and unlike
// the document route it does NOT require a token: this image is rendered by
// plain <Image>/<img> tags across the rider app, the driver map and the admin
// dashboard, none of which can attach an Authorization header without turning
// every avatar into a bespoke fetch-and-blob dance. What protects it instead
// is that the name is 128 bits of randomness and the route refuses any name no
// driver row actually points at — so it cannot be enumerated, and it cannot be
// used as a general-purpose anonymous image host for whatever somebody
// uploaded and abandoned.
//
// This is the opposite trade to /(api)/driver/documents, which is why the two
// live in separate directories on disk: a name that addresses a licence scan
// resolves to nothing here.
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function GET(request: Request) {
const name = new URL(request.url).searchParams.get("name");
const notFound = () =>
withCors(request, Response.json({ error: "Not found." }, { status: 404 }));
// Rejecting the name before it reaches the filesystem is what keeps a
// crafted "../../.env" from ever being joined onto the upload directory.
if (!isStoredUploadName(name)) return notFound();
try {
// Only photos a driver profile actually points at are served. Without
// this, any signed-in driver could upload an arbitrary image and walk away
// with a permanent public URL for it.
const rows = await sql<{ used: boolean }>`
SELECT EXISTS (
SELECT 1 FROM drivers WHERE profile_image_url = ${name}
) AS used
`;
if (!rows[0]?.used) return notFound();
const bytes = await readUpload(name, "photo");
if (!bytes) return notFound();
return withCors(
request,
new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": uploadMimeType(name),
"Content-Length": String(bytes.length),
// The name changes whenever the photo does, so the bytes behind a
// given URL are immutable and can be cached hard. That matters: the
// rider's nearby-drivers view re-renders these constantly.
"Cache-Control": "public, max-age=604800, immutable",
"X-Content-Type-Options": "nosniff",
},
}),
);
} catch (error) {
console.error("[DRIVER_PHOTO_GET]: ", error);
return withCors(
request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
}
/**
* Photos are cheap compared with a scan (no Vision call), but still a disk
* write, so keep a lid on how fast one account can retake theirs.
*/
const PHOTO_LIMIT = 15;
const PHOTO_WINDOW_MS = 60 * 60 * 1000;
const recentUploads = new Map<string, number[]>();
const overPhotoLimit = (userId: string): boolean => {
const now = Date.now();
const cutoff = now - PHOTO_WINDOW_MS;
const history = (recentUploads.get(userId) ?? []).filter((at) => at > cutoff);
if (history.length >= PHOTO_LIMIT) {
recentUploads.set(userId, history);
return true;
}
history.push(now);
recentUploads.set(userId, history);
if (recentUploads.size > 500) {
for (const [key, times] of recentUploads) {
if (times.every((at) => at <= cutoff)) recentUploads.delete(key);
}
}
return false;
};
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
let lastPruneAt = 0;
/**
* A driver who takes a photo and then abandons onboarding leaves a file
* nothing points at. Same sweep as the scan route, over the photo directory.
*/
const pruneOrphansOccasionally = async (): Promise<void> => {
if (Date.now() - lastPruneAt < PRUNE_INTERVAL_MS) return;
lastPruneAt = Date.now();
try {
const rows = await sql<{ profile_image_url: string | null }>`
SELECT profile_image_url FROM drivers
WHERE profile_image_url IS NOT NULL
`;
const referenced = new Set(
rows.map((row) => row.profile_image_url).filter(Boolean) as string[],
);
await pruneOrphanUploads(referenced, "photo");
} catch (error) {
console.error("[DRIVER_PHOTO_PRUNE]: ", error);
}
};
// POST — upload or replace the driver's profile photo.
//
// A driver who already has a profile row gets it attached straight away, so
// retaking a bad photo is one step. During onboarding there is no row yet, so
// the name is just returned and travels up with the profile submission.
export async function POST(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const users = await sql<{ role: string | null }>`
SELECT role FROM users WHERE id = ${auth.userId}
`;
if (users[0]?.role !== "driver") {
return Response.json(
{ error: "Only driver accounts can upload a driver photo." },
{ status: 403 },
);
}
if (overPhotoLimit(auth.userId)) {
return Response.json(
{
error: "Too many uploads. Wait a few minutes and try again.",
code: "PHOTO_RATE_LIMIT",
},
{ status: 429 },
);
}
const body = await req.json();
const raw = body.image_base64;
if (typeof raw !== "string" || raw.length === 0) {
return Response.json(
{ error: "image_base64 is required." },
{ status: 400 },
);
}
const encoded = raw.includes(",") ? raw.slice(raw.indexOf(",") + 1) : raw;
// Base64 inflates by 4/3, so reject on the encoded length before
// allocating — otherwise an oversized upload is buffered just to be
// refused.
if (encoded.length > MAX_UPLOAD_BYTES * 1.4) {
return Response.json(
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
{ status: 413 },
);
}
const image = Buffer.from(encoded, "base64");
if (image.length > MAX_UPLOAD_BYTES) {
return Response.json(
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
{ status: 413 },
);
}
const mimeType = sniffImageType(image);
if (!mimeType) {
return Response.json(
{
error: "Upload a JPEG, PNG or WebP photo.",
code: "UNSUPPORTED_IMAGE",
},
{ status: 400 },
);
}
const photo = await storeUpload(image, mimeType, "photo");
// Attach it now if the driver already has a profile, so retaking a bad
// photo is a single step. Mid-onboarding there is no row yet and the name
// simply travels up with the profile submission instead.
//
// This deliberately does not touch approval_status: a driver swapping a
// blurry photo for a clear one shouldn't be knocked out of service, and
// the reviewer sees whatever the current photo is when they next open the
// profile.
const existing = await sql<{ profile_image_url: string | null }>`
SELECT profile_image_url FROM drivers WHERE user_id = ${auth.userId}
`;
const attached = existing.length > 0;
if (attached) {
await sql`
UPDATE drivers SET profile_image_url = ${photo}
WHERE user_id = ${auth.userId}
`;
// Only a name we stored is safe to unlink — an owner may have set an
// external URL from the dashboard, and that is not ours to delete.
const previous = existing[0].profile_image_url;
if (previous && previous !== photo && isStoredUploadName(previous)) {
await deleteUpload(previous, "photo");
}
}
void pruneOrphansOccasionally();
return Response.json({
data: {
/** Opaque stored name; send it with the profile if onboarding. */
photo,
attached,
},
});
} catch (error) {
console.error("[DRIVER_PHOTO_POST]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+247 -16
View File
@@ -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 });
}
}
}
+218 -32
View File
@@ -1,11 +1,21 @@
import { requireDriverProfile } from "@/lib/driver";
import { sql } from "@/lib/db";
import { DRIVER_BUSY_ARRAY, expireStaleRequests } from "@/lib/ride-lifecycle";
import { boundingBox, haversine } from "@/lib/utils";
import { BROADCAST_RADIUS_M, REQUEST_TTL_SECONDS } from "@/constants/dispatch";
import { splitFare } from "@/lib/pricing";
// GET — the driver's world in one poll:
// offers : incoming ride_offers awaiting this driver's accept/decline,
// each joined to its ride so the card can show pickup/dest/fare.
// active : the ride this driver is currently on (accepted or en_route).
// requests: open ride requests broadcast near this driver, each carrying
// how far the pickup is, what the driver would earn, and whether
// they have already offered on it.
// active : the ride this driver is currently on (accepted -> en_route).
// recent : rides completed today, for the earnings summary.
//
// Requests are found by distance from the driver's own last position, using
// the same radius lib/dispatch broadcasts over — the two questions ("who
// should be told about this request?" and "what is open near me?") have to
// agree, or a driver gets pushed a job their dashboard then hides.
export async function GET(req: Request) {
const result = await requireDriverProfile(req);
if ("error" in result) return result.error;
@@ -13,53 +23,211 @@ export async function GET(req: Request) {
try {
const { driverId } = result;
const offers = await sql`
SELECT
ro.id AS offer_id, ro.offered_at,
r.ride_id, r.origin_address, r.destination_address,
r.origin_latitude, r.origin_longitude,
r.destination_latitude, r.destination_longitude,
r.ride_time, r.fare_price, r.payment_status, r.service, r.user_id
FROM ride_offers ro
JOIN rides r ON r.ride_id = ro.ride_id
WHERE ro.driver_id = ${driverId} AND ro.status = 'offered'
ORDER BY ro.offered_at DESC
// This poll is one of the lazy paths that stands in for a background
// worker, so it also buries requests nobody was picked for. Awaited: the
// list read below should not include a request that just died.
await expireStaleRequests();
// The driver's own position and state. A driver with no fix yet can't be
// told what's near them, and one who is offline shouldn't be shown work.
const [me] = await sql<{
latitude: number | null;
longitude: number | null;
service: string;
online: boolean;
}>`
SELECT latitude, longitude, service, online
FROM drivers WHERE id = ${driverId}
`;
const canSeeRequests =
me?.online === true && me.latitude !== null && me.longitude !== null;
// Coarse box in the index, great-circle pass afterwards — the same
// two-step every other proximity query in this codebase uses.
const box = canSeeRequests
? boundingBox(me.latitude!, me.longitude!, BROADCAST_RADIUS_M)
: null;
const openRequests = box
? await sql<OpenRequestRow>`
SELECT
r.ride_id, r.origin_address, r.destination_address,
r.origin_latitude, r.origin_longitude,
r.destination_latitude, r.destination_longitude,
r.ride_time, r.fare_price, r.service, r.created_at,
u.name AS rider_name, u.rating AS rider_rating,
mine.id AS my_offer_id,
(SELECT COUNT(*)::int FROM ride_offers ro
WHERE ro.ride_id = r.ride_id AND ro.status = 'offered')
AS offer_count
FROM rides r
LEFT JOIN users u ON u.id = r.user_id
LEFT JOIN ride_offers mine
ON mine.ride_id = r.ride_id
AND mine.driver_id = ${driverId}
AND mine.status = 'offered'
WHERE r.status = 'requested'
AND r.service = ${me.service}
AND r.created_at > CURRENT_TIMESTAMP - make_interval(secs => ${REQUEST_TTL_SECONDS})
AND r.origin_latitude BETWEEN ${box.minLat} AND ${box.maxLat}
AND r.origin_longitude BETWEEN ${box.minLng} AND ${box.maxLng}
ORDER BY r.created_at DESC
`
: [];
// Distance is computed here rather than in SQL so the filter and the
// number the driver reads on the card are the same calculation.
const requests = (openRequests as unknown as OpenRequestRow[])
.map((row) => ({
...row,
pickup_distance_m: Math.round(
haversine(
me.latitude!,
me.longitude!,
Number(row.origin_latitude),
Number(row.origin_longitude),
),
),
}))
.filter((row) => row.pickup_distance_m <= BROADCAST_RADIUS_M)
.sort((a, b) => a.pickup_distance_m - b.pickup_distance_m);
// Note: pickup_code is deliberately NOT selected here. The whole point of
// the code is that the driver has to get it from the rider at the car.
//
// The rider's phone number isn't selected either. It used to be shipped to
// the driver client and never rendered — personal data in transit for
// nothing. Driver↔rider contact goes through the in-app chat and WebRTC
// call, which is this app's equivalent of a masked number.
const active = await sql`
SELECT
r.ride_id, r.status, r.service, r.payment_status,
r.origin_address, r.destination_address,
r.origin_latitude, r.origin_longitude,
r.destination_latitude, r.destination_longitude,
r.ride_time, r.fare_price, r.created_at,
u.name AS rider_name, u.phone AS rider_phone
r.ride_time, r.fare_price, r.created_at, r.arrived_at,
u.name AS rider_name, u.rating AS rider_rating
FROM rides r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.driver_id = ${driverId} AND r.status IN ('accepted', 'en_route')
WHERE r.driver_id = ${driverId}
AND r.status = ANY(${DRIVER_BUSY_ARRAY}::text[])
ORDER BY r.created_at DESC
LIMIT 1
`;
const recent = await sql`
SELECT ride_id, fare_price, service, completed_at
// driver_payout_cents is what the driver actually keeps; fare_price is
// what the rider paid. Everything the driver sees is the payout — COALESCE
// covers rides completed before the split existed.
const recent = await sql<RecentRow>`
SELECT ride_id, fare_price, service, payment_status, completed_at,
COALESCE(driver_payout_cents, fare_price) AS payout_cents,
COALESCE(platform_fee_cents, 0) AS fee_cents
FROM rides
WHERE driver_id = ${driverId} AND status = 'completed'
AND completed_at >= CURRENT_DATE
ORDER BY completed_at DESC
`;
const earnings = recent.reduce(
(sum, r) => sum + Number(r.fare_price),
0,
// The driver's running balance with the company, across all time rather
// than just today — an unremitted commission doesn't stop mattering at
// midnight. Two directions: cash commission they're holding for us, and
// card payouts we still owe them.
const [balance] = await sql<{
owes_company_cents: number;
owed_to_driver_cents: number;
}>`
SELECT
COALESCE(SUM(platform_fee_cents)
FILTER (WHERE platform_fee_settled_at IS NULL), 0)::int
AS owes_company_cents,
COALESCE(SUM(driver_payout_cents)
FILTER (WHERE driver_payout_settled_at IS NULL), 0)::int
AS owed_to_driver_cents
FROM rides
WHERE driver_id = ${driverId}
AND status = 'completed'
AND payment_status IN ('paid','cash_collected')
`;
// A ride the driver finished recently and hasn't rated. Surfaced as a
// prompt on the dashboard so the rating survives the driver immediately
// accepting their next trip.
const pendingRating = await sql`
SELECT r.ride_id, u.name AS rider_name
FROM rides r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.driver_id = ${driverId}
AND r.status = 'completed'
AND r.completed_at > CURRENT_TIMESTAMP - INTERVAL '1 day'
AND NOT EXISTS (
SELECT 1 FROM ride_ratings rr
WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'driver'
)
ORDER BY r.completed_at DESC
LIMIT 1
`;
const settled = (r: RecentRow) =>
r.payment_status === "paid" || r.payment_status === "cash_collected";
const sumPayout = (rows: typeof recent) =>
rows.reduce((sum, r) => sum + Number(r.payout_cents), 0);
const sumFares = (rows: typeof recent) =>
rows.reduce((sum, r) => sum + Number(r.fare_price), 0);
// Earnings count settled money only, and count the driver's share of it.
// A cash ride the driver marked "not collected" is still an unpaid trip
// and used to land in this headline anyway, so the figure a driver saw and
// the figure they'd be paid against disagreed from day one.
const earnings = sumPayout(recent.filter(settled));
// The platform's cut of the same rides, so the number above is explainable
// rather than mysteriously smaller than the fares they remember charging.
const platformFees = recent
.filter(settled)
.reduce((sum, r) => sum + Number(r.fee_cents), 0);
// Cash the driver has taken in hand today — the full fare, because that's
// the physical money in their pocket, not their share of it. This is the
// figure they'll be reconciled against, and the platform's cut of it is
// owed back.
const cashCollected = sumFares(
recent.filter((r) => r.payment_status === "cash_collected"),
);
// Fares that were never collected. Surfaced rather than hidden so an
// unpaid trip is visible to the driver on the day it happened.
const cashOwed = sumFares(recent.filter((r) => r.payment_status === "cash"));
// A driver deciding whether to take a ride cares what they'll be paid, not
// what the rider is charged. The split isn't stored until completion, so
// it's computed here from the same helper that stamps it later — the two
// can't disagree, and the driver is never shown a number they won't get.
const withPayout = <T extends { fare_price: number }>(row: T) => ({
...row,
payout_cents: splitFare(Number(row.fare_price)).driverPayoutCents,
});
return Response.json({
data: {
offers: offers as unknown as OfferRow[],
active: (active[0] as unknown as ActiveRide | undefined) ?? null,
recent: recent as unknown as RecentRow[],
// The server's clock, so the client can draw a request countdown that
// matches the TTL dispatch actually enforces. Without it a phone whose
// clock is a few seconds out shows a timer that expires early or late.
now: new Date().toISOString(),
requests: requests.map(withPayout),
active: active[0]
? withPayout(active[0] as unknown as ActiveRide)
: null,
recent,
earnings,
platform_fees: platformFees,
cash_collected: cashCollected,
cash_owed: cashOwed,
owes_company: Number(balance?.owes_company_cents ?? 0),
owed_to_driver: Number(balance?.owed_to_driver_cents ?? 0),
pending_rating:
(pendingRating[0] as unknown as PendingRatingRow | undefined) ?? null,
},
});
} catch (error) {
@@ -68,9 +236,7 @@ export async function GET(req: Request) {
}
}
type OfferRow = {
offer_id: number;
offered_at: string;
type OpenRequestRow = {
ride_id: number;
origin_address: string;
destination_address: string;
@@ -80,12 +246,23 @@ type OfferRow = {
destination_longitude: number;
ride_time: number;
fare_price: number;
payment_status: string;
service: string;
user_id: string;
created_at: string;
rider_name: string | null;
rider_rating: number | null;
/** The id of this driver's live offer on the request, or null. */
my_offer_id: number | null;
/** How many drivers are competing for it, this one included. */
offer_count: number;
/** Metres from the driver's last position to the pickup. */
pickup_distance_m?: number;
/** The driver's share of the fare, computed per request. */
payout_cents?: number;
};
type ActiveRide = {
/** The driver's share of the fare, computed per request. */
payout_cents?: number;
ride_id: number;
status: string;
service: string;
@@ -99,13 +276,22 @@ type ActiveRide = {
ride_time: number;
fare_price: number;
created_at: string;
arrived_at: string | null;
rider_name: string | null;
rider_phone: string | null;
rider_rating: number | null;
};
type RecentRow = {
ride_id: number;
fare_price: number;
payout_cents: number;
fee_cents: number;
service: string;
payment_status: string;
completed_at: string;
};
};
type PendingRatingRow = {
ride_id: number;
rider_name: string | null;
};
+209
View File
@@ -0,0 +1,209 @@
import { sql } from "@/lib/db";
import {
isDocumentType,
OcrUnavailableError,
parseDocumentText,
recogniseDocument,
} from "@/lib/document-ocr";
import { requireAuth } from "@/lib/jwt";
import {
MAX_UPLOAD_BYTES,
pruneOrphanUploads,
sniffImageType,
storeUpload,
} from "@/lib/uploads";
// POST — a driver photographs one of their documents; we keep the scan and
// read what we can off it to prefill the onboarding form.
//
// The scan is stored whether or not OCR succeeds: the reviewer wants to see the
// actual licence next to the numbers the driver submitted, and that value does
// not depend on Vision having had a good day. When OCR fails the route still
// answers 200 with an empty field set and a code the client uses to say "type
// these in yourself" — an unreadable photo is a normal outcome, not an error.
/**
* Scans are the most expensive call in the app (a paid Vision request plus a
* disk write), so cap how fast one account can make them. In-process and
* therefore per-server — enough to stop a stuck retry loop or a bored driver
* burning the Vision quota, not a defence against a distributed attacker.
*/
const SCAN_LIMIT = 20;
const SCAN_WINDOW_MS = 60 * 60 * 1000;
const recentScans = new Map<string, number[]>();
const overScanLimit = (userId: string): boolean => {
const now = Date.now();
const cutoff = now - SCAN_WINDOW_MS;
const history = (recentScans.get(userId) ?? []).filter((at) => at > cutoff);
if (history.length >= SCAN_LIMIT) {
recentScans.set(userId, history);
return true;
}
history.push(now);
recentScans.set(userId, history);
// Without this the map grows one entry per driver forever. Anything whose
// whole history has aged out is a driver who isn't scanning any more.
if (recentScans.size > 500) {
for (const [key, times] of recentScans) {
if (times.every((at) => at <= cutoff)) recentScans.delete(key);
}
}
return false;
};
/**
* Abandoned onboarding leaves identity documents on disk that nothing points
* at. Sweeping them here rather than on a cron keeps the deployment to one
* process; once an hour is often enough for files that get a day's grace.
*/
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
let lastPruneAt = 0;
const pruneOrphansOccasionally = async (): Promise<void> => {
if (Date.now() - lastPruneAt < PRUNE_INTERVAL_MS) return;
lastPruneAt = Date.now();
try {
const rows = 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 license_image_url IS NOT NULL
OR id_image_url IS NOT NULL
OR vehicle_reg_image_url IS NOT NULL
`;
const referenced = new Set<string>();
for (const row of rows) {
for (const name of Object.values(row)) {
if (name) referenced.add(name);
}
}
await pruneOrphanUploads(referenced, "document");
} catch (error) {
// A failed sweep must never fail the driver's scan.
console.error("[DRIVER_SCAN_PRUNE]: ", error);
}
};
export async function POST(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const body = await req.json();
const { doc_type: docType } = body;
if (!isDocumentType(docType)) {
return Response.json(
{ error: "doc_type must be license, id or vehicle_reg." },
{ status: 400 },
);
}
// Same gate as onboarding itself: only a driver-role account has any
// business uploading driver documents.
const users = await sql<{ role: string | null }>`
SELECT role FROM users WHERE id = ${auth.userId}
`;
if (users[0]?.role !== "driver") {
return Response.json(
{ error: "Only driver accounts can scan documents." },
{ status: 403 },
);
}
if (overScanLimit(auth.userId)) {
return Response.json(
{
error: "Too many scans. Wait a few minutes and try again.",
code: "SCAN_RATE_LIMIT",
},
{ status: 429 },
);
}
const raw = body.image_base64;
if (typeof raw !== "string" || raw.length === 0) {
return Response.json(
{ error: "image_base64 is required." },
{ status: 400 },
);
}
// Some clients send a full data URI. Take the payload either way.
const encoded = raw.includes(",") ? raw.slice(raw.indexOf(",") + 1) : raw;
// Base64 inflates by 4/3, so reject on the encoded length before
// allocating — otherwise an oversized upload is buffered just to be
// refused.
if (encoded.length > MAX_UPLOAD_BYTES * 1.4) {
return Response.json(
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
{ status: 413 },
);
}
const image = Buffer.from(encoded, "base64");
if (image.length > MAX_UPLOAD_BYTES) {
return Response.json(
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
{ status: 413 },
);
}
// The magic bytes decide the type, not whatever the client claimed, so a
// non-image can't be parked on the disk under a .jpg name.
const mimeType = sniffImageType(image);
if (!mimeType) {
return Response.json(
{
error: "Upload a JPEG, PNG or WebP photo.",
code: "UNSUPPORTED_IMAGE",
},
{ status: 400 },
);
}
const document = await storeUpload(image, mimeType, "document");
void pruneOrphansOccasionally();
let fields = {};
let ocrFailed = false;
try {
const text = await recogniseDocument(image);
fields = parseDocumentText(text, docType);
} catch (error) {
if (!(error instanceof OcrUnavailableError)) throw error;
// Logged, not surfaced: the message can name the API key's failure mode
// and the driver can do nothing with it but type the fields manually.
console.error("[DRIVER_SCAN_OCR]: ", error.message);
ocrFailed = true;
}
return Response.json({
data: {
doc_type: docType,
/** Opaque stored name; submit it with the profile to attach the scan. */
document,
fields,
...(ocrFailed ? { code: "OCR_UNAVAILABLE" } : {}),
},
});
} catch (error) {
console.error("[DRIVER_SCAN_POST]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+80
View File
@@ -0,0 +1,80 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
// Device registration for push notifications.
//
// POST — claim this device for the signed-in user. Upsert on the token, so
// signing in as a different account on the same phone MOVES the
// device rather than leaving the previous account subscribed to
// notifications that are now someone else's.
// DELETE — release the device, called on sign-out.
//
// Not driver-only: riders need it too (a driver accepting, arriving, or the
// search timing out are all things worth waking a phone for), so it lives
// under /push rather than /driver.
const isExpoToken = (v: unknown): v is string =>
typeof v === "string" &&
v.length <= 256 &&
/^Expo(nent)?PushToken\[[^\]]+\]$/.test(v);
export async function POST(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const body = await req.json();
const { token, platform } = body;
if (!isExpoToken(token)) {
return Response.json(
{ error: "A valid Expo push token is required." },
{ status: 400 },
);
}
const rows = await sql<{ token: string }>`
INSERT INTO push_tokens (token, user_id, platform)
VALUES (${token}, ${auth.userId}, ${platform ?? null})
ON CONFLICT (token) DO UPDATE
SET user_id = EXCLUDED.user_id,
platform = EXCLUDED.platform,
updated_at = CURRENT_TIMESTAMP
RETURNING token
`;
return Response.json({ data: { registered: Boolean(rows[0]) } });
} catch (error) {
console.error("[PUSH_TOKEN_POST]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function DELETE(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const body = await req.json().catch(() => ({}));
const { token } = body as { token?: unknown };
if (!isExpoToken(token)) {
return Response.json(
{ error: "A valid Expo push token is required." },
{ status: 400 },
);
}
// Scoped to the caller: a token can only be released by the account that
// currently holds it.
await sql`
DELETE FROM push_tokens
WHERE token = ${token} AND user_id = ${auth.userId}
`;
return Response.json({ data: { released: true } });
} catch (error) {
console.error("[PUSH_TOKEN_DELETE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+272 -38
View File
@@ -1,11 +1,22 @@
import { requireAuth } from "@/lib/jwt";
import { sql, query } from "@/lib/db";
import { matchNextDriver } from "@/lib/dispatch";
import { sql } from "@/lib/db";
import { broadcastRequest } from "@/lib/dispatch";
import { requireDriverProfile } from "@/lib/driver";
import {
isCancellationReason,
DRIVER_CANCELLABLE_ARRAY,
RIDER_CANCELLABLE_ARRAY,
} from "@/lib/ride-lifecycle";
import { REQUEST_TTL_SECONDS } from "@/constants/dispatch";
import { COMMISSION_RATE } from "@/lib/pricing";
// GET — single ride by id, the rider's status-poll endpoint. If the ride is
// still 'requested' with no offer in flight, kick auto-match before reading
// so the rider's poll itself drives matching forward (no background worker).
// GET — single ride by id, the rider's status-poll endpoint.
//
// While the ride is still open this also returns the drivers who have offered
// on it, which is what the rider chooses from. The poll re-drives the
// broadcast too (a no-op once announced), so a request whose announcement lost
// its race with the push service still reaches drivers on the next tick —
// there is no background worker to do it.
export async function GET(request: Request, { id }: { id: string }) {
const auth = requireAuth(request);
if ("error" in auth) return auth.error;
@@ -23,9 +34,13 @@ export async function GET(request: Request, { id }: { id: string }) {
return Response.json({ error: "Ride not found." }, { status: 404 });
}
// Lazy match: try to offer the ride to a driver if it's still requested.
// Lazy dispatch: announce the request if that hasn't happened yet, and
// give up on it if it has run past its window. Awaited, because the row
// this request is about to read is the one the sweep may rewrite — a
// rider whose request just expired should be told, not shown a list of
// drivers they can no longer pick.
if (ride[0].status === "requested") {
void matchNextDriver(rideId);
await broadcastRequest(rideId);
}
const rows = await sql`
@@ -43,8 +58,22 @@ export async function GET(request: Request, { id }: { id: string }) {
r.status,
r.service,
r.created_at,
r.accepted_at,
r.arrived_at,
r.started_at,
r.completed_at,
r.cancelled_at,
r.cancelled_by,
r.cancellation_reason,
r.cash_collected_at,
-- The rider's copy of the pickup code. Only ever sent to the ride's
-- own rider (this route is rider-scoped), and only while it still
-- matters: once the trip has started the code is spent.
CASE WHEN r.status IN ('accepted', 'arrived') THEN r.pickup_code END
AS pickup_code,
-- Has this rider already rated the ride? Drives the rating card.
(SELECT rr.rating FROM ride_ratings rr
WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'rider') AS my_rating,
json_build_object(
'id', d.id,
'first_name', d.first_name,
@@ -53,6 +82,7 @@ export async function GET(request: Request, { id }: { id: string }) {
'profile_image_url', d.profile_image_url,
'car_image_url', d.car_image_url,
'rating', d.rating,
'rating_count', d.rating_count,
'service', d.service,
'car_model', d.car_model,
'latitude', d.latitude,
@@ -63,7 +93,38 @@ export async function GET(request: Request, { id }: { id: string }) {
WHERE r.ride_id = ${rideId}
`;
return Response.json({ data: rows[0] });
// The drivers who have volunteered, newest first. Only while the request
// is open: once it is assigned, the losing offers are nobody's business
// and the winning one is just "your driver". Coordinates are deliberately
// not included — a rider comparing offers needs how far away each driver
// is, not where they are, and only the chosen driver's position is theirs
// to watch.
const offers =
rows[0]?.status === "requested"
? await sql`
SELECT
ro.id AS offer_id, ro.offered_at, ro.pickup_distance_m,
d.id AS driver_id, d.first_name, d.last_name,
d.profile_image_url, d.car_image_url, d.car_model, d.car_seats,
d.rating, d.rating_count, d.service
FROM ride_offers ro
JOIN drivers d ON d.id = ro.driver_id
WHERE ro.ride_id = ${rideId} AND ro.status = 'offered'
ORDER BY ro.pickup_distance_m NULLS LAST, ro.offered_at
`
: [];
return Response.json({
data: {
...rows[0],
offers,
// The server's clock and the request window, so the "still looking"
// countdown the rider watches is the one the server actually enforces
// rather than whatever their phone thinks the time is.
now: new Date().toISOString(),
request_ttl_seconds: REQUEST_TTL_SECONDS,
},
});
} catch (error) {
console.error("[GET_RIDE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
@@ -71,17 +132,27 @@ export async function GET(request: Request, { id }: { id: string }) {
}
// PATCH — ride lifecycle transitions.
// Rider: { status: 'cancelled' } — only from 'requested' or 'accepted', and
// only on their own ride.
// Driver: { status: 'en_route' | 'completed' } — only on the ride they own
// (driver_id = their profile), from the right prior state.
// Rider: { status: 'cancelled', reason? } — before the trip starts, on
// their own ride.
// Driver: { status: 'arrived' } accepted -> arrived
// { status: 'en_route', pickup_code } arrived -> en_route
// { status: 'completed', cash_collected? } en_route -> completed
// { status: 'cancelled', reason? } before the trip starts
// Every transition is a single guarded UPDATE: the prior state is part of the
// WHERE clause, so a double-tap or a stale client can't skip a step or
// resurrect a finished ride, and two racing writers can't both win.
export async function PATCH(request: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
let body: { status?: string };
let body: {
status?: string;
reason?: string;
pickup_code?: string;
cash_collected?: boolean;
};
try {
body = await request.json();
} catch {
@@ -89,60 +160,223 @@ export async function PATCH(request: Request, { id }: { id: string }) {
}
const next = body.status;
// A reason is optional, but if one is sent it has to be a known code — the
// admin portal counts these, and free text would make them uncountable.
const reason = body.reason;
if (reason !== undefined && !isCancellationReason(reason)) {
return Response.json(
{ error: "Unknown cancellation reason." },
{ status: 400 },
);
}
try {
// Rider cancel — authenticate by ownership of the ride.
// Cancel — either the rider (any time before the trip starts) or the
// assigned driver (same window). Rider path is tried first: a user who is
// also a driver should cancel their own ride as a rider, not be misrouted
// to the driver branch.
if (next === "cancelled") {
const auth = requireAuth(request);
if ("error" in auth) return auth.error;
const rows = await sql<{ status: string }>`
const riderCancel = await sql<{ status: string }>`
UPDATE rides
SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP
SET status = 'cancelled',
cancelled_at = CURRENT_TIMESTAMP,
cancelled_by = 'rider',
cancellation_reason = ${reason ?? null}
WHERE ride_id = ${rideId}
AND user_id = ${auth.userId}
AND status IN ('requested', 'accepted')
AND status = ANY(${RIDER_CANCELLABLE_ARRAY}::text[])
RETURNING status
`;
if (!rows[0]) {
return Response.json(
{ error: "Ride cannot be cancelled." },
{ status: 409 },
);
if (riderCancel[0]) {
// Free the driver's offer so dispatch doesn't keep a phantom offer in
// flight for a ride that no longer exists.
await sql`
UPDATE ride_offers
SET status = 'cancelled', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND status = 'offered'
`;
return Response.json({ data: { status: riderCancel[0].status } });
}
return Response.json({ data: { status: rows[0].status } });
const driver = await requireDriverProfile(request);
if (!("error" in driver)) {
const driverCancel = await sql<{ status: string }>`
UPDATE rides
SET status = 'cancelled',
cancelled_at = CURRENT_TIMESTAMP,
cancelled_by = 'driver',
cancellation_reason = ${reason ?? null}
WHERE ride_id = ${rideId}
AND driver_id = ${driver.driverId}
AND status = ANY(${DRIVER_CANCELLABLE_ARRAY}::text[])
RETURNING status
`;
if (driverCancel[0]) {
return Response.json({ data: { status: driverCancel[0].status } });
}
}
return Response.json(
{ error: "Ride cannot be cancelled." },
{ status: 409 },
);
}
// Driver transitions — must be the driver assigned to the ride.
if (next === "en_route" || next === "completed") {
if (next === "arrived" || next === "en_route" || next === "completed") {
const result = await requireDriverProfile(request);
if ("error" in result) return result.error;
const { driverId } = result;
const priorStatus = next === "en_route" ? "accepted" : "en_route";
const setClause =
next === "completed"
? "status = $1, completed_at = CURRENT_TIMESTAMP, driver_id = $2"
: "status = $1, driver_id = $2";
const rows = await query<{ status: string }>(
`UPDATE rides SET ${setClause}
WHERE ride_id = $3 AND driver_id = $2 AND status = $4
RETURNING status`,
[next, driverId, rideId, priorStatus],
);
// Driver is at the pickup point. Purely informational for the rider,
// but it's the signal that turns "on the way" into "your car is here".
if (next === "arrived") {
const rows = await sql<{ status: string }>`
UPDATE rides
SET status = 'arrived', arrived_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'accepted'
RETURNING status
`;
if (!rows[0]) {
return Response.json(
{ error: "Ride cannot transition to that state." },
{ status: 409 },
);
}
return Response.json({ data: { status: rows[0].status } });
}
// Start the trip. The pickup code is the handshake that proves the
// person in the car is the rider who ordered it — checked inside the
// UPDATE so a wrong code can't start the trip even under a race.
if (next === "en_route") {
const code = String(body.pickup_code ?? "").trim();
if (!code) {
return Response.json(
{ error: "Pickup code required.", code: "PICKUP_CODE_REQUIRED" },
{ status: 400 },
);
}
const rows = await sql<{ status: string }>`
UPDATE rides
SET status = 'en_route', started_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status IN ('accepted', 'arrived')
AND pickup_code = ${code}
RETURNING status
`;
if (!rows[0]) {
// Distinguish "wrong code" from "wrong state" — the driver needs to
// know whether to re-ask the rider or reload the screen.
const current = await sql<{
status: string;
pickup_code: string | null;
}>`
SELECT status, pickup_code FROM rides
WHERE ride_id = ${rideId} AND driver_id = ${driverId}
`;
if (
current[0] &&
["accepted", "arrived"].includes(current[0].status) &&
current[0].pickup_code !== code
) {
return Response.json(
{
error: "That code doesn't match.",
code: "PICKUP_CODE_INVALID",
},
{ status: 403 },
);
}
return Response.json(
{ error: "Ride cannot transition to that state." },
{ status: 409 },
);
}
return Response.json({ data: { status: rows[0].status } });
}
// Complete. For a cash ride the driver also confirms they collected the
// fare, which is what moves the money from "owed" to "settled" — a cash
// ride left at payment_status='cash' is an unreconciled debt, and the
// admin portal reports on exactly that gap.
const settleCash = body.cash_collected === true;
// Stamp the fare split at completion. Computed from the row's own
// fare_price inside the UPDATE so it can't disagree with what was
// charged, and recorded with the rate used so a later rate change never
// rewrites what this driver was owed today.
//
// The ::numeric casts are load-bearing. Parameters are sent untyped, so
// Postgres infers each one from context — and next to an integer column
// it infers `fare_price * $n` as integer multiplication, then refuses to
// parse "0.2" as an integer. Every completion failed on that, which is
// what left drivers unable to end a trip at all.
const rows = await sql<{ status: string; payment_status: string }>`
UPDATE rides
SET status = 'completed',
completed_at = CURRENT_TIMESTAMP,
commission_rate = ${COMMISSION_RATE}::numeric,
platform_fee_cents = ROUND(fare_price * ${COMMISSION_RATE}::numeric),
driver_payout_cents =
fare_price - ROUND(fare_price * ${COMMISSION_RATE}::numeric),
payment_status = CASE
WHEN payment_status = 'cash' AND ${settleCash}::boolean
THEN 'cash_collected'
ELSE payment_status
END,
cash_collected_at = CASE
WHEN payment_status = 'cash' AND ${settleCash}::boolean
THEN CURRENT_TIMESTAMP
ELSE cash_collected_at
END,
-- Whoever physically holds their own share is settled immediately;
-- only the other side is left owed. A card ride means the company
-- has its fee and owes the driver; a collected cash fare means the
-- driver has their payout and owes the company. See
-- lib/settlement.ts, which is where this rule is defined.
platform_fee_settled_at = CASE
WHEN payment_status = 'paid' THEN CURRENT_TIMESTAMP
ELSE platform_fee_settled_at
END,
driver_payout_settled_at = CASE
WHEN payment_status = 'cash' AND ${settleCash}::boolean
THEN CURRENT_TIMESTAMP
ELSE driver_payout_settled_at
END
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'en_route'
RETURNING status, payment_status
`;
if (!rows[0]) {
return Response.json(
{ error: "Ride cannot transition to that state." },
{ status: 409 },
);
}
return Response.json({ data: { status: rows[0].status } });
return Response.json({
data: {
status: rows[0].status,
payment_status: rows[0].payment_status,
},
});
}
return Response.json({ error: "Unknown status transition." }, { status: 400 });
return Response.json(
{ error: "Unknown status transition." },
{ status: 400 },
);
} catch (error) {
console.error("[PATCH_RIDE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
}
+243
View File
@@ -0,0 +1,243 @@
import { sql } from "@/lib/db";
import { requireRideParticipant, rideIsActive } from "@/lib/ride-participants";
// In-app WebRTC audio call signaling, carried over the same DB-backed polling
// pattern as chat (no WebSocket). Non-trickle ICE: each side gathers all
// candidates locally and bundles them into a single SDP offer/answer stored as
// text, so the whole handshake is a few polled round-trips.
//
// POST { sdp_offer } -> caller starts a call (status=ringing)
// GET -> poll: callee reads the offer, both read
// the answer + status; lazily sweeps stale
// ringing calls to 'missed'.
// PATCH { action, sdp_answer? } -> answer / decline / end
// A ringing call older than this with no answer is treated as missed. Swept
// lazily inside GET, the way the broadcast advances on the ride-status poll.
const RINGING_TTL_SECONDS = 30;
type CallRow = {
id: number;
ride_id: number;
caller_type: "rider" | "driver";
status: "ringing" | "answered" | "ended" | "declined" | "missed";
sdp_offer: string | null;
sdp_answer: string | null;
started_at: string | null;
ended_at: string | null;
created_at: string;
};
// POST — initiate a call. Rejects if the ride isn't active or a call is already
// in flight for it, so two calls can't stack on one ride.
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: { sdp_offer?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const sdpOffer = body.sdp_offer;
if (!sdpOffer || typeof sdpOffer !== "string") {
return Response.json({ error: "Missing sdp_offer." }, { status: 400 });
}
try {
if (!(await rideIsActive(rideId))) {
return Response.json(
{ error: "This ride is no longer active." },
{ status: 409 },
);
}
// Snapshot both parties onto the call row so authorization is one
// equality check on poll and the call survives a driver reassignment.
const ride = await sql<{ user_id: string; driver_id: number }>`
SELECT user_id, driver_id FROM rides
WHERE ride_id = ${rideId} AND driver_id IS NOT NULL
`;
if (!ride[0]) {
return Response.json(
{ error: "This ride has no driver assigned." },
{ status: 409 },
);
}
// Only one non-terminal call per ride at a time.
const inFlight = await sql<{ n: number }>`
SELECT COUNT(*)::int AS n FROM calls
WHERE ride_id = ${rideId} AND status IN ('ringing','answered')
`;
if ((inFlight[0]?.n ?? 0) > 0) {
return Response.json(
{ error: "A call is already in progress for this ride." },
{ status: 409 },
);
}
const inserted = await sql<{ id: number }>`
INSERT INTO calls (ride_id, user_id, driver_id, caller_type, status, sdp_offer)
VALUES (
${rideId},
${ride[0].user_id},
${ride[0].driver_id},
${participant.role},
'ringing',
${sdpOffer}
)
RETURNING id
`;
return Response.json({ data: { callId: inserted[0].id } }, { status: 201 });
} catch (error) {
console.error("[POST_CALL]: ", error);
return Response.json({ error: "Internal Server Error." }, { status: 500 });
}
}
// GET — poll the call for this ride. Returns the latest non-terminal call (or
// the most recent terminal one so the caller sees ended/declined/missed), with
// `is_caller` so each side knows whether it placed the call.
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 {
// Lazy missed-call sweep: a ringing call nobody answered in time is
// marked missed so the caller's screen can stop ringing.
await sql`
UPDATE calls
SET status = 'missed', ended_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND status = 'ringing'
AND created_at < CURRENT_TIMESTAMP - make_interval(secs => ${RINGING_TTL_SECONDS})
`;
const rows = await sql<CallRow>`
SELECT id, ride_id, caller_type, status, sdp_offer, sdp_answer,
started_at, ended_at, created_at
FROM calls
WHERE ride_id = ${rideId}
ORDER BY created_at DESC
LIMIT 1
`;
const call = rows[0] ?? null;
return Response.json({
data: call
? { ...call, is_caller: call.caller_type === participant.role }
: null,
});
} catch (error) {
console.error("[GET_CALL]: ", error);
return Response.json({ error: "Internal Server Error." }, { status: 500 });
}
}
// PATCH — answer (callee only), decline (callee only), or end (either).
export async function PATCH(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: { action?: string; sdp_answer?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const action = body.action;
if (action !== "answer" && action !== "decline" && action !== "end") {
return Response.json(
{ error: "action must be 'answer', 'decline', or 'end'." },
{ status: 400 },
);
}
try {
// Answer/decline are the callee's moves; end is either party's.
const isCaller = (callerType: string) => callerType === participant.role;
const rows = await sql<{ caller_type: string; status: string }>`
SELECT caller_type, status FROM calls
WHERE ride_id = ${rideId} AND status IN ('ringing','answered')
ORDER BY created_at DESC LIMIT 1
`;
const call = rows[0];
if (!call) {
return Response.json(
{ error: "No active call for this ride." },
{ status: 409 },
);
}
if (action === "answer") {
if (isCaller(call.caller_type)) {
return Response.json(
{ error: "Caller cannot answer their own call." },
{ status: 403 },
);
}
if (call.status !== "ringing") {
return Response.json(
{ error: "Call is no longer ringing." },
{ status: 409 },
);
}
const sdpAnswer = body.sdp_answer;
if (!sdpAnswer || typeof sdpAnswer !== "string") {
return Response.json({ error: "Missing sdp_answer." }, { status: 400 });
}
await sql`
UPDATE calls
SET status = 'answered', sdp_answer = ${sdpAnswer}, started_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND status = 'ringing'
`;
return Response.json({ data: { action: "answered" } });
}
if (action === "decline") {
if (isCaller(call.caller_type)) {
return Response.json(
{ error: "Caller cannot decline their own call." },
{ status: 403 },
);
}
await sql`
UPDATE calls
SET status = 'declined', ended_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND status = 'ringing'
`;
return Response.json({ data: { action: "declined" } });
}
// end — either party, while ringing or answered.
await sql`
UPDATE calls
SET status = 'ended', ended_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND status IN ('ringing','answered')
`;
return Response.json({ data: { action: "ended" } });
} catch (error) {
console.error("[PATCH_CALL]: ", error);
return Response.json({ error: "Internal Server Error." }, { status: 500 });
}
}
+154
View File
@@ -0,0 +1,154 @@
import { sql } from "@/lib/db";
import { requireRideParticipant, rideIsActive } from "@/lib/ride-participants";
// In-app chat for a ride. Both the rider and the assigned driver can read and
// post, but only while the ride is active (accepted / en_route); a terminal
// ride is read-only so the conversation is frozen once the trip ends.
type MessageRow = {
id: number;
ride_id: number;
sender_type: "rider" | "driver";
sender_id: string;
body: string;
created_at: string;
sender_name: string;
sender_avatar: string | null;
};
// GET — messages for the ride. `?since=<id>` returns only rows with id > since
// (the polling cursor), oldest-first so the client can append directly. With
// no cursor the full history is returned for the initial load.
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;
const sinceParam = new URL(req.url).searchParams.get("since");
const since = Number(sinceParam);
const hasCursor = Number.isInteger(since) && since > 0;
try {
// The optional `since` cursor can't be a nested sql fragment (sql executes
// immediately), so branch into two queries that each take no extra params.
const rows = hasCursor
? await sql<MessageRow>`
SELECT
m.id,
m.ride_id,
m.sender_type,
COALESCE(m.sender_user_id::text, m.sender_driver_id::text) AS sender_id,
m.body,
m.created_at,
COALESCE(u.name, CONCAT_WS(' ', d.first_name, d.last_name)) AS sender_name,
d.profile_image_url AS sender_avatar
FROM messages m
LEFT JOIN users u ON u.id = m.sender_user_id
LEFT JOIN drivers d ON d.id = m.sender_driver_id
WHERE m.ride_id = ${rideId} AND m.id > ${since}
ORDER BY m.id ASC
`
: await sql<MessageRow>`
SELECT
m.id,
m.ride_id,
m.sender_type,
COALESCE(m.sender_user_id::text, m.sender_driver_id::text) AS sender_id,
m.body,
m.created_at,
COALESCE(u.name, CONCAT_WS(' ', d.first_name, d.last_name)) AS sender_name,
d.profile_image_url AS sender_avatar
FROM messages m
LEFT JOIN users u ON u.id = m.sender_user_id
LEFT JOIN drivers d ON d.id = m.sender_driver_id
WHERE m.ride_id = ${rideId}
ORDER BY m.id ASC
`;
return Response.json({ data: rows });
} catch (error) {
console.error("[GET_MESSAGES]: ", error);
return Response.json({ error: "Internal Server Error." }, { status: 500 });
}
}
// POST — send a message. Rejected (409) if the ride is no longer active, so a
// completed/cancelled trip can't receive new messages.
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: { body?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const text = (body.body ?? "").trim();
if (!text) {
return Response.json({ error: "Message body is empty." }, { status: 400 });
}
if (text.length > 4000) {
return Response.json({ error: "Message is too long." }, { status: 400 });
}
try {
if (!(await rideIsActive(rideId))) {
return Response.json(
{ error: "This ride is no longer active." },
{ status: 409 },
);
}
const inserted = await sql<MessageRow>`
INSERT INTO messages (ride_id, sender_type, sender_user_id, sender_driver_id, body)
VALUES (
${rideId},
${participant.role},
${participant.role === "rider" ? participant.userId : null},
${participant.role === "driver" ? participant.driverId : null},
${text}
)
RETURNING
id,
ride_id,
sender_type,
COALESCE(sender_user_id::text, sender_driver_id::text) AS sender_id,
body,
created_at
`;
// Join the sender's name/avatar for the returned row so the client can
// render the optimistic bubble identically to polled ones.
const message = inserted[0];
if (participant.role === "driver") {
const driver = await sql<{ name: string; avatar: string | null }>`
SELECT CONCAT_WS(' ', first_name, last_name) AS name, profile_image_url AS avatar
FROM drivers WHERE id = ${participant.driverId}
`;
message.sender_name = driver[0]?.name ?? "";
message.sender_avatar = driver[0]?.avatar ?? null;
} else {
const rider = await sql<{ name: string }>`
SELECT name FROM users WHERE id = ${participant.userId}
`;
message.sender_name = rider[0]?.name ?? "";
message.sender_avatar = null;
}
return Response.json({ data: message }, { status: 201 });
} catch (error) {
console.error("[POST_MESSAGE]: ", error);
return Response.json({ error: "Internal Server Error." }, { status: 500 });
}
}
+171
View File
@@ -0,0 +1,171 @@
import { requireApprovedDriver } from "@/lib/driver";
import { sql, transaction } from "@/lib/db";
import { sendPushToUser } from "@/lib/push";
import { DRIVER_BUSY_ARRAY } from "@/lib/ride-lifecycle";
import { haversine } from "@/lib/utils";
// POST — a driver's answer to a broadcast request.
//
// { action: 'offer' } — volunteer for it. The rider sees this driver
// appear in their list of offers and may pick them.
// { action: 'withdraw' } — take the offer back, before the rider picks.
//
// Offering is not an assignment: several drivers can be offered on the same
// request at once and none of them is committed until the rider chooses. That
// is why offering doesn't take a driver off the board, and why withdrawing is
// free — the cost of a driver changing their mind lands here rather than on a
// rider whose ride was already promised away.
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 });
}
// Approval is re-checked here, not just at broadcast time: a driver
// suspended between seeing a request and tapping Offer must not be able to
// put themselves in front of a rider. (Rides already under way stay under
// requireDriverProfile — a suspension must never strand a rider who is
// sitting in the car.)
const result = await requireApprovedDriver(req);
if ("error" in result) return result.error;
const { driverId } = result;
let body: { action?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const action = body.action;
if (action !== "offer" && action !== "withdraw") {
return Response.json(
{ error: "action must be 'offer' or 'withdraw'." },
{ status: 400 },
);
}
try {
if (action === "withdraw") {
const withdrawn = await sql<{ id: number }>`
UPDATE ride_offers
SET status = 'withdrawn', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'offered'
RETURNING id
`;
if (!withdrawn[0]) {
return Response.json(
{ error: "There is no live offer to withdraw." },
{ status: 409 },
);
}
return Response.json({ data: { status: "withdrawn" } });
}
const offered = await transaction<{
userId: string;
alreadyOffered: boolean;
} | null>(async (tx) => {
// Lock the request so a rider picking someone else at this exact moment
// and this driver offering can't both believe they won.
const rides = await tx<{
status: string;
user_id: string;
service: string;
lat: number;
lng: number;
}>`
SELECT status, user_id, service,
origin_latitude AS lat, origin_longitude AS lng
FROM rides WHERE ride_id = ${rideId} FOR UPDATE
`;
const ride = rides[0];
if (!ride || ride.status !== "requested") return null;
// The driver's own state has to be re-read here rather than trusted from
// the dashboard that drew the button: service, liveness and — above all
// — whether they picked up another ride in the meantime.
const drivers = await tx<{
service: string;
online: boolean;
latitude: number | null;
longitude: number | null;
}>`
SELECT service, online, latitude, longitude
FROM drivers WHERE id = ${driverId}
`;
const driver = drivers[0];
if (!driver || !driver.online || driver.service !== ride.service) {
return null;
}
const busy = await tx<{ n: number }>`
SELECT COUNT(*)::int AS n FROM rides
WHERE driver_id = ${driverId}
AND status = ANY(${DRIVER_BUSY_ARRAY}::text[])
`;
if ((busy[0]?.n ?? 0) > 0) return null;
const distance =
driver.latitude === null || driver.longitude === null
? null
: Math.round(
haversine(ride.lat, ride.lng, driver.latitude, driver.longitude),
);
// ON CONFLICT rather than an existence check: the unique index is the
// real guard, and a driver who taps Offer twice (or re-offers after
// withdrawing) should end up with one live offer either way.
const rows = await tx<{ inserted: boolean }>`
INSERT INTO ride_offers (ride_id, driver_id, status, pickup_distance_m)
VALUES (${rideId}, ${driverId}, 'offered', ${distance})
ON CONFLICT (ride_id, driver_id) DO UPDATE
SET status = 'offered',
offered_at = CURRENT_TIMESTAMP,
responded_at = NULL,
pickup_distance_m = EXCLUDED.pickup_distance_m
WHERE ride_offers.status IN ('withdrawn', 'offered')
RETURNING (xmax = 0) AS inserted
`;
// No row means the conflict target existed in a state we refuse to
// revive — the rider already picked someone, or this offer was closed
// with the request.
if (!rows[0]) return null;
return { userId: ride.user_id, alreadyOffered: !rows[0].inserted };
});
if (!offered) {
return Response.json(
{ error: "This request is no longer open." },
{ status: 409 },
);
}
// Nudge the rider — they are sitting on a screen watching for exactly
// this. Only for the first offer on the request: the rest arrive on the
// list they are already looking at, and a buzz per driver would turn a
// busy street into a nuisance.
if (!offered.alreadyOffered) {
const [count] = await sql<{ n: number }>`
SELECT COUNT(*)::int AS n FROM ride_offers
WHERE ride_id = ${rideId} AND status = 'offered'
`;
if ((count?.n ?? 0) === 1) {
void sendPushToUser(offered.userId, {
title: "A driver is available",
body: "Open your ride to see who can pick you up.",
data: { type: "ride_offer_received", rideId },
});
}
}
return Response.json({ data: { status: "offered" } });
} catch (error) {
console.error("[RIDE_OFFER]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+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 });
}
}
-102
View File
@@ -1,102 +0,0 @@
import { requireDriverProfile } from "@/lib/driver";
import { transaction } from "@/lib/db";
import { matchNextDriver } from "@/lib/dispatch";
// POST — a driver responds to a ride offer.
// { action: 'accept' } — claim the ride: offer -> accepted, ride -> accepted,
// ride.driver_id set to this driver. Guarded so only
// the offered driver can accept, and only while the
// offer is still 'offered' (not expired/timed out).
// { action: 'decline' } — release the ride: offer -> declined, then offer
// it to the next-nearest driver via matchNextDriver.
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 result = await requireDriverProfile(req);
if ("error" in result) return result.error;
const { driverId } = result;
let body: { action?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const action = body.action;
if (action !== "accept" && action !== "decline") {
return Response.json(
{ error: "action must be 'accept' or 'decline'." },
{ status: 400 },
);
}
try {
if (action === "accept") {
const claimed = await transaction(async (tx) => {
// Atomically flip the offer to accepted only if it's still offered to
// this driver. This is the race guard: two drivers can't both accept,
// and an expired offer can't be revived.
const offer = await tx<{ id: number }>`
UPDATE ride_offers
SET status = 'accepted', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'offered'
RETURNING id
`;
if (!offer[0]) return null;
// Assign the ride to this driver. The status='requested' guard means
// we never overwrite a ride another driver already accepted.
const ride = await tx`
UPDATE rides
SET status = 'accepted', driver_id = ${driverId}
WHERE ride_id = ${rideId} AND status = 'requested'
RETURNING ride_id
`;
if (!ride[0]) return null;
return offer[0].id;
});
if (claimed === null) {
return Response.json(
{ error: "This offer is no longer available." },
{ status: 409 },
);
}
return Response.json({ data: { action: "accepted" } });
}
// Decline: mark the offer declined and offer the ride to the next driver.
const declined = await transaction(async (tx) => {
const offer = await tx`
UPDATE ride_offers
SET status = 'declined', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'offered'
RETURNING id
`;
return offer[0]?.id ?? null;
});
if (declined === null) {
return Response.json(
{ error: "This offer is no longer available." },
{ status: 409 },
);
}
void matchNextDriver(rideId);
return Response.json({ data: { action: "declined" } });
} catch (error) {
console.error("[RIDE_RESPOND]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+202
View File
@@ -0,0 +1,202 @@
import { requireAuth } from "@/lib/jwt";
import { transaction } from "@/lib/db";
import { getOrder, consumeOrderForRide } from "@/lib/payment-orders";
import { sendPushToDriver } from "@/lib/push";
import { DRIVER_BUSY_ARRAY, generatePickupCode } from "@/lib/ride-lifecycle";
// POST — the rider picks one of the drivers who offered, and pays.
//
// { offer_id, payment_method: 'cash' }
// { offer_id, payment_method: 'card', payment_order_id }
//
// This is the single moment a ride is assigned. Everything that has to be true
// at once — the request is still open, this offer is still live, the driver is
// still free, and (for card) a paid order of the right amount exists and has
// not been spent — is checked inside one transaction, so a rider and a
// disappearing driver can't half-complete it.
//
// The card order is consumed here rather than earlier for the same reason: if
// the pick fails because the driver just took another job, the transaction
// rolls back with the order still 'paid', and the rider can pick a different
// driver with the money they already put down instead of paying twice.
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 auth = requireAuth(req);
if ("error" in auth) return auth.error;
let body: {
offer_id?: number;
payment_method?: string;
payment_order_id?: string;
};
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const offerId = Number(body.offer_id);
if (!Number.isInteger(offerId)) {
return Response.json({ error: "offer_id is required." }, { status: 400 });
}
const method = body.payment_method;
if (method !== "cash" && method !== "card") {
return Response.json({ error: "Invalid payment method." }, { status: 400 });
}
try {
// Card: everything about the order is verified before the transaction
// opens, so the only thing left to do inside it is spend it.
if (method === "card") {
if (!body.payment_order_id) {
return Response.json(
{ error: "Missing payment order id." },
{ status: 400 },
);
}
const order = await getOrder(body.payment_order_id);
if (!order)
return Response.json(
{ error: "Payment order not found." },
{ status: 404 },
);
if (order.user_id !== auth.userId)
return Response.json({ error: "Unauthorized." }, { status: 403 });
if (order.status !== "paid")
return Response.json(
{ error: "Payment not verified." },
{ status: 400 },
);
}
const picked = await transaction<
| { driverId: number; fare: number }
| "gone"
| "amount_mismatch"
| "order_spent"
>(async (tx) => {
// Lock the request. A second tap on a second driver serialises behind
// this and finds the ride already assigned.
const rides = await tx<{
status: string;
fare_price: number;
origin_address: string;
}>`
SELECT status, fare_price, origin_address
FROM rides
WHERE ride_id = ${rideId} AND user_id = ${auth.userId}
FOR UPDATE
`;
const ride = rides[0];
if (!ride || ride.status !== "requested") return "gone";
const offers = await tx<{ driver_id: number }>`
SELECT driver_id FROM ride_offers
WHERE id = ${offerId} AND ride_id = ${rideId} AND status = 'offered'
`;
const offer = offers[0];
if (!offer) return "gone";
// The driver may have been picked by somebody else in the seconds the
// rider spent deciding. Their other ride is the authority, not the offer.
const busy = await tx<{ n: number }>`
SELECT COUNT(*)::int AS n FROM rides
WHERE driver_id = ${offer.driver_id}
AND status = ANY(${DRIVER_BUSY_ARRAY}::text[])
`;
if ((busy[0]?.n ?? 0) > 0) return "gone";
let paymentStatus = "cash";
let orderId: string | null = null;
if (method === "card") {
const order = await getOrder(body.payment_order_id!);
if (!order) return "gone";
// Re-checked against the row we just locked: the fare is authoritative
// here, not the number the client did its arithmetic with.
if (order.amount_cents !== Number(ride.fare_price))
return "amount_mismatch";
const consumed = await consumeOrderForRide(
body.payment_order_id!,
auth.userId,
tx,
);
if (!consumed) return "order_spent";
paymentStatus = "paid";
orderId = body.payment_order_id!;
}
// Assign. The status='requested' guard is what stops a double-submit
// from reassigning a ride that already has a driver.
const assigned = await tx<{ ride_id: number }>`
UPDATE rides
SET status = 'accepted',
driver_id = ${offer.driver_id},
accepted_at = CURRENT_TIMESTAMP,
payment_status = ${paymentStatus},
payment_order_id = COALESCE(${orderId}, payment_order_id),
pickup_code = COALESCE(pickup_code, ${generatePickupCode()})
WHERE ride_id = ${rideId} AND status = 'requested'
RETURNING ride_id
`;
if (!assigned[0]) return "gone";
await tx`
UPDATE ride_offers
SET status = 'accepted', responded_at = CURRENT_TIMESTAMP
WHERE id = ${offerId}
`;
// Everyone else who volunteered is released in the same breath, so no
// driver is left with a card for a job that is already someone else's.
await tx`
UPDATE ride_offers
SET status = 'passed', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND id <> ${offerId} AND status = 'offered'
`;
return { driverId: offer.driver_id, fare: Number(ride.fare_price) };
});
if (picked === "amount_mismatch") {
return Response.json(
{ error: "Payment does not match this ride." },
{ status: 400 },
);
}
if (picked === "order_spent") {
return Response.json(
{ error: "That payment has already been used." },
{ status: 409 },
);
}
if (picked === "gone") {
return Response.json(
{
error: "That driver is no longer available.",
code: "OFFER_UNAVAILABLE",
},
{ status: 409 },
);
}
void sendPushToDriver(picked.driverId, {
title: "You got the ride",
body: "The rider picked you. Head to the pickup point.",
data: { type: "ride_assigned", rideId },
});
return Response.json({ data: { status: "accepted" } });
} catch (error) {
console.error("[RIDE_SELECT]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+82
View File
@@ -0,0 +1,82 @@
import { sql } from "@/lib/db";
import { requireAuth } from "@/lib/jwt";
import { ACTIVE_STATUS_ARRAY, expireStaleRequests } from "@/lib/ride-lifecycle";
// GET — "does this rider have unfinished business?", answered in one call.
//
// active : a ride still in flight (requested/accepted/arrived/en_route).
// Killing the app used to strand a rider away from their
// tracking screen with no way back; the home banner reads
// this to put them back on it.
// pending_rating : a ride that finished recently and hasn't been rated yet,
// so the prompt survives the app being backgrounded at
// drop-off — the moment ratings are most often lost.
export async function GET(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
// Sweep searches that have run past the TTL (unscoped — this is one of the
// lazy paths that stands in for a background worker), so the banner never
// advertises a ride that is really long dead.
await expireStaleRequests();
const active = await sql<{
ride_id: number;
status: string;
service: string;
origin_address: string;
destination_address: string;
fare_price: number;
driver_name: string | null;
}>`
SELECT
r.ride_id, r.status, r.service,
r.origin_address, r.destination_address, r.fare_price,
NULLIF(TRIM(COALESCE(d.first_name, '') || ' ' || COALESCE(d.last_name, '')), '')
AS driver_name
FROM rides r
LEFT JOIN drivers d ON d.id = r.driver_id
WHERE r.user_id = ${auth.userId}
AND r.status = ANY(${ACTIVE_STATUS_ARRAY}::text[])
ORDER BY r.created_at DESC
LIMIT 1
`;
// Only prompt for rides that ended in the last day — a week-old ride is a
// nag, not a reminder.
const pending = await sql<{
ride_id: number;
destination_address: string;
driver_name: string | null;
driver_avatar: string | null;
}>`
SELECT
r.ride_id, r.destination_address,
NULLIF(TRIM(COALESCE(d.first_name, '') || ' ' || COALESCE(d.last_name, '')), '')
AS driver_name,
d.profile_image_url AS driver_avatar
FROM rides r
LEFT JOIN drivers d ON d.id = r.driver_id
WHERE r.user_id = ${auth.userId}
AND r.status = 'completed'
AND r.completed_at > CURRENT_TIMESTAMP - INTERVAL '1 day'
AND NOT EXISTS (
SELECT 1 FROM ride_ratings rr
WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'rider'
)
ORDER BY r.completed_at DESC
LIMIT 1
`;
return Response.json({
data: {
active: active[0] ?? null,
pending_rating: pending[0] ?? null,
},
});
} catch (error) {
console.error("[RIDE_ACTIVE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+44 -118
View File
@@ -1,18 +1,25 @@
import { requireAuth } from "@/lib/jwt";
import { sql, transaction } from "@/lib/db";
import { getOrder, consumeOrderForRide } from "@/lib/payment-orders";
import { matchNextDriver } from "@/lib/dispatch";
import { sql } from "@/lib/db";
import { broadcastRequest } from "@/lib/dispatch";
import { isServiceId } from "@/lib/driver";
import { ACTIVE_STATUS_ARRAY } from "@/lib/ride-lifecycle";
import { DEFAULT_SERVICE } from "@/constants/services";
// Explicit missing check — a truthy check would reject legitimate 0 values
// like latitude 0.0 (the equator) or a zero fare.
const isMissing = (v: unknown): boolean => v === undefined || v === null;
// POST — request a ride. The rider no longer picks a driver; the ride is
// created with status='requested' and driver_id=NULL, then auto-match offers
// it to the nearest eligible driver of the requested service. `driver_id` in
// the body is accepted for backward compatibility but ignored.
// POST — open a ride request.
//
// This fires the moment the rider taps "Find now", before any payment
// decision: the ride is created with status='requested', driver_id=NULL and
// payment_status='pending', then broadcast to every eligible driver near the
// pickup. Drivers volunteer, the rider picks one, and /ride/:id/select is
// where the driver, the payment method and (for card) the paid order all land
// together.
//
// Nothing is charged here, so there is nothing to refund if no driver takes
// it — which is the point of moving payment behind the pick.
export async function POST(request: Request) {
const auth = requireAuth(request);
if ("error" in auth) return auth.error;
@@ -28,8 +35,6 @@ export async function POST(request: Request) {
destination_longitude,
ride_time,
fare_price,
payment_method,
payment_order_id,
service,
} = body;
@@ -49,116 +54,33 @@ export async function POST(request: Request) {
);
}
if (payment_method !== "card" && payment_method !== "cash")
return Response.json(
{ error: "Invalid payment method." },
{ status: 400 },
);
const rideService = isServiceId(service) ? service : DEFAULT_SERVICE;
const fareCents = Math.round(Number(fare_price));
if (payment_method === "card") {
// Card: the ride is only recorded once a paid, server-authoritative
// payment order is consumed. The client can no longer self-declare
// payment_status='paid'.
if (isMissing(payment_order_id))
return Response.json(
{ error: "Missing payment order id." },
{ status: 400 },
);
const order = await getOrder(payment_order_id);
if (!order)
return Response.json(
{ error: "Payment order not found." },
{ status: 404 },
);
if (order.user_id !== auth.userId)
return Response.json({ error: "Unauthorized." }, { status: 403 });
if (order.status !== "paid")
return Response.json(
{ error: "Payment not verified." },
{ status: 400 },
);
if (order.amount_cents !== fareCents)
return Response.json(
{ error: "Payment amount mismatch." },
{ status: 400 },
);
// Reconcile route intent (driver isn't known yet, so driver_id is no
// longer part of the intent check). Null intent fields are skipped.
const intentsMatch =
(order.origin_address === null ||
order.origin_address === origin_address) &&
(order.destination_address === null ||
order.destination_address === destination_address) &&
(order.ride_time === null || order.ride_time === Number(ride_time));
if (!intentsMatch)
return Response.json(
{ error: "Payment does not match this ride." },
{ status: 400 },
);
// Consume the order and insert the ride on one connection, so a failure
// rolls back both and no paid order is wasted without a ride.
const inserted = await transaction(async (tx) => {
const consumed = await consumeOrderForRide(
payment_order_id,
auth.userId,
tx,
);
if (!consumed) throw new Error("PAYMENT_ORDER_NOT_CONSUMABLE");
const rows = await tx`
INSERT INTO rides (
origin_address,
destination_address,
origin_latitude,
origin_longitude,
destination_latitude,
destination_longitude,
ride_time,
fare_price,
payment_status,
driver_id,
user_id,
payment_order_id,
status,
service
) VALUES (
${origin_address},
${destination_address},
${origin_latitude},
${origin_longitude},
${destination_latitude},
${destination_longitude},
${ride_time},
${fareCents},
'paid',
NULL,
${auth.userId},
${payment_order_id},
'requested',
${rideService}
)
RETURNING *
`;
return rows[0];
});
// Kick off auto-match asynchronously — don't block the response on it.
void matchNextDriver(inserted.ride_id);
return Response.json({ data: inserted }, { status: 201 });
if (!Number.isFinite(fareCents) || fareCents <= 0) {
return Response.json({ error: "Invalid fare." }, { status: 400 });
}
// One ride in flight per rider. Without this a rider who backs out of the
// tracking screen and re-books ends up with two live requests broadcast to
// the same drivers, who then see the same job twice from one person.
const inFlight = await sql<{ ride_id: number; status: string }>`
SELECT ride_id, status FROM rides
WHERE user_id = ${auth.userId}
AND status = ANY(${ACTIVE_STATUS_ARRAY}::text[])
ORDER BY created_at DESC
LIMIT 1
`;
if (inFlight[0]) {
return Response.json(
{
error: "You already have a ride in progress.",
code: "RIDE_IN_PROGRESS",
ride_id: inFlight[0].ride_id,
},
{ status: 409 },
);
}
// Cash: settled directly with the driver at drop-off. No order involved.
const response = await sql`
INSERT INTO rides (
origin_address,
@@ -183,7 +105,7 @@ export async function POST(request: Request) {
${destination_longitude},
${ride_time},
${fareCents},
'cash',
'pending',
NULL,
${auth.userId},
'requested',
@@ -192,11 +114,15 @@ export async function POST(request: Request) {
RETURNING *
`;
void matchNextDriver(response[0].ride_id);
// Announce it to nearby drivers. Not awaited: the rider's screen should
// open on "looking for drivers" immediately, and the rider's own status
// poll re-drives the broadcast if this one loses its race with the push
// service.
void broadcastRequest(response[0].ride_id);
return Response.json({ data: response[0] }, { status: 201 });
} catch (error) {
console.error("[CREATE_RIDES]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
}
+7 -2
View File
@@ -1,5 +1,6 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
import { TERMINAL_STATUS_ARRAY } from "@/lib/ride-lifecycle";
// GET — the signed-in rider's ride history (completed + cancelled rides),
// newest first, with the assigned driver (nullable via LEFT JOIN). This feeds
@@ -27,6 +28,10 @@ export async function GET(req: Request) {
r.created_at,
r.completed_at,
r.cancelled_at,
r.cancelled_by,
r.cancellation_reason,
(SELECT rr.rating FROM ride_ratings rr
WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'rider') AS my_rating,
json_build_object(
'id', d.id,
'first_name', d.first_name,
@@ -41,7 +46,7 @@ export async function GET(req: Request) {
FROM rides r
LEFT JOIN drivers d ON d.id = r.driver_id
WHERE r.user_id = ${auth.userId}
AND r.status IN ('completed', 'cancelled')
AND r.status = ANY(${TERMINAL_STATUS_ARRAY}::text[])
ORDER BY r.created_at DESC
`;
@@ -50,4 +55,4 @@ export async function GET(req: Request) {
console.error("[GET_RIDE_LIST]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
}
+26 -1
View File
@@ -18,6 +18,15 @@ export async function GET(req: Request) {
}
}
// PATCH — one-time role selection, straight after sign-up.
//
// The role is write-once. It used to be freely re-assignable, which meant any
// account could flip itself to 'driver' on demand; combined with self-service
// onboarding that was a rider account away from receiving live pickups. Role
// is no longer a credential on its own (driver profiles are vetted), but it
// still shouldn't be a toggle: a user who genuinely needs to switch goes
// through support, which leaves a record. Re-sending the same role is a no-op
// so a retried request from the role screen still succeeds.
export async function PATCH(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
@@ -32,11 +41,27 @@ export async function PATCH(req: Request) {
const response = await sql`
UPDATE users SET role = ${role}
WHERE id = ${auth.userId}
AND (role IS NULL OR role = ${role})
RETURNING id, role
`;
if (response.length === 0) {
return Response.json({ error: "User not found." }, { status: 404 });
const existing = await sql<{ role: string | null }>`
SELECT role FROM users WHERE id = ${auth.userId}
`;
if (!existing[0]) {
return Response.json({ error: "User not found." }, { status: 404 });
}
return Response.json(
{
error: "Your account role has already been set.",
code: "ROLE_ALREADY_SET",
role: existing[0].role,
},
{ status: 409 },
);
}
return Response.json({ data: response[0] });
+7
View File
@@ -63,6 +63,13 @@ const TabsLayout = () => {
tabBarActiveTintColor: "white",
tabBarInactiveTintColor: "white",
tabBarShowLabel: false,
// Get out of the way while someone is typing. The bar floats
// (position: absolute) and Android resizes the window around the
// keyboard, so it doesn't stay at the bottom of the screen — it rides up
// and parks on top of the address suggestions the rider is trying to
// tap, which is the worst possible place for it during a pickup or
// destination search.
tabBarHideOnKeyboard: true,
tabBarStyle: {
backgroundColor: isDark ? "#0a0a0a" : "#333",
borderRadius: 50,
+8 -35
View File
@@ -1,38 +1,11 @@
import { Image, ScrollView, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { ChatThread } from "@/components/chat-thread";
import { images } from "@/constants";
import { useT } from "@/lib/i18n";
// Tab-bar footprint: 78px tall + 20px bottom margin (see (tabs)/_layout.tsx).
// It's position:"absolute" so it reserves no layout space of its own — the
// composer below needs this much extra clearance or the floating pill bar
// sits on top of it.
const TAB_BAR_CLEARANCE = 98;
const Chat = () => {
const t = useT();
const Chat = () => <ChatThread tabBarClearance={TAB_BAR_CLEARANCE} />;
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 p-5">
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
<Text className="text-2xl font-JakartaBold text-black dark:text-white">
{t("chat.title")}
</Text>
<View className="flex-1 h-fit flex justify-center items-center">
<Image
source={images.message}
alt={t("chat.messageAlt")}
className="w-full h-40"
resizeMode="contain"
/>
<Text className="text-3xl font-JakartaBold mt-3 text-black dark:text-white">
{t("chat.noMessages")}
</Text>
<Text className="text-base mt-2 text-center px-7 text-general-200 dark:text-neutral-400">
{t("chat.startConversation")}
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
};
export default Chat;
export default Chat;
+10 -1
View File
@@ -9,6 +9,7 @@ import {
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { ActiveRideBanner } from "@/components/active-ride-banner";
import { GoogleTextInput } from "@/components/google-text-input";
import { LocationNotice } from "@/components/location-notice";
import { Map } from "@/components/map";
@@ -28,6 +29,7 @@ const Home = () => {
const setDestinationLocation = useLocationStore(
(state) => state.setDestinationLocation,
);
const clearDestination = useLocationStore((state) => state.clearDestination);
const { signOut, user } = useSession();
const { isDark } = useTheme();
const t = useT();
@@ -36,6 +38,9 @@ const Home = () => {
const { status: locationStatus, retry: retryLocation } = useUserLocation();
const handleSignOut = () => {
// A different person signing in on this phone must not inherit the last
// rider's destination — the store lives in the JS process, not the session.
clearDestination();
signOut();
router.replace("/(auth)/sign-in");
@@ -103,6 +108,10 @@ const Home = () => {
</View>
</View>
{/* Unfinished ride or unrated trip — the way back into a ride the
rider navigated away from. */}
<ActiveRideBanner />
<GoogleTextInput
icon={icons.search}
containerStyles="bg-white dark:bg-neutral-900 shadow-md shadow-neutral-300 dark:shadow-neutral-950/40"
@@ -118,7 +127,7 @@ const Home = () => {
<>
{/* The map draws straight away on the Beirut fallback so the
slot never sits empty while the fix is still coming. */}
<Map />
<Map routeless />
{locationStatus === "pending" ? (
<View className="absolute bottom-3 self-center flex-row items-center rounded-full bg-white/95 dark:bg-neutral-900/95 px-4 py-2 shadow-md shadow-neutral-400/40 dark:shadow-neutral-950/40">
+113 -138
View File
@@ -1,8 +1,15 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useFocusEffect } from "expo-router";
import { Alert, Linking, Platform, ScrollView, Text, View } from "react-native";
import {
Alert,
Linking,
Platform,
ScrollView,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useCallback, useState } from "react";
import { Children, Fragment, useCallback, useState } from "react";
import { SettingsRow } from "@/components/settings-row";
import {
@@ -12,7 +19,6 @@ import {
} from "@/lib/settings";
import { useT } from "@/lib/i18n";
import { useLocationPermission } from "@/lib/use-location-permission";
import { useTheme } from "@/lib/theme";
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>["name"];
@@ -22,15 +28,45 @@ const SectionHeader = ({ title }: { title: string }) => (
</Text>
);
const Card = ({ children }: { children: React.ReactNode }) => (
<View className="rounded-2xl bg-white dark:bg-neutral-900 overflow-hidden shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
{children}
</View>
);
/**
* A grouped settings card. Renders an optional muted description header, then
* its children with an automatic divider between each row — so callers never
* hand-thread `border-t` wrapper Views. Null/conditional children (and arrays
* from `.map`) are flattened by `Children.toArray`, so conditionals like
* `status !== "granted" ? <Row/> : null` and `options.map(...)` both work.
*/
const SettingsCard = ({
description,
children,
}: {
description?: string;
children: React.ReactNode;
}) => {
const rows = Children.toArray(children);
return (
<View className="rounded-2xl bg-white dark:bg-neutral-900 overflow-hidden shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
{description ? (
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{description}
</Text>
</View>
) : null}
{rows.map((row, index) => (
<Fragment key={index}>
{index > 0 ? (
<View className="border-t border-neutral-100 dark:border-neutral-800" />
) : null}
{row}
</Fragment>
))}
</View>
);
};
const Settings = () => {
const t = useT();
const { isDark } = useTheme();
const mode = useSettingsStore((state) => state.mode);
const setMode = useSettingsStore((state) => state.setMode);
@@ -63,20 +99,6 @@ const Settings = () => {
? t("settings.maps.statusBlocked")
: t("settings.maps.statusUnknown");
const modeLabel =
mode === "light"
? t("settings.appearance.light")
: mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system");
const langLabel =
lang === "en"
? t("settings.language.en")
: lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr");
const callEmergency = useCallback(async () => {
try {
await Linking.openURL("tel:112");
@@ -158,7 +180,7 @@ const Settings = () => {
{/* 1. Maps & Navigation */}
<SectionHeader title={t("settings.maps.title")} />
<Card>
<SettingsCard>
<SettingsRow
icon="map-marker-radius"
title={t("settings.maps.title")}
@@ -167,60 +189,39 @@ const Settings = () => {
value={locationStatusLabel}
/>
{status !== "granted" ? (
<View className="border-t border-neutral-100 dark:border-neutral-800">
<SettingsRow
icon="cog"
title={t("settings.maps.openSettings")}
right="chevron"
onPress={openSettings}
/>
</View>
<SettingsRow
icon="cog"
title={t("settings.maps.openSettings")}
right="chevron"
onPress={openSettings}
/>
) : null}
</Card>
</SettingsCard>
{/* 2. Appearance */}
<SectionHeader title={t("settings.appearance.title")} />
<Card>
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{t("settings.appearance.description")}
</Text>
</View>
{appearanceOptions.map((option, index) => (
<View
<SettingsCard description={t("settings.appearance.description")}>
{appearanceOptions.map((option) => (
<SettingsRow
key={option.mode}
className={
index > 0
? "border-t border-neutral-100 dark:border-neutral-800"
: ""
icon={option.icon}
title={
option.mode === "light"
? t("settings.appearance.light")
: option.mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system")
}
>
<SettingsRow
icon={option.icon}
title={
option.mode === "light"
? t("settings.appearance.light")
: option.mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system")
}
right="value"
value={
mode === option.mode
? isDark
? "✓"
: "✓"
: ""
}
onPress={() => setMode(option.mode)}
/>
</View>
right="check"
selected={mode === option.mode}
onPress={() => setMode(option.mode)}
/>
))}
</Card>
</SettingsCard>
{/* 3. Safety */}
<SectionHeader title={t("settings.safety.title")} />
<Card>
<SettingsCard>
<SettingsRow
icon="phone-in-talk"
title={t("settings.safety.call112")}
@@ -230,64 +231,45 @@ const Settings = () => {
onPress={callEmergency}
/>
{safetyTiles.map((tile) => (
<View
<SettingsRow
key={tile.key}
className="border-t border-neutral-100 dark:border-neutral-800"
>
<SettingsRow
icon={tile.icon}
title={tile.title}
subtitle={
expandedSafety === tile.key ? undefined : tile.body
}
right="chevron"
onPress={() =>
setExpandedSafety((current) =>
current === tile.key ? null : tile.key,
)
}
/>
</View>
icon={tile.icon}
title={tile.title}
subtitle={expandedSafety === tile.key ? undefined : tile.body}
right="chevron"
onPress={() =>
setExpandedSafety((current) =>
current === tile.key ? null : tile.key,
)
}
/>
))}
</Card>
</SettingsCard>
{/* 4. Language */}
<SectionHeader title={t("settings.language.title")} />
<Card>
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{t("settings.language.description")}
</Text>
</View>
{languageOptions.map((option, index) => (
<View
<SettingsCard description={t("settings.language.description")}>
{languageOptions.map((option) => (
<SettingsRow
key={option.lang}
className={
index > 0
? "border-t border-neutral-100 dark:border-neutral-800"
: ""
icon={option.icon}
title={
option.lang === "en"
? t("settings.language.en")
: option.lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr")
}
>
<SettingsRow
icon={option.icon}
title={
option.lang === "en"
? t("settings.language.en")
: option.lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr")
}
right="value"
value={lang === option.lang ? "✓" : ""}
onPress={() => chooseLanguage(option.lang)}
/>
</View>
right="check"
selected={lang === option.lang}
onPress={() => chooseLanguage(option.lang)}
/>
))}
</Card>
</SettingsCard>
{/* 5. Keep awake */}
<SectionHeader title={t("settings.keepAwake.title")} />
<Card>
{/* 5. General — keep-awake toggle + (Android) display-over-other-apps */}
<SectionHeader title={t("settings.general.title")} />
<SettingsCard>
<SettingsRow
icon="monitor"
title={t("settings.keepAwake.title")}
@@ -296,27 +278,20 @@ const Settings = () => {
switchValue={keepAwake}
onSwitchChange={setKeepAwake}
/>
</Card>
{/* 6. Display over other apps (Android only) */}
{Platform.OS === "android" ? (
<>
<SectionHeader title={t("settings.overlay.title")} />
<Card>
<SettingsRow
icon="application-brackets"
title={t("settings.overlay.allow")}
subtitle={
overlayRequested
? t("settings.overlay.openedHint")
: t("settings.overlay.description")
}
right="chevron"
onPress={openOverlaySettings}
/>
</Card>
</>
) : null}
{Platform.OS === "android" ? (
<SettingsRow
icon="application-brackets"
title={t("settings.overlay.allow")}
subtitle={
overlayRequested
? t("settings.overlay.openedHint")
: t("settings.overlay.description")
}
right="chevron"
onPress={openOverlaySettings}
/>
) : null}
</SettingsCard>
</ScrollView>
</SafeAreaView>
);
+42 -12
View File
@@ -1,18 +1,48 @@
import { Stack } from "expo-router";
import { Redirect, Stack } from "expo-router";
import CallWatcher from "@/components/call-watcher";
import { useSession } from "@/lib/session";
const RootLayout = () => {
const { isLoaded, isSignedIn } = useSession();
// Everything under (root) is behind the session, so the check belongs here
// rather than in each screen. app/index.tsx only guards the way in, which
// left a session that ended *while* a screen was open with nowhere to go:
// the screen stayed mounted and kept polling with a token the server had
// already rejected.
//
// Sign-in, not welcome: someone who reaches this point had an account a
// moment ago, and the onboarding carousel is not what they need.
if (!isLoaded) return null;
if (!isSignedIn) return <Redirect href="/(auth)/sign-in" />;
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="find-ride" options={{ headerShown: false }} />
<Stack.Screen name="confirm-ride" options={{ headerShown: false }} />
<Stack.Screen name="book-ride" options={{ headerShown: false }} />
<Stack.Screen name="role" options={{ headerShown: false }} />
<Stack.Screen
name="driver-home"
options={{ headerShown: false, gestureEnabled: false }}
/>
</Stack>
<>
{/* Watches for incoming WebRTC calls on the active ride and routes the
user to the call screen regardless of which tab is open. No UI. */}
<CallWatcher />
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="find-ride" options={{ headerShown: false }} />
<Stack.Screen name="adjust-pin" options={{ headerShown: false }} />
<Stack.Screen name="book-ride" options={{ headerShown: false }} />
<Stack.Screen name="role" options={{ headerShown: false }} />
<Stack.Screen
name="driver-home"
options={{ headerShown: false, gestureEnabled: false }}
/>
<Stack.Screen name="driver-chat" options={{ headerShown: false }} />
<Stack.Screen
name="call"
options={{
headerShown: false,
presentation: "fullScreenModal",
gestureEnabled: false,
}}
/>
</Stack>
</>
);
};
+199
View File
@@ -0,0 +1,199 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import * as Location from "expo-location";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { CustomButton } from "@/components/custom-button";
import { PinAdjuster } from "@/components/pin-adjuster";
import { useT } from "@/lib/i18n";
import { addressForCoords } from "@/lib/reverse-geocode";
import { useLocationStore } from "@/store";
// "Move the pin to where you actually are."
//
// An address from autocomplete lands on whatever the geocoder considers the
// centre of that place — which can be the wrong side of a building, the wrong
// end of a long street, or the middle of a junction the driver can't stop in.
// The rider knows the doorway; this screen lets them say so, for the pickup
// and the drop-off alike.
//
// Reverse geocoding is debounced rather than run on every frame of the pan:
// the label only has to be right once the map stops.
const GEOCODE_DEBOUNCE_MS = 450;
// Falls back to Beirut, matching the map's own default, so the screen always
// has somewhere to open even before a fix arrives.
const FALLBACK = { latitude: 33.8938, longitude: 35.5018 };
type Coords = { latitude: number; longitude: number };
const AdjustPin = () => {
const t = useT();
const params = useLocalSearchParams<{ mode?: string }>();
const mode = params.mode === "destination" ? "destination" : "origin";
const {
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
setUserLocation,
setDestinationLocation,
} = useLocationStore();
// Open on the point being edited. A destination that hasn't been chosen yet
// starts at the rider instead of an arbitrary city centre, because the place
// they're going is usually near the place they are.
const initial: Coords =
mode === "origin"
? {
latitude: userLatitude ?? FALLBACK.latitude,
longitude: userLongitude ?? FALLBACK.longitude,
}
: {
latitude: destinationLatitude ?? userLatitude ?? FALLBACK.latitude,
longitude:
destinationLongitude ?? userLongitude ?? FALLBACK.longitude,
};
const [coords, setCoords] = useState<Coords>(initial);
const [address, setAddress] = useState<string | null>(null);
const [resolving, setResolving] = useState(true);
const debounce = useRef<ReturnType<typeof setTimeout>>();
const resolve = useCallback((next: Coords) => {
setCoords(next);
clearTimeout(debounce.current);
debounce.current = setTimeout(async () => {
const label = await addressForCoords(next.latitude, next.longitude);
setAddress(label);
setResolving(false);
}, GEOCODE_DEBOUNCE_MS);
}, []);
// Label the point the screen opened on, so the card isn't blank on arrival.
useEffect(() => {
resolve(initial);
return () => clearTimeout(debounce.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const confirm = () => {
const payload = {
latitude: coords.latitude,
longitude: coords.longitude,
address: address ?? t("common.yourLocation"),
};
if (mode === "origin") setUserLocation(payload);
else setDestinationLocation(payload);
router.back();
};
// Jump back to the rider's own position — the usual reason to open this
// screen is that the suggested pickup drifted away from where they're
// standing.
const recenter = async () => {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") return;
const position = await Location.getLastKnownPositionAsync({
maxAge: 60_000,
});
if (!position) return;
setResolving(true);
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
} catch (error) {
console.log("[ADJUST_PIN_RECENTER]: ", error);
}
};
return (
<View className="flex-1 bg-white dark:bg-neutral-950">
<PinAdjuster
initial={initial}
onMoveStart={() => setResolving(true)}
onSettled={resolve}
/>
<SafeAreaView className="flex-1" pointerEvents="box-none">
<View className="px-5 pt-2" pointerEvents="box-none">
<TouchableOpacity
onPress={() => router.back()}
accessibilityLabel={t("common.back")}
className="w-10 h-10 rounded-full bg-white dark:bg-neutral-900 items-center justify-center shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
>
<MaterialCommunityIcons name="arrow-left" size={20} color="#0286ff" />
</TouchableOpacity>
</View>
<View className="flex-1" pointerEvents="none" />
<View className="px-5 pb-5" pointerEvents="box-none">
<TouchableOpacity
onPress={recenter}
accessibilityLabel={t("adjustPin.recenter")}
className="self-end mb-3 w-11 h-11 rounded-full bg-white dark:bg-neutral-900 items-center justify-center shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
>
<MaterialCommunityIcons
name="crosshairs-gps"
size={20}
color="#0286ff"
/>
</TouchableOpacity>
<View className="rounded-2xl bg-white dark:bg-neutral-900 p-5 shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
<Text className="text-xs font-JakartaSemiBold uppercase tracking-wide text-general-200 dark:text-neutral-500 mb-1">
{mode === "origin"
? t("adjustPin.pickupLabel")
: t("adjustPin.destinationLabel")}
</Text>
<View className="flex-row items-center min-h-[26px] mb-1">
{resolving ? (
<>
<ActivityIndicator size="small" color="#0286ff" />
<Text className="ml-2 font-JakartaMedium text-general-200 dark:text-neutral-400">
{t("adjustPin.locating")}
</Text>
</>
) : (
<Text
className="font-JakartaBold text-black dark:text-white text-base flex-1"
numberOfLines={2}
>
{address}
</Text>
)}
</View>
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mb-4">
{t("adjustPin.hint")}
</Text>
<CustomButton
title={
mode === "origin"
? t("adjustPin.confirmPickup")
: t("adjustPin.confirmDestination")
}
onPress={confirm}
disabled={resolving}
/>
</View>
</View>
</SafeAreaView>
</View>
);
};
export default AdjustPin;
+340 -32
View File
@@ -1,54 +1,99 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
Image,
ScrollView,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { CancelSheet } from "@/components/cancel-sheet";
import { CustomButton } from "@/components/custom-button";
import { Map } from "@/components/map";
import { OfferList } from "@/components/offer-list";
import { PaymentChoiceSheet } from "@/components/payment-choice-sheet";
import { RatingSheet } from "@/components/rating-sheet";
import { icons, images } from "@/constants";
import { driverPhotoUri } from "@/lib/driver-photo";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { payByCard, selectDriver } from "@/lib/request-ride";
import { useSession } from "@/lib/session";
import { formatTime } from "@/lib/utils";
import { useLocationStore } from "@/store";
import type { Ride } from "@/types/type";
import type { Ride, RideOffer } from "@/types/type";
const POLL_MS = 3000;
// While the request is open, offers arrive one driver at a time and the rider
// is staring at the list waiting for them. A three-second gap between a driver
// tapping Offer and their face appearing reads as nothing happening.
const OPEN_POLL_MS = 1500;
const STATUS_KEY: Record<string, string> = {
requested: "bookRide.status.requested",
accepted: "bookRide.status.accepted",
arrived: "bookRide.status.arrived",
en_route: "bookRide.status.enRoute",
completed: "bookRide.status.completed",
cancelled: "bookRide.status.cancelled",
expired: "bookRide.status.expired",
};
const TERMINAL = ["completed", "cancelled", "expired"];
// book-ride is now the live ride-status screen. The rider lands here after
// requesting a ride and polls its status until it completes (or they cancel).
const BookRide = () => {
const { id } = useLocalSearchParams<{ id: string }>();
const rideId = Number(id);
const t = useT();
const { user } = useSession();
const setUserLocation = useLocationStore((s) => s.setUserLocation);
const setDestinationLocation = useLocationStore((s) => s.setDestinationLocation);
const setDestinationLocation = useLocationStore(
(s) => s.setDestinationLocation,
);
const clearDestination = useLocationStore((s) => s.clearDestination);
const [ride, setRide] = useState<Ride | null>(null);
const [loading, setLoading] = useState(true);
const [cancelling, setCancelling] = useState(false);
// The offer the rider tapped, held while they choose how to pay.
const [picked, setPicked] = useState<RideOffer | null>(null);
const [paying, setPaying] = useState(false);
// A card order that was paid but whose selection then failed. Kept so the
// rider can pick a different driver without paying a second time — the
// server only consumes an order when a driver is actually assigned.
const paidOrder = useRef<string | null>(null);
// Server clock minus device clock, so the elapsed counter is measured on the
// clock the request window is actually enforced against.
const clockOffset = useRef(0);
const [error, setError] = useState<string | null>(null);
const [cancelOpen, setCancelOpen] = useState(false);
// Set once, when the ride first lands on 'completed' during this session,
// so dismissing the sheet doesn't immediately re-open it on the next poll.
const [ratingOpen, setRatingOpen] = useState(false);
const [ratingHandled, setRatingHandled] = useState(false);
const load = useCallback(async () => {
try {
const res = await fetchAPI(`/(api)/ride/${rideId}`);
const r = res.data as Ride;
if (r.now) clockOffset.current = Date.parse(r.now) - Date.now();
setRide(r);
// Ask for the rating the moment the driver ends the trip — the rider is
// still in the car and still remembers. `my_rating` covers the case
// where they already rated from the home banner.
if (r.status === "completed" && r.my_rating == null && !ratingHandled) {
setRatingOpen(true);
}
// Keep the map's origin/destination in sync with the ride so the route
// line renders even if the rider reached this screen via history.
setUserLocation({
@@ -69,28 +114,98 @@ const BookRide = () => {
} finally {
setLoading(false);
}
}, [rideId, setUserLocation, setDestinationLocation, t]);
}, [rideId, setUserLocation, setDestinationLocation, ratingHandled, t]);
useEffect(() => {
void load();
}, [load]);
// Poll while the ride is still in a non-terminal state.
// Drop the route when the rider leaves this screen.
//
// Nothing used to clear it, so a destination survived for the life of the
// process — and since backgrounding an app doesn't end that process, the
// next launch drew a line to a trip that had already finished. Cleared on
// unmount rather than on completion because `load` re-sets it on every poll:
// clearing while still on screen would just fight the next poll, and the
// tracking map would lose the route the rider is watching.
useEffect(() => () => clearDestination(), [clearDestination]);
// Poll while the ride is still in a non-terminal state, quickly while
// offers are still coming in.
useEffect(() => {
const status = ride?.status;
if (!status || status === "completed" || status === "cancelled") return;
const timer = setInterval(() => void load(), POLL_MS);
if (!status || TERMINAL.includes(status)) return;
const every = status === "requested" ? OPEN_POLL_MS : POLL_MS;
const timer = setInterval(() => void load(), every);
return () => clearInterval(timer);
}, [ride?.status, load]);
const cancel = async () => {
// Take one of the offers. This is the call that assigns the ride: it pays
// (or commits to cash), locks in that driver and releases the others.
//
// A 409 means the driver was taken while the rider was deciding — a normal
// outcome of several riders competing for the same cars, not an error. The
// list simply reloads without them, and any card payment already made stays
// unspent and is reused for the next pick.
const pay = async (method: "cash" | "card") => {
const offer = picked;
if (!offer || !ride) return;
setPaying(true);
try {
let orderId = paidOrder.current ?? undefined;
if (method === "card" && !orderId) {
orderId = await payByCard({
ride,
user: { name: user?.name ?? "", email: user?.email ?? "" },
});
paidOrder.current = orderId;
}
await selectDriver({
rideId,
offerId: offer.offer_id,
method,
orderId: method === "card" ? orderId : undefined,
});
// Assigned: the money is spent and the ride has a driver.
paidOrder.current = null;
setPicked(null);
await load();
} catch (err) {
console.log("[BOOK_RIDE_SELECT]: ", err);
setPicked(null);
if (err instanceof ApiError && err.status === 409) {
Alert.alert(
t("bookRide.offers.goneTitle"),
paidOrder.current
? t("bookRide.offers.goneBodyPaid")
: t("bookRide.offers.goneBody"),
);
} else {
Alert.alert(
t("bookRide.alertErrorTitle"),
err instanceof ApiError ? err.message : t("bookRide.match.alertBody"),
);
}
await load();
} finally {
setPaying(false);
}
};
const cancel = async (reason: string) => {
setCancelling(true);
try {
await fetchAPI(`/(api)/ride/${rideId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "cancelled" }),
body: JSON.stringify({ status: "cancelled", reason }),
});
setCancelOpen(false);
await load();
} catch (err) {
console.log("[BOOK_RIDE_CANCEL]: ", err);
@@ -124,35 +239,127 @@ const BookRide = () => {
}
const driver = ride.driver;
const terminal = ride.status === "completed" || ride.status === "cancelled";
const driverId = driver.id;
const terminal = TERMINAL.includes(ride.status);
const driverName = [driver.first_name, driver.last_name]
.filter(Boolean)
.join(" ");
const cashDue = ride.payment_status === "cash";
const offers = (ride.offers ?? []) as RideOffer[];
// Whole seconds the search has been running, measured on the server's clock.
const searchSeconds = Math.max(
0,
Math.round(
(Date.now() + clockOffset.current - Date.parse(ride.created_at)) / 1000,
),
);
return (
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
<View className="h-[45%] bg-blue-500">
<Map />
<Map trackedDriver={driverId ? { ...driver, id: driverId } : null} />
</View>
<View className="flex-1 px-5 pt-4">
{/* Scrollable, because the number of things below the map isn't fixed:
four drivers offering on a request push the fare, the cancel button
— and the fourth driver — off the bottom of the screen, and a rider
who can't reach an offer can't take it. */}
<ScrollView
className="flex-1 px-5 pt-4"
contentContainerStyle={{ flexGrow: 1, paddingBottom: 24 }}
>
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
{STATUS_KEY[ride.status] ? t(STATUS_KEY[ride.status]) : ride.status}
{/* Once drivers have volunteered the screen stops being a search and
becomes a decision, and the heading has to say which one it is —
a rider reading "finding your driver" over a list of drivers
doesn't know it's waiting on them. */}
{ride.status === "requested" && offers.length > 0
? t("bookRide.status.choosing")
: STATUS_KEY[ride.status]
? t(STATUS_KEY[ride.status])
: ride.status}
</Text>
{/* Searching state */}
{ride.status === "requested" ? (
<View className="items-center mt-6">
{/* Waiting on the first driver to volunteer. The elapsed counter is
there because a spinner with no number on it reads as broken after
about ten seconds — and the request legitimately sits open for a
couple of minutes. A rider who can see it counting knows their
request is still live. */}
{ride.status === "requested" && offers.length === 0 ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-5 mt-2 items-center">
<ActivityIndicator size="large" color="#0286ff" />
<Text className="text-general-200 dark:text-neutral-400 mt-3 text-center">
{t("bookRide.matchingDriver", { service: ride.service })}
</Text>
<Text className="text-xs text-general-200 dark:text-neutral-400 mt-2">
{t("bookRide.searchingFor", { seconds: searchSeconds })}
</Text>
</View>
) : null}
{/* Driver card — shown once a driver is assigned. */}
{driver?.id ? (
{/* Drivers who want the job. The rider picks; everyone else is let go
the moment they do. */}
{ride.status === "requested" && offers.length > 0 ? (
<OfferList
offers={offers}
pendingOfferId={paying ? (picked?.offer_id ?? null) : null}
busy={paying}
onPick={setPicked}
/>
) : null}
{/* Pickup code — the rider's half of the handshake. Shown from the
moment a driver is assigned until the trip starts; the driver
can't start without hearing it, which is what stops a rider from
getting into the wrong car (and the wrong car from taking them). */}
{ride.pickup_code ? (
<View
className={`rounded-2xl p-4 mt-2 items-center ${
ride.status === "arrived"
? "bg-emerald-500"
: "bg-white dark:bg-neutral-900"
}`}
>
<Text
className={`text-xs font-JakartaMedium ${
ride.status === "arrived"
? "text-white/90"
: "text-general-200 dark:text-neutral-400"
}`}
>
{ride.status === "arrived"
? t("bookRide.driverHere")
: t("bookRide.pickupCodeLabel")}
</Text>
<Text
className={`text-4xl font-JakartaExtraBold tracking-[8px] mt-1 ${
ride.status === "arrived"
? "text-white"
: "text-black dark:text-white"
}`}
>
{ride.pickup_code}
</Text>
<Text
className={`text-xs text-center mt-1 ${
ride.status === "arrived"
? "text-white/90"
: "text-general-200 dark:text-neutral-400"
}`}
>
{t("bookRide.pickupCodeHint")}
</Text>
</View>
) : null}
{/* Driver card — shown once the pairing is confirmed. While the ride
is still 'matched' the confirmation card above is showing the same
driver, and two cards for one driver reads as two drivers. */}
{driver?.id && ride.status !== "matched" ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-2">
<View className="flex-row items-center">
<Image
source={{ uri: driver.profile_image_url ?? undefined }}
source={{ uri: driverPhotoUri(driver.profile_image_url) }}
className="w-16 h-16 rounded-full"
/>
<View className="ml-4 flex-1">
@@ -171,20 +378,48 @@ const BookRide = () => {
) : null}
</View>
</View>
<Text className="text-xs text-general-200 dark:text-neutral-400 capitalize">
{driver.service ?? ride.service}
</Text>
<View className="flex-row items-center">
<Text className="text-xs text-general-200 dark:text-neutral-400 capitalize mr-3">
{driver.service ?? ride.service}
</Text>
{/* Call the driver — only while the ride is active. */}
{!terminal ? (
<TouchableOpacity
onPress={() =>
router.push({
pathname: "/(root)/call",
params: { rideId: String(ride.ride_id), mode: "start" },
})
}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
accessibilityLabel={t("chat.call")}
className="w-9 h-9 rounded-full bg-general-400 items-center justify-center"
>
<MaterialCommunityIcons
name="phone"
size={18}
color="white"
/>
</TouchableOpacity>
) : null}
</View>
</View>
<View className="flex-row items-center gap-x-2 mt-4">
<Image source={icons.to} className="w-4 h-4" />
<Text className="font-JakartaMedium text-sm text-black dark:text-white" numberOfLines={1}>
<Text
className="font-JakartaMedium text-sm text-black dark:text-white"
numberOfLines={1}
>
{ride.origin_address}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mt-2">
<Image source={icons.point} className="w-4 h-4" />
<Text className="font-JakartaMedium text-sm text-black dark:text-white" numberOfLines={1}>
<Text
className="font-JakartaMedium text-sm text-black dark:text-white"
numberOfLines={1}
>
{ride.destination_address}
</Text>
</View>
@@ -212,14 +447,41 @@ const BookRide = () => {
<Text className="text-general-200 dark:text-neutral-400 text-sm mt-1">
{t("bookRide.tripTime", { time: formatTime(ride.ride_time) })}
</Text>
{/* A cash ride the driver hasn't marked collected is money still
owed — say so rather than showing a clean "all done". */}
{cashDue ? (
<Text className="text-amber-600 dark:text-amber-400 text-sm mt-2 text-center">
{t("bookRide.cashDue", {
amount: (ride.fare_price / 100).toFixed(2),
})}
</Text>
) : null}
{ride.my_rating ? (
<Text className="text-general-200 dark:text-neutral-400 text-sm mt-2">
{t("bookRide.youRated", { n: ride.my_rating })}
</Text>
) : (
<TouchableOpacity
onPress={() => setRatingOpen(true)}
className="mt-3"
>
<Text className="font-JakartaBold text-primary-500">
{t("bookRide.rateDriver")}
</Text>
</TouchableOpacity>
)}
</View>
) : null}
{/* Cancelled */}
{ride.status === "cancelled" ? (
{/* Cancelled / expired */}
{ride.status === "cancelled" || ride.status === "expired" ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-4 items-center">
<Text className="text-general-200 dark:text-neutral-400">
{t("bookRide.rideCancelled")}
<Text className="text-general-200 dark:text-neutral-400 text-center">
{ride.status === "expired"
? t("bookRide.noDriversFound")
: ride.cancelled_by === "driver"
? t("bookRide.cancelledByDriver")
: t("bookRide.rideCancelled")}
</Text>
</View>
) : null}
@@ -230,21 +492,67 @@ const BookRide = () => {
title={t("bookRide.backHome")}
onPress={() => router.replace("/(root)/(tabs)/home")}
/>
) : ride.status === "en_route" ? (
// Once the trip is under way there is nothing to cancel — the
// rider is in the car. Ending it early is the driver's action.
<Text className="text-center text-general-200 dark:text-neutral-400 text-sm pb-3">
{t("bookRide.enRouteNotice")}
</Text>
) : (
<TouchableOpacity
onPress={cancel}
onPress={() => setCancelOpen(true)}
disabled={cancelling}
className="rounded-full py-3 bg-white dark:bg-neutral-900 items-center border border-rose-300 dark:border-rose-900"
>
<Text className="font-JakartaBold text-rose-500">
{cancelling ? t("bookRide.cancelling") : t("bookRide.cancelRide")}
{cancelling
? t("bookRide.cancelling")
: t("bookRide.cancelRide")}
</Text>
</TouchableOpacity>
)}
</View>
</View>
</ScrollView>
<PaymentChoiceSheet
visible={picked !== null}
driverName={
picked
? [picked.first_name, picked.last_name].filter(Boolean).join(" ")
: null
}
fareCents={ride.fare_price}
submitting={paying}
onPay={(method) => void pay(method)}
onCancel={() => setPicked(null)}
/>
<CancelSheet
visible={cancelOpen}
audience="rider"
submitting={cancelling}
onCancel={() => setCancelOpen(false)}
onConfirm={(reason) => void cancel(reason)}
/>
<RatingSheet
visible={ratingOpen}
rideId={rideId}
audience="rider"
subjectName={driverName || null}
subjectAvatar={driver.profile_image_url}
onDone={() => {
setRatingOpen(false);
setRatingHandled(true);
void load();
}}
onSkip={() => {
setRatingOpen(false);
setRatingHandled(true);
}}
/>
</SafeAreaView>
);
};
export default BookRide;
export default BookRide;
+228
View File
@@ -0,0 +1,228 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Text, TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { RTCView } from "react-native-webrtc";
import { fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { useCall } from "@/lib/use-call";
import type { ChatActiveRide } from "@/types/type";
// In-app WebRTC audio call screen. Two entry modes:
// mode=start — caller opened this from the chat header; we place the call.
// mode=incoming — CallWatcher detected a ringing call; we attach and wait
// for the user to Accept/Decline.
// Either way the authoritative ride/role/peer come from GET /(api)/chat/active
// (so a stale nav param never dials the wrong ride).
const Call = () => {
const t = useT();
const params = useLocalSearchParams<{
rideId?: string;
role?: "rider" | "driver";
mode?: "start" | "incoming";
}>();
const [active, setActive] = useState<ChatActiveRide | null>(null);
const [resolving, setResolving] = useState(true);
const call = useCall();
const startedRef = useRef(false);
// Resolve the active ride + peer once, then kick off the right flow.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetchAPI("/(api)/chat/active");
const a = (res.data ?? null) as ChatActiveRide | null;
if (cancelled) return;
setActive(a);
if (!a) return;
if (startedRef.current) return;
startedRef.current = true;
const peerName = a.peer?.name ?? "";
if (params.mode === "start") {
void call.startCall(a.ride_id, a.role, peerName);
} else {
call.watch(a.ride_id, a.role, peerName);
}
} catch (err) {
console.log("[CALL_SCREEN_RESOLVE]: ", err);
} finally {
if (!cancelled) setResolving(false);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Surface a mic-permission denial and back out.
useEffect(() => {
if (call.micError) {
Alert.alert(t("call.micDeniedTitle"), t("call.micDeniedBody"), [
{ text: "OK", onPress: () => router.back() },
]);
}
}, [call.micError, t]);
// When the call reaches a terminal state, show the label briefly, then
// leave the screen so the user returns to where they came from.
useEffect(() => {
if (call.status !== "ended") return;
const timer = setTimeout(() => router.back(), 1200);
return () => clearTimeout(timer);
}, [call.status]);
const peerName = active?.peer?.name ?? call.peerName ?? "";
const handleEnd = useCallback(() => {
void call.endCall();
}, [call]);
const handleAccept = useCallback(() => {
void call.answerCall();
}, [call]);
const handleDecline = useCallback(() => {
void call.declineCall();
}, [call]);
if (resolving) {
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
<Text className="text-general-200 dark:text-neutral-400">
{t("call.connecting")}
</Text>
</SafeAreaView>
);
}
if (!active) {
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center px-7">
<Text className="text-base text-center text-general-200 dark:text-neutral-400">
{t("call.unavailable")}
</Text>
<TouchableOpacity
onPress={() => router.back()}
className="mt-6 px-6 py-3 rounded-full bg-general-400"
>
<Text className="text-white font-JakartaBold">
{t("call.cancel")}
</Text>
</TouchableOpacity>
</SafeAreaView>
);
}
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-between py-10">
{/* Audio sink — hidden; keeps the native audio pipeline attached even
though this is an audio-only call (RTCView is the stream sink). */}
{call.remoteStream ? (
<RTCView
streamURL={call.remoteStream.toURL()}
className="w-1 h-1 opacity-0"
/>
) : null}
{/* Peer identity + status */}
<View className="items-center mt-16">
<View className="w-28 h-28 rounded-full bg-general-400 items-center justify-center mb-6">
<Text className="text-4xl font-JakartaBold text-white">
{(peerName.trim()[0] ?? "?").toUpperCase()}
</Text>
</View>
<Text className="text-2xl font-JakartaBold text-black dark:text-white">
{peerName}
</Text>
<Text className="text-base mt-1 text-general-200 dark:text-neutral-400">
{call.status === "incoming"
? t("call.incoming")
: call.status === "outgoing" || call.status === "connecting"
? t("call.connectingWith", { name: peerName })
: call.status === "in-call"
? t("call.inCall")
: call.status === "ended"
? t("call.ended")
: t("call.connecting")}
</Text>
</View>
{/* Controls vary by state */}
<View className="flex-row items-center justify-center mb-10">
{call.status === "incoming" ? (
<>
<CallButton
icon="phone-hangup"
color="#ef4444"
label={t("call.decline")}
onPress={handleDecline}
/>
<CallButton
icon="phone"
color="#22c55e"
label={t("call.accept")}
onPress={handleAccept}
/>
</>
) : (
<>
<CallButton
icon={call.muted ? "microphone-off" : "microphone"}
color={call.muted ? "#ef4444" : "#6b7280"}
label={call.muted ? t("call.unmute") : t("call.mute")}
onPress={call.toggleMute}
/>
<CallButton
icon="phone-hangup"
color="#ef4444"
label={t("call.end")}
onPress={handleEnd}
/>
<CallButton
icon={call.speakerOn ? "volume-high" : "volume-off"}
color={call.speakerOn ? "#0286ff" : "#6b7280"}
label={call.speakerOn ? t("call.speaker") : t("call.speakerOff")}
onPress={call.toggleSpeaker}
/>
</>
)}
</View>
</SafeAreaView>
);
};
const CallButton = ({
icon,
color,
label,
onPress,
}: {
icon: React.ComponentProps<typeof MaterialCommunityIcons>["name"];
color: string;
label: string;
onPress: () => void;
}) => (
<TouchableOpacity
onPress={onPress}
className="items-center mx-6"
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
>
<View
className="w-16 h-16 rounded-full items-center justify-center"
style={{ backgroundColor: color }}
>
<MaterialCommunityIcons name={icon} size={28} color="white" />
</View>
<Text className="text-xs mt-2 text-general-200 dark:text-neutral-400">
{label}
</Text>
</TouchableOpacity>
);
export default Call;
-349
View File
@@ -1,349 +0,0 @@
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, Alert, Text, TouchableOpacity, View } from "react-native";
import { CustomButton } from "@/components/custom-button";
import { RideLayout } from "@/components/ride-layout";
import { SERVICES } from "@/constants/services";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { calculateTripFare } from "@/lib/map";
import { formatLBP } from "@/lib/pricing";
import { requestRide } from "@/lib/request-ride";
import { useSession } from "@/lib/session";
import { formatTime, haversine } from "@/lib/utils";
import { useLocationStore, useServiceStore } from "@/store";
type PaymentMethod = "cash" | "card";
type NearbyDriver = {
id: number;
first_name: string;
latitude: number;
longitude: number;
};
// Confirm-ride is now the request screen: the rider no longer browses and
// picks a driver. They see a single fare estimate + nearest-driver ETA, pick a
// payment method, and tap Request — auto-match assigns the driver and they're
// routed to the live status screen.
const ConfirmRide = () => {
const params = useLocalSearchParams<{ service?: string }>();
const {
userAddress,
userLatitude,
userLongitude,
destinationAddress,
destinationLatitude,
destinationLongitude,
} = useLocationStore();
const { service: storeService, setService } = useServiceStore();
const { user } = useSession();
const t = useT();
const service = params.service ?? storeService;
const selected = SERVICES.find((s) => s.id === service) ?? SERVICES[0];
const [method, setMethod] = useState<PaymentMethod>("cash");
const [estimate, setEstimate] = useState<{
fare: string;
durationSeconds: number;
} | null>(null);
const [nearestEta, setNearestEta] = useState<number | null>(null);
const [driversOnline, setDriversOnline] = useState<number | null>(null);
const [estimating, setEstimating] = useState(true);
const [processing, setProcessing] = useState(false);
// Trip fare estimate — one Directions call for the trip leg, recomputed when
// the route or service changes. Independent of driver availability.
useEffect(() => {
if (
!userLatitude ||
!userLongitude ||
!destinationLatitude ||
!destinationLongitude
)
return;
let cancelled = false;
setEstimating(true);
const run = async () => {
const trip = await calculateTripFare({
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
service: selected.id,
});
if (cancelled) return;
setEstimate(
trip
? { fare: trip.fare, durationSeconds: trip.durationSeconds }
: null,
);
};
void run().finally(() => {
if (!cancelled) setEstimating(false);
});
return () => {
cancelled = true;
};
}, [
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
selected.id,
]);
// Online-driver availability for the selected service, polled so the "no
// drivers" state self-heals the moment a driver of this service comes
// online. The nearest driver's pickup ETA is resolved alongside the count.
useEffect(() => {
if (!userLatitude || !userLongitude) return;
let cancelled = false;
const check = async () => {
try {
const res = await fetchAPI(
`/(api)/driver/nearby?service=${selected.id}&lat=${userLatitude}&lng=${userLongitude}`,
);
const drivers = (res.data ?? []) as NearbyDriver[];
if (cancelled) return;
setDriversOnline(drivers.length);
if (drivers.length === 0) {
setNearestEta(null);
return;
}
const nearest = drivers
.map((d) => ({
d,
dist: haversine(
userLatitude,
userLongitude,
d.latitude,
d.longitude,
),
}))
.sort((a, b) => a.dist - b.dist)[0].d;
const directionsRes = await fetch(
`https://maps.googleapis.com/maps/api/directions/json?origin=${nearest.latitude},${nearest.longitude}&destination=${userLatitude},${userLongitude}&key=${process.env.EXPO_PUBLIC_GOOGLE_API_KEY}`,
);
const data = await directionsRes.json();
const leg = data.routes?.[0]?.legs?.[0];
if (!cancelled)
setNearestEta(leg ? Math.round(leg.duration.value / 60) : null);
} catch {
if (!cancelled) {
setDriversOnline(null);
setNearestEta(null);
}
}
};
void check();
const timer = setInterval(() => void check(), 10000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [userLatitude, userLongitude, selected.id]);
const request = async () => {
if (!userLatitude || !userLongitude || !destinationLatitude || !destinationLongitude) {
Alert.alert(
t("confirmRide.alertMissingRouteTitle"),
t("confirmRide.alertMissingRouteBody"),
);
return;
}
if (!estimate) {
Alert.alert(
t("confirmRide.alertNoEstimateTitle"),
t("confirmRide.alertNoEstimateBody"),
);
return;
}
// Nested so the guards above narrow userLatitude/estimate to non-null for
// the card-confirm callback as well as the direct cash path.
const doRequest = async () => {
setProcessing(true);
try {
// Keep the store in sync with whatever service we resolved for this ride.
setService(selected.id);
const { ride } = await requestRide({
method,
service: selected.id,
user: { name: user?.name ?? "", email: user?.email ?? "" },
origin: {
address: userAddress ?? "",
latitude: userLatitude,
longitude: userLongitude,
},
destination: {
address: destinationAddress ?? "",
latitude: destinationLatitude,
longitude: destinationLongitude,
},
rideTimeSeconds: estimate.durationSeconds,
fareCents: Math.round(parseFloat(estimate.fare) * 100),
});
router.replace(`/(root)/book-ride?id=${ride.ride_id}`);
} catch (err) {
console.log("[REQUEST_RIDE]: ", err);
const msg =
err instanceof ApiError
? err.message
: t("confirmRide.alertErrorFallback");
Alert.alert(t("confirmRide.alertErrorTitle"), msg);
} finally {
setProcessing(false);
}
};
if (method === "card") {
Alert.alert(
t("confirmRide.alertPayCardTitle"),
t("confirmRide.alertPayCardBody", { fare: estimate.fare }),
[
{ text: t("common.cancel"), style: "cancel" },
{ text: t("common.continue"), onPress: () => void doRequest() },
],
);
} else {
void doRequest();
}
};
return (
<RideLayout title={t("confirmRide.title")} snapPoints={["60%", "88%"]}>
<Text className="text-xl font-JakartaSemiBold mb-1 text-black dark:text-white">
{t("confirmRide.yourTrip")}
</Text>
<View className="flex-row items-center gap-x-2 mb-1">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("confirmRide.pickup")}
</Text>
</View>
<Text className="font-JakartaMedium mb-3 text-black dark:text-white" numberOfLines={1}>
{userAddress}
</Text>
<View className="flex-row items-center gap-x-2 mb-1">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("confirmRide.destination")}
</Text>
</View>
<Text className="font-JakartaMedium mb-4 text-black dark:text-white" numberOfLines={1}>
{destinationAddress}
</Text>
<View className="flex-row items-center justify-between bg-general-500 dark:bg-neutral-950 rounded-2xl p-4 mb-4">
<View>
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t(selected.labelKey)} · {t(selected.taglineKey)}
</Text>
<Text className="text-general-200 dark:text-neutral-400 text-xs mt-1">
{t("confirmRide.tripTime", {
time: estimate ? formatTime(estimate.durationSeconds / 60) : "…",
})}
</Text>
</View>
<View className="items-end">
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{estimating
? "…"
: estimate
? t("confirmRide.fareDisplay", { fare: estimate.fare })
: "—"}
</Text>
{estimate ? (
<Text className="text-xs text-general-200 dark:text-neutral-400">
{t("confirmRide.lbpEstimate", {
lbp: formatLBP(parseFloat(estimate.fare)),
})}
</Text>
) : null}
</View>
</View>
<Text
className={`text-base font-JakartaMedium mb-2 ${
driversOnline === 0
? "text-rose-500"
: "text-general-200 dark:text-neutral-400"
}`}
>
{driversOnline === 0
? t("confirmRide.noDrivers", { service: t(selected.labelKey) })
: nearestEta == null
? t("confirmRide.findingDrivers")
: t("confirmRide.nearestDriver", { eta: nearestEta })}
</Text>
<Text className="text-lg font-JakartaSemiBold mt-2 mb-2 text-black dark:text-white">
{t("confirmRide.paymentMethod")}
</Text>
<View className="flex-row gap-x-3 mb-2">
<TouchableOpacity
onPress={() => setMethod("cash")}
className={`flex-1 items-center py-3 rounded-xl border ${
method === "cash"
? "bg-general-600 dark:bg-primary-500/20 border-primary-500"
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
}`}
>
<Text
className={`font-JakartaMedium ${
method === "cash" ? "text-white" : "text-black dark:text-white"
}`}
>
{t("confirmRide.cash")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setMethod("card")}
className={`flex-1 items-center py-3 rounded-xl border ${
method === "card"
? "bg-general-600 dark:bg-primary-500/20 border-primary-500"
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
}`}
>
<Text
className={`font-JakartaMedium ${
method === "card" ? "text-white" : "text-black dark:text-white"
}`}
>
{t("confirmRide.card")}
</Text>
</TouchableOpacity>
</View>
<CustomButton
title={
processing
? t("confirmRide.requesting")
: driversOnline === 0
? t("confirmRide.noDriversOnline")
: method === "cash"
? t("confirmRide.requestCash")
: t("confirmRide.requestCard")
}
className="mt-4"
onPress={request}
disabled={processing || estimating || !estimate || driversOnline === 0}
/>
</RideLayout>
);
};
export default ConfirmRide;
+11
View File
@@ -0,0 +1,11 @@
import { ChatThread } from "@/components/chat-thread";
// Standalone chat screen for the driver side. Reuses the same ChatThread as
// the rider's (tabs) Chat screen, but outside the rider's (tabs) navigator —
// routing a driver into "/(root)/(tabs)/chat" would mount the rider's tab bar
// (Home/Rides/Chat/Profile/Settings) around them, exposing rider-only screens
// and clashing visually with the composer at the bottom. No tab bar here, so
// no extra clearance is needed.
const DriverChat = () => <ChatThread />;
export default DriverChat;
+1527 -123
View File
File diff suppressed because it is too large Load Diff
+284 -9
View File
@@ -1,11 +1,128 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
// Every control on this screen lives inside the RideLayout bottom sheet, and
// on Android a react-native touchable in there loses its first press to the
// sheet's gesture handler — which is why "Find now" had to be tapped twice to
// send a request. The sheet's own touchables are the fix the library ships for
// this; on iOS they are react-native's, unchanged.
import { TouchableOpacity } from "@gorhom/bottom-sheet";
import { router } from "expo-router";
import { useEffect, useState } from "react";
import { Alert, Text, View } from "react-native";
import { CustomButton } from "@/components/custom-button";
import { GoogleTextInput } from "@/components/google-text-input";
import { RideLayout } from "@/components/ride-layout";
import { icons } from "@/constants";
import { SERVICES, type ServiceId } from "@/constants/services";
import { ApiError } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { useLocationStore } from "@/store";
import { router } from "expo-router";
import { Text, View } from "react-native";
import { calculateTripFare } from "@/lib/map";
import { formatLBP } from "@/lib/pricing";
import { createRideRequest } from "@/lib/request-ride";
import { useServiceAvailability } from "@/lib/use-service-availability";
import { formatTime } from "@/lib/utils";
import { useLocationStore, useServiceStore } from "@/store";
/**
* "Set it on the map" for one of the two points.
*
* An autocomplete result lands on whatever the geocoder calls the centre of a
* place, which is regularly the wrong side of a building or the wrong end of a
* long street — and a driver sent to the wrong side of a divided road can't
* simply turn around. This is the escape hatch: the rider drags the map to the
* exact doorway.
*/
const AdjustOnMap = ({ mode }: { mode: "origin" | "destination" }) => {
const t = useT();
return (
<TouchableOpacity
onPress={() =>
router.push({ pathname: "/(root)/adjust-pin", params: { mode } })
}
className="flex-row items-center gap-x-2 mt-2 self-start px-1 py-1.5"
>
<MaterialCommunityIcons
name="map-marker-radius"
size={16}
color="#0286ff"
/>
<Text className="text-sm font-JakartaBold text-primary-500">
{t("findRide.adjustOnMap")}
</Text>
</TouchableOpacity>
);
};
/**
* Which service the request goes out on, with live availability.
*
* It lives on this screen because this is now the last screen before drivers
* are contacted — the request is broadcast on tap, so the choice of who to
* broadcast it to has to be made here, next to the button that sends it.
*/
const ServiceRow = ({
service,
counts,
onSelect,
}: {
service: ServiceId;
counts: Record<ServiceId, number>;
onSelect: (id: ServiceId) => void;
}) => {
const t = useT();
return (
<View className="flex-row gap-2">
{SERVICES.map((item) => {
const active = item.id === service;
const available = counts[item.id] ?? 0;
return (
<TouchableOpacity
key={item.id}
onPress={() => onSelect(item.id)}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityState={{ selected: active }}
className={`flex-1 items-center rounded-2xl border py-2.5 ${
active
? "border-primary-500 bg-primary-500/10"
: "border-neutral-100 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800"
}`}
>
<MaterialCommunityIcons
name={item.icon}
size={20}
color={active ? "#0286ff" : "#858585"}
/>
<Text
className={`text-[11px] mt-1 font-JakartaMedium ${
active
? "text-primary-500"
: "text-general-200 dark:text-neutral-400"
}`}
>
{t(item.labelKey)}
</Text>
{/* The count is the honest version of an empty map: it says
whether asking this service is worth doing before the rider
sends a request nobody will answer. */}
<Text
className={`text-[10px] ${
available > 0
? "text-emerald-600 dark:text-emerald-400"
: "text-general-200 dark:text-neutral-500"
}`}
>
{available > 0 ? t("findRide.nAvailable", { n: available }) : "—"}
</Text>
</TouchableOpacity>
);
})}
</View>
);
};
const FindRide = () => {
const t = useT();
@@ -19,13 +136,126 @@ const FindRide = () => {
setDestinationLocation,
setUserLocation,
} = useLocationStore();
const { service, setService } = useServiceStore();
const canFind =
const [estimate, setEstimate] = useState<{
fare: string;
durationSeconds: number;
} | null>(null);
const [estimating, setEstimating] = useState(false);
const [sending, setSending] = useState(false);
const hasRoute =
!!userLatitude &&
!!userLongitude &&
!!destinationLatitude &&
!!destinationLongitude;
const { counts } = useServiceAvailability(userLatitude, userLongitude);
// The fare is quoted before the request goes out, not after: it is what the
// drivers deciding whether to take the job are shown, so it has to exist by
// the time the request does. Recomputed when the route or service changes.
useEffect(() => {
if (!hasRoute) {
setEstimate(null);
return;
}
let cancelled = false;
setEstimating(true);
void calculateTripFare({
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
service,
})
.then((trip) => {
if (cancelled) return;
setEstimate(
trip
? { fare: trip.fare, durationSeconds: trip.durationSeconds }
: null,
);
})
.finally(() => {
if (!cancelled) setEstimating(false);
});
return () => {
cancelled = true;
};
}, [
hasRoute,
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
service,
]);
const findNow = async () => {
if (!hasRoute || !estimate) return;
setSending(true);
try {
const ride = await createRideRequest({
service,
origin: {
address: userAddress ?? "",
latitude: userLatitude!,
longitude: userLongitude!,
},
destination: {
address: destinationAddress ?? "",
latitude: destinationLatitude!,
longitude: destinationLongitude!,
},
rideTimeSeconds: estimate.durationSeconds,
fareCents: Math.round(parseFloat(estimate.fare) * 100),
});
router.replace(`/(root)/book-ride?id=${ride.ride_id}`);
} catch (err) {
console.log("[FIND_RIDE]: ", err);
// The rider already has a ride in flight. Booking a second one isn't
// what they want — they want the one they lost track of, so take them
// to it instead of showing an error they can't act on.
if (
err instanceof ApiError &&
err.status === 409 &&
err.body?.code === "RIDE_IN_PROGRESS"
) {
const inProgressId = String(err.body.ride_id);
Alert.alert(
t("confirmRide.alertInProgressTitle"),
t("confirmRide.alertInProgressBody"),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("confirmRide.viewRide"),
onPress: () =>
router.replace(`/(root)/book-ride?id=${inProgressId}`),
},
],
);
return;
}
Alert.alert(
t("confirmRide.alertErrorTitle"),
err instanceof ApiError
? err.message
: t("confirmRide.alertErrorFallback"),
);
} finally {
setSending(false);
}
};
return (
<RideLayout title={t("findRide.title")} snapPoints={["85%"]}>
<View className="my-3">
@@ -39,6 +269,8 @@ const FindRide = () => {
containerStyles="bg-neutral-100 dark:bg-neutral-800"
handlePress={setUserLocation}
/>
<AdjustOnMap mode="origin" />
</View>
<View className="my-3">
@@ -52,16 +284,59 @@ const FindRide = () => {
containerStyles="bg-neutral-100 dark:bg-neutral-800"
handlePress={setDestinationLocation}
/>
<AdjustOnMap mode="destination" />
</View>
<Text className="text-sm font-JakartaSemiBold mb-2 mt-1 text-black dark:text-white">
{t("findRide.service")}
</Text>
<ServiceRow service={service} counts={counts} onSelect={setService} />
{/* The quote, shown before the request goes out rather than on a screen
after it. This is the number the rider agrees to and the number every
driver who sees the request is offered, so it belongs next to the
button that sends it. */}
<View className="flex-row items-center justify-between rounded-2xl bg-general-500 dark:bg-neutral-950 px-4 py-3 mt-4">
<View>
<Text className="text-xs font-JakartaMedium text-general-200 dark:text-neutral-400">
{t("findRide.estimatedFare")}
</Text>
<Text className="text-[11px] text-general-200 dark:text-neutral-400 mt-0.5">
{estimate
? t("confirmRide.tripTime", {
time: formatTime(estimate.durationSeconds / 60),
})
: t("findRide.setBothPoints")}
</Text>
</View>
<View className="items-end">
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{estimating ? "…" : estimate ? `$${estimate.fare}` : "—"}
</Text>
{estimate ? (
<Text className="text-[11px] text-general-200 dark:text-neutral-400">
{t("confirmRide.lbpEstimate", {
lbp: formatLBP(parseFloat(estimate.fare)),
})}
</Text>
) : null}
</View>
</View>
<Text className="text-[11px] text-center text-general-200 dark:text-neutral-400 mt-3">
{t("findRide.payLaterHint")}
</Text>
<CustomButton
title={t("findRide.findNow")}
onPress={() => router.push("/(root)/confirm-ride")}
disabled={!canFind}
className={`mt-5 ${!canFind ? "opacity-50" : ""}`}
Touchable={TouchableOpacity}
title={sending ? t("findRide.sending") : t("findRide.findNow")}
onPress={() => void findNow()}
disabled={!hasRoute || !estimate || estimating || sending}
className={`mt-3 ${!hasRoute || !estimate || estimating || sending ? "opacity-50" : ""}`}
/>
</RideLayout>
);
};
export default FindRide;
export default FindRide;
+11
View File
@@ -5,13 +5,24 @@ import { useEffect } from "react";
import "react-native-reanimated";
import { I18nProvider } from "@/lib/i18n";
import { configureNotificationHandler } from "@/lib/notifications";
import { SessionProvider } from "@/lib/session";
import { SettingsProvider } from "@/lib/settings-provider";
import { ThemeProvider } from "@/lib/theme";
// Registers the driver background-location task. Imported for the side effect
// alone: Android can restart the app process headlessly to deliver a location
// update, and the task must already be defined when the bundle finishes
// evaluating — which means at module scope, not inside a component.
import "@/lib/location-task";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
// A ride offer that arrives while the app is open still needs to be seen — the
// driver may be on another screen, and they only have 15 seconds to answer.
configureNotificationHandler();
const RootLayout = () => {
const [loaded] = useFonts({
"Jakarta-Bold": require("../assets/fonts/PlusJakartaSans-Bold.ttf"),