Build driver app, Uber-style dispatch, POI suggestions; fix map tiles
Driver side (was a stub): - In-app driver onboarding: a driver-role user creates their own linked drivers profile (driver/profile+api GET/POST/PATCH). - Driver dashboard: online/offline toggle, today's earnings, incoming request cards (accept/decline), active ride panel (start/complete trip). Polls /driver/rides every 4s while online. - Location heartbeat (use-driver-location): watchPositionAsync pings /driver/location every ~5s; restarts the watch on app foreground so a backgrounded driver doesn't go permanently stale and miss requests. Dispatch (auto-match nearest, Uber-style): - Ride state machine: requested -> accepted -> en_route -> completed/cancelled with a nullable driver_id until matched (lib/dispatch.matchNextDriver). - matchNextDriver locks the ride (SELECT FOR UPDATE), expires 15s-stale offers, picks the nearest eligible driver of the matching service by haversine, offers one at a time. Called from ride/create, ride/[id] GET (lazy match on the rider's poll), and ride/[id]/respond (on decline). - ride/create is now a request endpoint (driver_id NULL, status=requested, service); drops the pre-match driver_id payment reconciliation. - ride/[id] GET returns status/service/nullable driver; PATCH handles rider cancel + driver en_route/completed. ride/list backs the history tabs. Rider flow (best experience): - confirm-ride is now a request screen: single trip fare + nearest-driver ETA + cash/card + Request Ride -> live status. Periodically polls online drivers of the selected service and disables Request when none are online (prevents the "stuck searching forever" state). - book-ride is the live ride-status screen (searching -> accepted -> en_route -> completed/cancelled + Cancel), polling every 3s. - lib/request-ride unifies the Areeba card flow + cash path. - Map reads /driver/nearby (real positions, service-filtered); lib/map adds calculateTripFare + service-aware fares. POI suggestions: - lib/places (Google Nearby Search) + nearby-suggestions chips for mall/hospital/pharmacy/restaurant on the home screen. Service categories now drive both matching and a per-service fare multiplier (car 1.0 / moto 0.7 / courier 0.85 / chauffeur 1.5). Map tiles: react-native-maps rendered blank on Android because no Google Maps key was set. Switched app.json -> app.config.js so android.config.googleMaps.apiKey is injected from EXPO_PUBLIC_GOOGLE_API_KEY at build time (keeps the key out of git). Requires a native rebuild (expo run:android) to take effect. Also includes the prior payment/auth hardening (server-authoritative payment_orders ledger with double-spend guards, peppered OTP, register TOCTOU fix, stats cents fix) that was left uncommitted. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,38 +1,123 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { createCheckoutSession } from "@/lib/areeba";
|
||||
import { createOrder } from "@/lib/payment-orders";
|
||||
|
||||
// Only these return URLs may be handed to the gateway. Anything else
|
||||
// (including open redirects) is rejected and we fall back to the deep link.
|
||||
const isAllowedReturnUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// The app's own deep link is always allowed.
|
||||
if (parsed.protocol === "waseel:") return true;
|
||||
|
||||
// The configured server origin (EXPO_PUBLIC_SERVER_URL), if set.
|
||||
const serverUrl = process.env.EXPO_PUBLIC_SERVER_URL;
|
||||
if (serverUrl) {
|
||||
const server = new URL(serverUrl);
|
||||
if (parsed.protocol === server.protocol && parsed.host === server.host)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve the fare to integer cents. Accept fare_cents directly, or fare /
|
||||
// amount in dollars (legacy client field) and convert.
|
||||
const resolveAmountCents = (fareCents: unknown, fare: unknown, amount: unknown): number | null => {
|
||||
let cents: number;
|
||||
if (fareCents !== undefined && fareCents !== null) {
|
||||
cents = Math.round(Number(fareCents));
|
||||
} else if (fare !== undefined && fare !== null) {
|
||||
cents = Math.round(Number(fare) * 100);
|
||||
} else if (amount !== undefined && amount !== null) {
|
||||
cents = Math.round(Number(amount) * 100);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(cents) || cents <= 0) return null;
|
||||
return cents;
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
const { name, email, amount, returnUrl } = body;
|
||||
const auth = requireAuth(req);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
if (!name || !email || !amount)
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing required payment information." }),
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const {
|
||||
name,
|
||||
email,
|
||||
amount,
|
||||
fare_cents,
|
||||
fare,
|
||||
returnUrl,
|
||||
driver_id,
|
||||
origin_address,
|
||||
destination_address,
|
||||
origin_latitude,
|
||||
origin_longitude,
|
||||
destination_latitude,
|
||||
destination_longitude,
|
||||
ride_time,
|
||||
} = body;
|
||||
|
||||
if (!name || !email)
|
||||
return Response.json(
|
||||
{ error: "Missing required payment information." },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
const amountCents = resolveAmountCents(fare_cents, fare, amount);
|
||||
if (amountCents === null)
|
||||
return Response.json({ error: "Invalid fare amount." }, { status: 400 });
|
||||
|
||||
const finalReturnUrl =
|
||||
typeof returnUrl === "string" && isAllowedReturnUrl(returnUrl)
|
||||
? returnUrl
|
||||
: "waseel://book-ride";
|
||||
|
||||
try {
|
||||
const orderId = `waseel-${Date.now()}`;
|
||||
const orderId = randomUUID();
|
||||
|
||||
const session = await createCheckoutSession({
|
||||
orderId,
|
||||
amount: parseFloat(amount),
|
||||
amount: amountCents / 100,
|
||||
currency: "USD",
|
||||
description: `Waseel ride payment for ${name}`,
|
||||
returnUrl: returnUrl || "waseel://book-ride",
|
||||
returnUrl: finalReturnUrl,
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
orderId,
|
||||
checkoutUrl: session.checkoutUrl,
|
||||
successIndicator: session.successIndicator,
|
||||
}),
|
||||
);
|
||||
// The successIndicator stays server-side; the client never sees it.
|
||||
if (!session.successIndicator)
|
||||
throw new Error("Areeba did not return a successIndicator.");
|
||||
|
||||
await createOrder({
|
||||
order_id: orderId,
|
||||
user_id: auth.userId,
|
||||
amount_cents: amountCents,
|
||||
currency: "USD",
|
||||
driver_id: driver_id ?? null,
|
||||
origin_address: origin_address ?? null,
|
||||
destination_address: destination_address ?? null,
|
||||
origin_latitude: origin_latitude ?? null,
|
||||
origin_longitude: origin_longitude ?? null,
|
||||
destination_latitude: destination_latitude ?? null,
|
||||
destination_longitude: destination_longitude ?? null,
|
||||
ride_time: ride_time ?? null,
|
||||
success_indicator: session.successIndicator,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
return Response.json({ orderId, checkoutUrl: session.checkoutUrl });
|
||||
} catch (err) {
|
||||
console.log("[AREEBA_PAYMENT_CREATE]: ", err);
|
||||
|
||||
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
|
||||
status: 500,
|
||||
});
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,69 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { retrieveOrder } from "@/lib/areeba";
|
||||
import { getOrder, markPaid } from "@/lib/payment-orders";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
const { orderId, resultIndicator, successIndicator } = body;
|
||||
const auth = requireAuth(req);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
if (!orderId)
|
||||
return new Response(JSON.stringify({ error: "Missing order id." }), {
|
||||
status: 400,
|
||||
});
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { orderId, resultIndicator } = body;
|
||||
|
||||
if (!orderId || !resultIndicator)
|
||||
return Response.json(
|
||||
{ error: "Missing order id or result indicator." },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
try {
|
||||
const order = await retrieveOrder(orderId);
|
||||
// Look the order up server-side — never trust a client-supplied
|
||||
// successIndicator value, only the one we persisted at creation time.
|
||||
const order = await getOrder(orderId);
|
||||
if (!order)
|
||||
return Response.json({ error: "Order not found." }, { status: 404 });
|
||||
|
||||
// The gateway appends resultIndicator to the return URL after payment;
|
||||
// it must match the successIndicator issued when the session was created.
|
||||
const indicatorMatches =
|
||||
!successIndicator || resultIndicator === successIndicator;
|
||||
if (order.user_id !== auth.userId)
|
||||
return Response.json({ error: "Unauthorized." }, { status: 403 });
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: order.paid && indicatorMatches,
|
||||
status: order.status,
|
||||
amount: order.amount,
|
||||
currency: order.currency,
|
||||
}),
|
||||
);
|
||||
// An order can only be verified once. A 'paid' or 'consumed' order has
|
||||
// already settled — rejecting here is the primary double-spend defense: it
|
||||
// stops a client from re-verifying an order it already used for a ride.
|
||||
if (order.status !== "pending")
|
||||
return Response.json(
|
||||
{ error: "This payment order is no longer pending." },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
if (
|
||||
!order.success_indicator ||
|
||||
order.success_indicator !== resultIndicator
|
||||
)
|
||||
return Response.json(
|
||||
{ error: "Payment verification failed." },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
const retrieved = await retrieveOrder(orderId);
|
||||
|
||||
if (!retrieved.paid)
|
||||
return Response.json({ error: "Payment not captured." }, { status: 400 });
|
||||
|
||||
// Reconcile the gateway's amount/currency against what we stored, so a
|
||||
// tampered or partial payment cannot mark a full-fare order paid.
|
||||
const gatewayCents = Math.round(Number(retrieved.amount) * 100);
|
||||
const gatewayCurrency = retrieved.currency ?? "USD";
|
||||
if (gatewayCents !== order.amount_cents || gatewayCurrency !== order.currency)
|
||||
return Response.json(
|
||||
{ error: "Payment amount mismatch." },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
await markPaid(orderId);
|
||||
|
||||
return Response.json({ success: true, orderId });
|
||||
} catch (err) {
|
||||
console.log("[AREEBA_PAYMENT_VERIFY]: ", err);
|
||||
|
||||
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
|
||||
status: 500,
|
||||
});
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,11 @@ 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), 0)::int FROM rides WHERE payment_status = 'paid') AS revenue,
|
||||
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides WHERE payment_status = 'paid') AS revenue,
|
||||
(SELECT COUNT(*)::int FROM rides WHERE created_at >= CURRENT_DATE) AS rides_today,
|
||||
(SELECT COALESCE(ROUND(AVG(fare_price)), 0)::int FROM rides WHERE payment_status = 'paid') AS avg_fare,
|
||||
(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), 0)::int FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_revenue,
|
||||
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_revenue,
|
||||
(SELECT COUNT(*)::int FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days') AS new_users_7d
|
||||
`;
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function GET(request: Request) {
|
||||
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'), 0)::int AS revenue
|
||||
COALESCE(SUM(r.fare_price) FILTER (WHERE r.payment_status = 'paid') / 100.0, 0)::float8 AS revenue
|
||||
FROM generate_series(
|
||||
CURRENT_DATE - INTERVAL '13 days',
|
||||
CURRENT_DATE,
|
||||
@@ -58,7 +58,7 @@ export async function GET(request: Request) {
|
||||
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'), 0)::int AS revenue
|
||||
COALESCE(SUM(r.fare_price) FILTER (WHERE r.payment_status = 'paid') / 100.0, 0)::float8 AS revenue
|
||||
FROM drivers d
|
||||
LEFT JOIN rides r ON r.driver_id = d.id
|
||||
GROUP BY d.id, d.first_name, d.last_name
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from "@/lib/db";
|
||||
import { sendEmail } from "@/lib/mailer";
|
||||
import { sql, transaction } from "@/lib/db";
|
||||
import { isDevOtpExposed, sendEmail } from "@/lib/mailer";
|
||||
import {
|
||||
CODE_TTL_MINUTES,
|
||||
generateCode,
|
||||
@@ -26,20 +26,24 @@ export async function POST(req: Request) {
|
||||
return Response.json({ data: { sent: false } });
|
||||
}
|
||||
|
||||
const code = generateCode();
|
||||
const code = await transaction(async (tx) => {
|
||||
const generated = generateCode();
|
||||
|
||||
await sql`
|
||||
INSERT INTO password_reset_codes (email, code_hash, expires_at)
|
||||
VALUES (
|
||||
${normalized},
|
||||
${hashCode(normalized, code)},
|
||||
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
code_hash = EXCLUDED.code_hash,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
attempts = 0
|
||||
`;
|
||||
await tx`
|
||||
INSERT INTO password_reset_codes (email, code_hash, expires_at)
|
||||
VALUES (
|
||||
${normalized},
|
||||
${hashCode(normalized, generated)},
|
||||
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
code_hash = EXCLUDED.code_hash,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
attempts = 0
|
||||
`;
|
||||
|
||||
return generated;
|
||||
});
|
||||
|
||||
const mail = resetEmail(code);
|
||||
const delivered = await sendEmail(normalized, mail.subject, mail.text);
|
||||
@@ -48,8 +52,9 @@ export async function POST(req: Request) {
|
||||
data: {
|
||||
sent: delivered,
|
||||
// Without SMTP configured there is nothing to receive, so surface the
|
||||
// code to keep the reset flow usable on a self-hosted box.
|
||||
...(delivered ? {} : { devCode: code }),
|
||||
// code to keep the reset flow usable on a self-hosted box. Never
|
||||
// expose the code in production, even on delivery failure.
|
||||
...(delivered || !isDevOtpExposed() ? {} : { devCode: code }),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { sql } from "@/lib/db";
|
||||
import { sql, transaction } from "@/lib/db";
|
||||
import { hashPassword } from "@/lib/password";
|
||||
import { sendEmail } from "@/lib/mailer";
|
||||
import { isDevOtpExposed, sendEmail } from "@/lib/mailer";
|
||||
import {
|
||||
CODE_TTL_MINUTES,
|
||||
generateCode,
|
||||
hashCode,
|
||||
verificationEmail,
|
||||
} from "@/lib/otp";
|
||||
|
||||
const normalizePhone = (raw: string): string => {
|
||||
const cleaned = raw.replace(/[^\d+]/g, "");
|
||||
if (cleaned.startsWith("+")) return cleaned;
|
||||
return `+961${cleaned.replace(/^0+/, "")}`;
|
||||
};
|
||||
import { normalizePhone } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { name, email, phone, password, role } = await req.json();
|
||||
@@ -46,37 +41,42 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// Unverified rows may be re-registered (e.g. the first mail never arrived).
|
||||
await sql`
|
||||
INSERT INTO users (name, email, phone, password_hash, email_verified, role)
|
||||
VALUES (
|
||||
${name.trim()},
|
||||
${email.trim().toLowerCase()},
|
||||
${phone ? normalizePhone(phone) : null},
|
||||
${hashPassword(password)},
|
||||
FALSE,
|
||||
${normalizedRole}
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
phone = COALESCE(EXCLUDED.phone, users.phone),
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role
|
||||
`;
|
||||
const code = await transaction(async (tx) => {
|
||||
await tx`
|
||||
INSERT INTO users (name, email, phone, password_hash, email_verified, role)
|
||||
VALUES (
|
||||
${name.trim()},
|
||||
${email.trim().toLowerCase()},
|
||||
${phone ? normalizePhone(phone) : null},
|
||||
${hashPassword(password)},
|
||||
FALSE,
|
||||
${normalizedRole}
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
phone = COALESCE(EXCLUDED.phone, users.phone),
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role
|
||||
WHERE users.email_verified = FALSE
|
||||
`;
|
||||
|
||||
const code = generateCode();
|
||||
const generated = generateCode();
|
||||
|
||||
await sql`
|
||||
INSERT INTO email_verification_codes (email, code_hash, expires_at)
|
||||
VALUES (
|
||||
${email.trim().toLowerCase()},
|
||||
${hashCode(email.trim().toLowerCase(), code)},
|
||||
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
code_hash = EXCLUDED.code_hash,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
attempts = 0
|
||||
`;
|
||||
await tx`
|
||||
INSERT INTO email_verification_codes (email, code_hash, expires_at)
|
||||
VALUES (
|
||||
${email.trim().toLowerCase()},
|
||||
${hashCode(email.trim().toLowerCase(), generated)},
|
||||
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
code_hash = EXCLUDED.code_hash,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
attempts = 0
|
||||
`;
|
||||
|
||||
return generated;
|
||||
});
|
||||
|
||||
const mail = verificationEmail(code);
|
||||
const delivered = await sendEmail(
|
||||
@@ -90,8 +90,9 @@ export async function POST(req: Request) {
|
||||
data: {
|
||||
sent: delivered,
|
||||
// Without SMTP/Gmail configured there is nothing to receive, so
|
||||
// surface the code to keep self-hosted sign-up usable.
|
||||
...(delivered ? {} : { devCode: code }),
|
||||
// surface the code to keep self-hosted sign-up usable. Never expose
|
||||
// the code in production, even on delivery failure.
|
||||
...(delivered || !isDevOtpExposed() ? {} : { devCode: code }),
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sql } from "@/lib/db";
|
||||
import { transaction } from "@/lib/db";
|
||||
import { MAX_CODE_ATTEMPTS, codeMatches } from "@/lib/otp";
|
||||
import { hashPassword } from "@/lib/password";
|
||||
import { issueSession, toProfile } from "@/lib/users";
|
||||
@@ -24,52 +24,67 @@ export async function POST(req: Request) {
|
||||
|
||||
try {
|
||||
// Charge the attempt before comparing so concurrent guesses can't race
|
||||
// past the cap, and so a correct guess still costs one of the five.
|
||||
const attempts = await sql<{ code_hash: string; attempts: number }>`
|
||||
UPDATE password_reset_codes
|
||||
SET attempts = attempts + 1
|
||||
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
|
||||
RETURNING code_hash, attempts
|
||||
`;
|
||||
// past the cap, and so a correct guess still costs one of the five. Wrap
|
||||
// the attempt charge, password update, and code cleanup in one
|
||||
// transaction.
|
||||
const result = await transaction(async (tx) => {
|
||||
const attempts = await tx<{ code_hash: string; attempts: number }>`
|
||||
UPDATE password_reset_codes
|
||||
SET attempts = attempts + 1
|
||||
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
|
||||
RETURNING code_hash, attempts
|
||||
`;
|
||||
|
||||
const record = attempts[0];
|
||||
const record = attempts[0];
|
||||
|
||||
if (
|
||||
!record ||
|
||||
record.attempts > MAX_CODE_ATTEMPTS ||
|
||||
!codeMatches(record.code_hash, normalized, code)
|
||||
) {
|
||||
if (
|
||||
!record ||
|
||||
record.attempts > MAX_CODE_ATTEMPTS ||
|
||||
!codeMatches(record.code_hash, normalized, code)
|
||||
) {
|
||||
return { kind: "invalid" as const };
|
||||
}
|
||||
|
||||
// A successful reset also proves control of the mailbox, so verify it
|
||||
// too.
|
||||
const rows = await tx<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string | null;
|
||||
}>`
|
||||
UPDATE users
|
||||
SET password_hash = ${hashPassword(password)}, email_verified = TRUE
|
||||
WHERE email = ${normalized}
|
||||
RETURNING id, name, email, role
|
||||
`;
|
||||
|
||||
const user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
return { kind: "not_found" as const };
|
||||
}
|
||||
|
||||
await tx`DELETE FROM password_reset_codes WHERE email = ${normalized}`;
|
||||
|
||||
return { kind: "ok" as const, user };
|
||||
});
|
||||
|
||||
if (result.kind === "invalid") {
|
||||
return Response.json(
|
||||
{ error: "Invalid or expired reset code." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// A successful reset also proves control of the mailbox, so verify it too.
|
||||
const rows = await sql<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string | null;
|
||||
}>`
|
||||
UPDATE users
|
||||
SET password_hash = ${hashPassword(password)}, email_verified = TRUE
|
||||
WHERE email = ${normalized}
|
||||
RETURNING id, name, email, role
|
||||
`;
|
||||
|
||||
const user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
if (result.kind === "not_found") {
|
||||
return Response.json({ error: "User not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
await sql`DELETE FROM password_reset_codes WHERE email = ${normalized}`;
|
||||
|
||||
const session = issueSession(user);
|
||||
const session = issueSession(result.user);
|
||||
|
||||
return Response.json({
|
||||
data: { token: session.token, user: toProfile(user) },
|
||||
data: { token: session.token, user: toProfile(result.user) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[RESET_PASSWORD]: ", error);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sql } from "@/lib/db";
|
||||
import { transaction } from "@/lib/db";
|
||||
import {
|
||||
MAX_CODE_ATTEMPTS,
|
||||
codeMatches,
|
||||
@@ -19,50 +19,63 @@ export async function POST(req: Request) {
|
||||
|
||||
try {
|
||||
// Charge the attempt before comparing so concurrent guesses can't race
|
||||
// past the cap, and so a correct guess still costs one of the five.
|
||||
const attempts = await sql<{ code_hash: string; attempts: number }>`
|
||||
UPDATE email_verification_codes
|
||||
SET attempts = attempts + 1
|
||||
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
|
||||
RETURNING code_hash, attempts
|
||||
`;
|
||||
// past the cap, and so a correct guess still costs one of the five. Wrap
|
||||
// the attempt charge, verification, and code cleanup in one transaction.
|
||||
const result = await transaction(async (tx) => {
|
||||
const attempts = await tx<{ code_hash: string; attempts: number }>`
|
||||
UPDATE email_verification_codes
|
||||
SET attempts = attempts + 1
|
||||
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
|
||||
RETURNING code_hash, attempts
|
||||
`;
|
||||
|
||||
const record = attempts[0];
|
||||
const record = attempts[0];
|
||||
|
||||
if (
|
||||
!record ||
|
||||
record.attempts > MAX_CODE_ATTEMPTS ||
|
||||
!codeMatches(record.code_hash, normalized, code)
|
||||
) {
|
||||
if (
|
||||
!record ||
|
||||
record.attempts > MAX_CODE_ATTEMPTS ||
|
||||
!codeMatches(record.code_hash, normalized, code)
|
||||
) {
|
||||
return { kind: "invalid" as const };
|
||||
}
|
||||
|
||||
const rows = await tx<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string | null;
|
||||
}>`
|
||||
UPDATE users SET email_verified = TRUE
|
||||
WHERE email = ${normalized}
|
||||
RETURNING id, name, email, role
|
||||
`;
|
||||
|
||||
const user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
return { kind: "not_found" as const };
|
||||
}
|
||||
|
||||
await tx`DELETE FROM email_verification_codes WHERE email = ${normalized}`;
|
||||
|
||||
return { kind: "ok" as const, user };
|
||||
});
|
||||
|
||||
if (result.kind === "invalid") {
|
||||
return Response.json(
|
||||
{ error: "Invalid or expired verification code." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await sql<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string | null;
|
||||
}>`
|
||||
UPDATE users SET email_verified = TRUE
|
||||
WHERE email = ${normalized}
|
||||
RETURNING id, name, email, role
|
||||
`;
|
||||
|
||||
const user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
if (result.kind === "not_found") {
|
||||
return Response.json({ error: "User not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
await sql`DELETE FROM email_verification_codes WHERE email = ${normalized}`;
|
||||
|
||||
const session = issueSession(user);
|
||||
const session = issueSession(result.user);
|
||||
|
||||
return Response.json({
|
||||
data: { token: session.token, user: toProfile(user) },
|
||||
data: { token: session.token, user: toProfile(result.user) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[VERIFY]: ", error);
|
||||
|
||||
+10
-3
@@ -1,8 +1,15 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { sql } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(req: Request) {
|
||||
const auth = requireAuth(req);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
try {
|
||||
const response = await sql`SELECT * FROM drivers`;
|
||||
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) {
|
||||
@@ -10,4 +17,4 @@ export async function GET() {
|
||||
|
||||
return Response.json({ error }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { requireDriverProfile } from "@/lib/driver";
|
||||
import { sql } from "@/lib/db";
|
||||
|
||||
// POST — driver location heartbeat. Each ping updates lat/lng/last_seen and
|
||||
// keeps the driver marked online. The client (use-driver-location) fires this
|
||||
// every few seconds while the driver's online toggle is on; going offline is
|
||||
// an explicit PATCH to /driver/profile, not the absence of pings.
|
||||
export async function POST(req: Request) {
|
||||
const result = await requireDriverProfile(req);
|
||||
if ("error" in result) return result.error;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { latitude, longitude } = body;
|
||||
|
||||
if (
|
||||
typeof latitude !== "number" ||
|
||||
typeof longitude !== "number" ||
|
||||
Number.isNaN(latitude) ||
|
||||
Number.isNaN(longitude)
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "latitude and longitude must be numbers." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { driverId } = result;
|
||||
const rows = await sql`
|
||||
UPDATE drivers
|
||||
SET latitude = ${latitude},
|
||||
longitude = ${longitude},
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
online = TRUE
|
||||
WHERE id = ${driverId}
|
||||
RETURNING id, latitude, longitude, last_seen, online
|
||||
`;
|
||||
|
||||
return Response.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_LOCATION]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { sql } from "@/lib/db";
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Query: ?service=car&lat=33.89&lng=35.50&radius=8000
|
||||
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 service = url.searchParams.get("service") ?? "car";
|
||||
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 rows = await sql`
|
||||
SELECT id, first_name, last_name, profile_image_url, car_image_url,
|
||||
car_seats, rating, service, car_model, latitude, longitude,
|
||||
last_seen
|
||||
FROM drivers
|
||||
WHERE service = ${service}
|
||||
AND online = TRUE
|
||||
AND user_id IS NOT NULL
|
||||
AND last_seen > CURRENT_TIMESTAMP - INTERVAL '60 seconds'
|
||||
AND latitude IS NOT NULL
|
||||
AND longitude IS NOT NULL
|
||||
`;
|
||||
|
||||
return Response.json({ data: rows });
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_NEARBY]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { sql, query } from "@/lib/db";
|
||||
import { isServiceId, requireDriverProfile } from "@/lib/driver";
|
||||
import { SERVICES, type ServiceId } from "@/constants/services";
|
||||
|
||||
// GET — the signed-in user's own driver profile, or 403 (code: ONBOARD) when
|
||||
// they haven't onboarded yet. The client uses the code to show the form.
|
||||
export async function GET(req: Request) {
|
||||
const result = await requireDriverProfile(req);
|
||||
if ("error" in result) return result.error;
|
||||
|
||||
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
|
||||
FROM drivers WHERE id = ${driverId}
|
||||
`;
|
||||
return Response.json({ data: rows[0], userId: auth.userId });
|
||||
}
|
||||
|
||||
// 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.
|
||||
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;
|
||||
|
||||
// The user must be flagged a driver to onboard a driver profile.
|
||||
const users = await sql<{ role: string | null; name: string | null }>`
|
||||
SELECT role, name FROM users WHERE id = ${auth.userId}
|
||||
`;
|
||||
if (!users[0] || users[0].role !== "driver") {
|
||||
return Response.json(
|
||||
{ error: "Only driver accounts can onboard a driver profile." },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isServiceId(service)) {
|
||||
return Response.json(
|
||||
{ error: `service must be one of: ${SERVICES.map((s) => s.id).join(", ")}.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const seats = Number(car_seats);
|
||||
if (!Number.isInteger(seats) || seats < 1 || seats > 8) {
|
||||
return Response.json(
|
||||
{ error: "car_seats must be a whole number between 1 and 8." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const [firstName, ...rest] = (users[0].name ?? "").split(" ");
|
||||
|
||||
// One profile per driver user. The partial unique index on user_id
|
||||
// guarantees this at the DB level; surface a clean 409 on collision.
|
||||
try {
|
||||
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
|
||||
) VALUES (
|
||||
${auth.userId},
|
||||
${firstName || "Driver"},
|
||||
${rest.join(" ") || ""},
|
||||
${profile_image_url ?? null},
|
||||
${car_image_url ?? null},
|
||||
${seats},
|
||||
5.0,
|
||||
${service as ServiceId},
|
||||
${car_model ?? null},
|
||||
FALSE
|
||||
)
|
||||
RETURNING id, service, online
|
||||
`;
|
||||
return Response.json({ data: rows[0] }, { status: 201 });
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === "23505") {
|
||||
return Response.json(
|
||||
{ error: "Driver profile already exists." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_PROFILE_POST]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH — update mutable profile fields, most importantly the online toggle.
|
||||
export async function PATCH(req: Request) {
|
||||
const result = await requireDriverProfile(req);
|
||||
if ("error" in result) return result.error;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { online, car_model, car_seats, service } = body;
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: (string | number | boolean | null)[] = [];
|
||||
let idx = 1;
|
||||
const push = (col: string, value: string | number | boolean | null) => {
|
||||
updates.push(`${col} = $${idx++}`);
|
||||
values.push(value);
|
||||
};
|
||||
|
||||
if (typeof online === "boolean") push("online", online);
|
||||
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) {
|
||||
return Response.json(
|
||||
{ error: "car_seats must be a whole number between 1 and 8." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
push("car_seats", seats);
|
||||
}
|
||||
if (service !== undefined) {
|
||||
if (!isServiceId(service)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid service." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
push("service", service as string);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return Response.json({ error: "No fields to update." }, { status: 400 });
|
||||
}
|
||||
|
||||
values.push(result.driverId);
|
||||
const rows = await query(
|
||||
`UPDATE drivers SET ${updates.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
values,
|
||||
);
|
||||
return Response.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_PROFILE_PATCH]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { requireDriverProfile } from "@/lib/driver";
|
||||
import { sql } from "@/lib/db";
|
||||
|
||||
// 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).
|
||||
// recent : rides completed today, for the earnings summary.
|
||||
export async function GET(req: Request) {
|
||||
const result = await requireDriverProfile(req);
|
||||
if ("error" in result) return result.error;
|
||||
|
||||
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
|
||||
`;
|
||||
|
||||
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
|
||||
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')
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const recent = await sql`
|
||||
SELECT ride_id, fare_price, service, completed_at
|
||||
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,
|
||||
);
|
||||
|
||||
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[],
|
||||
earnings,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_RIDES]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
type OfferRow = {
|
||||
offer_id: number;
|
||||
offered_at: string;
|
||||
ride_id: number;
|
||||
origin_address: string;
|
||||
destination_address: string;
|
||||
origin_latitude: number;
|
||||
origin_longitude: number;
|
||||
destination_latitude: number;
|
||||
destination_longitude: number;
|
||||
ride_time: number;
|
||||
fare_price: number;
|
||||
payment_status: string;
|
||||
service: string;
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
type ActiveRide = {
|
||||
ride_id: number;
|
||||
status: string;
|
||||
service: string;
|
||||
payment_status: string;
|
||||
origin_address: string;
|
||||
destination_address: string;
|
||||
origin_latitude: number;
|
||||
origin_longitude: number;
|
||||
destination_latitude: number;
|
||||
destination_longitude: number;
|
||||
ride_time: number;
|
||||
fare_price: number;
|
||||
created_at: string;
|
||||
rider_name: string | null;
|
||||
rider_phone: string | null;
|
||||
};
|
||||
|
||||
type RecentRow = {
|
||||
ride_id: number;
|
||||
fare_price: number;
|
||||
service: string;
|
||||
completed_at: string;
|
||||
};
|
||||
+136
-34
@@ -1,46 +1,148 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { sql } from "@/lib/db";
|
||||
import { sql, query } from "@/lib/db";
|
||||
import { matchNextDriver } from "@/lib/dispatch";
|
||||
import { requireDriverProfile } from "@/lib/driver";
|
||||
|
||||
// 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).
|
||||
export async function GET(request: Request, { id }: { id: string }) {
|
||||
const auth = requireAuth(request);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
try {
|
||||
const response = await sql`
|
||||
SELECT
|
||||
rides.ride_id,
|
||||
rides.origin_address,
|
||||
rides.destination_address,
|
||||
rides.origin_latitude,
|
||||
rides.origin_longitude,
|
||||
rides.destination_latitude,
|
||||
rides.destination_longitude,
|
||||
rides.ride_time,
|
||||
rides.fare_price,
|
||||
rides.payment_status,
|
||||
rides.created_at,
|
||||
json_build_object(
|
||||
'driver_id', drivers.id,
|
||||
'first_name', drivers.first_name,
|
||||
'last_name', drivers.last_name,
|
||||
'profile_image_url', drivers.profile_image_url,
|
||||
'car_image_url', drivers.car_image_url,
|
||||
'car_seats', drivers.car_seats,
|
||||
'rating', drivers.rating
|
||||
) AS driver
|
||||
FROM
|
||||
rides
|
||||
INNER JOIN
|
||||
drivers ON rides.driver_id = drivers.id
|
||||
WHERE
|
||||
rides.user_id = ${auth.userId}
|
||||
ORDER BY
|
||||
rides.created_at DESC;
|
||||
`;
|
||||
const rideId = Number(id);
|
||||
if (!Number.isInteger(rideId)) {
|
||||
return Response.json({ error: "Invalid ride id." }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json({ data: response });
|
||||
try {
|
||||
const ride = await sql`
|
||||
SELECT status FROM rides WHERE ride_id = ${rideId} AND user_id = ${auth.userId}
|
||||
`;
|
||||
if (!ride[0]) {
|
||||
return Response.json({ error: "Ride not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
// Lazy match: try to offer the ride to a driver if it's still requested.
|
||||
if (ride[0].status === "requested") {
|
||||
void matchNextDriver(rideId);
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
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.payment_status,
|
||||
r.status,
|
||||
r.service,
|
||||
r.created_at,
|
||||
r.completed_at,
|
||||
r.cancelled_at,
|
||||
json_build_object(
|
||||
'id', d.id,
|
||||
'first_name', d.first_name,
|
||||
'last_name', d.last_name,
|
||||
'car_seats', d.car_seats,
|
||||
'profile_image_url', d.profile_image_url,
|
||||
'car_image_url', d.car_image_url,
|
||||
'rating', d.rating,
|
||||
'service', d.service,
|
||||
'car_model', d.car_model,
|
||||
'latitude', d.latitude,
|
||||
'longitude', d.longitude
|
||||
) AS driver
|
||||
FROM rides r
|
||||
LEFT JOIN drivers d ON d.id = r.driver_id
|
||||
WHERE r.ride_id = ${rideId}
|
||||
`;
|
||||
|
||||
return Response.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
console.error("[GET_RIDE]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const next = body.status;
|
||||
|
||||
try {
|
||||
// Rider cancel — authenticate by ownership of the ride.
|
||||
if (next === "cancelled") {
|
||||
const auth = requireAuth(request);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
const rows = await sql<{ status: string }>`
|
||||
UPDATE rides
|
||||
SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP
|
||||
WHERE ride_id = ${rideId}
|
||||
AND user_id = ${auth.userId}
|
||||
AND status IN ('requested', 'accepted')
|
||||
RETURNING status
|
||||
`;
|
||||
if (!rows[0]) {
|
||||
return Response.json(
|
||||
{ error: "Ride cannot be cancelled." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return Response.json({ data: { status: rows[0].status } });
|
||||
}
|
||||
|
||||
// Driver transitions — must be the driver assigned to the ride.
|
||||
if (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],
|
||||
);
|
||||
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({ error: "Unknown status transition." }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[PATCH_RIDE]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
+168
-41
@@ -1,6 +1,18 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { sql } from "@/lib/db";
|
||||
import { sql, transaction } from "@/lib/db";
|
||||
import { getOrder, consumeOrderForRide } from "@/lib/payment-orders";
|
||||
import { matchNextDriver } from "@/lib/dispatch";
|
||||
import { isServiceId } from "@/lib/driver";
|
||||
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.
|
||||
export async function POST(request: Request) {
|
||||
const auth = requireAuth(request);
|
||||
if ("error" in auth) return auth.error;
|
||||
@@ -16,21 +28,20 @@ export async function POST(request: Request) {
|
||||
destination_longitude,
|
||||
ride_time,
|
||||
fare_price,
|
||||
payment_status,
|
||||
driver_id,
|
||||
payment_method,
|
||||
payment_order_id,
|
||||
service,
|
||||
} = body;
|
||||
|
||||
if (
|
||||
!origin_address ||
|
||||
!destination_address ||
|
||||
!origin_latitude ||
|
||||
!origin_longitude ||
|
||||
!destination_latitude ||
|
||||
!destination_longitude ||
|
||||
!ride_time ||
|
||||
!fare_price ||
|
||||
!payment_status ||
|
||||
!driver_id
|
||||
isMissing(origin_address) ||
|
||||
isMissing(destination_address) ||
|
||||
isMissing(origin_latitude) ||
|
||||
isMissing(origin_longitude) ||
|
||||
isMissing(destination_latitude) ||
|
||||
isMissing(destination_longitude) ||
|
||||
isMissing(ride_time) ||
|
||||
isMissing(fare_price)
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Missing required fields" },
|
||||
@@ -38,38 +49,154 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const response = await sql`
|
||||
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
|
||||
) VALUES (
|
||||
${origin_address},
|
||||
${destination_address},
|
||||
${origin_latitude},
|
||||
${origin_longitude},
|
||||
${destination_latitude},
|
||||
${destination_longitude},
|
||||
${ride_time},
|
||||
${fare_price},
|
||||
${payment_status},
|
||||
${driver_id},
|
||||
${auth.userId}
|
||||
)
|
||||
RETURNING *;
|
||||
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 });
|
||||
}
|
||||
|
||||
// Cash: settled directly with the driver at drop-off. No order involved.
|
||||
const response = await sql`
|
||||
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,
|
||||
status,
|
||||
service
|
||||
) VALUES (
|
||||
${origin_address},
|
||||
${destination_address},
|
||||
${origin_latitude},
|
||||
${origin_longitude},
|
||||
${destination_latitude},
|
||||
${destination_longitude},
|
||||
${ride_time},
|
||||
${fareCents},
|
||||
'cash',
|
||||
NULL,
|
||||
${auth.userId},
|
||||
'requested',
|
||||
${rideService}
|
||||
)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
void matchNextDriver(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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { sql } from "@/lib/db";
|
||||
|
||||
// GET — the signed-in rider's ride history (completed + cancelled rides),
|
||||
// newest first, with the assigned driver (nullable via LEFT JOIN). This feeds
|
||||
// the "Recent Rides" / "All rides" lists; the active/in-progress ride is
|
||||
// tracked separately on the book-ride status screen.
|
||||
export async function GET(req: Request) {
|
||||
const auth = requireAuth(req);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
try {
|
||||
const response = await sql`
|
||||
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.payment_status,
|
||||
r.status,
|
||||
r.service,
|
||||
r.created_at,
|
||||
r.completed_at,
|
||||
r.cancelled_at,
|
||||
json_build_object(
|
||||
'id', d.id,
|
||||
'first_name', d.first_name,
|
||||
'last_name', d.last_name,
|
||||
'car_seats', d.car_seats,
|
||||
'profile_image_url', d.profile_image_url,
|
||||
'car_image_url', d.car_image_url,
|
||||
'rating', d.rating,
|
||||
'service', d.service,
|
||||
'car_model', d.car_model
|
||||
) AS driver
|
||||
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')
|
||||
ORDER BY r.created_at DESC
|
||||
`;
|
||||
|
||||
return Response.json({ data: response });
|
||||
} catch (error) {
|
||||
console.error("[GET_RIDE_LIST]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user