Waseel: driver capture, chat/calls, dispatch, and session fixes
Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.
Camera permission on Android:
- Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
own plugin never declares CAMERA, and Android denies a request for an
undeclared permission instantly and silently — no dialog is ever shown,
which is indistinguishable from the app not asking at all.
- Handle canAskAgain: once Android stops showing the dialog, repeating why
we need it is a dead end, so offer Open Settings instead (lib/capture-
permission.ts), matching what the location flow already did.
Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.
Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1d84003e0a
commit
8807ff41c5
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user