diff --git a/README.md b/README.md
index 849656a..9290f57 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,194 @@
+
+
+# π UrbanPulse
+
+**Production-grade ride-sharing backend** built with a distributed microservices architecture.
+
+*Real-time driver matching β’ Live GPS tracking β’ OTP-verified pickups β’ PostGIS fare calculation*
+
+[](https://www.typescriptlang.org/)
+[](https://nodejs.org/)
+[](https://www.postgresql.org/)
+[](https://redis.io/)
+[](https://socket.io/)
+
+
+
+---
+
+## What is Urban Pulse ?
+
+UrbanPulse is a **full ride-sharing backend** , think of it as the engine behind apps like Uber/Ola. A rider requests a ride, the system finds the nearest available driver, handles the entire lifecycle through completion, and calculates the fare , all in real-time.
+
+It's a distributed system with **race condition protection**, **cascading driver matching**, **cross-process event emission**, and **geospatial queries** β the same patterns used in production ride-sharing platforms.
+
+---
+
+## The Ride Flow
+
+```
+Rider requests ride
+ β
+ Nearest driver found (Redis GEOSEARCH, 5km radius)
+ β
+ Offer sent via WebSocket β 30s timeout β cascade to next driver
+ β
+ Driver accepts (distributed SETNX lock prevents double-accept)
+ β
+ 4-digit OTP generated β sent to rider
+ β
+ Driver verifies OTP at pickup β ride starts
+ β
+ Live GPS streaming (throttled, with ETA) β rider sees driver moving
+ β
+ Driver completes ride β PostGIS distance β fare calculated
+ β
+ Both see ride in history with full details
+```
+
+---
+
+## Architecture
+
+```
+ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β API Gateway β
+β Express REST API + Socket.io WebSocket Server β
+β Auth (JWT) β’ Rate Limiting β’ Zod Validation β
+ββββββββββββββββ¬βββββββββββββββββββββββ¬βββββββββββββββββ
+ β BullMQ Jobs β Redis Pub/Sub
+ βΌ βΌ
+βββββββββββββββββββββββββ ββββββββββββββββββββββ
+β Ride Worker β β Notifications β
+β Matching β’ Lifecycle β β Socket Adapter β
+β State Machine β’ OTP β β Cross-process β
+ββββββββββββ¬βββββββββββββ ββββββββββββββββββββββ
+ β
+ βββββββ΄ββββββ
+ βΌ βΌ
+βββββββββββ βββββββββββ
+β Postgresβ β Redis β
+β PostGIS β β GEO+Pub β
+βββββββββββ βββββββββββ
+```
+
+**Monorepo** (Turborepo + pnpm workspaces) with 4 packages:
+
+| Package | Role |
+|---------|------|
+| `api-gateway` | REST API + WebSocket server |
+| `ride-worker` | Background job processing (BullMQ) |
+| `common` | Shared Prisma schema, Zod schemas, constants |
+| `notifications` | Socket.io Redis adapter for cross-process events |
+
+---
+
+## Key Engineering Decisions
+
+### πΊοΈ Why Redis GEO over SQL for driver lookup?
+`GEOSEARCH` returns drivers sorted by distance in **O(log N + M)** β orders of magnitude faster than a PostGIS query scanning the drivers table. Drivers update their position every few seconds; Redis handles this write-heavy workload without touching the database.
+
+### π Why SETNX for ride acceptance?
+Two drivers could accept the same ride simultaneously. `SETNX` (set-if-not-exists) acts as a **distributed lock** β the first driver wins atomically, the second gets a clean rejection. No database race conditions.
+
+### β‘ Why BullMQ instead of direct processing?
+Ride matching involves multiple network calls (Redis lookup β filter β DB write β WebSocket emit β schedule timeout). If any step fails, BullMQ **retries automatically**. The cascade timeout (30s per driver) uses BullMQ's delayed jobs β no cron needed.
+
+### π¦ Why throttle location updates?
+A driver's phone sends GPS every ~1 second. Without throttling, that's 60 PostGIS queries + 60 WebSocket broadcasts per minute per active ride. The **SETNX 3-second throttle** cuts this to 20/minute while still updating Redis GEO position on every tick.
+
+---
+
+## Tech Stack
+
+| Layer | Technology | Why |
+|-------|-----------|-----|
+| Language | TypeScript | End-to-end type safety across all packages |
+| API | Express.js | Lightweight, middleware-driven |
+| Realtime | Socket.io + Redis Adapter | Cross-process WebSocket events |
+| Database | PostgreSQL + PostGIS | Geospatial queries (distance, points) |
+| ORM | Prisma | Type-safe DB access + migrations |
+| Cache/Geo | Redis | GEO commands, pub/sub, distributed locks |
+| Queue | BullMQ | Reliable job processing with retries |
+| Validation | Zod | Runtime schema validation |
+| Auth | JWT + bcrypt | Stateless authentication |
+| Monorepo | Turborepo + pnpm | Shared packages, parallel builds |
+| Testing | Vitest | Fast unit tests (76 passing) |
+| Infra | Docker Compose | One-command dev environment |
+
+---
+
+## API Highlights
+
+
+Authentication
+
+- `POST /auth/register` β rider or driver registration
+- `POST /auth/login` β JWT token issuance
+- `GET /user/profile` β authenticated user profile
+
+
+
+Ride Lifecycle
+
+- `POST /rides/create` β request a ride (triggers matching)
+- `POST /rides/:tripId/accept` β driver accepts (SETNX lock)
+- `POST /rides/:tripId/verify-otp` β OTP verification at pickup
+- `POST /rides/:tripId/complete` β complete ride (fare calculation)
+- `PATCH /rides/cancel` β cancel ride
+
+
+
+Dashboard
+
+- `GET /rides/history` β paginated ride history
+- `GET /rides/:tripId` β ride details
+- `GET /user/driver/stats` β total rides, earnings, distance
+- `GET /user/driver/current-ride` β active ride + rider info
+- `GET /user/rider/current-ride` β active ride + driver location
+
+
+
+WebSocket Events
+
+- `ride:offer` β driver receives ride offer
+- `ride:accepted` β rider notified of match
+- `ride:otp` β rider receives pickup OTP
+- `ride:driver-location` β live tracking with ETA
+- `ride:completed` β fare breakdown
+
+
+---
+
+## Quick Start
+
+```bash
+# Clone and start infrastructure
+git clone https://github.com/codeRisshi25/urbanpulse-backend.git
+cd urbanpulse-backend
+cp .env.example .env
+
+# Start PostgreSQL + Redis
+docker compose up -d
+
+# Install and run
+pnpm install
+pnpm run dev
+```
+
+---
+
+## Testing
+
```bash
- source activate.sh
- dcu
- # for psql client
- dc
- psql -h host -U user -d db
+pnpm test # 76 tests across all packages
+pnpm typecheck # TypeScript validation
```
-# small dev log
+A Postman collection (`UrbanPulse_Postman_Collection.json`) is included for manual API testing.
+
+---
-- postgres migration on check
-- turbo repo
+
+Built as a systems design exercise in distributed real-time architectures.
+
diff --git a/apps/api-gateway/src/routes/ride.routes.ts b/apps/api-gateway/src/routes/ride.routes.ts
index acb297e..1031d8f 100644
--- a/apps/api-gateway/src/routes/ride.routes.ts
+++ b/apps/api-gateway/src/routes/ride.routes.ts
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { validate } from '../middleware/validate.js';
-import { rideSchema, otpVerifySchema, rideCancelSchema } from 'common';
+import { rideSchema, otpVerifySchema, rideCancelSchema, rideHistoryQuerySchema, tripIdParamSchema } from 'common';
import { authenticate, authorize } from '../middleware/auth.js';
import {
createRide,
@@ -9,6 +9,9 @@ import {
rejectRide,
verifyOtp,
driverCancelRide,
+ completeRide,
+ getRideHistory,
+ getRideDetail,
} from '../services/ride.service.js';
import { getNearbyAvailableRides } from '../services/driver.service.js';
@@ -18,34 +21,47 @@ const rideRouter: Router = Router();
rideRouter.post('/create', authenticate, validate(rideSchema), async (req, res) => {
try {
- if (!req.user) {
- return res.status(401).json({ success: false, message: 'Unauthorized' });
- }
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const ride = await createRide(req.body, req.user.userId);
if (!ride.success) return res.status(400).json(ride);
return res.status(201).json(ride);
} catch (error) {
- return res.status(500).json({
- success: false,
- message: error instanceof Error ? error.message : 'Internal server error',
- });
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
}
});
rideRouter.patch('/cancel', authenticate, validate(rideCancelSchema), async (req, res) => {
try {
- if (!req.user) {
- return res.status(401).json({ success: false, message: 'Unauthorized' });
- }
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const { tripId } = req.body;
const ride = await cancelRide(req.user.userId, tripId);
if (!ride.success) return res.status(400).json(ride);
return res.status(200).json(ride);
} catch (error) {
- return res.status(500).json({
- success: false,
- message: error instanceof Error ? error.message : 'Internal server error',
- });
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
+ }
+});
+
+// βββ Ride history & detail βββββββββββββββββββββββββββββββββββββββββββββββ
+
+rideRouter.get('/history', authenticate, validate(rideHistoryQuerySchema), async (req, res) => {
+ try {
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
+ const { page, limit } = req.query as unknown as { page: number; limit: number };
+ const result = await getRideHistory(req.user.userId, req.user.role, page || 1, limit || 20);
+ return res.status(result.success ? 200 : 400).json(result);
+ } catch (error) {
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
+ }
+});
+
+rideRouter.get('/:tripId', authenticate, validate(tripIdParamSchema), async (req, res) => {
+ try {
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
+ const result = await getRideDetail(req.user.userId, req.params.tripId);
+ return res.status(result.success ? 200 : (result.message.includes('access') ? 403 : 400)).json(result);
+ } catch (error) {
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
}
});
@@ -53,84 +69,65 @@ rideRouter.patch('/cancel', authenticate, validate(rideCancelSchema), async (req
rideRouter.get('/available', authenticate, authorize('driver'), async (req, res) => {
try {
- if (!req.user) {
- return res.status(401).json({ success: false, message: 'Unauthorized' });
- }
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const result = await getNearbyAvailableRides(req.user.userId);
return res.status(result.success ? 200 : 400).json(result);
} catch (error) {
- return res.status(500).json({
- success: false,
- message: error instanceof Error ? error.message : 'Internal server error',
- });
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
}
});
rideRouter.post('/:tripId/accept', authenticate, authorize('driver'), async (req, res) => {
try {
- if (!req.user) {
- return res.status(401).json({ success: false, message: 'Unauthorized' });
- }
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const { offerId } = req.body;
- if (!offerId) {
- return res.status(400).json({ success: false, message: 'offerId is required' });
- }
+ if (!offerId) return res.status(400).json({ success: false, message: 'offerId is required' });
const result = await acceptRide(req.user.userId, req.params.tripId, offerId);
return res.status(result.success ? 200 : 400).json(result);
} catch (error) {
- return res.status(500).json({
- success: false,
- message: error instanceof Error ? error.message : 'Internal server error',
- });
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
}
});
rideRouter.post('/:tripId/reject', authenticate, authorize('driver'), async (req, res) => {
try {
- if (!req.user) {
- return res.status(401).json({ success: false, message: 'Unauthorized' });
- }
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const { offerId } = req.body;
- if (!offerId) {
- return res.status(400).json({ success: false, message: 'offerId is required' });
- }
+ if (!offerId) return res.status(400).json({ success: false, message: 'offerId is required' });
const result = await rejectRide(req.user.userId, req.params.tripId, offerId);
return res.status(result.success ? 200 : 400).json(result);
} catch (error) {
- return res.status(500).json({
- success: false,
- message: error instanceof Error ? error.message : 'Internal server error',
- });
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
}
});
rideRouter.post('/:tripId/verify-otp', authenticate, authorize('driver'), validate(otpVerifySchema), async (req, res) => {
try {
- if (!req.user) {
- return res.status(401).json({ success: false, message: 'Unauthorized' });
- }
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const result = await verifyOtp(req.user.userId, req.params.tripId, req.body.otp);
return res.status(result.success ? 200 : 400).json(result);
} catch (error) {
- return res.status(500).json({
- success: false,
- message: error instanceof Error ? error.message : 'Internal server error',
- });
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
+ }
+});
+
+rideRouter.post('/:tripId/complete', authenticate, authorize('driver'), async (req, res) => {
+ try {
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
+ const result = await completeRide(req.user.userId, req.params.tripId);
+ return res.status(result.success ? 200 : 400).json(result);
+ } catch (error) {
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
}
});
rideRouter.post('/:tripId/driver-cancel', authenticate, authorize('driver'), async (req, res) => {
try {
- if (!req.user) {
- return res.status(401).json({ success: false, message: 'Unauthorized' });
- }
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const result = await driverCancelRide(req.user.userId, req.params.tripId);
return res.status(result.success ? 200 : 400).json(result);
} catch (error) {
- return res.status(500).json({
- success: false,
- message: error instanceof Error ? error.message : 'Internal server error',
- });
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
}
});
diff --git a/apps/api-gateway/src/routes/user.routes.ts b/apps/api-gateway/src/routes/user.routes.ts
index 36c4167..3ad9fde 100644
--- a/apps/api-gateway/src/routes/user.routes.ts
+++ b/apps/api-gateway/src/routes/user.routes.ts
@@ -3,6 +3,7 @@ import { authenticate, authorize } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { getUserProfile } from '../services/auth.service.js';
import { setDriverOnline, setDriverOffline, updateDriverLocation } from '../services/driver.service.js';
+import { getDriverStats, getDriverCurrentRide, getRiderCurrentRide } from '../services/ride.service.js';
import { driverStatusSchema, driverLocationSchema } from 'common';
import logger from '../logger.js';
@@ -132,4 +133,52 @@ userRouter.post(
}
);
+/**
+ * @route GET /user/driver/stats
+ * @desc Driver statistics (total rides, earnings, distance)
+ * @access Private (driver only)
+ */
+userRouter.get('/driver/stats', authenticate, authorize('driver'), async (req: Request, res: Response) => {
+ try {
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
+ const result = await getDriverStats(req.user.userId);
+ return res.status(result.success ? 200 : 400).json(result);
+ } catch (error) {
+ logger.error(error, 'Error fetching driver stats');
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
+ }
+});
+
+/**
+ * @route GET /user/driver/current-ride
+ * @desc Driver's current active ride (ACCEPTED or STARTED)
+ * @access Private (driver only)
+ */
+userRouter.get('/driver/current-ride', authenticate, authorize('driver'), async (req: Request, res: Response) => {
+ try {
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
+ const result = await getDriverCurrentRide(req.user.userId);
+ return res.status(result.success ? 200 : 400).json(result);
+ } catch (error) {
+ logger.error(error, 'Error fetching driver current ride');
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
+ }
+});
+
+/**
+ * @route GET /user/rider/current-ride
+ * @desc Rider's current active ride (REQUESTED, ACCEPTED, or STARTED)
+ * @access Private
+ */
+userRouter.get('/rider/current-ride', authenticate, authorize('rider'), async (req: Request, res: Response) => {
+ try {
+ if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
+ const result = await getRiderCurrentRide(req.user.userId);
+ return res.status(result.success ? 200 : 400).json(result);
+ } catch (error) {
+ logger.error(error, 'Error fetching rider current ride');
+ return res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Internal server error' });
+ }
+});
+
export default userRouter;
diff --git a/apps/api-gateway/src/services/__tests__/ride.service.test.ts b/apps/api-gateway/src/services/__tests__/ride.service.test.ts
index 3c5c53f..77de6a7 100644
--- a/apps/api-gateway/src/services/__tests__/ride.service.test.ts
+++ b/apps/api-gateway/src/services/__tests__/ride.service.test.ts
@@ -4,18 +4,20 @@ const prismaMock = vi.hoisted(() => ({
user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
driver: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
rider: { findUnique: vi.fn(), create: vi.fn() },
- trip: { findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn() },
+ trip: { findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn(), aggregate: vi.fn() },
rideOffer: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
$transaction: vi.fn(),
$queryRaw: vi.fn(),
+ $queryRawUnsafe: vi.fn(),
}));
const queueAddMock = vi.hoisted(() => vi.fn().mockResolvedValue({ id: 'job-1' }));
const redisSetMock = vi.hoisted(() => vi.fn());
+const redisGeoposMock = vi.hoisted(() => vi.fn());
vi.mock('../../utils/db.js', () => ({ default: prismaMock }));
vi.mock('../../utils/redis.js', () => ({
- default: { set: redisSetMock, get: vi.fn(), del: vi.fn() },
+ default: { set: redisSetMock, get: vi.fn(), del: vi.fn(), geopos: redisGeoposMock },
}));
vi.mock('../../logger.js', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn() } }));
vi.mock('bullmq', () => {
@@ -26,7 +28,7 @@ vi.mock('bullmq', () => {
return { Queue: QueueMock };
});
-import { createRide, cancelRide, acceptRide, rejectRide, verifyOtp } from '../ride.service.js';
+import { createRide, cancelRide, acceptRide, rejectRide, verifyOtp, completeRide, getDriverStats } from '../ride.service.js';
const mockRider = { id: 'rider-1', userId: 'user-1' };
const mockDriver = { id: 'driver-1', userId: 'user-driver-1' };
@@ -232,4 +234,69 @@ describe('ride.service', () => {
);
});
});
+
+ // ββ completeRide ββββββββββββββββββββββββββββββββββββββββββββββ
+
+ describe('completeRide', () => {
+ it('returns error if ride is not in STARTED state', async () => {
+ prismaMock.driver.findUnique.mockResolvedValue(mockDriver);
+ prismaMock.trip.findUnique.mockResolvedValue({ id: 'trip-1', driverId: 'driver-1', status: 'ACCEPTED' });
+ const result = await completeRide('user-driver-1', 'trip-1');
+ expect(result.success).toBe(false);
+ expect(result.message).toMatch(/not in STARTED/i);
+ });
+
+ it('publishes COMPLETE job when ride is STARTED', async () => {
+ prismaMock.driver.findUnique.mockResolvedValue(mockDriver);
+ prismaMock.trip.findUnique.mockResolvedValue({ id: 'trip-1', driverId: 'driver-1', status: 'STARTED' });
+
+ const result = await completeRide('user-driver-1', 'trip-1');
+
+ expect(result.success).toBe(true);
+ expect(result.message).toMatch(/completion submitted/i);
+ expect(queueAddMock).toHaveBeenCalledWith(
+ 'lifecycle-complete',
+ expect.objectContaining({
+ action: 'COMPLETE',
+ tripId: 'trip-1',
+ driverId: 'driver-1',
+ }),
+ expect.objectContaining({ jobId: 'complete:trip-1' })
+ );
+ });
+
+ it('returns error if driver is not assigned to trip', async () => {
+ prismaMock.driver.findUnique.mockResolvedValue({ id: 'driver-2', userId: 'user-driver-2' });
+ prismaMock.trip.findUnique.mockResolvedValue({ id: 'trip-1', driverId: 'driver-1', status: 'STARTED' });
+ const result = await completeRide('user-driver-2', 'trip-1');
+ expect(result.success).toBe(false);
+ expect(result.message).toMatch(/not assigned/i);
+ });
+ });
+
+ // ββ getDriverStats ββββββββββββββββββββββββββββββββββββββββββββ
+
+ describe('getDriverStats', () => {
+ it('returns error if driver not found', async () => {
+ prismaMock.driver.findUnique.mockResolvedValue(null);
+ const result = await getDriverStats('user-unknown');
+ expect(result.success).toBe(false);
+ expect(result.message).toMatch(/driver not found/i);
+ });
+
+ it('returns aggregate stats for completed rides', async () => {
+ prismaMock.driver.findUnique.mockResolvedValue(mockDriver);
+ prismaMock.trip.aggregate.mockResolvedValue({
+ _count: { id: 5 },
+ _sum: { fare: 650.50, distance: 42.3 },
+ });
+
+ const result = await getDriverStats('user-driver-1');
+
+ expect(result.success).toBe(true);
+ expect(result.data?.totalRides).toBe(5);
+ expect(result.data?.totalEarnings).toBe(650.50);
+ expect(result.data?.totalDistance).toBe(42.3);
+ });
+ });
});
diff --git a/apps/api-gateway/src/services/ride.service.ts b/apps/api-gateway/src/services/ride.service.ts
index 60590c7..fca1b77 100644
--- a/apps/api-gateway/src/services/ride.service.ts
+++ b/apps/api-gateway/src/services/ride.service.ts
@@ -345,3 +345,288 @@ export const driverCancelRide = async (userId: string, tripId: string): Promise<
throw new Error('Could not cancel ride. Please try again.');
}
};
+
+// βββ COMPLETE RIDE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export const completeRide = async (userId: string, tripId: string): Promise => {
+ try {
+ const driver = await prisma.driver.findUnique({ where: { userId } });
+ if (!driver) return { success: false, message: 'Driver not found' };
+
+ const trip = await prisma.trip.findUnique({ where: { id: tripId } });
+ if (!trip) return { success: false, message: 'Trip not found' };
+ if (trip.driverId !== driver.id) return { success: false, message: 'You are not assigned to this ride' };
+ if (trip.status !== 'STARTED') return { success: false, message: 'Ride is not in STARTED state' };
+
+ await rideLifecycleQueue.add(
+ 'lifecycle-complete',
+ {
+ action: 'COMPLETE' as const,
+ tripId,
+ driverId: driver.id,
+ },
+ { jobId: `complete:${tripId}` },
+ );
+
+ return { success: true, message: 'Ride completion submitted' };
+ } catch (error) {
+ logger.error(error, 'Error completing ride');
+ throw new Error('Could not complete ride. Please try again.');
+ }
+};
+
+// βββ RIDE HISTORY βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export const getRideHistory = async (
+ userId: string,
+ role: string,
+ page: number,
+ limit: number,
+): Promise => {
+ try {
+ let whereClause = '';
+ let countClause = '';
+
+ if (role === 'rider') {
+ const rider = await prisma.rider.findUnique({ where: { userId }, select: { id: true } });
+ if (!rider) return { success: false, message: 'Rider not found' };
+ whereClause = `WHERE t."riderId" = '${rider.id}'`;
+ countClause = whereClause;
+ } else {
+ const driver = await prisma.driver.findUnique({ where: { userId }, select: { id: true } });
+ if (!driver) return { success: false, message: 'Driver not found' };
+ whereClause = `WHERE t."driverId" = '${driver.id}'`;
+ countClause = whereClause;
+ }
+
+ const offset = (page - 1) * limit;
+
+ const rides = await prisma.$queryRawUnsafe[]>(`
+ SELECT
+ t.id,
+ t.status,
+ ST_AsText(t."pickupLocation") as "pickupLocation",
+ ST_AsText(t."dropoffLocation") as "dropoffLocation",
+ t.fare,
+ t.distance,
+ t."createdAt",
+ t."completedAt"
+ FROM "Trip" t
+ ${whereClause}
+ ORDER BY t."createdAt" DESC
+ LIMIT ${limit} OFFSET ${offset}
+ `);
+
+ const countResult = await prisma.$queryRawUnsafe<{ count: bigint }[]>(`
+ SELECT COUNT(*) as count FROM "Trip" t ${countClause}
+ `);
+ const total = Number(countResult[0]?.count ?? 0);
+
+ return {
+ success: true,
+ message: 'Ride history retrieved',
+ data: {
+ rides,
+ pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
+ },
+ };
+ } catch (error) {
+ logger.error(error, 'Error fetching ride history');
+ throw new Error('Could not fetch ride history.');
+ }
+};
+
+// βββ RIDE DETAIL ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export const getRideDetail = async (userId: string, tripId: string): Promise => {
+ try {
+ // Check access: must be the rider or assigned driver
+ const rider = await prisma.rider.findUnique({ where: { userId }, select: { id: true } });
+ const driver = await prisma.driver.findUnique({ where: { userId }, select: { id: true } });
+
+ const trip = await prisma.$queryRaw[]>`
+ SELECT
+ t.id,
+ t."riderId",
+ t."driverId",
+ t.status,
+ t.otp,
+ t.fare,
+ t.distance,
+ ST_AsText(t."pickupLocation") as "pickupLocation",
+ ST_AsText(t."dropoffLocation") as "dropoffLocation",
+ t."createdAt",
+ t."completedAt"
+ FROM "Trip" t WHERE t.id = ${tripId}
+ `;
+
+ if (!trip.length) return { success: false, message: 'Trip not found' };
+
+ const tripData = trip[0];
+ const isRider = rider && tripData.riderId === rider.id;
+ const isDriver = driver && tripData.driverId === driver.id;
+
+ if (!isRider && !isDriver) {
+ return { success: false, message: 'You do not have access to this ride' };
+ }
+
+ // OTP should only be visible to the rider, never the driver
+ if (!isRider) {
+ delete tripData.otp;
+ }
+
+ return {
+ success: true,
+ message: 'Ride detail retrieved',
+ data: tripData,
+ };
+ } catch (error) {
+ logger.error(error, 'Error fetching ride detail');
+ throw new Error('Could not fetch ride detail.');
+ }
+};
+
+// βββ DRIVER STATS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export const getDriverStats = async (userId: string): Promise => {
+ try {
+ const driver = await prisma.driver.findUnique({ where: { userId }, select: { id: true } });
+ if (!driver) return { success: false, message: 'Driver not found' };
+
+ const stats = await prisma.trip.aggregate({
+ where: { driverId: driver.id, status: 'COMPLETED' },
+ _count: { id: true },
+ _sum: { fare: true, distance: true },
+ });
+
+ return {
+ success: true,
+ message: 'Driver stats retrieved',
+ data: {
+ totalRides: stats._count.id,
+ totalEarnings: stats._sum.fare ?? 0,
+ totalDistance: stats._sum.distance ?? 0,
+ },
+ };
+ } catch (error) {
+ logger.error(error, 'Error fetching driver stats');
+ throw new Error('Could not fetch driver stats.');
+ }
+};
+
+// βββ DRIVER CURRENT RIDE βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export const getDriverCurrentRide = async (userId: string): Promise => {
+ try {
+ const driver = await prisma.driver.findUnique({ where: { userId }, select: { id: true } });
+ if (!driver) return { success: false, message: 'Driver not found' };
+
+ const trip = await prisma.$queryRaw[]>`
+ SELECT
+ t.id,
+ t."riderId",
+ t.status,
+ ST_AsText(t."pickupLocation") as "pickupLocation",
+ ST_AsText(t."dropoffLocation") as "dropoffLocation",
+ t."createdAt",
+ r."userId" as "riderUserId"
+ FROM "Trip" t
+ JOIN "Rider" r ON r.id = t."riderId"
+ WHERE t."driverId" = ${driver.id}
+ AND t.status IN ('ACCEPTED', 'STARTED')
+ ORDER BY t."createdAt" DESC
+ LIMIT 1
+ `;
+
+ if (!trip.length) {
+ return { success: true, message: 'No active ride', data: { ride: null } };
+ }
+
+ // Get rider basic info
+ const riderUser = await prisma.user.findUnique({
+ where: { id: trip[0].riderUserId as string },
+ select: { name: true, number: true },
+ });
+
+ return {
+ success: true,
+ message: 'Current ride retrieved',
+ data: { ride: { ...trip[0], riderName: riderUser?.name, riderPhone: riderUser?.number } },
+ };
+ } catch (error) {
+ logger.error(error, 'Error fetching driver current ride');
+ throw new Error('Could not fetch current ride.');
+ }
+};
+
+// βββ RIDER CURRENT RIDE ββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export const getRiderCurrentRide = async (userId: string): Promise => {
+ try {
+ const rider = await prisma.rider.findUnique({ where: { userId }, select: { id: true } });
+ if (!rider) return { success: false, message: 'Rider not found' };
+
+ const trip = await prisma.$queryRaw[]>`
+ SELECT
+ t.id,
+ t."driverId",
+ t.status,
+ t.otp,
+ ST_AsText(t."pickupLocation") as "pickupLocation",
+ ST_AsText(t."dropoffLocation") as "dropoffLocation",
+ t."createdAt"
+ FROM "Trip" t
+ WHERE t."riderId" = ${rider.id}
+ AND t.status IN ('REQUESTED', 'ACCEPTED', 'STARTED')
+ ORDER BY t."createdAt" DESC
+ LIMIT 1
+ `;
+
+ if (!trip.length) {
+ return { success: true, message: 'No active ride', data: { ride: null } };
+ }
+
+ const tripData = trip[0] as Record;
+
+ // If driver is assigned, get their info + location
+ if (tripData.driverId) {
+ const driverRecord = await prisma.driver.findUnique({
+ where: { id: tripData.driverId as string },
+ select: { userId: true },
+ });
+ if (driverRecord) {
+ const driverUser = await prisma.user.findUnique({
+ where: { id: driverRecord.userId },
+ select: { name: true, number: true },
+ });
+ tripData.driverName = driverUser?.name;
+ tripData.driverPhone = driverUser?.number;
+
+ // If STARTED, include driver's current location from Redis GEO
+ if (tripData.status === 'STARTED') {
+ try {
+ const pos = await redis.geopos('drivers:active', driverRecord.userId);
+ const coords = pos?.[0];
+ if (coords?.[0] != null && coords?.[1] != null) {
+ tripData.driverLocation = {
+ lng: parseFloat(coords[0] as string),
+ lat: parseFloat(coords[1] as string),
+ };
+ }
+ } catch {
+ // Redis GEO might not have position β that's ok
+ }
+ }
+ }
+ }
+
+ return {
+ success: true,
+ message: 'Current ride retrieved',
+ data: { ride: tripData },
+ };
+ } catch (error) {
+ logger.error(error, 'Error fetching rider current ride');
+ throw new Error('Could not fetch current ride.');
+ }
+};
diff --git a/apps/api-gateway/src/sockets/__tests__/driver-handler.test.ts b/apps/api-gateway/src/sockets/__tests__/driver-handler.test.ts
index 2f7f82e..c411263 100644
--- a/apps/api-gateway/src/sockets/__tests__/driver-handler.test.ts
+++ b/apps/api-gateway/src/sockets/__tests__/driver-handler.test.ts
@@ -17,6 +17,16 @@ vi.mock('../../services/ride.service.js', () => ({
acceptRide: acceptRideMock,
rejectRide: rejectRideMock,
}));
+vi.mock('../../utils/redis.js', () => ({
+ default: { set: vi.fn(), geopos: vi.fn() },
+}));
+vi.mock('../../utils/db.js', () => ({
+ default: {
+ driver: { findUnique: vi.fn() },
+ trip: { findFirst: vi.fn() },
+ $queryRaw: vi.fn(),
+ },
+}));
vi.mock('../../logger.js', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
@@ -30,6 +40,7 @@ const makeSocket = (userId: string): Socket & { _trigger: (e: string, ...a: unkn
data: { user: { userId, role: 'driver' } },
join: vi.fn().mockResolvedValue(undefined),
emit: vi.fn(),
+ to: vi.fn().mockReturnValue({ emit: vi.fn() }),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
listeners[event] = handler;
}),
diff --git a/apps/api-gateway/src/sockets/handlers/driver.ts b/apps/api-gateway/src/sockets/handlers/driver.ts
index 46ad4eb..1df855f 100644
--- a/apps/api-gateway/src/sockets/handlers/driver.ts
+++ b/apps/api-gateway/src/sockets/handlers/driver.ts
@@ -2,7 +2,15 @@ import type { Socket } from 'socket.io';
import { setDriverOnline, setDriverOffline, updateDriverLocation } from '../../services/driver.service.js';
import { acceptRide, rejectRide } from '../../services/ride.service.js';
import type { JwtPayload } from '../../utils/jwt.js';
+import redis from '../../utils/redis.js';
+import prisma from '../../utils/db.js';
import logger from '../../logger.js';
+import {
+ LOCATION_THROTTLE_SECONDS,
+ LOCATION_THROTTLE_KEY_PREFIX,
+ CITY_AVG_SPEED_KMH,
+ DRIVERS_GEO_KEY,
+} from 'common';
/**
* Register driver-specific socket event handlers.
@@ -11,7 +19,7 @@ import logger from '../../logger.js';
* Events:
* driver:go-online { lng, lat } β set online in DB + Redis GEO
* driver:go-offline {} β set offline in DB + remove from Redis GEO
- * driver:location-update { lng, lat } β update Redis GEO position
+ * driver:location-update { lng, lat } β throttled update + broadcast to ride room
* driver:accept-ride { tripId, offerId } β accept ride offer (SETNX lock)
* driver:reject-ride { tripId, offerId } β reject ride offer β cascade
*/
@@ -42,9 +50,54 @@ export const registerDriverHandlers = (socket: Socket): void => {
}
});
- socket.on('driver:location-update', async (data: { lng: number; lat: number }) => {
+ socket.on('driver:location-update', async (data: { lng: number; lat: number; heading?: number; speed?: number }) => {
try {
+ // 1. Always update Redis GEO position
await updateDriverLocation(user.userId, [data.lng, data.lat]);
+
+ // 2. Throttle broadcast: SETNX with 3s TTL β drop if key exists
+ const throttleKey = `${LOCATION_THROTTLE_KEY_PREFIX}${user.userId}`;
+ const allowed = await redis.set(throttleKey, '1', 'EX', LOCATION_THROTTLE_SECONDS, 'NX');
+ if (!allowed) return; // Throttled β skip broadcast
+
+ // 3. Find driver's active STARTED trip
+ const driver = await prisma.driver.findUnique({ where: { userId: user.userId }, select: { id: true } });
+ if (!driver) return;
+
+ const activeTrip = await prisma.trip.findFirst({
+ where: { driverId: driver.id, status: 'STARTED' },
+ select: { id: true },
+ });
+ if (!activeTrip) return; // No active ride β no broadcast needed
+
+ // 4. Calculate remaining distance: ST_DistanceSphere(current, dropoff)
+ let remainingDistanceKm = 0;
+ let etaMinutes = 0;
+ try {
+ const distResult = await prisma.$queryRaw<{ distance_meters: number }[]>`
+ SELECT ST_DistanceSphere(
+ ST_SetSRID(ST_MakePoint(${data.lng}, ${data.lat}), 4326)::geometry,
+ "dropoffLocation"::geometry
+ ) as distance_meters
+ FROM "Trip" WHERE id = ${activeTrip.id}
+ `;
+ const meters = distResult[0]?.distance_meters ?? 0;
+ remainingDistanceKm = Math.round((meters / 1000) * 100) / 100;
+ etaMinutes = Math.round((remainingDistanceKm / CITY_AVG_SPEED_KMH) * 60 * 10) / 10;
+ } catch {
+ // PostGIS query might fail β still broadcast location without ETA
+ }
+
+ // 5. Broadcast to ride:{tripId} room
+ socket.to(`ride:${activeTrip.id}`).emit('ride:driver-location', {
+ lng: data.lng,
+ lat: data.lat,
+ heading: data.heading,
+ speed: data.speed,
+ remainingDistanceKm,
+ etaMinutes,
+ timestamp: new Date().toISOString(),
+ });
} catch (err) {
logger.error({ err, userId: user.userId }, 'Error handling driver:location-update');
}
@@ -55,7 +108,6 @@ export const registerDriverHandlers = (socket: Socket): void => {
const result = await acceptRide(user.userId, data.tripId, data.offerId);
socket.emit('driver:accept-ride:ack', result);
if (result.success) {
- // Auto-join the ride room
void socket.join(`ride:${data.tripId}`);
}
} catch (err) {
diff --git a/apps/ride-worker/src/workers/ride-lifecycle.worker.ts b/apps/ride-worker/src/workers/ride-lifecycle.worker.ts
index 75c784a..407055b 100644
--- a/apps/ride-worker/src/workers/ride-lifecycle.worker.ts
+++ b/apps/ride-worker/src/workers/ride-lifecycle.worker.ts
@@ -11,6 +11,9 @@ import {
OTP_TTL_SECONDS,
MAX_OTP_ATTEMPTS,
RIDE_LOCK_KEY_PREFIX,
+ BASE_FARE,
+ PER_KM_RATE,
+ MIN_FARE,
} from 'common';
const prisma = new PrismaClient();
@@ -262,6 +265,100 @@ const handleCancel = async (data: RideLifecycleJobData): Promise => {
logger.info({ tripId, reason }, 'Ride CANCELLED');
};
+// βββ COMPLETE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const handleComplete = async (data: RideLifecycleJobData): Promise => {
+ const { tripId, driverId } = data;
+
+ const trip = await prisma.trip.findUnique({ where: { id: tripId } });
+ if (!trip) throw new Error(`Trip ${tripId} not found`);
+
+ // Idempotent: if already COMPLETED (e.g. BullMQ retry after partial failure),
+ // skip validation + DB update but still run cleanup/notifications
+ const alreadyCompleted = trip.status === 'COMPLETED';
+ if (!alreadyCompleted) {
+ validateTransition(trip.status, 'COMPLETED');
+ }
+
+ const completedAt = trip.completedAt ?? new Date();
+
+ // 1. Calculate distance using PostGIS ST_DistanceSphere
+ const distanceResult = await prisma.$queryRaw<{ distance_meters: number }[]>`
+ SELECT ST_DistanceSphere(
+ "pickupLocation"::geometry,
+ "dropoffLocation"::geometry
+ ) as distance_meters
+ FROM "Trip" WHERE id = ${tripId}
+ `;
+
+ const distanceMeters = distanceResult[0]?.distance_meters ?? 0;
+ const distanceKm = distanceMeters / 1000;
+
+ // 2. Calculate fare: max(MIN_FARE, BASE_FARE + distance_km * PER_KM_RATE)
+ const calculatedFare = BASE_FARE + distanceKm * PER_KM_RATE;
+ const fare = Math.round(Math.max(MIN_FARE, calculatedFare) * 100) / 100;
+
+ // 3. Update Trip: COMPLETED, fare, distance, completedAt (skip if already done)
+ if (!alreadyCompleted) {
+ await prisma.trip.update({
+ where: { id: tripId },
+ data: {
+ status: 'COMPLETED',
+ fare,
+ distance: Math.round(distanceKm * 100) / 100,
+ completedAt,
+ },
+ });
+ }
+
+ // 4. Remove driver from busy set
+ if (trip.driverId) {
+ const driver = await prisma.driver.findUnique({
+ where: { id: trip.driverId },
+ select: { userId: true },
+ });
+ if (driver) {
+ await redis.srem(DRIVERS_BUSY_KEY, driver.userId);
+ }
+ }
+
+ // 5. Clean up Redis keys
+ await redis.del(`${RIDE_LOCK_KEY_PREFIX}${tripId}`);
+ await redis.del(`${OTP_KEY_PREFIX}${tripId}`);
+ await redis.del(`${OTP_ATTEMPTS_KEY_PREFIX}${tripId}`);
+
+ // 6. Get pickup/dropoff as text for notification
+ const locations = await prisma.$queryRaw<{ pickup: string; dropoff: string }[]>`
+ SELECT ST_AsText("pickupLocation") as pickup, ST_AsText("dropoffLocation") as dropoff
+ FROM "Trip" WHERE id = ${tripId}
+ `;
+
+ // 7. Notify ride room
+ await emitToRoom(`ride:${tripId}`, 'ride:completed', {
+ tripId,
+ fare,
+ distanceKm: Math.round(distanceKm * 100) / 100,
+ pickupLocation: locations[0]?.pickup ?? '',
+ dropoffLocation: locations[0]?.dropoff ?? '',
+ completedAt: completedAt.toISOString(),
+ });
+
+ // Also notify rider personal room
+ const rider = await prisma.rider.findUnique({
+ where: { id: trip.riderId },
+ select: { userId: true },
+ });
+ if (rider) {
+ await emitToRoom(`rider:${rider.userId}`, 'ride:completed', {
+ tripId,
+ fare,
+ distanceKm: Math.round(distanceKm * 100) / 100,
+ });
+ }
+
+ logger.info({ tripId, fare, distanceKm, driverId }, 'Ride COMPLETED');
+};
+
// βββ MAIN PROCESSOR ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const processRideLifecycle = async (job: Job) => {
@@ -281,7 +378,7 @@ const processRideLifecycle = async (job: Job) => {
logger.info({ tripId }, 'START action β handled via VERIFY_OTP flow');
break;
case 'COMPLETE':
- logger.info({ tripId }, 'COMPLETE action β will be implemented in M5');
+ await handleComplete(job.data);
break;
case 'CANCEL':
await handleCancel(job.data);
diff --git a/learn/m5-tracking-completion.md b/learn/m5-tracking-completion.md
new file mode 100644
index 0000000..1862228
--- /dev/null
+++ b/learn/m5-tracking-completion.md
@@ -0,0 +1,123 @@
+# M5 β Live Location Tracking & Ride Completion
+
+> **Branch:** `feature/m5-tracking-completion` | **Closes:** Issue #10
+
+---
+
+## What this milestone does
+
+M5 completes the end-to-end ride-sharing flow. After a ride is STARTED (M4), the driver streams live location to the rider, and when the ride ends, the system calculates the fare using PostGIS.
+
+---
+
+## Throttled Location Broadcast
+
+```text
+Driver GPS update (every ~1s)
+ β
+ βΌ
+driver:location-update { lng, lat, heading?, speed? }
+ β
+ βββ Always: GEOADD Redis GEO (update position)
+ β
+ βββ SETNX driver:location-throttle:{userId} β 3s TTL
+ β βββ OK (allowed): proceed to broadcast
+ β βββ null (throttled): skip broadcast (update still saved)
+ β
+ βββ Look up driver's STARTED trip
+ β
+ βββ PostGIS: ST_DistanceSphere(currentPos, dropoff) β remaining km
+ β
+ βββ ETA: remainingKm / 30 km/h * 60 β minutes
+ β
+ βββ socket.to(ride:{tripId}).emit('ride:driver-location', {
+ lng, lat, heading, speed,
+ remainingDistanceKm, etaMinutes,
+ timestamp
+ })
+```
+
+### Why throttle?
+Without throttling, a driver sending GPS every second = 60 PostGIS queries/minute + 60 socket broadcasts. With 3s throttle: 20/minute β 3x reduction.
+
+---
+
+## Ride Completion β COMPLETE Action
+
+```text
+POST /rides/:tripId/complete (driver)
+ β
+ βΌ
+lifecycle queue β ride-lifecycle worker
+ β
+ βββ 1. Validate STARTED β COMPLETED transition
+ βββ 2. PostGIS: ST_DistanceSphere(pickup, dropoff) β meters
+ βββ 3. Fare: max(50, 50 + distanceKm Γ 12) β round to 2 decimals
+ βββ 4. Update Trip: status=COMPLETED, fare, distance, completedAt
+ βββ 5. Remove driver from drivers:busy set
+ βββ 6. Cleanup Redis: lock, OTP keys
+ βββ 7. Notify ride:{tripId} room + rider room β ride:completed
+```
+
+| Constant | Value | Purpose |
+|----------|-------|---------|
+| BASE_FARE | 50 | Minimum base charge |
+| PER_KM_RATE | 12 | Rate per kilometer |
+| MIN_FARE | 50 | Floor fare |
+| CITY_AVG_SPEED_KMH | 30 | ETA estimation speed |
+
+---
+
+## New REST Endpoints
+
+| Method | Path | Auth | Purpose |
+|--------|------|------|---------|
+| POST | `/rides/:tripId/complete` | driver | Complete ride β fare calculation |
+| GET | `/rides/history` | any | Paginated ride history (page/limit) |
+| GET | `/rides/:tripId` | owner | Single ride detail (rider or driver) |
+| GET | `/user/driver/stats` | driver | Total rides, earnings, distance |
+| GET | `/user/driver/current-ride` | driver | Active ACCEPTED/STARTED ride |
+| GET | `/user/rider/current-ride` | rider | Active REQUESTED/ACCEPTED/STARTED ride |
+
+---
+
+## Ride History β PostGIS + Raw SQL
+
+Standard Prisma `findMany` can't handle geometry fields. Solution: `$queryRaw` with `ST_AsText`:
+
+```sql
+SELECT t.id, t.status,
+ ST_AsText(t."pickupLocation") as "pickupLocation",
+ ST_AsText(t."dropoffLocation") as "dropoffLocation",
+ t.fare, t.distance, t."createdAt", t."completedAt"
+FROM "Trip" t WHERE t."riderId" = $1
+ORDER BY t."createdAt" DESC LIMIT 20 OFFSET 0
+```
+
+---
+
+## Rider Current Ride β Driver Location from Redis
+
+When a rider checks their active ride during `STARTED` status, the system fetches the driver's live position from Redis GEO:
+
+```ts
+const pos = await redis.geopos('drivers:active', driverUserId);
+// β [[lng, lat]] or [[null, null]]
+```
+
+This avoids DB queries and gives real-time position.
+
+---
+
+## Complete Flow (M1βM5)
+
+```text
+1. Rider: POST /rides/create β REQUESTED
+2. Worker: cascade match β nearest driver β ride:offer
+3. Driver: driver:accept-ride β SETNX lock β ACCEPTED
+4. Worker: OTP generated β ride:otp β rider
+5. Driver arrives β rider shares OTP β POST /rides/:tripId/verify-otp β STARTED
+6. Driver streams location β throttled broadcast β ride:driver-location
+7. Driver: POST /rides/:tripId/complete β COMPLETED (fare + distance)
+8. Both: GET /rides/history β see past rides
+```
diff --git a/packages/common/constants/fare.ts b/packages/common/constants/fare.ts
new file mode 100644
index 0000000..1039155
--- /dev/null
+++ b/packages/common/constants/fare.ts
@@ -0,0 +1,19 @@
+/** Fare calculation constants. */
+
+/** Base fare in currency units. */
+export const BASE_FARE = 50;
+
+/** Rate per kilometer. */
+export const PER_KM_RATE = 12;
+
+/** Minimum fare (never below base fare). */
+export const MIN_FARE = 50;
+
+/** Average city speed in km/h for ETA estimation. */
+export const CITY_AVG_SPEED_KMH = 30;
+
+/** Throttle interval for driver location updates (seconds). */
+export const LOCATION_THROTTLE_SECONDS = 3;
+
+/** Redis key prefix for location throttle β full key: `driver:location-throttle:{userId}` */
+export const LOCATION_THROTTLE_KEY_PREFIX = 'driver:location-throttle:';
diff --git a/packages/common/index.ts b/packages/common/index.ts
index fbf9a25..914ed28 100644
--- a/packages/common/index.ts
+++ b/packages/common/index.ts
@@ -2,5 +2,6 @@ export * from './schemas/index.js';
export * from './queues/index.js';
export * from './events/index.js';
export * from './constants/matching.js';
+export * from './constants/fare.js';
export { PrismaClient } from '@prisma/client';
diff --git a/packages/common/schemas/history.schema.ts b/packages/common/schemas/history.schema.ts
new file mode 100644
index 0000000..9732eae
--- /dev/null
+++ b/packages/common/schemas/history.schema.ts
@@ -0,0 +1,20 @@
+import { z } from 'zod';
+
+/** Schema for ride history query params (pagination). */
+export const rideHistoryQuerySchema = z.object({
+ query: z.object({
+ page: z.coerce.number().int().min(1).default(1),
+ limit: z.coerce.number().int().min(1).max(50).default(20),
+ }),
+});
+
+export type RideHistoryQuery = z.infer['query'];
+
+/** Schema for :tripId route param validation. */
+export const tripIdParamSchema = z.object({
+ params: z.object({
+ tripId: z.string().uuid('tripId must be a valid UUID'),
+ }),
+});
+
+export type TripIdParam = z.infer['params'];
diff --git a/packages/common/schemas/index.ts b/packages/common/schemas/index.ts
index 76fdf25..920254b 100644
--- a/packages/common/schemas/index.ts
+++ b/packages/common/schemas/index.ts
@@ -2,3 +2,4 @@ export * from './user.schema.js';
export * from './ride.schema.js';
export * from './driver.schema.js';
export * from './otp.schema.js';
+export * from './history.schema.js';