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 }),
);
}