/** * Drive (ride-hailing) callables. * * Environment variables (set in functions/.env, same convention as AI Chat): * - DRIVE_BASE_FARE, DRIVE_PRICE_PER_KM, DRIVE_PRICE_PER_MIN, DRIVE_CURRENCY * - DRIVE_ALLOW_SELF_ACCEPT — `"true"` only for kit dogfood (same uid * requests then accepts). Omit or any other value to block self-accept. * Secret (set via `firebase functions:secrets:set MAPBOX_ACCESS_TOKEN`): * - MAPBOX_ACCESS_TOKEN — same token the Flutter app uses for map tiles; * kept here too so the server can compute the authoritative fare instead * of trusting whatever distance/duration a client might send. */ import {onCall, HttpsError} from "firebase-functions/v2/https"; import {defineSecret} from "firebase-functions/params"; import {Timestamp} from "firebase-admin/firestore"; import {fetchDrivingRoute} from "./mapbox_directions"; import {encodeGeohash} from "./geohash"; import {driverRepository, rideRepository} from "./repositories/repositories"; // Same radius the `onRideCreated` trigger uses to notify nearby drivers — // keeps "who got notified" and "who can see it in the list" consistent. // How far (meters) an online driver can be from a pending pickup to see it // in listNearbyRideRequests / onRideCreated. Documented in docs/drive.*.md — // change here (and the matching constant in triggers.ts) if you retune the // market; there is no env override yet. const SEARCH_RADIUS_METERS = 5000; const RIDE_GEOHASH_PRECISION = 5; const mapboxAccessToken = defineSecret("MAPBOX_ACCESS_TOKEN"); // Default coefficients used when functions/.env doesn't set them — override // per project via `kasy configure`. const DEFAULT_BASE_FARE = 5.0; const DEFAULT_PRICE_PER_KM = 1.5; const DEFAULT_PRICE_PER_MIN = 0.25; const DEFAULT_CURRENCY = "USD"; /** Kit dogfood only. Shipping projects must leave this unset / not `"true"`. */ function allowSelfAccept(): boolean { return process.env.DRIVE_ALLOW_SELF_ACCEPT === "true"; } function estimatePrice(distanceMeters: number, durationSeconds: number): { price: number; currency: string; } { const baseFare = Number(process.env.DRIVE_BASE_FARE ?? DEFAULT_BASE_FARE); const pricePerKm = Number(process.env.DRIVE_PRICE_PER_KM ?? DEFAULT_PRICE_PER_KM); const pricePerMin = Number(process.env.DRIVE_PRICE_PER_MIN ?? DEFAULT_PRICE_PER_MIN); const currency = process.env.DRIVE_CURRENCY ?? DEFAULT_CURRENCY; const km = distanceMeters / 1000; const minutes = durationSeconds / 60; const price = baseFare + km * pricePerKm + minutes * pricePerMin; return {price: Math.round(price * 100) / 100, currency}; } interface RequestRideData { pickupLat: number; pickupLng: number; pickupAddress: string; dropoffLat: number; dropoffLng: number; dropoffAddress: string; } /** * Creates a ride request. The fare is computed here, server-side, from a * fresh Mapbox Directions call — never trusted from the client — so it's the * value persisted on the ride document. */ export const requestRide = onCall( {secrets: [mapboxAccessToken]}, async (request) => { if (!request.auth) { throw new HttpsError("unauthenticated", "Authentication required"); } const data = request.data as Partial; if ( typeof data.pickupLat !== "number" || typeof data.pickupLng !== "number" || typeof data.dropoffLat !== "number" || typeof data.dropoffLng !== "number" || !data.pickupAddress || !data.dropoffAddress ) { throw new HttpsError("invalid-argument", "pickup/dropoff lat, lng and address are required"); } let route; try { route = await fetchDrivingRoute( {lat: data.pickupLat, lng: data.pickupLng}, {lat: data.dropoffLat, lng: data.dropoffLng}, mapboxAccessToken.value(), ); } catch (e) { throw new HttpsError("internal", `Could not compute route: ${e instanceof Error ? e.message : e}`); } const {price, currency} = estimatePrice(route.distanceMeters, route.durationSeconds); const ride = await rideRepository.create({ passengerId: request.auth.uid, status: "requested", pickupLat: data.pickupLat, pickupLng: data.pickupLng, pickupAddress: data.pickupAddress, pickupGeohash: encodeGeohash(data.pickupLat, data.pickupLng, RIDE_GEOHASH_PRECISION), dropoffLat: data.dropoffLat, dropoffLng: data.dropoffLng, dropoffAddress: data.dropoffAddress, estimatedDistanceM: route.distanceMeters, estimatedDurationS: route.durationSeconds, estimatedPrice: price, currency, requestedAt: Timestamp.now(), retryCount: 0, }); return { rideId: ride.id, estimatedDistanceM: route.distanceMeters, estimatedDurationS: route.durationSeconds, estimatedPrice: price, currency, }; }, ); /** Driver accepts a pending ride — compare-and-swap, only one driver wins. */ export const acceptRide = onCall(async (request) => { if (!request.auth) { throw new HttpsError("unauthenticated", "Authentication required"); } const rideId = (request.data as { rideId?: string }).rideId; if (!rideId) { throw new HttpsError("invalid-argument", "rideId is required"); } const driver = await driverRepository.getFromId(request.auth.uid); if (!driver) { throw new HttpsError("permission-denied", "Caller has no driver profile"); } const existing = await rideRepository.getFromId(rideId); if (!existing) { throw new HttpsError("not-found", "Ride not found"); } if (existing.passengerId === request.auth.uid && !allowSelfAccept()) { throw new HttpsError( "failed-precondition", "Drivers cannot accept their own ride request", ); } const won = await rideRepository.acceptRide(rideId, request.auth.uid); if (!won) { throw new HttpsError("failed-precondition", "Ride was already accepted or no longer exists"); } return {accepted: true}; }); /** * Lists pending ride requests near the calling (online) driver. Rides * aren't directly readable by non-parties (see firestore.rules), so this is * how a driver's home screen discovers what it can accept — a server-side * geo query, not a client-side collection listener. */ export const listNearbyRideRequests = onCall(async (request) => { if (!request.auth) { throw new HttpsError("unauthenticated", "Authentication required"); } const driver = await driverRepository.getFromId(request.auth.uid); if (!driver) { throw new HttpsError("permission-denied", "Caller has no driver profile"); } if (driver.status !== "online" || driver.currentLat == null || driver.currentLng == null) { return {rides: []}; } const rides = await rideRepository.findNearbyRequested( driver.currentLat, driver.currentLng, SEARCH_RADIUS_METERS, request.auth.uid, ); const uid = request.auth.uid; const visible = allowSelfAccept() ? rides : rides.filter((ride) => ride.passengerId !== uid); return { rides: visible.map((ride) => ({ rideId: ride.id, pickupAddress: ride.pickupAddress, dropoffAddress: ride.dropoffAddress, pickupLat: ride.pickupLat, pickupLng: ride.pickupLng, dropoffLat: ride.dropoffLat, dropoffLng: ride.dropoffLng, estimatedDistanceM: ride.estimatedDistanceM, estimatedDurationS: ride.estimatedDurationS, estimatedPrice: ride.estimatedPrice, currency: ride.currency, })), }; }); /** * Driver declines a pending request — does NOT cancel it. Every other nearby * driver still sees it; this driver just stops seeing it until the passenger * retries (see `RideRepository.declineRide`). */ export const declineRide = onCall(async (request) => { if (!request.auth) { throw new HttpsError("unauthenticated", "Authentication required"); } const rideId = (request.data as { rideId?: string }).rideId; if (!rideId) { throw new HttpsError("invalid-argument", "rideId is required"); } const driver = await driverRepository.getFromId(request.auth.uid); if (!driver) { throw new HttpsError("permission-denied", "Caller has no driver profile"); } await rideRepository.declineRide(rideId, request.auth.uid); return {declined: true}; }); /** * Passenger re-broadcasts a still-pending request after no driver accepted — * e.g. following the client's "no drivers found, try again?" prompt. Clears * prior declines so drivers who said no the first time see it again. */ export const retryRide = onCall(async (request) => { if (!request.auth) { throw new HttpsError("unauthenticated", "Authentication required"); } const rideId = (request.data as { rideId?: string }).rideId; if (!rideId) { throw new HttpsError("invalid-argument", "rideId is required"); } const ride = await rideRepository.getFromId(rideId); if (!ride) { throw new HttpsError("not-found", "Ride not found"); } if (ride.passengerId !== request.auth.uid) { throw new HttpsError("permission-denied", "Only the requesting passenger can retry this ride"); } const retried = await rideRepository.retryRide(rideId); if (!retried) { throw new HttpsError("failed-precondition", "Ride was already matched, canceled or completed"); } return {retried: true}; }); async function requireRideParty(rideId: string, uid: string) { const ride = await rideRepository.getFromId(rideId); if (!ride) { throw new HttpsError("not-found", "Ride not found"); } if (ride.passengerId !== uid && ride.driverId !== uid) { throw new HttpsError("permission-denied", "Caller is not part of this ride"); } return ride; } /** Driver marks the ride as started (passenger picked up). */ export const startRide = onCall(async (request) => { if (!request.auth) { throw new HttpsError("unauthenticated", "Authentication required"); } const rideId = (request.data as { rideId?: string }).rideId; if (!rideId) { throw new HttpsError("invalid-argument", "rideId is required"); } const ride = await requireRideParty(rideId, request.auth.uid); if (ride.driverId !== request.auth.uid) { throw new HttpsError("permission-denied", "Only the assigned driver can start the ride"); } await rideRepository.startRide(rideId); return {started: true}; }); /** Marks the ride as completed (dropped off at destination). */ export const completeRide = onCall(async (request) => { if (!request.auth) { throw new HttpsError("unauthenticated", "Authentication required"); } const rideId = (request.data as { rideId?: string }).rideId; if (!rideId) { throw new HttpsError("invalid-argument", "rideId is required"); } const ride = await requireRideParty(rideId, request.auth.uid); if (ride.driverId !== request.auth.uid) { throw new HttpsError("permission-denied", "Only the assigned driver can complete the ride"); } await rideRepository.completeRide(rideId); return {completed: true}; }); /** Either party cancels the ride, before it reaches a terminal state. */ export const cancelRide = onCall(async (request) => { if (!request.auth) { throw new HttpsError("unauthenticated", "Authentication required"); } const data = request.data as { rideId?: string; reason?: string }; if (!data.rideId) { throw new HttpsError("invalid-argument", "rideId is required"); } await requireRideParty(data.rideId, request.auth.uid); const canceled = await rideRepository.cancelRide(data.rideId, data.reason); if (!canceled) { throw new HttpsError("failed-precondition", "Ride is already completed or canceled"); } return {canceled: true}; });