import {FieldValue, Timestamp} from "firebase-admin/firestore"; import {NewRideEntityData, RideAdapter, RideEntity} from "../entities/ride_entity"; import {distanceMeters, encodeGeohash, geohashNeighbors} from "../geohash"; // Same coarse cell size as DriverRepository's query precision — the two // collections are queried from the same "3x3 neighbor grid" pattern. const GEOHASH_QUERY_PRECISION = 5; // Passenger-facing "try again" cap after "no drivers found" — matches the // client's own limit (`RideTrackingPage._maxRetries`). Once a ride hits this, // `retryRide` refuses and the app shows a final "no drivers available" state // instead of an endless retry loop. const MAX_RIDE_RETRIES = 2; export class RideRepository { constructor(private db: FirebaseFirestore.Firestore) {} private collection() { return this.db.collection("rides"); } async getFromId(rideId: string): Promise { const snapshot = await this.collection().doc(rideId).get(); if (!snapshot.exists) return null; return RideEntity.fromDocument(snapshot as FirebaseFirestore.QueryDocumentSnapshot); } async create(data: NewRideEntityData): Promise { const ref = this.collection().doc(); await ref.set(RideAdapter.toMap(data)); return new RideEntity({...data, id: ref.id}); } /** * Atomically assigns a driver to a ride — the compare-and-swap that * guarantees only ONE driver can win a given ride request. Two drivers * tapping "accept" at the same moment race the same Firestore transaction; * whichever commits first wins, the other reads a `status` that is no * longer "requested" and gets `false`. Same shape as * `UserRepository.claimWelcome`. */ async acceptRide(rideId: string, driverId: string): Promise { const ref = this.collection().doc(rideId); return this.db.runTransaction(async (tx) => { const snapshot = await tx.get(ref); if (!snapshot.exists) return false; if (snapshot.get("status") !== "requested") return false; tx.update(ref, { driverId, status: "accepted", acceptedAt: Timestamp.now(), }); return true; }); } /** Marks the ride as started (driver picked up the passenger). */ async startRide(rideId: string): Promise { await this.collection().doc(rideId).update({ status: "in_progress", startedAt: Timestamp.now(), }); } /** Marks the ride as completed (driver dropped off the passenger). */ async completeRide(rideId: string): Promise { await this.collection().doc(rideId).update({ status: "completed", completedAt: Timestamp.now(), }); } /** * Finds pending ride requests within `radiusMeters` of (lat, lng), for a * driver deciding what to accept. Rides aren't readable directly by * non-parties (see firestore.rules), so this is the only path a driver has * to discover a nearby request — same geohash neighbor-cell pre-filter as * `DriverRepository.findNearbyOnline`, then an exact haversine cut. * * `declinedByDriverId` drops rides that driver already declined — a * decline never changes `status`, so without this filter the same request * would come right back on their next poll. Filtered in memory (not a * Firestore query): `array-contains` can't be combined with the geohash * range filter above in one query, and the candidate set per cell is * already small. */ async findNearbyRequested( lat: number, lng: number, radiusMeters: number, declinedByDriverId?: string, ): Promise { const cells = geohashNeighbors(encodeGeohash(lat, lng, GEOHASH_QUERY_PRECISION)); const snapshots = await Promise.all( cells.map((cell) => this.collection() .where("status", "==", "requested") .where("pickupGeohash", ">=", cell) .where("pickupGeohash", "<", cell + "~") .get() ) ); const byId = new Map(); for (const snapshot of snapshots) { for (const doc of snapshot.docs) { byId.set(doc.id, RideEntity.fromDocument(doc)); } } return Array.from(byId.values()).filter( (ride) => distanceMeters(lat, lng, ride.pickupLat, ride.pickupLng) <= radiusMeters && (!declinedByDriverId || !(ride.declinedDriverIds ?? []).includes(declinedByDriverId)) ); } /** * Driver declines a pending request. This is NOT a cancellation — `status` * stays "requested" so every other nearby driver still sees it; only the * declining driver's own uid is recorded so it stops reappearing for them * (see `findNearbyRequested`). Silently no-ops once the ride has moved past * "requested" (already matched / canceled) — nothing left to decline. */ async declineRide(rideId: string, driverId: string): Promise { const ref = this.collection().doc(rideId); await this.db.runTransaction(async (tx) => { const snapshot = await tx.get(ref); if (!snapshot.exists || snapshot.get("status") !== "requested") return; tx.update(ref, {declinedDriverIds: FieldValue.arrayUnion(driverId)}); }); } /** * Passenger re-broadcasts a request no driver has accepted yet: clears * every prior decline (so drivers who said no the first time get another * chance) and bumps `requestedAt` so a fresh "how long has this been * waiting" countdown starts client-side. Only valid while still * "requested" — once a driver has accepted there is nothing to retry. * Refuses once `retryCount` reaches `MAX_RIDE_RETRIES` — the client is * expected to stop offering "try again" at that point, this is the * server-side backstop. */ async retryRide(rideId: string): Promise { const ref = this.collection().doc(rideId); return this.db.runTransaction(async (tx) => { const snapshot = await tx.get(ref); if (!snapshot.exists || snapshot.get("status") !== "requested") return false; const retryCount = (snapshot.get("retryCount") as number | undefined) ?? 0; if (retryCount >= MAX_RIDE_RETRIES) return false; tx.update(ref, { declinedDriverIds: [], requestedAt: Timestamp.now(), retryCount: retryCount + 1, }); return true; }); } /** Cancels a ride — only valid before it reaches a terminal state. */ async cancelRide(rideId: string, reason?: string): Promise { const ref = this.collection().doc(rideId); return this.db.runTransaction(async (tx) => { const snapshot = await tx.get(ref); if (!snapshot.exists) return false; const status = snapshot.get("status"); if (status === "completed" || status === "canceled") return false; tx.update(ref, { status: "canceled", canceledAt: Timestamp.now(), cancelReason: reason ?? null, }); return true; }); } }