diff --git a/.env.example b/.env.example index 747ef95..69a6d13 100644 --- a/.env.example +++ b/.env.example @@ -22,10 +22,12 @@ SMTP_USER=you@gmail.com SMTP_PASS=your-16-char-app-password SMTP_FROM="Waseel " -# geoapify api key +# geoapify api key (static map tiles only) EXPO_PUBLIC_GEOAPIFY_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX -# google api key +# google api key — powers Places autocomplete, Places Nearby Search +# (mall/hospital/pharmacy/restaurant chips), and the per-marker Directions +# ETA/fare estimates. Note: this key is embedded in the client bundle. EXPO_PUBLIC_GOOGLE_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # areeba payment gateway (credentials issued after merchant onboarding) @@ -33,3 +35,6 @@ AREEBA_API_BASE_URL="https://your-gateway-host.areeba.com" AREEBA_MERCHANT_ID=XXXXXXXXXXXX AREEBA_API_PASSWORD=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX AREEBA_API_VERSION=100 + +# admin dashboard origin for CORS (lib/admin.ts); defaults to * when unset +ADMIN_CORS_ORIGIN=* diff --git a/.gitignore b/.gitignore index b715f76..ab92a84 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,10 @@ expo-env.d.ts # env .env + +# Native projects generated by `expo prebuild` / `expo run:*`. +/android +/ios + +# admin dashboard build output +dashboard/dist/ diff --git a/app.config.js b/app.config.js index f8b82e3..ce49624 100644 --- a/app.config.js +++ b/app.config.js @@ -1,64 +1,67 @@ -// Dynamic config layered over app.json. +// Dynamic Expo config. We use a JS config (rather than static app.json) so the +// Android Google Maps API key can be pulled from EXPO_PUBLIC_GOOGLE_API_KEY +// at build time without committing the key to the repo. // -// react-native-maps reads the Google Maps key from the *native* manifest -// (AndroidManifest `com.google.android.geo.API_KEY`), not from the JS bundle, -// so EXPO_PUBLIC_GOOGLE_API_KEY has to be injected here at build time. Without -// it Android renders an empty grey tile area instead of a map. +// react-native-maps renders blank tiles (just the Google logo, nothing else) +// on Android when no Maps API key is set in the AndroidManifest. Expo's +// prebuild reads `android.config.googleMaps.apiKey` and writes it to the +// manifest as com.google.android.geo.API_KEY — that is what makes the map +// actually draw. On iOS the default provider is Apple Maps, which needs no +// key, so nothing is injected there. const googleMapsApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY; -const LOCATION_PERMISSION = - "Waseel uses your location to show nearby drivers and set your pickup point."; - -module.exports = ({ config }) => { - if (!googleMapsApiKey) { - const message = - "EXPO_PUBLIC_GOOGLE_API_KEY is not set — the Android manifest will have no " + - "Maps key and the map will render blank."; - - // `.env` is gitignored, so it is never uploaded to EAS. Unless the key is - // also registered as an EAS environment variable the build silently - // produces a keyless APK, and the blank map only shows up on the device. - // Fail the build here rather than shipping that. - if (process.env.EAS_BUILD) { - throw new Error( - `[app.config] ${message} Register it with \`eas env:create\` (or in the ` + - "EAS dashboard) for this build profile.", - ); - } - - console.warn(`[app.config] ${message}`); - } - - return { - ...config, - ios: { - ...config.ios, - // iOS uses Apple Maps via PROVIDER_DEFAULT, so this only matters if the - // Map component is switched to PROVIDER_GOOGLE. - config: { ...config.ios?.config, googleMapsApiKey }, - infoPlist: { - ...config.ios?.infoPlist, - NSLocationWhenInUseUsageDescription: LOCATION_PERMISSION, +module.exports = ({ config }) => ({ + ...config, + name: "Waseel", + description: "Find your perfect ride with Waseel.", + githubUrl: "https://github.com/sanidhyy/uber-clone", + slug: "waseel", + version: "1.0.0", + orientation: "portrait", + icon: "./assets/images/icon.png", + scheme: "waseel", + userInterfaceStyle: "automatic", + splash: { + image: "./assets/images/splash.png", + resizeMode: "contain", + backgroundColor: "#2F80ED", + }, + ios: { + supportsTablet: true, + bundleIdentifier: "com.waseel.app", + }, + android: { + adaptiveIcon: { + foregroundImage: "./assets/images/adaptive-icon.png", + backgroundColor: "#ffffff", + }, + package: "com.waseel.app", + config: { + googleMaps: { + apiKey: googleMapsApiKey ?? "", }, }, - android: { - ...config.android, - config: { - ...config.android?.config, - googleMaps: { apiKey: googleMapsApiKey }, + }, + web: { + bundler: "metro", + output: "server", + favicon: "./assets/images/favicon.png", + }, + plugins: [ + [ + "expo-router", + { + origin: "https://example.com/", }, - // Location permissions come from the expo-location plugin below. - }, - plugins: [ - ...(config.plugins ?? []), - [ - "expo-location", - { - locationAlwaysAndWhenInUsePermission: LOCATION_PERMISSION, - locationWhenInUsePermission: LOCATION_PERMISSION, - }, - ], ], - }; -}; + ], + experiments: { + typedRoutes: true, + }, + extra: { + router: { + origin: "https://example.com/", + }, + }, +}); \ No newline at end of file diff --git a/app.json b/app.json deleted file mode 100644 index bc2f350..0000000 --- a/app.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "expo": { - "name": "Waseel", - "description": "Find your perfect ride with Waseel.", - "githubUrl": "https://github.com/sanidhyy/uber-clone", - "slug": "waseel", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/images/icon.png", - "scheme": "waseel", - "userInterfaceStyle": "automatic", - "splash": { - "image": "./assets/images/splash.png", - "resizeMode": "contain", - "backgroundColor": "#2F80ED" - }, - "ios": { - "supportsTablet": true, - "bundleIdentifier": "com.waseel.app" - }, - "android": { - "adaptiveIcon": { - "foregroundImage": "./assets/images/adaptive-icon.png", - "backgroundColor": "#ffffff" - }, - "package": "com.waseel.app" - }, - "web": { - "bundler": "metro", - "output": "server", - "favicon": "./assets/images/favicon.png" - }, - "plugins": [ - [ - "expo-router", - { - "origin": "https://example.com/" - } - ] - ], - "experiments": { - "typedRoutes": true - }, - "extra": { - "router": { - "origin": "https://example.com/" - } - } - } -} diff --git a/app/(api)/(areeba)/create+api.ts b/app/(api)/(areeba)/create+api.ts index 69fbcaf..ad0fc18 100644 --- a/app/(api)/(areeba)/create+api.ts +++ b/app/(api)/(areeba)/create+api.ts @@ -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 }); } -} +} \ No newline at end of file diff --git a/app/(api)/(areeba)/verify+api.ts b/app/(api)/(areeba)/verify+api.ts index cdc61f7..55b5b97 100644 --- a/app/(api)/(areeba)/verify+api.ts +++ b/app/(api)/(areeba)/verify+api.ts @@ -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 }); } -} +} \ No newline at end of file diff --git a/app/(api)/admin/stats+api.ts b/app/(api)/admin/stats+api.ts index 67ccf2d..1e090ea 100644 --- a/app/(api)/admin/stats+api.ts +++ b/app/(api)/admin/stats+api.ts @@ -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 diff --git a/app/(api)/auth/forgot-password+api.ts b/app/(api)/auth/forgot-password+api.ts index ce70c23..faf2132 100644 --- a/app/(api)/auth/forgot-password+api.ts +++ b/app/(api)/auth/forgot-password+api.ts @@ -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) { diff --git a/app/(api)/auth/register+api.ts b/app/(api)/auth/register+api.ts index 146dd03..b46ed15 100644 --- a/app/(api)/auth/register+api.ts +++ b/app/(api)/auth/register+api.ts @@ -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 }, diff --git a/app/(api)/auth/reset-password+api.ts b/app/(api)/auth/reset-password+api.ts index 834648d..da2fba9 100644 --- a/app/(api)/auth/reset-password+api.ts +++ b/app/(api)/auth/reset-password+api.ts @@ -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); diff --git a/app/(api)/auth/verify+api.ts b/app/(api)/auth/verify+api.ts index 9647b5b..a6f3ea9 100644 --- a/app/(api)/auth/verify+api.ts +++ b/app/(api)/auth/verify+api.ts @@ -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); diff --git a/app/(api)/driver+api.ts b/app/(api)/driver+api.ts index 5c3bdb6..dbb7cd2 100644 --- a/app/(api)/driver+api.ts +++ b/app/(api)/driver+api.ts @@ -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 }); } -} +} \ No newline at end of file diff --git a/app/(api)/driver/location+api.ts b/app/(api)/driver/location+api.ts new file mode 100644 index 0000000..fdf5dc8 --- /dev/null +++ b/app/(api)/driver/location+api.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/(api)/driver/nearby+api.ts b/app/(api)/driver/nearby+api.ts new file mode 100644 index 0000000..56ecd4b --- /dev/null +++ b/app/(api)/driver/nearby+api.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/(api)/driver/profile+api.ts b/app/(api)/driver/profile+api.ts new file mode 100644 index 0000000..6aa1d1b --- /dev/null +++ b/app/(api)/driver/profile+api.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/(api)/driver/rides+api.ts b/app/(api)/driver/rides+api.ts new file mode 100644 index 0000000..5927368 --- /dev/null +++ b/app/(api)/driver/rides+api.ts @@ -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; +}; \ No newline at end of file diff --git a/app/(api)/ride/[id]+api.ts b/app/(api)/ride/[id]+api.ts index 3de91bc..173891e 100644 --- a/app/(api)/ride/[id]+api.ts +++ b/app/(api)/ride/[id]+api.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/(api)/ride/[id]/respond+api.ts b/app/(api)/ride/[id]/respond+api.ts new file mode 100644 index 0000000..010238b --- /dev/null +++ b/app/(api)/ride/[id]/respond+api.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/(api)/ride/create+api.ts b/app/(api)/ride/create+api.ts index 8b32af5..fb0a1a9 100644 --- a/app/(api)/ride/create+api.ts +++ b/app/(api)/ride/create+api.ts @@ -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 }); } -} +} \ No newline at end of file diff --git a/app/(api)/ride/list+api.ts b/app/(api)/ride/list+api.ts new file mode 100644 index 0000000..5edebc5 --- /dev/null +++ b/app/(api)/ride/list+api.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/(root)/(tabs)/_layout.tsx b/app/(root)/(tabs)/_layout.tsx index ce12012..23edf06 100644 --- a/app/(root)/(tabs)/_layout.tsx +++ b/app/(root)/(tabs)/_layout.tsx @@ -31,7 +31,7 @@ const TabIcon = ({ const TabsLayout = () => ( { (state) => state.setDestinationLocation, ); const { signOut, user } = useSession(); - const { data: recentRides, loading } = useFetch( - `/(api)/ride/${user?.id}`, - ); + const { data: recentRides, loading } = useFetch("/(api)/ride/list"); const { status: locationStatus, retry: retryLocation } = useUserLocation(); @@ -140,6 +139,10 @@ const Home = () => { + + + + Recent Rides diff --git a/app/(root)/(tabs)/profile.tsx b/app/(root)/(tabs)/profile.tsx index dbce75b..d230cde 100644 --- a/app/(root)/(tabs)/profile.tsx +++ b/app/(root)/(tabs)/profile.tsx @@ -2,6 +2,7 @@ import { Image, ScrollView, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { InputField } from "@/components/input-field"; +import { icons } from "@/constants"; import { useSession } from "@/lib/session"; const Profile = () => { @@ -17,7 +18,7 @@ const Profile = () => { Your Avatar { { { - const { user } = useSession(); - const { data: recentRides, loading } = useFetch( - `/(api)/ride/${user?.id}`, - ); + const { data: recentRides, loading } = useFetch("/(api)/ride/list"); return ( diff --git a/app/(root)/book-ride.tsx b/app/(root)/book-ride.tsx index 844df6b..fbe5255 100644 --- a/app/(root)/book-ride.tsx +++ b/app/(root)/book-ride.tsx @@ -1,136 +1,248 @@ -import { router } from "expo-router"; -import { Image, Text, View } from "react-native"; +import { router, useLocalSearchParams } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { + ActivityIndicator, + Alert, + Image, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; import { CustomButton } from "@/components/custom-button"; -import { Payment } from "@/components/payment"; -import { RideLayout } from "@/components/ride-layout"; -import { icons } from "@/constants"; -import { formatLBP } from "@/lib/pricing"; -import { useSession } from "@/lib/session"; +import { Map } from "@/components/map"; +import { icons, images } from "@/constants"; +import { ApiError, fetchAPI } from "@/lib/fetch"; import { formatTime } from "@/lib/utils"; -import { useDriverStore, useLocationStore } from "@/store"; +import { useLocationStore } from "@/store"; +import type { Ride } from "@/types/type"; +const POLL_MS = 3000; + +const statusLabel: Record = { + requested: "Finding your driver…", + accepted: "Driver assigned — heading to you", + en_route: "On your trip", + completed: "You've arrived!", + cancelled: "Ride cancelled", +}; + +// book-ride is now the live ride-status screen. The rider lands here after +// requesting a ride and polls its status until it completes (or they cancel). const BookRide = () => { - const { user } = useSession(); - const { userAddress, destinationAddress } = useLocationStore(); - const { drivers, selectedDriver } = useDriverStore(); + const { id } = useLocalSearchParams<{ id: string }>(); + const rideId = Number(id); + const setUserLocation = useLocationStore((s) => s.setUserLocation); + const setDestinationLocation = useLocationStore((s) => s.setDestinationLocation); - const driverDetails = drivers?.filter( - (driver) => +driver.id === selectedDriver, - )[0]; + const [ride, setRide] = useState(null); + const [loading, setLoading] = useState(true); + const [cancelling, setCancelling] = useState(false); + const [error, setError] = useState(null); - if (!driverDetails) { + const load = useCallback(async () => { + try { + const res = await fetchAPI(`/(api)/ride/${rideId}`); + const r = res.data as Ride; + setRide(r); + + // Keep the map's origin/destination in sync with the ride so the route + // line renders even if the rider reached this screen via history. + setUserLocation({ + latitude: r.origin_latitude, + longitude: r.origin_longitude, + address: r.origin_address, + }); + setDestinationLocation({ + latitude: r.destination_latitude, + longitude: r.destination_longitude, + address: r.destination_address, + }); + } catch (err) { + console.log("[BOOK_RIDE_LOAD]: ", err); + if (err instanceof ApiError && err.status === 404) { + setError("Ride not found."); + } + } finally { + setLoading(false); + } + }, [rideId, setUserLocation, setDestinationLocation]); + + useEffect(() => { + void load(); + }, [load]); + + // Poll while the ride is still in a non-terminal state. + useEffect(() => { + const status = ride?.status; + if (!status || status === "completed" || status === "cancelled") return; + const timer = setInterval(() => void load(), POLL_MS); + return () => clearInterval(timer); + }, [ride?.status, load]); + + const cancel = async () => { + setCancelling(true); + try { + await fetchAPI(`/(api)/ride/${rideId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "cancelled" }), + }); + await load(); + } catch (err) { + console.log("[BOOK_RIDE_CANCEL]: ", err); + Alert.alert("Error", "Could not cancel this ride. Please try again."); + } finally { + setCancelling(false); + } + }; + + if (loading) { return ( - - - - No driver selected.{"\n"}Please go back and choose a driver first. - - - router.replace("/(root)/confirm-ride")} - className="mt-6" - /> - - + + + ); } + if (error || !ride) { + return ( + + + {error ?? "Could not load this ride."} + + router.replace("/(root)/(tabs)/home")} + className="mt-6" + /> + + ); + } + + const driver = ride.driver; + const terminal = ride.status === "completed" || ride.status === "cancelled"; + return ( - - <> - - Ride Information + + + + + + + + {statusLabel[ride.status] ?? ride.status} - - Driver Avatar - - - - {driverDetails?.title} + {/* Searching state */} + {ride.status === "requested" ? ( + + + + We're matching you with the nearest {ride.service} driver. + + ) : null} - + {/* Driver card — shown once a driver is assigned. */} + {driver?.id ? ( + + Star + + + {driver.first_name} {driver.last_name} + + + + + {driver.rating?.toFixed(1) ?? "—"} + + {driver.car_model ? ( + + {driver.car_model} + + ) : null} + + + + {driver.service ?? ride.service} + + - - {driverDetails?.rating} + + + + {ride.origin_address} + + + + + + {ride.destination_address} + + + + + + {ride.payment_status === "cash" + ? "💵 Cash to driver" + : "💳 Paid by card"} + + + ${(ride.fare_price / 100).toFixed(2)} - + ) : null} - - - Ride Price + {/* Completed summary */} + {ride.status === "completed" ? ( + + + + Fare: ${(ride.fare_price / 100).toFixed(2)} + + + Trip time {formatTime(ride.ride_time)} + + + ) : null} - - - ${driverDetails?.price} + {/* Cancelled */} + {ride.status === "cancelled" ? ( + + + This ride was cancelled. + + + ) : null} + + + {terminal ? ( + router.replace("/(root)/(tabs)/home")} + /> + ) : ( + + + {cancelling ? "Cancelling…" : "Cancel Ride"} - - - ≈ {formatLBP(parseFloat(driverDetails?.price ?? "0"))} - - - - - - Pickup Time - - - {formatTime(driverDetails?.time!)} - - - - - Car Seats - - - {driverDetails?.car_seats} - - + + )} - - - - To - - - {userAddress} - - - - - Point - - - {destinationAddress} - - - - - - - + + ); }; -export default BookRide; +export default BookRide; \ No newline at end of file diff --git a/app/(root)/confirm-ride.tsx b/app/(root)/confirm-ride.tsx index b8c7793..c1ee2cf 100644 --- a/app/(root)/confirm-ride.tsx +++ b/app/(root)/confirm-ride.tsx @@ -1,44 +1,325 @@ -import { router } from "expo-router"; -import { FlatList, Text, View } from "react-native"; +import { router, useLocalSearchParams } from "expo-router"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, Alert, Text, TouchableOpacity, View } from "react-native"; import { CustomButton } from "@/components/custom-button"; -import { DriverCard } from "@/components/driver-card"; import { RideLayout } from "@/components/ride-layout"; -import { useDriverStore } from "@/store"; +import { SERVICES } from "@/constants/services"; +import { ApiError, fetchAPI } from "@/lib/fetch"; +import { calculateTripFare } from "@/lib/map"; +import { formatLBP } from "@/lib/pricing"; +import { requestRide } from "@/lib/request-ride"; +import { useSession } from "@/lib/session"; +import { formatTime, haversine } from "@/lib/utils"; +import { useLocationStore, useServiceStore } from "@/store"; +type PaymentMethod = "cash" | "card"; + +type NearbyDriver = { + id: number; + first_name: string; + latitude: number; + longitude: number; +}; + +// Confirm-ride is now the request screen: the rider no longer browses and +// picks a driver. They see a single fare estimate + nearest-driver ETA, pick a +// payment method, and tap Request — auto-match assigns the driver and they're +// routed to the live status screen. const ConfirmRide = () => { - const { drivers, selectedDriver, setSelectedDriver } = useDriverStore(); + const params = useLocalSearchParams<{ service?: string }>(); + const { + userAddress, + userLatitude, + userLongitude, + destinationAddress, + destinationLatitude, + destinationLongitude, + } = useLocationStore(); + const { service: storeService, setService } = useServiceStore(); + const { user } = useSession(); + + const service = params.service ?? storeService; + const selected = SERVICES.find((s) => s.id === service) ?? SERVICES[0]; + + const [method, setMethod] = useState("cash"); + const [estimate, setEstimate] = useState<{ + fare: string; + durationSeconds: number; + } | null>(null); + const [nearestEta, setNearestEta] = useState(null); + const [driversOnline, setDriversOnline] = useState(null); + const [estimating, setEstimating] = useState(true); + const [processing, setProcessing] = useState(false); + + // Trip fare estimate — one Directions call for the trip leg, recomputed when + // the route or service changes. Independent of driver availability. + useEffect(() => { + if ( + !userLatitude || + !userLongitude || + !destinationLatitude || + !destinationLongitude + ) + return; + + let cancelled = false; + setEstimating(true); + + const run = async () => { + const trip = await calculateTripFare({ + userLatitude, + userLongitude, + destinationLatitude, + destinationLongitude, + service: selected.id, + }); + if (cancelled) return; + setEstimate( + trip + ? { fare: trip.fare, durationSeconds: trip.durationSeconds } + : null, + ); + }; + + void run().finally(() => { + if (!cancelled) setEstimating(false); + }); + + return () => { + cancelled = true; + }; + }, [ + userLatitude, + userLongitude, + destinationLatitude, + destinationLongitude, + selected.id, + ]); + + // Online-driver availability for the selected service, polled so the "no + // drivers" state self-heals the moment a driver of this service comes + // online. The nearest driver's pickup ETA is resolved alongside the count. + useEffect(() => { + if (!userLatitude || !userLongitude) return; + + let cancelled = false; + + const check = async () => { + try { + const res = await fetchAPI( + `/(api)/driver/nearby?service=${selected.id}&lat=${userLatitude}&lng=${userLongitude}`, + ); + const drivers = (res.data ?? []) as NearbyDriver[]; + if (cancelled) return; + setDriversOnline(drivers.length); + if (drivers.length === 0) { + setNearestEta(null); + return; + } + const nearest = drivers + .map((d) => ({ + d, + dist: haversine( + userLatitude, + userLongitude, + d.latitude, + d.longitude, + ), + })) + .sort((a, b) => a.dist - b.dist)[0].d; + + const directionsRes = await fetch( + `https://maps.googleapis.com/maps/api/directions/json?origin=${nearest.latitude},${nearest.longitude}&destination=${userLatitude},${userLongitude}&key=${process.env.EXPO_PUBLIC_GOOGLE_API_KEY}`, + ); + const data = await directionsRes.json(); + const leg = data.routes?.[0]?.legs?.[0]; + if (!cancelled) + setNearestEta(leg ? Math.round(leg.duration.value / 60) : null); + } catch { + if (!cancelled) { + setDriversOnline(null); + setNearestEta(null); + } + } + }; + + void check(); + const timer = setInterval(() => void check(), 10000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [userLatitude, userLongitude, selected.id]); + + const request = async () => { + if (!userLatitude || !userLongitude || !destinationLatitude || !destinationLongitude) { + Alert.alert("Missing route", "Please set a pickup and destination first."); + return; + } + if (!estimate) { + Alert.alert("No estimate", "We couldn't estimate this fare. Please try again."); + return; + } + + // Nested so the guards above narrow userLatitude/estimate to non-null for + // the card-confirm callback as well as the direct cash path. + const doRequest = async () => { + setProcessing(true); + try { + // Keep the store in sync with whatever service we resolved for this ride. + setService(selected.id); + + const { ride } = await requestRide({ + method, + service: selected.id, + user: { name: user?.name ?? "", email: user?.email ?? "" }, + origin: { + address: userAddress ?? "", + latitude: userLatitude, + longitude: userLongitude, + }, + destination: { + address: destinationAddress ?? "", + latitude: destinationLatitude, + longitude: destinationLongitude, + }, + rideTimeSeconds: estimate.durationSeconds, + fareCents: Math.round(parseFloat(estimate.fare) * 100), + }); + + router.replace(`/(root)/book-ride?id=${ride.ride_id}`); + } catch (err) { + console.log("[REQUEST_RIDE]: ", err); + const msg = + err instanceof ApiError + ? err.message + : "Something went wrong while booking your ride. Please try again."; + Alert.alert("Error", msg); + } finally { + setProcessing(false); + } + }; + + if (method === "card") { + Alert.alert( + "Pay by card", + `Your card will be charged $${estimate.fare}.`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Continue", onPress: () => void doRequest() }, + ], + ); + } else { + void doRequest(); + } + }; return ( - - ( - setSelectedDriver(item.id)} - /> - )} - ListEmptyComponent={ - - No drivers available on this route right now.{"\n"}Please try - another destination. + + Your trip + + + Pickup + + + {userAddress} + + + + Destination + + + {destinationAddress} + + + + + + {selected.label} · {selected.tagline} + + Trip time {estimate ? formatTime(estimate.durationSeconds / 60) : "…"} + + + + + {estimating ? "…" : estimate ? `$${estimate.fare}` : "—"} + + {estimate ? ( + + ≈ {formatLBP(parseFloat(estimate.fare))} + + ) : null} + + + + + {driversOnline === 0 + ? `No ${selected.label} drivers online right now` + : nearestEta == null + ? "Finding drivers nearby…" + : `Nearest driver ≈ ${nearestEta} min away`} + + + + Payment Method + + + setMethod("cash")} + className={`flex-1 items-center py-3 rounded-xl border ${ + method === "cash" + ? "bg-general-600 border-primary-500" + : "bg-white border-general-700" + }`} + > + + 💵 Cash + + + setMethod("card")} + className={`flex-1 items-center py-3 rounded-xl border ${ + method === "card" + ? "bg-general-600 border-primary-500" + : "bg-white border-general-700" + }`} + > + + 💳 Card + + + + + - router.push("/(root)/book-ride")} - disabled={selectedDriver === null} - className={selectedDriver === null ? "opacity-50" : ""} - /> - - } + className="mt-4" + onPress={request} + disabled={processing || estimating || !estimate || driversOnline === 0} /> ); }; -export default ConfirmRide; +export default ConfirmRide; \ No newline at end of file diff --git a/app/(root)/driver-home.tsx b/app/(root)/driver-home.tsx index 3ae3d18..91904f1 100644 --- a/app/(root)/driver-home.tsx +++ b/app/(root)/driver-home.tsx @@ -1,40 +1,549 @@ -import { Image, Text, View } from "react-native"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { + ActivityIndicator, + Alert, + Image, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { CustomButton } from "@/components/custom-button"; -import { images } from "@/constants"; +import { icons, images } from "@/constants"; +import { SERVICES, type ServiceId } from "@/constants/services"; +import { ApiError, fetchAPI } from "@/lib/fetch"; import { useSession } from "@/lib/session"; +import { useDriverLocation } from "@/lib/use-driver-location"; +import { formatTime } from "@/lib/utils"; + +// Poll cadence for the driver dashboard (offers / active ride / earnings). +const POLL_MS = 4000; + +type Profile = { + id: number; + first_name: string; + last_name: string; + profile_image_url: string | null; + car_image_url: string | null; + car_seats: number; + rating: number; + service: ServiceId; + online: boolean; + car_model: string | null; +}; + +type Offer = { + offer_id: number; + offered_at: string; + ride_id: number; + origin_address: string; + destination_address: string; + ride_time: number; + fare_price: number; + payment_status: string; + service: string; +}; + +type ActiveRide = { + ride_id: number; + status: string; + service: string; + payment_status: string; + origin_address: string; + destination_address: string; + ride_time: number; + fare_price: number; + rider_name: string | null; + rider_phone: string | null; +}; + +type Dashboard = { + offers: Offer[]; + active: ActiveRide | null; + recent: { ride_id: number; fare_price: number; service: string }[]; + earnings: number; +}; -// Placeholder driver home. The driver experience (going online, accepting -// rides) is not built yet — drivers are registered here and managed in the -// database for now. const DriverHome = () => { const { signOut, user } = useSession(); + const [loading, setLoading] = useState(true); + const [profile, setProfile] = useState(null); + const [online, setOnline] = useState(false); + const [dashboard, setDashboard] = useState(null); + const [busy, setBusy] = useState(false); + + const loadProfile = useCallback(async () => { + try { + const res = await fetchAPI("/(api)/driver/profile"); + const p = res.data as Profile; + setProfile(p); + setOnline(p.online); + } catch (err) { + // 403 with code ONBOARD means no profile yet — show the onboarding form. + if (err instanceof ApiError && err.status === 403) { + setProfile(null); + } else { + console.log("[DRIVER_PROFILE_LOAD]: ", err); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadProfile(); + }, [loadProfile]); + + // Keep the location heartbeat running only while the driver is online and + // has completed onboarding. + useDriverLocation(online && profile !== null); + + // Poll the dashboard while online. useCallback keeps the fetcher stable so the + // interval effect doesn't re-subscribe on every render. + const fetchDashboard = useCallback(async () => { + try { + const res = await fetchAPI("/(api)/driver/rides"); + setDashboard(res.data as Dashboard); + } catch (err) { + console.log("[DRIVER_DASHBOARD_POLL]: ", err); + } + }, []); + + useEffect(() => { + if (!online || !profile) return; + void fetchDashboard(); + const timer = setInterval(() => void fetchDashboard(), POLL_MS); + return () => clearInterval(timer); + }, [online, profile, fetchDashboard]); + + const toggleOnline = async () => { + if (!profile) return; + const next = !online; + setBusy(true); + try { + await fetchAPI("/(api)/driver/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ online: next }), + }); + setOnline(next); + setProfile({ ...profile, online: next }); + if (!next) setDashboard(null); + } catch (err) { + console.log("[DRIVER_TOGGLE]: ", err); + Alert.alert("Error", "Could not change your status. Please try again."); + } finally { + setBusy(false); + } + }; + + const respond = async (offer: Offer, action: "accept" | "decline") => { + setBusy(true); + try { + await fetchAPI(`/(api)/ride/${offer.ride_id}/respond`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + await fetchDashboard(); + } catch (err) { + console.log("[DRIVER_RESPOND]: ", err); + Alert.alert( + "Error", + action === "accept" + ? "Could not accept this ride. It may have been taken or expired." + : "Could not decline this ride. Please try again.", + ); + } finally { + setBusy(false); + } + }; + + const advance = async (rideId: number, status: "en_route" | "completed") => { + setBusy(true); + try { + await fetchAPI(`/(api)/ride/${rideId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + await fetchDashboard(); + } catch (err) { + console.log("[DRIVER_ADVANCE]: ", err); + Alert.alert("Error", "Could not update the ride. Please try again."); + } finally { + setBusy(false); + } + }; + + if (loading) { + return ( + + + + ); + } + + if (!profile) { + return ( + + ); + } + + const earnings = dashboard?.earnings ?? 0; + const rideCount = dashboard?.recent.length ?? 0; return ( - - Registered + + + + + Driver mode + + + Sign out + + - - You're registered as a driver, {user?.name || "there"}! - + {/* Online / offline toggle */} + + + {online ? "● Online — receiving ride requests" : "○ Go online to drive"} + + - - Driver mode is coming soon. We'll contact you at{" "} - {user?.email} once your account is activated. - + {/* Earnings summary */} + + + + Today's earnings + + + ${(earnings / 100).toFixed(2)} + + + + + Completed today + + {rideCount} + + - signOut()} - className="mt-10" - /> + {/* Active ride */} + {dashboard?.active ? ( + + ) : null} + + {/* Incoming offers */} + + Incoming requests {online ? "" : "(offline)"} + + + {!online ? null : dashboard?.offers.length ? ( + dashboard.offers.map((offer) => ( + respond(offer, "accept")} + onDecline={() => respond(offer, "decline")} + /> + )) + ) : ( + + + + {online ? "Waiting for ride requests…" : "Go online to start driving."} + + + )} + ); }; -export default DriverHome; +// --- Onboarding form ------------------------------------------------------ + +const Onboarding = ({ + onCreated, + signOut, + userName, +}: { + onCreated: () => Promise; + signOut: () => Promise; + userName?: string | null; +}) => { + const [service, setService] = useState("car"); + const [carModel, setCarModel] = useState(""); + const [carSeats, setCarSeats] = useState("4"); + const [submitting, setSubmitting] = useState(false); + + const submit = async () => { + const seats = Number(carSeats); + if (!Number.isInteger(seats) || seats < 1 || seats > 8) { + Alert.alert("Invalid seats", "Car seats must be a whole number 1–8."); + return; + } + setSubmitting(true); + try { + await fetchAPI("/(api)/driver/profile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + service, + car_model: carModel.trim() || null, + car_seats: seats, + }), + }); + await onCreated(); + } catch (err) { + console.log("[DRIVER_ONBOARD]: ", err); + Alert.alert("Error", "Could not create your driver profile. Please try again."); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + Welcome, {userName?.split(" ")[0] || "driver"} + + + Sign out + + + + + Set up your driver profile to start receiving ride requests. + + + + What will you drive? + + + {SERVICES.map((item) => { + const active = item.id === service; + return ( + setService(item.id)} + className={`flex-1 items-center rounded-2xl border py-3 ${ + active + ? "border-primary-500 bg-primary-500/10" + : "border-neutral-100 bg-neutral-100" + }`} + > + + + {item.label} + + + ); + })} + + + Car model + + + Car seats + + + + + + ); +}; + +// --- Offer card ----------------------------------------------------------- + +const OfferCard = ({ + offer, + busy, + onAccept, + onDecline, +}: { + offer: Offer; + busy: boolean; + onAccept: () => void; + onDecline: () => void; +}) => ( + + + + New request · {offer.service} + + + {offer.payment_status === "cash" ? "💵 Cash" : "💳 Card"} + + + + + From + + {offer.origin_address} + + + + To + + {offer.destination_address} + + + + + Trip time + + {formatTime(offer.ride_time)} + + + + Fare + + ${(offer.fare_price / 100).toFixed(2)} + + + + + + Decline + + + + {busy ? "…" : "Accept"} + + + + +); + +// --- Active ride card ----------------------------------------------------- + +const ActiveRideCard = ({ + ride, + busy, + onAdvance, +}: { + ride: ActiveRide; + busy: boolean; + onAdvance: (rideId: number, status: "en_route" | "completed") => void; +}) => { + const statusLabel = + ride.status === "accepted" + ? "Head to pickup" + : ride.status === "en_route" + ? "Trip in progress" + : ride.status; + + return ( + + + + ● {statusLabel} + + {ride.service} + + + {ride.rider_name ? ( + {ride.rider_name} + ) : null} + + + From + + {ride.origin_address} + + + + To + + {ride.destination_address} + + + + + Fare + + ${(ride.fare_price / 100).toFixed(2)} + + + + {ride.status === "accepted" ? ( + onAdvance(ride.ride_id, "en_route")} + className="mb-2" + /> + ) : null} + {ride.status === "en_route" ? ( + onAdvance(ride.ride_id, "completed")} + /> + ) : null} + + ); +}; + +export default DriverHome; \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx index 15a957f..e062411 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -3,7 +3,6 @@ import { Stack } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; import { useEffect } from "react"; -import { LogBox } from "react-native"; import "react-native-reanimated"; import { SessionProvider } from "@/lib/session"; @@ -11,8 +10,6 @@ import { SessionProvider } from "@/lib/session"; // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); -LogBox.ignoreAllLogs(); - const RootLayout = () => { const [loaded] = useFonts({ "Jakarta-Bold": require("../assets/fonts/PlusJakartaSans-Bold.ttf"), diff --git a/components/map.tsx b/components/map.tsx index 3ddc509..0b71562 100644 --- a/components/map.tsx +++ b/components/map.tsx @@ -10,7 +10,7 @@ import { calculateRegion, generateMarkersFromData, } from "@/lib/map"; -import { useDriverStore, useLocationStore } from "@/store"; +import { useDriverStore, useLocationStore, useServiceStore } from "@/store"; import type { Driver, MarkerData } from "@/types/type"; // react-native-maps sizes itself from a real style object, so give it explicit @@ -40,15 +40,24 @@ const MUTED_POI_STYLE = [ ]; export const Map = () => { - const { data: drivers, error } = useFetch("/(api)/driver"); - const { userLatitude, userLongitude, destinationLatitude, destinationLongitude, } = useLocationStore(); + const { service } = useServiceStore(); const { selectedDriver, setDrivers } = useDriverStore(); + + // Online drivers of the selected service near the rider. Falls back to a + // Beirut center when the rider's position isn't resolved yet so the map + // still populates instead of sitting empty. + const lat = userLatitude ?? 33.8938; + const lng = userLongitude ?? 35.5018; + const { data: drivers, error } = useFetch( + `/(api)/driver/nearby?service=${service}&lat=${lat}&lng=${lng}`, + ); + const [markers, setMarkers] = useState([]); const region = calculateRegion({ @@ -81,8 +90,9 @@ export const Map = () => { userLongitude, destinationLatitude, destinationLongitude, - }).then((drivers) => { - setDrivers(drivers as MarkerData[]); + service, + }).then((driversWithTimes) => { + setDrivers((driversWithTimes as MarkerData[]) ?? []); }); } }, [ @@ -92,6 +102,7 @@ export const Map = () => { userLatitude, userLongitude, setDrivers, + service, ]); // The map itself never waits on the driver list or the location fix: drivers diff --git a/components/nearby-suggestions.tsx b/components/nearby-suggestions.tsx new file mode 100644 index 0000000..56018a2 --- /dev/null +++ b/components/nearby-suggestions.tsx @@ -0,0 +1,120 @@ +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { useEffect, useState } from "react"; +import { ScrollView, Text, TouchableOpacity, View } from "react-native"; + +import { POI_CATEGORIES, searchNearby } from "@/lib/places"; +import { useLocationStore } from "@/store"; +import type { NearbyPlace } from "@/types/type"; + +// Four quick destination chips: nearest mall / hospital / pharmacy / restaurant +// around the rider. Tapping one sets it as the destination and opens find-ride. +// Each chip resolves independently, so a category with no result nearby just +// shows "none nearby" instead of breaking the whole row. +type ChipState = + | { status: "loading" } + | { status: "empty" } + | { status: "ready"; place: NearbyPlace }; + +export const NearbySuggestions = () => { + const { userLatitude, userLongitude, setDestinationLocation } = + useLocationStore(); + const [chips, setChips] = useState>({}); + + useEffect(() => { + if (userLatitude == null || userLongitude == null) return; + + let cancelled = false; + setChips({}); + + // Resolve all four categories in parallel. + POI_CATEGORIES.forEach(async (category) => { + setChips((prev) => ({ ...prev, [category.id]: { status: "loading" } })); + const place = await searchNearby(category.googleType, { + latitude: userLatitude, + longitude: userLongitude, + }); + if (cancelled) return; + setChips((prev) => ({ + ...prev, + [category.id]: place ? { status: "ready", place } : { status: "empty" }, + })); + }); + + return () => { + cancelled = true; + }; + }, [userLatitude, userLongitude]); + + const select = (place: NearbyPlace) => { + setDestinationLocation({ + latitude: place.latitude, + longitude: place.longitude, + address: place.name, + }); + router.push("/(root)/find-ride"); + }; + + return ( + + + Nearby suggestions + + + + {POI_CATEGORIES.map((category) => { + const state = chips[category.id]; + const ready = state?.status === "ready" ? state.place : null; + + return ( + ready && select(ready)} + activeOpacity={0.8} + className={`flex-row items-center rounded-2xl border px-3 py-2.5 ${ + ready + ? "border-primary-500 bg-primary-500/10" + : "border-neutral-200 bg-neutral-100" + }`} + style={{ minWidth: 150 }} + > + + + + + {category.label} + + + {!state || state.status === "loading" + ? "searching…" + : state.status === "empty" + ? "none nearby" + : state.place.distanceMeters != null + ? `${Math.round(state.place.distanceMeters / 100) / 10} km away` + : state.place.name} + + + + ); + })} + + + ); +}; \ No newline at end of file diff --git a/components/payment.tsx b/components/payment.tsx index 4cd2099..8063da3 100644 --- a/components/payment.tsx +++ b/components/payment.tsx @@ -5,7 +5,7 @@ import { Alert, Image, Text, TouchableOpacity, View } from "react-native"; import ReactNativeModal from "react-native-modal"; import { images } from "@/constants"; -import { fetchAPI } from "@/lib/fetch"; +import { ApiError, fetchAPI } from "@/lib/fetch"; import { formatLBP } from "@/lib/pricing"; import { useLocationStore } from "@/store"; import type { PaymentProps } from "@/types/type"; @@ -33,7 +33,9 @@ export const Payment = ({ const [success, setSuccess] = useState(false); const [processing, setProcessing] = useState(false); - const recordRide = async (paymentStatus: string) => { + const fareCents = Math.round(parseFloat(amount) * 100); // in cents + + const recordRide = async (paymentMethod: PaymentMethod, orderId?: string) => { await fetchAPI("/(api)/ride/create", { method: "POST", headers: { @@ -47,8 +49,9 @@ export const Payment = ({ destination_latitude: destinationLatitude, destination_longitude: destinationLongitude, ride_time: rideTime.toFixed(0), - fare_price: Math.round(parseFloat(amount) * 100), // in cents - payment_status: paymentStatus, + fare_price: fareCents, + payment_method: paymentMethod, + ...(orderId ? { payment_order_id: orderId } : {}), driver_id: driverId, }), }); @@ -75,8 +78,10 @@ export const Payment = ({ setProcessing(true); try { - // 1. Create an Areeba checkout session on our server. - const { orderId, checkoutUrl, successIndicator, error } = await fetchAPI( + // 1. Create an Areeba checkout session on our server. The server stores + // the ride intent and the successIndicator; the client only gets an + // orderId + checkoutUrl. + const { orderId, checkoutUrl, error } = await fetchAPI( "/(api)/(areeba)/create", { method: "POST", @@ -86,7 +91,15 @@ export const Payment = ({ body: JSON.stringify({ name: fullName || email, email, - amount, + fare_cents: fareCents, + driver_id: driverId, + origin_address: userAddress, + destination_address: destinationAddress, + origin_latitude: userLatitude, + origin_longitude: userLongitude, + destination_latitude: destinationLatitude, + destination_longitude: destinationLongitude, + ride_time: rideTime.toFixed(0), }), }, ); @@ -107,17 +120,21 @@ export const Payment = ({ ) as string; } - // 3. Verify the payment server-side before recording the ride. + // 3. Verify the payment server-side. The server compares + // resultIndicator against the stored successIndicator, reconciles + // the captured amount, and marks the order paid. const verification = await fetchAPI("/(api)/(areeba)/verify", { method: "POST", headers: { "Content-type": "application/json", }, - body: JSON.stringify({ orderId, resultIndicator, successIndicator }), + body: JSON.stringify({ orderId, resultIndicator }), }); if (verification.success) { - await recordRide("paid"); + // 4. Record the ride, consuming the paid order atomically. The client + // never sets payment_status itself. + await recordRide("card", orderId); setSuccess(true); } else { Alert.alert( @@ -127,10 +144,20 @@ export const Payment = ({ } } catch (err) { console.log("[PAYMENT]: ", err); - Alert.alert( - "Error", - "Something went wrong while processing your payment. Please try again.", - ); + // Verification failures (cancelled, not captured, amount/intent mismatch) + // come back as 400s. fetchAPI throws ApiError on non-2xx, so without this + // branch every cancellation lands in the generic "something went wrong". + if (err instanceof ApiError && err.status === 400) { + Alert.alert( + "Payment not completed", + "Your payment was cancelled or could not be verified. Please try again.", + ); + } else { + Alert.alert( + "Error", + "Something went wrong while processing your payment. Please try again.", + ); + } } finally { setProcessing(false); } @@ -230,4 +257,4 @@ export const Payment = ({ ); -}; +}; \ No newline at end of file diff --git a/constants/services.ts b/constants/services.ts index c1c6e01..7dbfe95 100644 --- a/constants/services.ts +++ b/constants/services.ts @@ -19,6 +19,8 @@ export type Service = { label: string; /** Shown under the row once the service is selected. */ tagline: string; + /** Multiplier applied to the base fare for this service (car = 1.0). */ + fareMultiplier: number; }; export const SERVICES: Service[] = [ @@ -27,24 +29,28 @@ export const SERVICES: Service[] = [ icon: "car", label: "Car", tagline: "An everyday ride, up to 4 seats.", + fareMultiplier: 1.0, }, { id: "moto", icon: "motorbike", label: "Moto", tagline: "Beat the traffic — one passenger, no luggage.", + fareMultiplier: 0.7, }, { id: "courier", icon: "package-variant-closed", label: "Courier", tagline: "Send a parcel across town without riding along.", + fareMultiplier: 0.85, }, { id: "chauffeur", icon: "steering", label: "My Car", tagline: "A driver comes to you and drives your own car.", + fareMultiplier: 1.5, }, ]; diff --git a/dashboard/src/pages/Drivers.tsx b/dashboard/src/pages/Drivers.tsx index 3468ee6..803167b 100644 --- a/dashboard/src/pages/Drivers.tsx +++ b/dashboard/src/pages/Drivers.tsx @@ -9,6 +9,9 @@ type Driver = { car_image_url: string | null; car_seats: number; rating: string; + service: string; + online: boolean; + car_model: string | null; total_rides: number; revenue: number; }; @@ -63,8 +66,10 @@ export default function Drivers() { ID Name + Service Seats Rating + Online Rides Revenue @@ -77,8 +82,12 @@ export default function Drivers() { {d.first_name} {d.last_name} + + {d.service} + {d.car_seats} {d.rating} + {d.online ? "● online" : "○ off"} {d.total_rides} {d.revenue.toLocaleString()} diff --git a/lib/dispatch.ts b/lib/dispatch.ts new file mode 100644 index 0000000..bf85ea9 --- /dev/null +++ b/lib/dispatch.ts @@ -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 => { + 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` + 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; + } +}; \ No newline at end of file diff --git a/lib/driver.ts b/lib/driver.ts new file mode 100644 index 0000000..a0f71c4 --- /dev/null +++ b/lib/driver.ts @@ -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 => { + 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 }; +}; \ No newline at end of file diff --git a/lib/mailer.ts b/lib/mailer.ts index 5222876..c9d03c8 100644 --- a/lib/mailer.ts +++ b/lib/mailer.ts @@ -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 => { 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; } }; diff --git a/lib/map.ts b/lib/map.ts index 278e5db..6d564dd 100644 --- a/lib/map.ts +++ b/lib/map.ts @@ -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; + } +}; \ No newline at end of file diff --git a/lib/otp.ts b/lib/otp.ts index 6627342..81e1d7e 100644 --- a/lib/otp.ts +++ b/lib/otp.ts @@ -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, diff --git a/lib/payment-orders.ts b/lib/payment-orders.ts new file mode 100644 index 0000000..5c8d2fc --- /dev/null +++ b/lib/payment-orders.ts @@ -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 = ( + strings: TemplateStringsArray, + ...values: SqlValue[] +) => Promise; + +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 => { + const rows = await sql` + 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 => { + const rows = await sql` + 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 => { + const rows = await sql` + 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 => { + const rows = await runner` + UPDATE payment_orders + SET status = 'consumed' + WHERE order_id = ${orderId} + AND user_id = ${userId} + AND status = 'paid' + RETURNING * + `; + return rows[0] ?? null; +}; \ No newline at end of file diff --git a/lib/places.ts b/lib/places.ts new file mode 100644 index 0000000..a0d42e5 --- /dev/null +++ b/lib/places.ts @@ -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 => { + 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; + } +}; \ No newline at end of file diff --git a/lib/pricing.ts b/lib/pricing.ts index bcaeaf8..c10c07e 100644 --- a/lib/pricing.ts +++ b/lib/pricing.ts @@ -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); }; diff --git a/lib/request-ride.ts b/lib/request-ride.ts new file mode 100644 index 0000000..cefd5ba --- /dev/null +++ b/lib/request-ride.ts @@ -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 => { + 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 => { + 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 }; +}; \ No newline at end of file diff --git a/lib/use-driver-location.ts b/lib/use-driver-location.ts new file mode 100644 index 0000000..f9c0c1b --- /dev/null +++ b/lib/use-driver-location.ts @@ -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(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]); +}; \ No newline at end of file diff --git a/lib/users.ts b/lib/users.ts index 3dee62c..83df7c3 100644 --- a/lib/users.ts +++ b/lib/users.ts @@ -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 => { - const rows = await sql` - SELECT id, name, email, role FROM users WHERE email = ${email} - `; - return rows[0] ?? null; -}; diff --git a/lib/utils.ts b/lib/utils.ts index 37758b7..90ebc1e 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -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)); +} diff --git a/package.json b/package.json index 9d4b88b..e4ef50c 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "test": "jest --watchAll", "lint": "expo lint" diff --git a/scripts/seed-db.mjs b/scripts/seed-db.mjs index 6e07048..3c19292 100644 --- a/scripts/seed-db.mjs +++ b/scripts/seed-db.mjs @@ -109,6 +109,19 @@ await sql`CREATE TABLE IF NOT EXISTS drivers ( rating NUMERIC(2,1) NOT NULL )`; +// Driver profiles are linked to a user account (in-app driver onboarding) and +// carry the live state the dispatch engine needs. +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE SET NULL`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS service VARCHAR(20) NOT NULL DEFAULT 'car'`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS online BOOLEAN NOT NULL DEFAULT FALSE`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS last_seen TIMESTAMPTZ`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS car_model VARCHAR(100)`; +// One driver profile per user account (legacy seed rows have NULL user_id). +await sql`CREATE UNIQUE INDEX IF NOT EXISTS drivers_user_id_key ON drivers(user_id) WHERE user_id IS NOT NULL`; +await sql`CREATE INDEX IF NOT EXISTS drivers_service_online_idx ON drivers(service, online)`; + await sql`CREATE TABLE IF NOT EXISTS rides ( ride_id SERIAL PRIMARY KEY, origin_address TEXT NOT NULL, @@ -122,18 +135,74 @@ await sql`CREATE TABLE IF NOT EXISTS rides ( payment_status VARCHAR(50) NOT NULL, driver_id INTEGER NOT NULL REFERENCES drivers(id), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + payment_order_id TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )`; +// Link a ride to the server-authoritative payment order that paid for it. +await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS payment_order_id TEXT`; + +// Ride lifecycle state machine: requested -> accepted -> en_route -> completed +// (or cancelled). A requested ride has no driver yet — auto-match assigns one +// when a driver accepts, so driver_id must be nullable. +await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'requested'`; +await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS service VARCHAR(20) NOT NULL DEFAULT 'car'`; +await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`; +await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMPTZ`; +await sql` + ALTER TABLE rides ALTER COLUMN driver_id DROP NOT NULL +`; +await sql`CREATE INDEX IF NOT EXISTS rides_user_id_idx ON rides(user_id)`; +await sql`CREATE INDEX IF NOT EXISTS rides_driver_id_idx ON rides(driver_id)`; +await sql`CREATE INDEX IF NOT EXISTS rides_status_idx ON rides(status)`; + +// Server-authoritative record of each card payment intent. The client never +// supplies payment_status or the successIndicator; both live here and are +// verified against the gateway before an order can be consumed for a ride. +await sql`CREATE TABLE IF NOT EXISTS payment_orders ( + order_id TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount_cents INTEGER NOT NULL, + currency VARCHAR(8) NOT NULL DEFAULT 'USD', + driver_id INTEGER REFERENCES drivers(id), + origin_address TEXT, + destination_address TEXT, + origin_latitude DOUBLE PRECISION, + origin_longitude DOUBLE PRECISION, + destination_latitude DOUBLE PRECISION, + destination_longitude DOUBLE PRECISION, + ride_time INTEGER, + success_indicator TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + paid_at TIMESTAMPTZ +)`; + +await sql`CREATE INDEX IF NOT EXISTS payment_orders_user_id_idx ON payment_orders(user_id)`; + +// Dispatch: each attempt to match a requested ride to a driver is recorded as +// an offer. A driver polls for status='offered' rows assigned to them; accept +// flips the ride to 'accepted', decline/expiry triggers the next-nearest match. +await sql`CREATE TABLE IF NOT EXISTS ride_offers ( + id SERIAL PRIMARY KEY, + ride_id INTEGER NOT NULL REFERENCES rides(ride_id) ON DELETE CASCADE, + driver_id INTEGER NOT NULL REFERENCES drivers(id), + status VARCHAR(20) NOT NULL DEFAULT 'offered', + offered_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + responded_at TIMESTAMPTZ +)`; +await sql`CREATE INDEX IF NOT EXISTS ride_offers_driver_status_idx ON ride_offers(driver_id, status)`; +await sql`CREATE INDEX IF NOT EXISTS ride_offers_ride_idx ON ride_offers(ride_id)`; + const count = await sql`SELECT COUNT(*)::int AS n FROM drivers`; if (count[0].n === 0) { await sql`INSERT INTO drivers - (first_name, last_name, profile_image_url, car_image_url, car_seats, rating) + (first_name, last_name, profile_image_url, car_image_url, car_seats, rating, service) VALUES - ('Karim', 'Haddad', 'https://randomuser.me/api/portraits/men/32.jpg', 'https://images.unsplash.com/photo-1555215695-3004980ad54e?w=600', 4, 4.8), - ('Rana', 'Khalil', 'https://randomuser.me/api/portraits/women/44.jpg', 'https://images.unsplash.com/photo-1552519507-da3b142c6e3d?w=600', 4, 4.9), - ('Omar', 'Chehab', 'https://randomuser.me/api/portraits/men/75.jpg', 'https://images.unsplash.com/photo-1580273916550-e323be2ae537?w=600', 4, 4.6), - ('Layal', 'Abou-Jaoude', 'https://randomuser.me/api/portraits/women/68.jpg', 'https://images.unsplash.com/photo-1590362891991-f776e747a588?w=600', 2, 4.7)`; + ('Karim', 'Haddad', 'https://randomuser.me/api/portraits/men/32.jpg', 'https://images.unsplash.com/photo-1555215695-3004980ad54e?w=600', 4, 4.8, 'car'), + ('Rana', 'Khalil', 'https://randomuser.me/api/portraits/women/44.jpg', 'https://images.unsplash.com/photo-1552519507-da3b142c6e3d?w=600', 1, 4.9, 'moto'), + ('Omar', 'Chehab', 'https://randomuser.me/api/portraits/men/75.jpg', 'https://images.unsplash.com/photo-1580273916550-e323be2ae537?w=600', 4, 4.6, 'car'), + ('Layal', 'Abou-Jaoude', 'https://randomuser.me/api/portraits/women/68.jpg', 'https://images.unsplash.com/photo-1590362891991-f776e747a588?w=600', 2, 4.7, 'courier')`; console.log("Seeded 4 drivers."); } else { console.log(`Drivers table already has ${count[0].n} rows, skipping seed.`); diff --git a/types/type.d.ts b/types/type.d.ts index 17e044f..59e297a 100644 --- a/types/type.d.ts +++ b/types/type.d.ts @@ -1,13 +1,20 @@ import { TextInputProps, TouchableOpacityProps } from "react-native"; declare interface Driver { - driver_id: number; + id: number; first_name: string; last_name: string; profile_image_url: string; car_image_url: string; car_seats: number; rating: number; + service: string; + online?: boolean; + latitude?: number | null; + longitude?: number | null; + user_id?: string | null; + car_model?: string | null; + last_seen?: string | null; } declare interface MarkerData { @@ -21,6 +28,9 @@ declare interface MarkerData { rating: number; first_name: string; last_name: string; + service?: string; + online?: boolean; + car_model?: string | null; time?: number; price?: string; } @@ -34,6 +44,7 @@ declare interface MapProps { } declare interface Ride { + ride_id?: number; origin_address: string; destination_address: string; origin_latitude: number; @@ -43,16 +54,46 @@ declare interface Ride { ride_time: number; fare_price: number; payment_status: string; - driver_id: number; - user_email: string; + status: string; + service: string; + driver_id: number | null; + user_id?: string; + payment_order_id?: string | null; created_at: string; + completed_at?: string | null; + cancelled_at?: string | null; driver: { - first_name: string; - last_name: string; - car_seats: number; + id: number | null; + first_name: string | null; + last_name: string | null; + car_seats: number | null; + profile_image_url: string | null; + car_image_url: string | null; + rating: number | null; + service: string | null; + car_model: string | null; }; } +declare interface RideOffer { + id: number; + ride_id: number; + driver_id: number; + status: string; + offered_at: string; + responded_at: string | null; + ride?: Ride; +} + +declare interface NearbyPlace { + name: string; + address: string; + latitude: number; + longitude: number; + distanceMeters?: number; + category?: string; +} + declare interface ButtonProps extends TouchableOpacityProps { title: string; bgVariant?: "primary" | "secondary" | "danger" | "outline" | "success";