diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..cfee818 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,90 @@ +# UrbanPulse Backend – Copilot Instructions + +## Architecture + +Turborepo + pnpm monorepo for a ride-sharing backend. Three workspace packages: + +- **`apps/api-gateway`** – Express 5 HTTP server (port 3001). All REST endpoints live here: auth, user, rides. +- **`apps/ride-worker`** – Background worker for ride-related processing (BullMQ). Consumes shared types and Prisma client from `packages/common`. +- **`packages/common`** – Shared Prisma client, Zod validation schemas, and type exports. Consumed via `"common": "workspace:^"`. + +Data flows: **Route → validate middleware (Zod) → authenticate middleware (JWT) → service → Prisma/raw SQL → PostgreSQL+PostGIS**. + +## Key Conventions + +### Module System + +ESM-only (`"type": "module"` everywhere). All local imports **must** use `.js` extensions (e.g., `import prisma from '../utils/db.js'`). TypeScript compiles with `"module": "nodenext"`. + +### Validation Pattern + +Zod schemas in `packages/common/schemas/` wrap `body`, `query`, `params` keys. The `validate()` middleware in `apps/api-gateway/src/middleware/validate.ts` calls `schema.parseAsync({ body, query, params })`. When adding a new schema, follow this structure: + +```ts +export const mySchema = z.object({ + body: z.object({ + /* fields */ + }), +}); +export type MyInput = z.infer['body']; +``` + +Export from `packages/common/schemas/index.ts` and import in routes via `import { mySchema } from 'common'`. + +### API Response Format + +All endpoints return `{ success: boolean, message: string, data?: ... }`. Errors include `errors` array for validation failures. Follow this in every route handler and service return type. + +### Auth & Authorization + +JWT via `Bearer` token. Middleware chain: `authenticate` (verifies token, sets `req.user: JwtPayload`) → optional `authorize('driver' | 'rider')`. `JwtPayload` contains `{ userId, number, role }`. + +### PostGIS / Spatial Data + +Prisma schema uses `Unsupported("geometry(Point,4326)")` for location fields. These **cannot** use standard Prisma CRUD – use `prisma.$queryRaw` with `ST_GeomFromText` / `ST_AsText` for spatial operations (see `ride.service.ts`). Coordinates are `[longitude, latitude]`. + +### Database + +Single Prisma client instance in `apps/api-gateway/src/utils/db.ts` (cached on `globalThis` in dev). Schema lives at `packages/common/prisma/schema.prisma`. Models: `User` ↔ `Driver`/`Rider` (1:1) → `Trip`. + +### Logging + +Pino logger (`apps/api-gateway/src/logger.ts`). Use `pino-pretty` in dev. Always use structured logging: `logger.info({ userId }, 'message')` not string interpolation. + +## Developer Workflow + +```bash +# Setup & run (Docker-based, includes PostGIS + Redis) +source activate.sh # loads aliases: dcu, dcd, dcr, dcl, dc +dcu # docker-compose up -d (postgres + redis + api-gateway + ride-worker) +dcl # tail logs + +# Build & typecheck +pnpm build # turbo build (common first, then api-gateway and ride-worker) +pnpm typecheck # turbo typecheck + +# DB migrations (run inside container or with DATABASE_URL set) +cd packages/common +npx prisma migrate dev --name +npx prisma generate # auto-runs on postinstall +``` + +## Adding a New Feature Checklist + +1. **Schema** – If new Zod validation is needed, add to `packages/common/schemas/`, export from `index.ts` +2. **Prisma** – If new model/field, edit `packages/common/prisma/schema.prisma`, run `prisma migrate dev` +3. **Service** – Add business logic in `apps/api-gateway/src/services/` with typed return interface +4. **Route** – Add route file in `apps/api-gateway/src/routes/`, wire middleware chain (`authenticate`, `validate(schema)`) +5. **Register** – Mount the router in `apps/api-gateway/src/routes.ts` +6. **Rebuild common** – After changing `packages/common`, run `pnpm build` so `dist/` is updated for the gateway + +## Environment Variables + +Passed via `.env` and Docker Compose. Key vars (see `turbo.json` passthrough): `DATABASE_URL`, `REDIS_HOST`, `REDIS_PORT`, `APP_PORT`, `JWT_SECRET`, `NODE_ENV`, `LOGGING_TOKEN`, `LOGGING_URL`. + +## File Naming + +- Route files: `.routes.ts` (e.g., `ride.routes.ts`) +- Service files: `.service.ts` +- Schema files: `.schema.ts` +- All in lowercase, kebab-case for multi-word diff --git a/.gitignore b/.gitignore index 213f0a2..aa94772 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,3 @@ tsconfig.tsbuildinfo docs/ .vscode/ .turbo/ -learn/ \ No newline at end of file diff --git a/apps/api-gateway/package.json b/apps/api-gateway/package.json index d02b2e8..b2e61d4 100644 --- a/apps/api-gateway/package.json +++ b/apps/api-gateway/package.json @@ -37,6 +37,7 @@ "@prisma/client": "^6.17.1", "bcrypt": "^6.0.0", "body-parser": "^2.2.0", + "bullmq": "^5.56.0", "common": "workspace:^", "compression": "^1.8.1", "cors": "^2.8.5", 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 7ea6608..f6bc2f7 100644 --- a/apps/api-gateway/src/services/__tests__/ride.service.test.ts +++ b/apps/api-gateway/src/services/__tests__/ride.service.test.ts @@ -9,8 +9,17 @@ const prismaMock = vi.hoisted(() => ({ $queryRaw: vi.fn(), })); +const queueAddMock = vi.hoisted(() => vi.fn().mockResolvedValue({ id: 'job-1' })); + vi.mock('../../utils/db.js', () => ({ default: prismaMock })); vi.mock('../../logger.js', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn() } })); +vi.mock('bullmq', () => { + const QueueMock = vi.fn(function (this: Record) { + this.add = queueAddMock; + this.close = vi.fn().mockResolvedValue(undefined); + }); + return { Queue: QueueMock }; +}); import { createRide, cancelRide } from '../ride.service.js'; @@ -59,6 +68,17 @@ describe('ride.service', () => { expect(result.data?.id).toBe('trip-1'); expect(result.data?.status).toBe('REQUESTED'); expect(prismaMock.$queryRaw).toHaveBeenCalledOnce(); + // M2: verify ride job is published to BullMQ queue + expect(queueAddMock).toHaveBeenCalledWith( + 'new-ride', + expect.objectContaining({ + tripId: 'trip-1', + riderId: 'rider-1', + pickupLng: 77.5946, + pickupLat: 12.9716, + }), + { jobId: 'trip-1' } + ); }); }); diff --git a/apps/api-gateway/src/services/ride.service.ts b/apps/api-gateway/src/services/ride.service.ts index ba5f587..5353022 100644 --- a/apps/api-gateway/src/services/ride.service.ts +++ b/apps/api-gateway/src/services/ride.service.ts @@ -1,7 +1,41 @@ +import { Queue } from 'bullmq'; +import { QUEUE_NAMES } from 'common'; import logger from '../logger.js'; import prisma from '../utils/db.js'; import type { RideInput } from 'common'; +const bullmqConnection = { + host: process.env.REDIS_HOST || 'localhost', + port: Number(process.env.REDIS_PORT) || 6379, +} as const; + +const rideRequestsQueue = new Queue(QUEUE_NAMES.RIDE_REQUESTS, { + connection: bullmqConnection, +}); + +// Graceful shutdown — close BullMQ queue to prevent connection leaks +let rideRequestsQueueShutdownRegistered = false; + +const registerRideRequestsQueueShutdown = (queue: Queue): void => { + if (rideRequestsQueueShutdownRegistered) return; + rideRequestsQueueShutdownRegistered = true; + + const shutdown = async (): Promise => { + try { + await queue.close(); + } catch (error) { + logger.error(error, 'Error closing rideRequestsQueue during shutdown'); + } + }; + + process.once('beforeExit', () => { void shutdown(); }); + (['SIGINT', 'SIGTERM', 'SIGQUIT'] as NodeJS.Signals[]).forEach((signal) => { + process.once(signal, () => { void shutdown(); }); + }); +}; + +registerRideRequestsQueueShutdown(rideRequestsQueue); + export interface RideResponse { success: boolean; message: string; @@ -59,6 +93,22 @@ export const createRide = async (input: RideInput, riderId: string): Promise { + logger.info({ signal }, 'Shutting down ride-worker...'); + + try { + await Promise.all(workers.map((w) => w.close())); + await Promise.all(workerQueues.map((q) => q.close())); + await redis.quit(); + + logger.info('ride-worker shutdown complete'); + process.exit(0); + } catch (err) { + logger.error({ err, signal }, 'Error during ride-worker shutdown'); + process.exit(1); + } +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/apps/ride-worker/src/logger.ts b/apps/ride-worker/src/logger.ts new file mode 100644 index 0000000..5e2ea7d --- /dev/null +++ b/apps/ride-worker/src/logger.ts @@ -0,0 +1,19 @@ +import pino from 'pino'; + +const isDevelopment = process.env.NODE_ENV !== 'production'; + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + ...(isDevelopment && { + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + }, + }), +}); + +export default logger; diff --git a/apps/ride-worker/src/utils/db.ts b/apps/ride-worker/src/utils/db.ts new file mode 100644 index 0000000..d10cedc --- /dev/null +++ b/apps/ride-worker/src/utils/db.ts @@ -0,0 +1,17 @@ +import { PrismaClient } from '@prisma/client'; + +declare global { + var prisma: PrismaClient | undefined; +} + +const prisma = + global.prisma || + new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + }); + +if (process.env.NODE_ENV !== 'production') { + global.prisma = prisma; +} + +export default prisma; diff --git a/apps/ride-worker/src/utils/redis.ts b/apps/ride-worker/src/utils/redis.ts new file mode 100644 index 0000000..71a9631 --- /dev/null +++ b/apps/ride-worker/src/utils/redis.ts @@ -0,0 +1,41 @@ +import { Redis } from 'ioredis'; +import logger from '../logger.js'; + +declare global { + var redis: Redis | undefined; +} + +const createRedisClient = (): Redis => { + const client = new Redis({ + host: process.env.REDIS_HOST || 'localhost', + port: Number(process.env.REDIS_PORT) || 6379, + maxRetriesPerRequest: null, // required by BullMQ + retryStrategy(times: number) { + const delay = Math.min(times * 100, 3000); + logger.warn({ attempt: times, delayMs: delay }, 'Redis reconnecting'); + return delay; + }, + }); + + client.on('connect', () => { + logger.info('Redis connected'); + }); + + client.on('error', (err: Error) => { + logger.error({ err }, 'Redis error'); + }); + + client.on('close', () => { + logger.warn('Redis connection closed'); + }); + + return client; +}; + +const redis: Redis = global.redis ?? createRedisClient(); + +if (process.env.NODE_ENV !== 'production') { + global.redis = redis; +} + +export default redis; diff --git a/apps/ride-worker/src/workers/ride-lifecycle.worker.ts b/apps/ride-worker/src/workers/ride-lifecycle.worker.ts new file mode 100644 index 0000000..6e1b1f7 --- /dev/null +++ b/apps/ride-worker/src/workers/ride-lifecycle.worker.ts @@ -0,0 +1,67 @@ +import { Worker, Job } from 'bullmq'; +import logger from '../logger.js'; +import { QUEUE_NAMES, REDIS_CONFIG } from '../config.js'; + +export type RideLifecycleAction = 'ACCEPT' | 'VERIFY_OTP' | 'START' | 'COMPLETE' | 'CANCEL'; + +export interface RideLifecycleJobData { + action: RideLifecycleAction; + tripId: string; + driverId?: string; + riderId?: string; + otp?: string; +} + +// Skeleton processor — fully implemented in M4/M5 +const processRideLifecycle = async (job: Job) => { + const { action, tripId } = job.data; + + logger.info( + { action, tripId }, + 'ride-lifecycle job received — state transitions will be implemented in M4/M5', + ); + + switch (action) { + case 'ACCEPT': + logger.info({ tripId }, 'ACCEPT action — driver matching accept (M4)'); + break; + case 'VERIFY_OTP': + logger.info({ tripId }, 'VERIFY_OTP action — OTP verification (M4)'); + break; + case 'START': + logger.info({ tripId }, 'START action — ride start (M4)'); + break; + case 'COMPLETE': + logger.info({ tripId }, 'COMPLETE action — ride completion and fare calculation (M5)'); + break; + case 'CANCEL': + logger.info({ tripId }, 'CANCEL action — ride cancellation (M4)'); + break; + default: + logger.warn({ action, tripId }, 'Unknown ride lifecycle action'); + } +}; + +export const createRideLifecycleWorker = () => { + const worker = new Worker( + QUEUE_NAMES.RIDE_LIFECYCLE, + processRideLifecycle, + { + connection: REDIS_CONFIG, + concurrency: 10, + }, + ); + + worker.on('completed', (job) => { + logger.info( + { jobId: job.id, action: job.data.action, tripId: job.data.tripId }, + 'ride-lifecycle job completed', + ); + }); + + worker.on('failed', (job, err) => { + logger.error({ jobId: job?.id, err }, 'ride-lifecycle job failed'); + }); + + return worker; +}; diff --git a/apps/ride-worker/src/workers/ride-matching.worker.ts b/apps/ride-worker/src/workers/ride-matching.worker.ts new file mode 100644 index 0000000..0322555 --- /dev/null +++ b/apps/ride-worker/src/workers/ride-matching.worker.ts @@ -0,0 +1,41 @@ +import { Worker, Job } from 'bullmq'; +import logger from '../logger.js'; +import { QUEUE_NAMES, REDIS_CONFIG } from '../config.js'; + +export interface RideMatchingJobData { + tripId: string; + riderId: string; + pickupLng: number; + pickupLat: number; + dropoffLng: number; + dropoffLat: number; + attempt?: number; +} + +// Skeleton processor — fully implemented in M4 (nearest-first cascade) +const processRideMatching = async (job: Job) => { + const { tripId, riderId, attempt = 1 } = job.data; + + logger.info( + { tripId, riderId, attempt }, + 'ride-matching job received — matching algorithm will be implemented in M4', + ); +}; + +export const createRideMatchingWorker = () => { + const worker = new Worker(QUEUE_NAMES.RIDE_MATCHING, processRideMatching, { + connection: REDIS_CONFIG, + concurrency: 10, + // Support for delayed jobs needed for matching timeout cascade (M4) + }); + + worker.on('completed', (job) => { + logger.info({ jobId: job.id, tripId: job.data.tripId }, 'ride-matching job completed'); + }); + + worker.on('failed', (job, err) => { + logger.error({ jobId: job?.id, err }, 'ride-matching job failed'); + }); + + return worker; +}; diff --git a/apps/ride-worker/src/workers/ride-request.worker.ts b/apps/ride-worker/src/workers/ride-request.worker.ts new file mode 100644 index 0000000..c93acc0 --- /dev/null +++ b/apps/ride-worker/src/workers/ride-request.worker.ts @@ -0,0 +1,51 @@ +import { Worker, Job, Queue } from 'bullmq'; +import logger from '../logger.js'; +import { QUEUE_NAMES, REDIS_CONFIG } from '../config.js'; + +export interface RideRequestJobData { + tripId: string; + riderId: string; + pickupLng: number; + pickupLat: number; + dropoffLng: number; + dropoffLat: number; +} + +const rideMatchingQueue = new Queue(QUEUE_NAMES.RIDE_MATCHING, { + connection: REDIS_CONFIG, +}); + +const processRideRequest = async (job: Job) => { + const { tripId, riderId, pickupLng, pickupLat, dropoffLng, dropoffLat } = job.data; + + logger.info( + { tripId, riderId, pickupLng, pickupLat, dropoffLng, dropoffLat }, + 'Processing ride request job', + ); + + // Publish a ride-matching job to initiate driver matching (fully implemented in M4) + await rideMatchingQueue.add( + 'match-driver', + { tripId, riderId, pickupLng, pickupLat, dropoffLng, dropoffLat }, + { jobId: `match:${tripId}` }, + ); + + logger.info({ tripId }, 'Matching not yet implemented — ride-matching job queued (M4)'); +}; + +export const createRideRequestWorker = () => { + const worker = new Worker(QUEUE_NAMES.RIDE_REQUESTS, processRideRequest, { + connection: REDIS_CONFIG, + concurrency: 5, + }); + + worker.on('completed', (job) => { + logger.info({ jobId: job.id, tripId: job.data.tripId }, 'ride-request job completed'); + }); + + worker.on('failed', (job, err) => { + logger.error({ jobId: job?.id, err }, 'ride-request job failed'); + }); + + return { worker, queue: rideMatchingQueue }; +}; diff --git a/apps/ride-worker/tsconfig.json b/apps/ride-worker/tsconfig.json new file mode 100644 index 0000000..6faca30 --- /dev/null +++ b/apps/ride-worker/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../../packages/common" }] +} diff --git a/compose.override.yml b/compose.override.yml index e6a2b7f..0a140ab 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -7,3 +7,11 @@ services: volumes: - .:/app - /app/node_modules + + ride-worker: + env_file: + - ./.env + command: pnpm dev + volumes: + - .:/app + - /app/node_modules diff --git a/compose.yml b/compose.yml index f807477..1a2a43f 100644 --- a/compose.yml +++ b/compose.yml @@ -28,6 +28,18 @@ services: - postgres - redis + ride-worker: + build: + context: . + dockerfile: Dockerfile + args: + APP_NAME: ride-worker + env_file: + - ./.env + depends_on: + - postgres + - redis + volumes: pgdata: driver: local diff --git a/learn/m1-redis-geo.md b/learn/m1-redis-geo.md new file mode 100644 index 0000000..038e366 --- /dev/null +++ b/learn/m1-redis-geo.md @@ -0,0 +1,214 @@ +# M1 — Redis Integration & Driver Location Service + +Learning document for the code changes introduced in Milestone 1. + +--- + +## 1. ioredis Singleton Pattern + +**File:** `apps/api-gateway/src/utils/redis.ts` + +### What it is +`ioredis` is the Node.js Redis client. A *singleton* means only **one** connection is created for the entire app lifetime (similar to how `PrismaClient` is singleton-ized in `db.ts`). + +### Key concepts + +```ts +import { Redis } from 'ioredis'; // named import — needed for ESM + TypeScript +``` + +`Redis` is the class. You `new Redis({ host, port })` to connect. + +### globalThis caching (dev hot-reload safety) +```ts +const redis: Redis = global.redis ?? createRedisClient(); + +if (process.env.NODE_ENV !== 'production') { + global.redis = redis; +} +``` +In development, `tsx --watch` re-evaluates the module file on every save. Without `globalThis` caching, a **new** Redis connection would be created on every hot-reload, quickly exhausting the connection pool. By storing the instance on `global`, subsequent re-evaluations reuse the same connection. + +### Reconnect strategy +```ts +retryStrategy(times: number) { + const delay = Math.min(times * 100, 3000); // 100ms, 200ms, … capped at 3s + return delay; // returning null would stop retrying +} +``` +ioredis calls this function after every failed connection attempt. Returning a number (ms) tells it to wait that long before trying again. Returning `null` stops retrying. + +### Event listeners +```ts +client.on('connect', () => logger.info('Redis connected')); +client.on('error', (err) => logger.error({ err }, 'Redis error')); +client.on('close', () => logger.warn('Redis connection closed')); +``` +These let you observe the connection lifecycle for logging and alerting. + +--- + +## 2. Redis GEO Commands + +Redis has a built-in **geospatial index** using sorted sets under the hood. + +### GEOADD — store a location +``` +GEOADD key longitude latitude member +``` +```ts +await redis.geoadd('drivers:active', lon, lat, driverId); +``` +- `drivers:active` is the key name (a Redis Sorted Set). +- `member` is the unique identifier (driver's DB id). +- Calling `GEOADD` on an existing member **updates** its position — no need to delete first. +- Coordinates follow **longitude first, latitude second** — same as PostGIS convention. + +### GEOPOS — read a stored location +```ts +const positions = await redis.geopos('drivers:active', driverId); +// positions[0] = [lonString, latString] | null +``` +Returns an array (one per member requested). Values are strings — always `parseFloat()` before doing math. + +### GEOSEARCH / GEORADIUS — find members near a point +```ts +// Modern syntax (Redis 6.2+): +await redis.call('GEOSEARCH', key, 'FROMLONLAT', lon, lat, 'BYRADIUS', 5, 'km', 'ASC') +``` +Used internally by the driver matching algorithm (M4) to find the closest available driver. + +### ZREM — remove from the GEO set +```ts +await redis.zrem('drivers:active', driverId); +``` +Because GEO is built on a sorted set, `ZREM` removes the member. Used when a driver goes offline. + +--- + +## 3. TTL-based Heartbeat + +**Problem:** What if a driver's app crashes without calling "go offline"? They'd stay in `drivers:active` forever — ghost drivers. + +**Solution:** A *heartbeat key* with a Time-To-Live (TTL). + +```ts +const HEARTBEAT_TTL_SECONDS = 60; +// Set key with automatic expiry: +await redis.set(`driver:heartbeat:${driverId}`, '1', 'EX', HEARTBEAT_TTL_SECONDS); +``` + +- `EX` sets expiry in **seconds**. `PX` is for milliseconds. +- Every time the driver sends a location update, TTL is **refreshed** (the key is re-SET, restarting the countdown). +- If no update arrives within 60s, Redis **automatically deletes** the key. +- A background job (or middleware) can check for existence of this key to decide if a driver is truly active. In M4, the matching worker uses this. + +--- + +## 4. Zod Schema with `.refine()` for Conditional Validation + +**File:** `packages/common/schemas/driver.schema.ts` + +```ts +export const driverStatusSchema = z.object({ + body: z.object({ + isActive: z.boolean(), + location: locationTuple.optional(), + }).refine( + (data) => !data.isActive || data.location !== undefined, + { message: 'location is required when going online', path: ['location'] } + ), +}); +``` + +`.refine()` adds **cross-field validation** — something basic field validators can't do. + +Logic: `!isActive || location !== undefined` means: +- If `isActive = false` → validation passes (offline doesn't need location). +- If `isActive = true` AND location is missing → validation **fails** with the provided message. + +`path: ['location']` tells Zod which field to attach the error to (so the client gets a helpful error pointing at `body.location`). + +--- + +## 5. Coordinate Tuple as Zod Type + +```ts +const locationTuple = z.tuple([z.number(), z.number()]).describe('[longitude, latitude]'); +``` + +`z.tuple([...])` validates a fixed-length array where each element can have its own type. Unlike `z.array(z.number())`, a tuple enforces **exactly** the positions specified. `.describe()` attaches human-readable documentation used by OpenAPI generators. + +--- + +## 6. PostGIS `ST_DWithin` for Proximity Queries + +**File:** `apps/api-gateway/src/services/driver.service.ts` + +```sql +WHERE ST_DWithin( + "pickupLocation"::geography, + ST_SetSRID(ST_MakePoint($lon, $lat), 4326)::geography, + $radiusMeters +) +``` + +- `ST_DWithin(geom_a, geom_b, distance)` returns true if the two geometries are within `distance` of each other. +- Casting to `::geography` makes the distance unit **meters** (not degrees). This is critical — without it, the radius would be in degrees which doesn't translate meaningfully. +- `ST_MakePoint(lon, lat)` creates a geometry point. `ST_SetSRID(..., 4326)` assigns the WGS 84 coordinate system (standard GPS). +- `ST_DWithin` uses a **spatial index** (GiST index on the geometry column) for very fast bounding-box pre-filtering. + +--- + +## 7. `authorize()` Middleware for Role-Based Access + +**File:** `apps/api-gateway/src/middleware/auth.ts` + +```ts +export const authorize = (...roles: Array<'driver' | 'rider'>) => { + return (req, res, next) => { + if (!roles.includes(req.user.role)) { /* 403 */ } + next(); + }; +}; +``` + +Usage in routes: +```ts +router.patch('/driver/status', authenticate, authorize('driver'), validate(schema), handler); +``` + +**Middleware chain order matters:** +1. `authenticate` — verifies JWT, sets `req.user` +2. `authorize('driver')` — checks `req.user.role` (requires step 1 first) +3. `validate(schema)` — validates body/query/params +4. handler — actual business logic + +--- + +## 8. Health Check with Async Redis Ping + +```ts +router.get('/health', async (req, res) => { + let redisStatus = 'ok'; + try { + await redis.ping(); // Redis replies with 'PONG' + } catch { + redisStatus = 'error'; + } + res.json({ services: { api: 'ok', redis: redisStatus } }); +}); +``` + +`redis.ping()` sends the simplest possible Redis command. If it throws (connection error, timeout), we catch it and report `'error'` instead of crashing the health endpoint. This pattern lets load balancers and orchestrators (Kubernetes liveness probes) detect partial outages. + +--- + +## Summary — What M1 enables for future milestones + +| Milestone | How M1 unblocks it | +|-----------|-------------------| +| M2 — BullMQ | BullMQ uses the same Redis connection for its job queues | +| M3 — Socket.io | Socket.io Redis adapter uses Redis pub/sub for cross-instance messaging | +| M4 — Matching | GEOSEARCH on `drivers:active` finds nearest online driver to a pickup point | +| M5 — Live tracking | Driver location stream updates GEO set; riders poll or subscribe for position | diff --git a/learn/m2-bullmq-workers.md b/learn/m2-bullmq-workers.md new file mode 100644 index 0000000..ac0f7ff --- /dev/null +++ b/learn/m2-bullmq-workers.md @@ -0,0 +1,211 @@ +# M2 — BullMQ Ride Queue & Worker App + +Learning document for the code changes introduced in Milestone 2. + +--- + +## 1. Why a Separate Worker Process? + +**Problem:** If the api-gateway processes driver matching synchronously inside the HTTP request, a slow database query or downstream failure blocks the client and ties up a Node.js thread. + +**Solution:** The api-gateway becomes the *producer* — it writes work to a queue and responds immediately. A separate `ride-worker` process is the *consumer* — it pulls jobs from the queue and does the heavy lifting independently. + +``` +Client → POST /rides/create → api-gateway → Queue (ride-requests) → ride-worker + ↑ | + responds with DB update + + { id, status: "REQUESTED" } cascade matching +``` + +Benefits: +- **Horizontal scaling**: run `docker compose scale ride-worker=3` to parallelize matching +- **Fault isolation**: a crash in the worker doesn't bring down the HTTP server +- **Retries**: BullMQ automatically retries failed jobs with backoff — no retry logic in business code + +--- + +## 2. BullMQ Core Concepts + +**File:** `apps/ride-worker/src/workers/ride-request.worker.ts` + +BullMQ has three primitives: **Queue**, **Worker**, and **Job**. + +### Queue — the producer side + +```ts +import { Queue } from 'bullmq'; + +const rideRequestsQueue = new Queue('ride-requests', { + connection: { host: 'localhost', port: 6379 }, +}); + +// Publish a job +await rideRequestsQueue.add('new-ride', { tripId, riderId, pickupLng, pickupLat, ... }); +``` + +- The queue is backed by Redis — each job is a Redis key. +- Calling `.add()` is non-blocking and returns immediately with a `Job` object. +- The second argument is the **job name** (a label). The third is the **job data** (serialised to JSON). +- `jobId` option (`{ jobId: tripId }`) prevents duplicate jobs — if a job with that ID already exists it won't be added again. + +### Worker — the consumer side + +```ts +import { Worker } from 'bullmq'; + +const worker = new Worker('ride-requests', async (job) => { + // job.data contains everything passed to queue.add() + const { tripId, riderId } = job.data; + // ... process ... +}, { connection, concurrency: 5 }); +``` + +- The `Worker` polls Redis for new jobs and calls your processor function. +- `concurrency: N` — the worker processes up to N jobs in parallel (Promise concurrency, not threads). +- If the processor throws, BullMQ marks the job `failed` and retries per the retry config. + +### Job lifecycle + +``` +waiting → active → completed + ↘ failed (retried up to maxAttempts) +``` + +--- + +## 3. Connection Options — Why Not Pass the ioredis Instance? + +BullMQ accepts either an **ioredis `Redis` instance** or a plain **connection options object** (`{ host, port }`). + +Passing the same ioredis client that the app uses for GEO commands causes a TypeScript type conflict because BullMQ's internal `Redis` type (from its own bundled ioredis types) doesn't fully align with the version imported by the app. The clean solution used here is a separate connection options object: + +```ts +const bullmqConnection = { + host: process.env.REDIS_HOST || 'localhost', + port: Number(process.env.REDIS_PORT) || 6379, +} as const; + +new Queue(QUEUE_NAMES.RIDE_REQUESTS, { connection: bullmqConnection }); +new Worker(QUEUE_NAMES.RIDE_REQUESTS, processor, { connection: bullmqConnection }); +``` + +BullMQ creates its own internal ioredis connection from these options. **Important:** for Worker connections, BullMQ requires `maxRetriesPerRequest: null` — this is set in `apps/ride-worker/src/utils/redis.ts` on the ioredis client used by the app itself (for non-BullMQ operations like GEO commands). + +--- + +## 4. Shared Queue Name Constants + +**File:** `packages/common/queues/index.ts` + +Both `api-gateway` (producer) and `ride-worker` (consumer) must use exactly the same queue names — a typo means jobs are published to a queue nobody reads. + +```ts +export const QUEUE_NAMES = { + RIDE_REQUESTS: 'ride-requests', // new ride → api-gateway publishes + RIDE_MATCHING: 'ride-matching', // cascade matching → worker publishes (M4) + RIDE_LIFECYCLE: 'ride-lifecycle', // state transitions → worker publishes (M4/M5) +} as const; +``` + +`as const` makes the values literal types (`'ride-requests'`, not `string`), so TypeScript catches misuse at compile time. + +Exporting from `packages/common` ensures a single source of truth across the entire monorepo. + +--- + +## 5. Multi-Queue Architecture + +Three queues handle different stages of the ride flow: + +| Queue | Producer | Consumer | Purpose | +|-------|----------|----------|---------| +| `ride-requests` | api-gateway | ride-worker | New ride enters the system | +| `ride-matching` | ride-worker | ride-worker | Cascade: offer to next driver after timeout | +| `ride-lifecycle` | ride-worker | ride-worker | State transitions: accept, start, complete, cancel | + +Splitting concerns into multiple queues allows independent scaling and monitoring. A spike in new ride requests doesn't block lifecycle processing. + +--- + +## 6. Delayed Jobs for Cascade Matching (preview of M4) + +BullMQ supports **delayed jobs** natively: + +```ts +await rideMatchingQueue.add( + 'match-driver', + { tripId, attempt: 2 }, + { delay: 30_000 }, // processed 30 seconds from now +); +``` + +This is how the cascade timeout will work in M4: +1. Offer sent to nearest driver → delayed job scheduled for 30 s +2. 30 s later, delayed job fires → check if offer still PENDING +3. If yes → offer expired, move to next driver → schedule another 30 s job +4. Repeat until accept or no drivers left + +BullMQ stores the job in a Redis sorted set ordered by `processAt` timestamp. A BullMQ internal timer moves jobs to the `waiting` state when their time arrives. No cron, no `setTimeout` — Redis + BullMQ handles everything. + +--- + +## 7. Graceful Shutdown + +**File:** `apps/ride-worker/src/index.ts` + +Abruptly killing a worker mid-job can leave a job in `active` state forever (stalled). Graceful shutdown tells BullMQ to finish the current batch before exiting: + +```ts +const shutdown = async (signal: string) => { + logger.info({ signal }, 'Shutting down ride-worker...'); + + // worker.close() waits for in-flight jobs to finish, then stops polling + await Promise.all(workers.map((w) => w.close())); + await redis.quit(); // flush pending Redis commands + + process.exit(0); +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); // Docker stop / k8s eviction +process.on('SIGINT', () => shutdown('SIGINT')); // Ctrl-C in dev +``` + +Docker sends `SIGTERM` before `SIGKILL` (with a 10 s grace period). Kubernetes does the same. Listening for both signals ensures a clean shutdown in all environments. + +--- + +## 8. Mocking BullMQ in Unit Tests + +BullMQ creates a Redis connection on module import (when `new Queue(...)` is called at the top-level). In tests there is no Redis, so we mock the entire module: + +```ts +// Must be hoisted — vi.hoisted runs before imports +const queueAddMock = vi.hoisted(() => vi.fn().mockResolvedValue({ id: 'job-1' })); + +// Use a constructor function (not arrow function) because Queue is used with `new` +vi.mock('bullmq', () => { + const QueueMock = vi.fn(function (this: Record) { + this.add = queueAddMock; + this.close = vi.fn().mockResolvedValue(undefined); + }); + return { Queue: QueueMock }; +}); +``` + +**Why `vi.fn(function() { ... })` and not `vi.fn(() => ({ ... }))`?** + +Arrow functions cannot be used as constructors (`new arrowFn()` throws `TypeError: is not a constructor`). Using a regular `function` lets `new Queue(...)` work correctly in the code under test. + +**Why `vi.hoisted`?** + +`vi.mock()` calls are hoisted to the top of the file by Vitest's transformer, but variable initialisers are not. `vi.hoisted()` wraps the value so it's available when the mock factory runs. + +--- + +## Summary — What M2 enables for future milestones + +| Milestone | How M2 unblocks it | +|-----------|-------------------| +| M3 — Socket.io | ride-worker will use `packages/notifications` to emit socket events via Redis adapter | +| M4 — Matching | `ride-matching` worker processes cascade; `ride-lifecycle` handles accept/OTP/start | +| M5 — Completion | `ride-lifecycle` handles COMPLETE action: distance calculation + fare + DB update | diff --git a/learn/testing-vitest.md b/learn/testing-vitest.md new file mode 100644 index 0000000..ded5037 --- /dev/null +++ b/learn/testing-vitest.md @@ -0,0 +1,236 @@ +# Testing with Vitest & GitHub Actions CI + +Learning document for the testing setup introduced in this PR. + +--- + +## 1. Why Vitest (not Jest)? + +This project is **ESM-only** (`"type": "module"` in every `package.json`). Jest has historically had poor native ESM support. Vitest is built on Vite, which handles ESM natively — no transform workarounds needed. + +Key advantages over Jest: +- Native ESM support +- Much faster (uses esbuild under the hood) +- Same API as Jest (`describe`, `it`, `expect`, `vi` ≈ `jest`) +- `vi.mock` hoisting works the same way as Jest's `jest.mock` + +--- + +## 2. `vitest.config.ts` — Configuration Anatomy + +```ts +export default defineConfig({ + test: { + globals: true, // no need to import describe/it/expect in every file + environment: 'node', // don't emulate browser DOM + clearMocks: true, // vi.fn() call history cleared between tests + restoreMocks: true, // vi.spyOn mocks restored between tests + include: ['src/**/*.test.ts'], + }, + resolve: { + alias: { + common: '/path/to/packages/common/dist/index.js', + }, + }, +}); +``` + +`globals: true` means you can write `describe(...)` without importing — Vitest injects them. The `resolve.alias` is critical for monorepo workspace packages that aren't auto-resolved by Vitest (unlike TypeScript, Vitest resolves at runtime). + +--- + +## 3. `vi.hoisted` — The Key to Correct Mocking + +This is the most important pattern in this codebase. + +**The problem without `vi.hoisted`:** +```ts +// ❌ This FAILS in Vitest/Jest because vi.mock is hoisted to the TOP of the file, +// but const declarations stay in place — so mockObj is undefined when vi.mock runs. +const mockObj = { findUnique: vi.fn() }; +vi.mock('../utils/db.js', () => ({ default: mockObj })); // mockObj is undefined here! +``` + +**The fix with `vi.hoisted`:** +```ts +// ✅ vi.hoisted executes BEFORE the module is even imported — safe to use in factory +const prismaMock = vi.hoisted(() => ({ + user: { findUnique: vi.fn(), create: vi.fn() }, + $queryRaw: vi.fn(), +})); +vi.mock('../../utils/db.js', () => ({ default: prismaMock })); +``` + +`vi.hoisted` lifts the callback to the very top of the file's execution, before imports. The factory for `vi.mock` then captures `prismaMock` correctly. + +--- + +## 4. `vi.mock` — Path Resolution Rules + +**Critical rule**: `vi.mock` paths are **resolved relative to the test file**, not relative to the module under test. + +``` +Project structure: + src/ + utils/ + db.ts ← actual file + services/ + auth.service.ts ← imports '../utils/db.js' + __tests__/ + auth.service.test.ts ← test file +``` + +From `__tests__/auth.service.test.ts`, to mock `src/utils/db.ts`: +```ts +vi.mock('../../utils/db.js', ...); // ✅ go up 2 levels from __tests__/ +vi.mock('../utils/db.js', ...); // ❌ resolves to src/services/utils/db.js (doesn't exist) +``` + +--- + +## 5. Mocking Module Default Exports + +Many Node.js utilities use `export default`. To mock them: + +```ts +vi.mock('../../utils/db.js', () => ({ + default: prismaMock, // ← must use 'default' key for default exports +})); +``` + +For named exports: +```ts +vi.mock('../../utils/password.js', () => ({ + hashPassword: vi.fn().mockResolvedValue('hashed'), + comparePassword: vi.fn(), +})); +``` + +--- + +## 6. `vi.fn()` — Mock Functions + +```ts +const mockFn = vi.fn(); + +// Set return value for all calls: +mockFn.mockReturnValue(42); +mockFn.mockResolvedValue({ id: 'user-1' }); // for async (returns a Promise) + +// Set return value for just the NEXT call: +mockFn.mockResolvedValueOnce(null); + +// Assert usage: +expect(mockFn).toHaveBeenCalledOnce(); +expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2'); +expect(mockFn).not.toHaveBeenCalled(); +``` + +`vi.mocked(fn)` wraps a function that TypeScript knows is a mock, providing type-safe access to `.mockResolvedValue` etc. + +--- + +## 7. Testing the Zod `.refine()` — Cross-Field Validation + +When you have conditional validation (e.g., "location is required when going online"), the error is attached to the specific path you specified in the schema: + +```ts +// Schema: +.refine( + (data) => !data.isActive || data.location !== undefined, + { message: '...', path: ['location'] } // path relative to the object being refined +) +``` + +In the test, the full error path includes the parent key (`body`): +```ts +const paths = result.error.issues.map((i) => i.path.join('.')); +expect(paths).toContain('body.location'); // 'body' + '.' + 'location' +``` + +`.safeParseAsync` is used instead of `.parseAsync` so that failures don't throw — they return `{ success: false, error }`. + +--- + +## 8. Mocking `prisma.$queryRaw` + +`$queryRaw` is used for PostGIS spatial queries. It's just a function on the Prisma client, so mocking it is the same as any other function: + +```ts +prismaMock.$queryRaw.mockResolvedValue([ + { id: 'trip-1', pickupLocation: 'POINT(77.5946 12.9716)', ... } +]); +``` + +The `prismaMock.$transaction` mock needs special handling because the service passes an async callback to it: + +```ts +prismaMock.$transaction.mockImplementation( + async (fn) => fn(prismaMock) // call the callback with the mock as the tx client +); +``` + +--- + +## 9. GitHub Actions CI Workflow + +**File:** `.github/workflows/ci.yml` + +```yaml +on: + pull_request: + branches: [main] # runs on every PR targeting main + push: + branches: [main] # also runs on direct pushes to main +``` + +### Key steps + +```yaml +- uses: pnpm/action-setup@v4 # installs pnpm (not included by default in runners) +- uses: actions/setup-node@v4 + with: + cache: 'pnpm' # caches node_modules across runs = faster CI + +- run: pnpm install --frozen-lockfile # --frozen-lockfile = fail if pnpm-lock.yaml is stale +- run: pnpm --filter common build # build common first (api-gateway depends on its dist/) +- run: pnpm --filter api-gateway test # run tests only in api-gateway (no infra needed) +``` + +`--filter ` is pnpm's way to run commands scoped to a specific workspace. Equivalent to `cd packages/common && pnpm build`. + +### Why no Docker / DB / Redis in CI? + +These tests are **unit tests** — they mock Prisma and Redis. No real infrastructure is needed. Integration tests (with real DB+Redis) can be added as a separate workflow triggered on merge to main, using GitHub Actions services: + +```yaml +services: + postgres: + image: postgis/postgis:14-3.4 + env: + POSTGRES_PASSWORD: test + redis: + image: redis:7 +``` + +--- + +## 10. Test file naming & location + +Tests live at `src/services/__tests__/` and `src/schemas/`. The `include: ['src/**/*.test.ts']` glob in `vitest.config.ts` picks up any `*.test.ts` file anywhere under `src/`. + +Convention: +- `auth.service.test.ts` tests `auth.service.ts` +- `driver.service.test.ts` tests `driver.service.ts` +- `schemas.test.ts` tests all Zod schemas + +--- + +## Summary — Testing Strategy by Milestone + +| Milestone | Test type added | +|-----------|----------------| +| M1 (current) | Unit tests for 3 services + Zod schemas; GitHub Actions CI | +| M3 (Socket.io) | Unit tests for socket event handlers (mock socket.io) | +| M4 (Matching) | Unit tests for matching algorithm (pure logic, no I/O) | +| M5 (Complete) | Integration tests with Docker Compose services (real DB+Redis) | diff --git a/packages/common/index.ts b/packages/common/index.ts index 8dfa83b..0966147 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -1,3 +1,4 @@ export * from './schemas/index.js'; +export * from './queues/index.js'; export { PrismaClient } from '@prisma/client'; diff --git a/packages/common/package.json b/packages/common/package.json index d75341f..942dce4 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -12,6 +12,10 @@ "./schemas/*": { "import": "./dist/schemas/*.js", "types": "./dist/schemas/*.d.ts" + }, + "./queues": { + "import": "./dist/queues/index.js", + "types": "./dist/queues/index.d.ts" } }, "scripts": { diff --git a/packages/common/queues/index.ts b/packages/common/queues/index.ts new file mode 100644 index 0000000..b5d5d5a --- /dev/null +++ b/packages/common/queues/index.ts @@ -0,0 +1,7 @@ +export const QUEUE_NAMES = { + RIDE_REQUESTS: 'ride-requests', + RIDE_MATCHING: 'ride-matching', + RIDE_LIFECYCLE: 'ride-lifecycle', +} as const; + +export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4df89fd..60633ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: body-parser: specifier: ^2.2.0 version: 2.2.0 + bullmq: + specifier: ^5.56.0 + version: 5.69.3 common: specifier: workspace:^ version: link:../../packages/common @@ -109,6 +112,40 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@24.8.1)(jiti@2.6.1)(tsx@4.20.6) + apps/ride-worker: + dependencies: + '@prisma/client': + specifier: ^6.17.1 + version: 6.17.1(prisma@6.17.1(typescript@5.9.3))(typescript@5.9.3) + bullmq: + specifier: ^5.56.0 + version: 5.69.3 + common: + specifier: workspace:^ + version: link:../../packages/common + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + ioredis: + specifier: ^5.8.1 + version: 5.8.1 + pino: + specifier: ^10.1.0 + version: 10.1.0 + devDependencies: + '@types/node': + specifier: ^24.5.2 + version: 24.8.1 + pino-pretty: + specifier: ^13.1.2 + version: 13.1.2 + tsx: + specifier: ^4.20.6 + version: 4.20.6 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + packages/common: dependencies: '@prisma/client': @@ -470,6 +507,9 @@ packages: '@ioredis/commands@1.4.0': resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + '@ioredis/commands@1.5.0': + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -504,6 +544,36 @@ packages: resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==} engines: {node: '>= 10'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -867,6 +937,9 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bullmq@5.69.3: + resolution: {integrity: sha512-P9uLsR7fDvejH/1m6uur6j7U9mqY6nNt+XvhlhStOUe7jdwbZoP/c2oWNtE+8ljOlubw4pRUKymtRqkyvloc4A==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -950,6 +1023,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -988,6 +1065,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -1213,6 +1294,10 @@ packages: resolution: {integrity: sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==} engines: {node: '>=12.22.0'} + ioredis@5.9.2: + resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} + engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -1296,6 +1381,10 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1345,6 +1434,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1358,6 +1454,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@8.5.0: resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} engines: {node: ^18 || ^20 || >= 21} @@ -1365,6 +1464,10 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -1600,6 +1703,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} @@ -1740,6 +1848,9 @@ packages: '@swc/wasm': optional: true + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.20.6: resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} engines: {node: '>=18.0.0'} @@ -1808,6 +1919,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -2083,6 +2198,8 @@ snapshots: '@ioredis/commands@1.4.0': {} + '@ioredis/commands@1.5.0': {} + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2127,6 +2244,24 @@ snapshots: '@msgpack/msgpack@2.8.0': {} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@pinojs/redact@0.4.0': {} '@prisma/client@6.17.1(prisma@6.17.1(typescript@5.9.3))(typescript@5.9.3)': @@ -2487,6 +2622,18 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bullmq@5.69.3: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.9.2 + msgpackr: 1.11.5 + node-abort-controller: 3.1.1 + semver: 7.7.4 + tslib: 2.8.1 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color + bytes@3.1.2: {} c12@3.1.0: @@ -2579,6 +2726,10 @@ snapshots: create-require@1.1.1: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + dateformat@4.6.3: {} debug@2.6.9: @@ -2601,6 +2752,9 @@ snapshots: destr@2.0.5: {} + detect-libc@2.1.2: + optional: true + diff@4.0.2: {} dotenv@16.6.1: {} @@ -2892,6 +3046,20 @@ snapshots: transitivePeerDependencies: - supports-color + ioredis@5.9.2: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3(supports-color@5.5.0) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} is-binary-path@2.1.0: @@ -2969,6 +3137,8 @@ snapshots: lodash.once@4.1.1: {} + luxon@3.7.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3011,16 +3181,39 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + nanoid@3.3.11: {} negotiator@0.6.4: {} negotiator@1.0.0: {} + node-abort-controller@3.1.1: {} + node-addon-api@8.5.0: {} node-fetch-native@1.6.7: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp-build@4.8.4: {} nodemon@3.1.10: @@ -3301,6 +3494,8 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: {} + send@1.2.0: dependencies: debug: 4.4.3(supports-color@5.5.0) @@ -3453,6 +3648,8 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + tslib@2.8.1: {} + tsx@4.20.6: dependencies: esbuild: 0.25.11 @@ -3507,6 +3704,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + v8-compile-cache-lib@3.0.1: {} vary@1.1.2: {} diff --git a/tsconfig.json b/tsconfig.json index a4bf8dc..c811b57 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,8 @@ { "files": [], - "references": [{ "path": "apps/api-gateway" }, { "path": "packages/common" }] + "references": [ + { "path": "apps/api-gateway" }, + { "path": "apps/ride-worker" }, + { "path": "packages/common" } + ] }