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:
Krikorios
2026-08-24 13:57:21 +03:00
co-authored by Claude
parent 4e0a7cca51
commit f50ff27e11
48 changed files with 3342 additions and 602 deletions
+107
View File
@@ -0,0 +1,107 @@
// Uber-style auto-match dispatch. A requested ride has no driver; this engine
// offers it to the nearest eligible driver of the matching service. Drivers
// accept/decline; a decline (or a 15s offer expiry) triggers the next-nearest
// match. There is no background worker — matchNextDriver is called lazily from
// the rider status poll and the driver poll, so matching progresses on every
// request cycle.
import { transaction } from "@/lib/db";
import { haversine } from "@/lib/utils";
// A driver has this long to respond to an offer before it expires and the next
// driver is offered. Tuned short so a rider searching for a driver isn't left
// hanging on a phone that's face-down on a seat.
const OFFER_TTL_SECONDS = 15;
// A driver whose last location ping is older than this is treated as offline
// even if their `online` flag is still true (they closed the app without
// toggling off).
const DRIVER_STALE_SECONDS = 60;
type EligibleDriver = {
id: number;
latitude: number;
longitude: number;
};
// Offer `rideId` to the nearest eligible driver, if no offer is already in
// flight for it. Idempotent: safe to call on every poll. Returns the driver id
// that was offered, or null if no driver was available.
export const matchNextDriver = async (
rideId: number,
): Promise<number | null> => {
try {
return await transaction(async (tx) => {
// Lock the ride row so concurrent matchers serialize on it.
const rides = await tx<{ status: string; service: string }>`
SELECT status, service FROM rides WHERE ride_id = ${rideId} FOR UPDATE
`;
const ride = rides[0];
if (!ride || ride.status !== "requested") return null;
// Expire any offers that have been sitting past their TTL.
await tx`
UPDATE ride_offers
SET status = 'expired', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND status = 'offered'
AND offered_at < CURRENT_TIMESTAMP - make_interval(secs => ${OFFER_TTL_SECONDS})
`;
// If there is still an active (unexpired) offer in flight, leave it —
// don't stack a second offer on top.
const inFlight = await tx<{ n: number }>`
SELECT COUNT(*)::int AS n FROM ride_offers
WHERE ride_id = ${rideId} AND status = 'offered'
`;
if ((inFlight[0]?.n ?? 0) > 0) return null;
const rideOrigin = await tx<{ lat: number; lng: number }>`
SELECT origin_latitude AS lat, origin_longitude AS lng
FROM rides WHERE ride_id = ${rideId}
`;
const origin = rideOrigin[0];
if (!origin) return null;
// Eligible: right service, online, fresh, a real account, not on an
// active ride, and not already offered/declined for THIS ride.
const candidates = await tx<EligibleDriver>`
SELECT d.id, d.latitude, d.longitude
FROM drivers d
WHERE d.service = ${ride.service}
AND d.online = TRUE
AND d.user_id IS NOT NULL
AND d.latitude IS NOT NULL
AND d.longitude IS NOT NULL
AND d.last_seen > CURRENT_TIMESTAMP - make_interval(secs => ${DRIVER_STALE_SECONDS})
AND NOT EXISTS (
SELECT 1 FROM rides r
WHERE r.driver_id = d.id AND r.status IN ('accepted', 'en_route')
)
AND NOT EXISTS (
SELECT 1 FROM ride_offers ro
WHERE ro.ride_id = ${rideId} AND ro.driver_id = d.id
)
`;
if (candidates.length === 0) return null;
// Nearest by great-circle distance to the pickup point.
candidates.sort((a, b) => {
const da = haversine(origin.lat, origin.lng, a.latitude, a.longitude);
const db = haversine(origin.lat, origin.lng, b.latitude, b.longitude);
return da - db;
});
const nearest = candidates[0];
await tx`
INSERT INTO ride_offers (ride_id, driver_id, status)
VALUES (${rideId}, ${nearest.id}, 'offered')
`;
return nearest.id;
});
} catch (error) {
console.error("[MATCH_NEXT_DRIVER]: ", error);
return null;
}
};
+50
View File
@@ -0,0 +1,50 @@
// Driver-side auth helper. Every driver-action endpoint first calls
// requireDriverProfile: it proves the request is from a signed-in user and
// that the user has completed onboarding (has a linked drivers row). A
// driver-role user who hasn't onboarded yet gets a 403 so the client can
// route them to the onboarding form rather than showing a bare 404.
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
import type { ServiceId } from "@/constants/services";
type Auth = { userId: string; email: string };
export type DriverProfile = {
auth: Auth;
driverId: number;
service: ServiceId;
online: boolean;
};
export type AuthError = { error: Response };
const VALID_SERVICES = ["car", "moto", "courier", "chauffeur"] as const;
export const isServiceId = (v: unknown): v is ServiceId =>
typeof v === "string" && (VALID_SERVICES as readonly string[]).includes(v);
// Returns the driver profile for the authenticated user, or a 401/403 the
// caller can return directly. A 403 with the onboarding code tells the client
// to show the onboarding form instead of treating it as a hard error.
export const requireDriverProfile = async (
req: Request,
): Promise<DriverProfile | AuthError> => {
const auth = requireAuth(req);
if ("error" in auth) return { error: auth.error };
const rows = await sql<{ id: number; service: ServiceId; online: boolean }>`
SELECT id, service, online FROM drivers WHERE user_id = ${auth.userId}
`;
if (!rows[0]) {
return {
error: Response.json(
{ error: "No driver profile — complete onboarding.", code: "ONBOARD" },
{ status: 403 },
),
};
}
const { id, service, online } = rows[0];
return { auth, driverId: id, service, online };
};
+12 -2
View File
@@ -15,6 +15,11 @@ const getPassword = (): string | undefined =>
export const isMailConfigured = (): boolean =>
Boolean(process.env.SMTP_USER && getPassword());
// Whether the OTP code may be surfaced outside email (response body or server
// stdout) for self-hosted development. Never in production.
export const isDevOtpExposed = (): boolean =>
process.env.NODE_ENV !== "production";
let transporter: nodemailer.Transporter | null = null;
const getTransporter = (): nodemailer.Transporter => {
@@ -47,7 +52,10 @@ export const sendEmail = async (
): Promise<boolean> => {
if (!isMailConfigured()) {
// Not configured: fall back to the server log so development still works.
console.log(`[MAIL to=${to}] ${subject}\n${text}`);
// In production never log the code to stdout; just report not sent.
if (isDevOtpExposed()) {
console.log(`[MAIL to=${to}] ${subject}\n${text}`);
}
return false;
}
@@ -60,7 +68,9 @@ export const sendEmail = async (
// Delivery is best-effort: report the failure and let the caller surface
// the code another way instead of failing the whole request.
console.error(`[MAIL to=${to}] send failed:`, error);
console.log(`[MAIL to=${to}] ${subject}\n${text}`);
if (isDevOtpExposed()) {
console.log(`[MAIL to=${to}] ${subject}\n${text}`);
}
return false;
}
};
+88 -15
View File
@@ -1,8 +1,12 @@
import { calculateFare } from "@/lib/pricing";
import { DEFAULT_SERVICE, type ServiceId } from "@/constants/services";
import type { Driver, MarkerData } from "@/types/type";
const directionsAPI = process.env.EXPO_PUBLIC_GOOGLE_API_KEY;
// Build map markers from driver rows. Drivers with a real GPS position use it
// directly; only legacy seed rows (no position) fall back to a small random
// scatter around the rider so the map isn't empty during local dev.
export const generateMarkersFromData = ({
data,
userLatitude,
@@ -12,18 +16,25 @@ export const generateMarkersFromData = ({
userLatitude: number;
userLongitude: number;
}): MarkerData[] => {
return data.map((driver, i) => {
const latOffset = (Math.random() - 0.5) * 0.01; // Random offset between -0.005 and 0.005
const lngOffset = (Math.random() - 0.5) * 0.01; // Random offset between -0.005 and 0.005
return data
.filter((driver) => driver.latitude != null && driver.longitude != null)
.map((driver) => {
const lat =
driver.latitude != null
? driver.latitude
: userLatitude + (Math.random() - 0.5) * 0.01;
const lng =
driver.longitude != null
? driver.longitude
: userLongitude + (Math.random() - 0.5) * 0.01;
return {
id: i,
latitude: userLatitude + latOffset,
longitude: userLongitude + lngOffset,
title: `${driver.first_name} ${driver.last_name}`,
...driver,
};
});
return {
...driver,
latitude: lat,
longitude: lng,
title: `${driver.first_name} ${driver.last_name}`,
};
});
};
export const calculateRegion = ({
@@ -75,18 +86,23 @@ export const calculateRegion = ({
};
};
// Per-driver ETA + fare. The rider pays for the trip leg only (distance +
// duration) — never the driver's approach leg. `service` drives the fare
// multiplier.
export const calculateDriverTimes = async ({
markers,
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
service = DEFAULT_SERVICE,
}: {
markers: MarkerData[];
userLatitude: number | null;
userLongitude: number | null;
destinationLatitude: number | null;
destinationLongitude: number | null;
service?: ServiceId;
}) => {
if (
!userLatitude ||
@@ -120,10 +136,13 @@ export const calculateDriverTimes = async ({
// The rider pays for the trip leg only (distance + duration) —
// never for the driver's approach.
const price = calculateFare({
distanceMeters: legToDestination.distance.value,
durationSeconds: timeToDestination,
});
const price = calculateFare(
{
distanceMeters: legToDestination.distance.value,
durationSeconds: timeToDestination,
},
service,
);
const totalTripTime = (timeToUser + timeToDestination) / 60; // Minutes until drop-off
@@ -135,3 +154,57 @@ export const calculateDriverTimes = async ({
console.error("Error calculating driver times:", error);
}
};
// A single trip-leg fare estimate for the confirm-ride screen. One Directions
// call instead of one per driver, since the trip leg is the same regardless of
// which driver arrives. Returns { fare, durationSeconds, distanceMeters } or
// null when the route is unreachable.
export const calculateTripFare = async ({
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
service = DEFAULT_SERVICE,
}: {
userLatitude: number | null;
userLongitude: number | null;
destinationLatitude: number | null;
destinationLongitude: number | null;
service?: ServiceId;
}): Promise<{
fare: string;
durationSeconds: number;
distanceMeters: number;
} | null> => {
if (
!userLatitude ||
!userLongitude ||
!destinationLatitude ||
!destinationLongitude
)
return null;
try {
const response = await fetch(
`https://maps.googleapis.com/maps/api/directions/json?origin=${userLatitude},${userLongitude}&destination=${destinationLatitude},${destinationLongitude}&key=${directionsAPI}`,
);
const data = await response.json();
const leg = data.routes?.[0]?.legs?.[0];
if (!leg) return null;
return {
fare: calculateFare(
{
distanceMeters: leg.distance.value,
durationSeconds: leg.duration.value,
},
service,
),
durationSeconds: leg.duration.value,
distanceMeters: leg.distance.value,
};
} catch (error) {
console.error("Error calculating trip fare:", error);
return null;
}
};
+15 -4
View File
@@ -1,8 +1,9 @@
// Shared helpers for the 6-digit email codes used by sign-up verification and
// password reset. Both flows store a salted hash keyed by email, so the code
// itself only ever lives in the outgoing mail.
// password reset. Both flows store a peppered HMAC-SHA256 hash keyed by email,
// so the code itself only ever lives in the outgoing mail. The HMAC uses
// AUTH_JWT_SECRET as a pepper: a DB dump alone cannot recover codes without it.
import { createHash, randomInt, timingSafeEqual } from "crypto";
import { createHmac, randomInt, timingSafeEqual } from "crypto";
export const CODE_TTL_MINUTES = 15;
@@ -13,8 +14,18 @@ export const MAX_CODE_ATTEMPTS = 5;
export const generateCode = (): string =>
String(randomInt(0, 1_000_000)).padStart(6, "0");
// Reuse the existing required secret as a pepper. No new env var and no
// schema change (the codes table has no salt column).
const pepper = (): string => {
const value = process.env.AUTH_JWT_SECRET;
if (!value) throw new Error("Missing AUTH_JWT_SECRET.");
return value;
};
export const hashCode = (email: string, code: string): string =>
createHash("sha256").update(`${email}:${code}`).digest("hex");
createHmac("sha256", pepper())
.update(`waseel-otp:${email}:${code}`)
.digest("hex");
export const codeMatches = (
storedHash: string,
+127
View File
@@ -0,0 +1,127 @@
// Server-authoritative payment order records. The client may never set
// payment_status or the Areeba successIndicator; both are stored here and
// verified against the gateway before an order can pay for a ride.
//
// A paid order can only be consumed once: consumeOrderForRide atomically
// flips status 'paid' -> 'consumed', so a single card payment can never buy
// two rides.
import type { QueryResultRow } from "pg";
import { sql, type SqlValue } from "@/lib/db";
// A tagged-template runner — either the pool-level `sql` helper or the `tx`
// passed inside a transaction() callback. consumeOrderForRide accepts one so
// the consume + ride insert can run on a single connection.
type Runner = <R extends QueryResultRow = QueryResultRow>(
strings: TemplateStringsArray,
...values: SqlValue[]
) => Promise<R[]>;
export type PaymentOrder = {
order_id: string;
user_id: string;
amount_cents: number;
currency: string;
driver_id: number | null;
origin_address: string | null;
destination_address: string | null;
origin_latitude: number | null;
origin_longitude: number | null;
destination_latitude: number | null;
destination_longitude: number | null;
ride_time: number | null;
success_indicator: string | null;
status: string;
created_at: Date;
paid_at: Date | null;
};
export type NewOrder = {
order_id: string;
user_id: string;
amount_cents: number;
currency: string;
driver_id?: number | null;
origin_address?: string | null;
destination_address?: string | null;
origin_latitude?: number | null;
origin_longitude?: number | null;
destination_latitude?: number | null;
destination_longitude?: number | null;
ride_time?: number | null;
success_indicator: string | null;
status?: string;
};
export const createOrder = async (order: NewOrder): Promise<PaymentOrder> => {
const rows = await sql<PaymentOrder>`
INSERT INTO payment_orders (
order_id, user_id, amount_cents, currency, driver_id,
origin_address, destination_address,
origin_latitude, origin_longitude,
destination_latitude, destination_longitude,
ride_time, success_indicator, status
) VALUES (
${order.order_id},
${order.user_id},
${order.amount_cents},
${order.currency},
${order.driver_id ?? null},
${order.origin_address ?? null},
${order.destination_address ?? null},
${order.origin_latitude ?? null},
${order.origin_longitude ?? null},
${order.destination_latitude ?? null},
${order.destination_longitude ?? null},
${order.ride_time ?? null},
${order.success_indicator},
${order.status ?? "pending"}
)
RETURNING *
`;
return rows[0];
};
export const getOrder = async (orderId: string): Promise<PaymentOrder | null> => {
const rows = await sql<PaymentOrder>`
SELECT * FROM payment_orders WHERE order_id = ${orderId}
`;
return rows[0] ?? null;
};
// Mark an order paid after the gateway confirms capture. The status='pending'
// guard means an already-paid or consumed order can never be flipped back to
// 'paid' — this is what prevents a single payment from being resurrected to
// buy multiple rides (double-spend). verify+api.ts also rejects non-pending
// orders, so this is defense-in-depth.
export const markPaid = async (orderId: string): Promise<PaymentOrder | null> => {
const rows = await sql<PaymentOrder>`
UPDATE payment_orders
SET status = 'paid', paid_at = CURRENT_TIMESTAMP
WHERE order_id = ${orderId}
AND status = 'pending'
RETURNING *
`;
return rows[0] ?? null;
};
// Atomically consume a paid order for a ride. The WHERE status='paid' guard
// means a paid order can only be used once; a second attempt gets no row.
// Pass the transaction `tx` runner so this can run inside ride/create's
// transaction together with the ride insert.
export const consumeOrderForRide = async (
orderId: string,
userId: string,
runner: Runner = sql,
): Promise<PaymentOrder | null> => {
const rows = await runner<PaymentOrder>`
UPDATE payment_orders
SET status = 'consumed'
WHERE order_id = ${orderId}
AND user_id = ${userId}
AND status = 'paid'
RETURNING *
`;
return rows[0] ?? null;
};
+86
View File
@@ -0,0 +1,86 @@
// Google Places (New) Nearby Search — powers the "nearby mall / hospital /
// pharmacy / restaurant" destination chips on the home screen. Reuses the same
// API key and header pattern as the autocomplete in components/google-text-input.
import { haversine } from "@/lib/utils";
import type { NearbyPlace } from "@/types/type";
const googleApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY!;
// The four POI categories surfaced as quick destination chips. Each maps to a
// Google Places (New) `includedTypes` value.
export type PoiCategory = {
id: "mall" | "hospital" | "pharmacy" | "restaurant";
label: string;
/** MaterialCommunityIcons glyph name. */
icon: string;
googleType: string;
};
export const POI_CATEGORIES: PoiCategory[] = [
{ id: "mall", label: "Mall", icon: "shopping-mall", googleType: "shopping_mall" },
{ id: "hospital", label: "Hospital", icon: "hospital", googleType: "hospital" },
{ id: "pharmacy", label: "Pharmacy", icon: "pill", googleType: "pharmacy" },
{ id: "restaurant", label: "Restaurant", icon: "silverware-fork-knife", googleType: "restaurant" },
];
const DEFAULT_RADIUS_M = 4000;
// Searches for the nearest place of `googleType` around (latitude, longitude)
// and returns it as a NearbyPlace with its distance from the rider. Returns
// null when no place of that type is found nearby — the chip then shows an
// empty state rather than a broken one.
export const searchNearby = async (
googleType: string,
{
latitude,
longitude,
radiusM = DEFAULT_RADIUS_M,
}: { latitude: number; longitude: number; radiusM?: number },
): Promise<NearbyPlace | null> => {
try {
const res = await fetch(
"https://places.googleapis.com/v1/places:searchNearby",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": googleApiKey,
"X-Goog-FieldMask":
"places.displayName,places.formattedAddress,places.location,places.id",
},
body: JSON.stringify({
includedTypes: [googleType],
languageCode: "en",
regionCode: "lb",
locationRestriction: {
circle: {
center: { latitude, longitude },
radius: radiusM,
},
},
}),
},
);
const data = await res.json();
const place = data.places?.[0];
if (!place) return null;
const lat = place.location?.latitude as number;
const lng = place.location?.longitude as number;
return {
name: (place.displayName?.text as string) ?? "Nearby place",
address: (place.formattedAddress as string) ?? "",
latitude: lat,
longitude: lng,
distanceMeters:
Number.isFinite(lat) && Number.isFinite(lng)
? haversine(latitude, longitude, lat, lng)
: undefined,
};
} catch (error) {
console.log("[PLACES_NEARBY]: ", error);
return null;
}
};
+16 -8
View File
@@ -4,6 +4,8 @@
// - Prices are quoted in USD (the de facto ride-hailing currency) with an
// L.B.P. equivalent shown for cash settlement.
import { DEFAULT_SERVICE, SERVICES, type ServiceId } from "@/constants/services";
export const FARE = {
base: 1.5, // USD, flag drop
perKm: 0.55, // USD per kilometer of the trip
@@ -14,18 +16,24 @@ export const FARE = {
// Parallel market rate used for the L.B.P. cash equivalent shown in-app.
export const LBP_RATE = 89500;
export const calculateFare = ({
distanceMeters,
durationSeconds,
}: {
distanceMeters: number;
durationSeconds: number;
}): string => {
export const calculateFare = (
{
distanceMeters,
durationSeconds,
}: {
distanceMeters: number;
durationSeconds: number;
},
service: ServiceId = DEFAULT_SERVICE,
): string => {
const km = distanceMeters / 1000;
const minutes = durationSeconds / 60;
const fare = FARE.base + km * FARE.perKm + minutes * FARE.perMin;
const multiplier =
SERVICES.find((s) => s.id === service)?.fareMultiplier ?? 1;
const fare = (FARE.base + km * FARE.perKm + minutes * FARE.perMin) * multiplier;
// The minimum fare is a floor on the final amount.
return Math.max(fare, FARE.minimum).toFixed(2);
};
+112
View File
@@ -0,0 +1,112 @@
import * as WebBrowser from "expo-web-browser";
import { ApiError, fetchAPI } from "@/lib/fetch";
import type { ServiceId } from "@/constants/services";
import type { Ride } from "@/types/type";
// The rider's request flow, used by the confirm-ride screen. This is the card
// (Areeba hosted checkout -> server verify -> consume order -> create ride)
// and cash (create ride directly) paths, now unified behind one entry point so
// the screen doesn't re-implement the gateway dance.
//
// The ride is always created with status='requested' and driver_id=null; the
// server's auto-match engine assigns a driver asynchronously. Returns the
// created ride so the caller can navigate to the status screen.
export type RequestInput = {
method: "cash" | "card";
service: ServiceId;
user: { name: string; email: string };
// Location snapshot at request time.
origin: { address: string; latitude: number; longitude: number };
destination: { address: string; latitude: number; longitude: number };
rideTimeSeconds: number;
fareCents: number;
};
export type RequestResult = { ride: Ride };
const recordRide = async (
input: RequestInput,
method: "cash" | "card",
orderId?: string,
): Promise<Ride> => {
const res = await fetchAPI("/(api)/ride/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
origin_address: input.origin.address,
destination_address: input.destination.address,
origin_latitude: input.origin.latitude,
origin_longitude: input.origin.longitude,
destination_latitude: input.destination.latitude,
destination_longitude: input.destination.longitude,
ride_time: Math.round(input.rideTimeSeconds),
fare_price: input.fareCents,
payment_method: method,
service: input.service,
...(orderId ? { payment_order_id: orderId } : {}),
}),
});
return res.data as Ride;
};
export const requestRide = async (input: RequestInput): Promise<RequestResult> => {
if (input.method === "cash") {
const ride = await recordRide(input, "cash");
return { ride };
}
// Card: create an Areeba checkout session on our server.
const { orderId, checkoutUrl, error } = await fetchAPI(
"/(api)/(areeba)/create",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: input.user.name || input.user.email,
email: input.user.email,
fare_cents: input.fareCents,
origin_address: input.origin.address,
destination_address: input.destination.address,
origin_latitude: input.origin.latitude,
origin_longitude: input.origin.longitude,
destination_latitude: input.destination.latitude,
destination_longitude: input.destination.longitude,
ride_time: Math.round(input.rideTimeSeconds),
}),
},
);
if (error || !checkoutUrl) throw new Error(error || "No checkout URL");
// Open Areeba's hosted payment page. After payment the gateway redirects
// back to the app (waseel://book-ride).
const browserResult = await WebBrowser.openAuthSessionAsync(
checkoutUrl,
"waseel://book-ride",
);
let resultIndicator: string | undefined;
if (browserResult.type === "success" && browserResult.url) {
resultIndicator = new URL(browserResult.url).searchParams.get(
"resultIndicator",
) ?? undefined;
}
// Verify the payment server-side.
const verification = await fetchAPI("/(api)/(areeba)/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderId, resultIndicator }),
});
if (!verification.success) {
throw new ApiError(
400,
"Your payment was cancelled or could not be verified. Please try again.",
);
}
const ride = await recordRide(input, "card", orderId);
return { ride };
};
+105
View File
@@ -0,0 +1,105 @@
import * as Location from "expo-location";
import { AppState } from "react-native";
import { useEffect, useRef } from "react";
import { fetchAPI } from "@/lib/fetch";
// While the driver is online, watch their position and POST it to the server
// as a heartbeat. Each ping both updates the driver's lat/lng and refreshes
// last_seen/online, which is what keeps the driver eligible for matching. The
// watch is started when `online` flips true and torn down on false/unmount.
//
// The watch must also restart when the app returns to the foreground: the OS
// suspends location updates in the background, and the subscription we hold
// does not auto-revive. Without this, a driver who briefly backgrounds the app
// goes permanently stale (last_seen older than the dispatch freshness window)
// and stops receiving ride requests until they toggle offline→online again.
//
// Pings are throttled to every PING_INTERVAL_MS so a fast-moving driver
// doesn't hammer the server, and the location permission is only requested
// once the driver actually intends to go online.
const PING_INTERVAL_MS = 5000;
export const useDriverLocation = (online: boolean) => {
const subscriptionRef = useRef<Location.LocationSubscription | null>(null);
const onlineRef = useRef(online);
onlineRef.current = online;
useEffect(() => {
if (!online) return;
let cancelled = false;
const ping = async (latitude: number, longitude: number) => {
try {
await fetchAPI("/(api)/driver/location", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ latitude, longitude }),
});
} catch (error) {
// A failed ping is non-fatal — the next one will retry. last_seen
// going stale is what takes a driver out of the match pool, not a 500.
console.log("[DRIVER_LOCATION_PING]: ", error);
}
};
const start = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (cancelled || status !== "granted") return;
if (!(await Location.hasServicesEnabledAsync())) return;
// Seed the server with the last known position immediately, so the
// driver is matchable without waiting for the first watch callback.
const cached = await Location.getLastKnownPositionAsync({
maxAge: 5 * 60 * 1000,
});
if (!cancelled && cached) {
void ping(cached.coords.latitude, cached.coords.longitude);
}
const subscription = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.Balanced,
timeInterval: PING_INTERVAL_MS,
distanceInterval: 20,
},
({ coords }) => {
if (!cancelled) void ping(coords.latitude, coords.longitude);
},
);
if (cancelled) {
await subscription.remove();
return;
}
subscriptionRef.current = subscription;
};
const stop = () => {
const sub = subscriptionRef.current;
subscriptionRef.current = null;
void sub?.remove();
};
void start();
// Restart the watch whenever the app comes back to the foreground. While
// backgrounded the OS pauses location updates and the old subscription is
// dead; without re-starting it the driver never pings again.
const onAppStateChange = (state: string) => {
if (state !== "active") return;
if (!onlineRef.current) return;
stop();
if (!cancelled) void start();
};
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => {
cancelled = true;
stop();
subscription.remove();
};
}, [online]);
};
-9
View File
@@ -1,4 +1,3 @@
import { sql } from "@/lib/db";
import { signJwt } from "@/lib/jwt";
export type UserProfile = {
@@ -30,11 +29,3 @@ export const issueSession = (
user: toProfile(row),
});
export const findUserByEmail = async (
email: string,
): Promise<UserRow | null> => {
const rows = await sql<UserRow>`
SELECT id, name, email, role FROM users WHERE email = ${email}
`;
return rows[0] ?? null;
};
+18
View File
@@ -50,3 +50,21 @@ export function normalizePhone(raw: string): string {
return `+961${cleaned.replace(/^0+/, "")}`;
}
// Great-circle distance between two lat/lng points, in meters. Used for
// nearest-driver matching and "X m away" POI chips. Haversine formula.
export function haversine(
lat1: number,
lng1: number,
lat2: number,
lng2: number,
): number {
const R = 6371000; // Earth radius, meters
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}