diff --git a/docs/architecture.md b/docs/architecture.md index 88e13bc..2b33699 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -189,9 +189,8 @@ SwiftChain_Backend/ │ │ └── fcmProvider.ts # Firebase Cloud Messaging implementation │ │ │ ├── controllers/ # HTTP request handlers -│ │ ├── delivery.controller.ts # Full delivery CRUD (uses delivery.service.ts) -│ │ ├── deliveryController.ts # ⚠ DEPRECATED — ETA endpoint only -│ │ ├── deliveryCrudController.ts # ⚠ DEPRECATED — basic CRUD exports +│ │ ├── delivery.controller.ts # Canonical delivery controller (CRUD + ETA, uses delivery.service.ts) +│ │ ├── deliveryController.ts # ⚠ DEPRECATED — compatibility shim re-exporting delivery.controller.ts │ │ ├── deliveryStatusController.ts # Status transitions │ │ ├── escrow.controller.ts # Escrow by delivery/contract + fund trigger │ │ ├── escrowController.ts # ⚠ DEPRECATED — admin flagged escrows @@ -865,9 +864,8 @@ New routes should use the middleware in `src/middlewares/`. | Status | File | Usage | |---|---|---| -| Active | `src/controllers/delivery.controller.ts` | Full delivery CRUD via `delivery.service.ts` | -| **DEPRECATED** | `src/controllers/deliveryController.ts` | ETA endpoint only (`GET /:id/eta`), wires to legacy `deliveryService` | -| **DEPRECATED** | `src/controllers/deliveryCrudController.ts` | Exports `createDelivery`, `getDeliveries`, `getDeliveryById`, `assignDriver` as standalone functions | +| Active | `src/controllers/delivery.controller.ts` | Full delivery CRUD (incl. `GET /:id/eta`) via `delivery.service.ts` | +| **DEPRECATED** | `src/controllers/deliveryController.ts` | Compatibility shim — re-exports the canonical controller from `delivery.controller.ts` | #### Routes diff --git a/docs/query-contract.md b/docs/query-contract.md new file mode 100644 index 0000000..eb1fc50 --- /dev/null +++ b/docs/query-contract.md @@ -0,0 +1,58 @@ +# Shared Query Contract + +All list endpoints expose a single, consistent query interface backed by +`buildQueryOptions` (`src/middlewares/queryMiddleware.ts`). This document is +the contract clients can rely on. + +## Parameters + +| Parameter | Type | Default | Description | +| --------- | ---- | ------- | ----------- | +| `page` | positive integer | `1` | 1-based page number. | +| `limit` | positive integer | route default (10/20) | Page size, clamped to the route's `maxLimit` (100 by default). | +| `sort` | comma-separated string | route default (`-createdAt`) | e.g. `sort=-createdAt,name`. Leading `-` = descending. Fields outside the route's whitelist are rejected with 400. | +| `search` | string | — | Free-text match against the route's `searchableFields` (case-insensitive, literal substring). | +| `` | route-declared type | — | Direct equality filter, e.g. `?status=pending`. Only whitelisted fields are accepted. | +| `[op]` | — | — | Comparison filter where `op` ∈ `eq, ne, gt, gte, lt, lte, in, nin`, e.g. `?createdAt[gte]=2026-01-01` or `?status[in]=pending,assigned`. Non-whitelisted operators/fields are rejected with 400. | + +## Pagination metadata + +Every paginated list response returns the same meta shape (inside the +response's `data`, alongside the collection): + +```json +{ + "totalItems": 42, + "totalPages": 5, + "currentPage": 1, + "limit": 10, + "hasNextPage": true, + "hasPreviousPage": false, + "nextPage": 2, + "previousPage": null +} +``` + +Built by `buildPaginationMeta(totalItems, page, limit)`. + +## Notes + +- Invalid `page`/`limit`/`sort`/filter values produce a 400 with a descriptive + message — they are never silently coerced or ignored. +- Unknown query-string fields are ignored, so adding new whitelisted fields + later is backward compatible. +- Errors surface through the global error handler in the standard envelope + (`success: false`, `error` populated). + +## Endpoint configuration + +| Endpoint | Filterable fields | Searchable | Sortable | +| -------- | ----------------- | ---------- | -------- | +| `GET /v1/deliveries` | `status`, `driver` | `trackingNumber`, `customer.name`, `customer.phone` | `createdAt` | +| `GET /v1/deliveries/archived` | — | — | `createdAt` | +| `GET /v1/users/deleted` | `role`, `status` | `email`, `firstName`, `lastName` | `deletedAt` | +| `GET /v1/disputes` | `status`, `reason`, `raisedBy`, `deliveryId` | — | `createdAt` | +| `GET /v1/fleets` | `isActive` | `name` | `createdAt` | +| `GET /v1/notifications` | `status`, `event` | — | `createdAt` | +| `GET /v1/webhooks` | `isActive` | — | `createdAt` | +| `GET /v1/eventlog/unprocessed` | — | — | `createdAt` | diff --git a/src/blockchain/soroban.service.ts b/src/blockchain/soroban.service.ts index 69a9e6e..cad0205 100644 --- a/src/blockchain/soroban.service.ts +++ b/src/blockchain/soroban.service.ts @@ -1,10 +1,12 @@ import { rpc as StellarRpc } from '@stellar/stellar-sdk'; import CircuitBreaker from 'opossum'; import logger from '../config/logger'; -import { sorobanRpcClient, stellarConfig } from '../config/stellar'; +import { stellarConfig } from '../config/stellar'; import env from '../config/env'; import { withRetry, RetryOptions } from '../utils/rpcRetry'; import { createCircuitBreaker, fireWithBreaker } from '../utils/circuitBreaker'; +import type { ISorobanRpcClient } from '../services/providers/sorobanRpcClient'; +import { defaultSorobanRpcClient } from '../services/providers/adapters'; /** * Result returned by a successful connectivity check. @@ -55,7 +57,7 @@ export interface DegradedLedgerResult { * its own errors — is not double-wrapped. */ export class SorobanService { - private readonly client: StellarRpc.Server; + private readonly client: ISorobanRpcClient; /** * Circuit breaker for this instance's Soroban RPC operations. @@ -67,7 +69,7 @@ export class SorobanService { /** Counter so each service instance owns an isolated breaker. */ private static instanceCount = 0; - constructor(client: StellarRpc.Server = sorobanRpcClient) { + constructor(client: ISorobanRpcClient = defaultSorobanRpcClient) { this.client = client; this.breaker = createCircuitBreaker<[() => Promise], unknown>( diff --git a/src/controllers/delivery.controller.ts b/src/controllers/delivery.controller.ts index ae9e994..804af93 100644 --- a/src/controllers/delivery.controller.ts +++ b/src/controllers/delivery.controller.ts @@ -1,14 +1,16 @@ import { Request, Response, NextFunction } from 'express'; import httpStatus from 'http-status-codes'; -import { DeliveryStatus } from '../models/Delivery'; import { deliveryService, CreateDeliveryInput, UpdateDeliveryInput, - DeliveryFilter, AssignDriverInput, } from '../services/delivery.service'; -import { sendSuccess } from '../utils/responseWrapper'; +// ETA lookups live in the dedicated delivery service; aliased so the two +// service modules can be imported side by side. +import { deliveryService as deliveryEtaService } from '../services/deliveryService'; +import { sendSuccess, sendError } from '../utils/responseWrapper'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; interface AuthenticatedRequest extends Request { user?: { id: string }; @@ -46,35 +48,47 @@ export class DeliveryController { } } + /** + * GET /api/v1/deliveries/:id/eta + * + * Calculates the delivery ETA from the stored pickup/dropoff coordinates. + * + * Responses: + * 200 — ETA calculated successfully. + * 400 — delivery ID missing. + * 404 — delivery not found. + * 500 — routing failure or delivery without complete coordinates. + */ + async getDeliveryETA(req: Request, res: Response, _next: NextFunction): Promise { + const { id } = req.params; + + if (!id) { + sendError(res, 'Delivery ID is required', httpStatus.BAD_REQUEST); + return; + } + + try { + const result = await deliveryEtaService.calculateDeliveryETA({ deliveryId: id }); + sendSuccess(res, result, 'ETA calculated successfully', httpStatus.OK); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + const statusCode = errorMessage.includes('not found') + ? httpStatus.NOT_FOUND + : httpStatus.INTERNAL_SERVER_ERROR; + sendError(res, errorMessage || 'Failed to calculate ETA', statusCode); + } + } + async list(req: Request, res: Response, next: NextFunction): Promise { try { - const statusParam = req.query.status as string | undefined; - const statusNormalized = statusParam ? statusParam.toLowerCase() : undefined; - const validatedStatus = Object.values(DeliveryStatus).includes( - statusNormalized as DeliveryStatus, - ) - ? (statusNormalized as DeliveryStatus) - : undefined; - - const filters: DeliveryFilter = { - status: validatedStatus, - driver: req.query.driver as string | undefined, - search: req.query.search as string | undefined, - page: req.query.page ? parseInt(req.query.page as string, 10) : 1, - limit: req.query.limit ? parseInt(req.query.limit as string, 10) : 10, - }; + const { filter, page, limit, sort } = resolveQueryOptions(req); - const result = await deliveryService.list(filters); + const result = await deliveryService.list({ filter, page, limit, sort }); sendSuccess( res, { deliveries: result.data, - meta: { - total: result.total, - page: result.page, - limit: result.limit, - totalPages: result.totalPages, - }, + meta: buildPaginationMeta(result.total, result.page, result.limit), }, 'Deliveries retrieved successfully', httpStatus.OK, @@ -123,20 +137,14 @@ export class DeliveryController { async listArchived(req: Request, res: Response, next: NextFunction): Promise { try { - const page = req.query.page ? parseInt(req.query.page as string, 10) : 1; - const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10; + const { page, limit, sort } = resolveQueryOptions(req); - const result = await deliveryService.listArchived(page, limit); + const result = await deliveryService.listArchived(page, limit, sort); sendSuccess( res, { deliveries: result.data, - meta: { - total: result.total, - page: result.page, - limit: result.limit, - totalPages: result.totalPages, - }, + meta: buildPaginationMeta(result.total, result.page, result.limit), }, 'Archived deliveries retrieved successfully', httpStatus.OK, diff --git a/src/controllers/deliveryController.ts b/src/controllers/deliveryController.ts index d2f3b75..c219290 100644 --- a/src/controllers/deliveryController.ts +++ b/src/controllers/deliveryController.ts @@ -1,28 +1,9 @@ -import { Request, Response } from 'express'; -import { StatusCodes } from 'http-status-codes'; -import { deliveryService } from '../services/deliveryService'; -import { sendSuccess, sendError } from '../utils/responseWrapper'; - -class DeliveryController { - async getDeliveryETA(req: Request, res: Response): Promise { - const { id } = req.params; - - if (!id) { - sendError(res, 'Delivery ID is required', StatusCodes.BAD_REQUEST); - return; - } - - try { - const result = await deliveryService.calculateDeliveryETA({ deliveryId: id }); - sendSuccess(res, result, 'ETA calculated successfully', StatusCodes.OK); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - const statusCode = errorMessage.includes('not found') - ? StatusCodes.NOT_FOUND - : StatusCodes.INTERNAL_SERVER_ERROR; - sendError(res, errorMessage || 'Failed to calculate ETA', statusCode); - } - } -} - -export const deliveryController = new DeliveryController(); +/** + * @deprecated Deprecated compatibility shim — canonical implementation lives in + * `delivery.controller.ts` (single canonical delivery controller). + * + * Re-exports the canonical `DeliveryController` singleton so existing imports + * of `./deliveryController` keep working during the migration. New code must + * import from `./delivery.controller` instead. + */ +export { DeliveryController, deliveryController } from './delivery.controller'; diff --git a/src/controllers/deliveryCrudController.ts b/src/controllers/deliveryCrudController.ts deleted file mode 100644 index 99302c5..0000000 --- a/src/controllers/deliveryCrudController.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { StatusCodes } from 'http-status-codes'; -import crypto from 'crypto'; -import mongoose from 'mongoose'; -import Delivery, { DeliveryStatus } from '../models/Delivery'; -import { sendSuccess, sendError } from '../utils/responseWrapper'; - -// POST /api/v1/deliveries -export const createDelivery = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const { sender, recipient, packageDescription, weight, estimatedValue, notes } = req.body; - - if (!sender?.name || !sender?.contact || !sender?.address) { - sendError( - res, - 'sender.name, sender.contact, and sender.address are required', - StatusCodes.BAD_REQUEST, - ); - return; - } - if (!recipient?.name || !recipient?.contact || !recipient?.address) { - sendError( - res, - 'recipient.name, recipient.contact, and recipient.address are required', - StatusCodes.BAD_REQUEST, - ); - return; - } - if (!packageDescription) { - sendError(res, 'packageDescription is required', StatusCodes.BAD_REQUEST); - return; - } - - const trackingId = `SWIFT-${crypto.randomUUID().replace(/-/g, '').slice(0, 10).toUpperCase()}`; - - const delivery = await Delivery.create({ - trackingId, - sender, - recipient, - packageDescription, - weight, - estimatedValue, - notes, - }); - - sendSuccess(res, delivery, 'Delivery created successfully', StatusCodes.CREATED); - } catch (err) { - next(err); - } -}; - -// GET /api/v1/deliveries -export const getDeliveries = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const page = Math.max(1, parseInt(req.query.page as string) || 1); - const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 10)); - const skip = (page - 1) * limit; - - const filter: Record = {}; - if (req.query.status) filter.status = req.query.status; - - const sortOrder = req.query.order === 'asc' ? 1 : -1; - - const [deliveries, total] = await Promise.all([ - Delivery.find(filter).sort({ createdAt: sortOrder }).skip(skip).limit(limit).lean(), - Delivery.countDocuments(filter), - ]); - - sendSuccess( - res, - { - deliveries, - pagination: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, - }, - 'Deliveries retrieved successfully', - StatusCodes.OK, - ); - } catch (err) { - next(err); - } -}; - -// GET /api/v1/deliveries/:id -export const getDeliveryById = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const { id } = req.params; - - if (!mongoose.Types.ObjectId.isValid(id)) { - sendError(res, 'Invalid delivery ID', StatusCodes.BAD_REQUEST); - return; - } - - const delivery = await Delivery.findById(id).lean(); - - if (!delivery) { - sendError(res, 'Delivery not found', StatusCodes.NOT_FOUND); - return; - } - - sendSuccess(res, delivery, 'Delivery retrieved successfully', StatusCodes.OK); - } catch (err) { - next(err); - } -}; - -// PUT /api/v1/deliveries/:id/assign -export const assignDriver = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - const { id } = req.params; - const { driverId } = req.body; - - if (!mongoose.Types.ObjectId.isValid(id)) { - sendError(res, 'Invalid delivery ID', StatusCodes.BAD_REQUEST); - return; - } - - if (!driverId || typeof driverId !== 'string' || !driverId.trim()) { - sendError(res, 'driverId is required', StatusCodes.BAD_REQUEST); - return; - } - - const delivery = await Delivery.findById(id); - - if (!delivery) { - sendError(res, 'Delivery not found', StatusCodes.NOT_FOUND); - return; - } - - if (delivery.status !== DeliveryStatus.PENDING) { - sendError( - res, - `Cannot assign driver to a delivery with status '${delivery.status}'`, - StatusCodes.CONFLICT, - ); - return; - } - - delivery.driverId = driverId.trim(); - delivery.status = DeliveryStatus.ASSIGNED; - await delivery.save(); - - sendSuccess(res, delivery, 'Driver assigned successfully', StatusCodes.OK); - } catch (err) { - next(err); - } -}; diff --git a/src/controllers/disputeController.ts b/src/controllers/disputeController.ts index f683159..ca110ab 100644 --- a/src/controllers/disputeController.ts +++ b/src/controllers/disputeController.ts @@ -13,12 +13,11 @@ import type { ResolveDisputeInput, AddEvidenceInput, UpdateDisputeInput, - DisputeFilter, } from '../validators/disputeValidator'; import type { IUser } from '../interfaces/IUser'; import AppError from '../utils/AppError'; -import { DisputeReason, DisputeStatus } from '../models/Dispute'; import { sendSuccess } from '../utils/responseWrapper'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; // ─── POST /api/v1/disputes ────────────────────────────────────── @@ -73,45 +72,15 @@ export const listDisputes = async ( next: NextFunction, ): Promise => { try { - const statusParam = req.query.status as string | undefined; - const status = - statusParam && Object.values(DisputeStatus).includes(statusParam as DisputeStatus) - ? (statusParam as DisputeStatus) - : undefined; - - const raisedBy = req.query.raisedBy as string | undefined; - const deliveryIdParam = req.query.deliveryId as string | undefined; - - const reasonParam = req.query.reason as string | undefined; - const reason = - reasonParam && Object.values(DisputeReason).includes(reasonParam as DisputeReason) - ? (reasonParam as DisputeReason) - : undefined; - - const page = req.query.page ? parseInt(req.query.page as string, 10) : 1; - const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10; - - const filters: DisputeFilter = { - status, - raisedBy, - deliveryId: deliveryIdParam, - reason, - page, - limit, - }; + const { filter, page, limit, sort } = resolveQueryOptions(req); - const result = await getDisputes(filters); + const result = await getDisputes({ filter, page, limit, sort }); sendSuccess( res, { disputes: result.data, - meta: { - total: result.total, - page: result.page, - limit: result.limit, - totalPages: result.totalPages, - }, + meta: buildPaginationMeta(result.total, result.page, result.limit), }, 'Disputes retrieved successfully', StatusCodes.OK, diff --git a/src/controllers/eventLogController.ts b/src/controllers/eventLogController.ts index 4e50683..23bca53 100644 --- a/src/controllers/eventLogController.ts +++ b/src/controllers/eventLogController.ts @@ -3,6 +3,7 @@ import { StatusCodes } from 'http-status-codes'; import eventLogService from '../services/eventLogService'; import { sendSuccess, sendError } from '../utils/responseWrapper'; import logger from '../config/logger'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; export class EventLogController { /** @@ -33,8 +34,17 @@ export class EventLogController { */ async getUnprocessedEvents(req: Request, res: Response, next: NextFunction): Promise { try { - const events = await eventLogService.getUnprocessedEvents(); - sendSuccess(res, events, 'Unprocessed events retrieved successfully', StatusCodes.OK); + const { page, limit, sort } = resolveQueryOptions(req); + const result = await eventLogService.getUnprocessedEvents(page, limit, sort); + sendSuccess( + res, + { + events: result.data, + meta: buildPaginationMeta(result.total, result.page, result.limit), + }, + 'Unprocessed events retrieved successfully', + StatusCodes.OK, + ); } catch (error) { logger.error('Error in getUnprocessedEvents:', error); next(error); diff --git a/src/controllers/fleetController.ts b/src/controllers/fleetController.ts index c4dc86b..35f0716 100644 --- a/src/controllers/fleetController.ts +++ b/src/controllers/fleetController.ts @@ -11,6 +11,7 @@ import mongoose from 'mongoose'; import Fleet from '../models/Fleet'; import User from '../models/User'; import { sendSuccess } from '../utils/responseWrapper'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; // ─── Request body types ──────────────────────────────────────────────────────── @@ -207,29 +208,24 @@ export const getAllFleets = async ( throw new AppError('Authentication required.', StatusCodes.UNAUTHORIZED); } - const page = parseInt(req.query.page as string) || 1; - const limit = parseInt(req.query.limit as string) || 10; - const skip = (page - 1) * limit; + const { filter, page, limit, sort } = resolveQueryOptions(req); - const fleets = await Fleet.find({ isActive: true }) - .skip(skip) - .limit(limit) - .sort({ createdAt: -1 }) - .populate('ownerId', 'name email') - .populate('members.userId', 'name email role'); - - const total = await Fleet.countDocuments({ isActive: true }); + const query = { isActive: true, ...filter }; + const [fleets, total] = await Promise.all([ + Fleet.find(query) + .sort(sort) + .skip((page - 1) * limit) + .limit(limit) + .populate('ownerId', 'name email') + .populate('members.userId', 'name email role'), + Fleet.countDocuments(query), + ]); sendSuccess( res, { fleets, - pagination: { - page, - limit, - total, - pages: Math.ceil(total / limit), - }, + meta: buildPaginationMeta(total, page, limit), }, 'Fleets retrieved successfully', StatusCodes.OK, diff --git a/src/controllers/notificationController.ts b/src/controllers/notificationController.ts index d0d956b..07b01e3 100644 --- a/src/controllers/notificationController.ts +++ b/src/controllers/notificationController.ts @@ -5,6 +5,7 @@ import type { IUser } from '../interfaces/IUser'; import { NotificationEvent } from '../models/NotificationPreference'; import { notificationService } from '../services/notificationService'; import AppError from '../utils/AppError'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; /** * NotificationController — HTTP surface for push notification preferences, @@ -38,10 +39,8 @@ export const unregisterDeviceSchema = z.object({ }); /** Query accepted by `GET /api/v1/notifications`. */ -export const listNotificationsSchema = z.object({ - page: z.coerce.number().int().min(1).default(1), - limit: z.coerce.number().int().min(1).max(100).default(20), -}); +// Pagination/validation for GET /notifications is handled by the shared +// `buildQueryOptions` middleware; the controller reads `req.queryOptions`. // ─── Helpers ─────────────────────────────────────────────────────────────────── @@ -181,18 +180,19 @@ export const listNotifications = async ( next: NextFunction, ): Promise => { try { - const { page, limit } = req.query as unknown as z.infer; - const result = await notificationService.listForUser(requireUserId(req), page, limit); + const { filter, page, limit, sort } = resolveQueryOptions(req); + + const result = await notificationService.listForUser(requireUserId(req), { + filter, + page, + limit, + sort, + }); res.status(StatusCodes.OK).json({ status: 'success', data: result.data, - pagination: { - total: result.total, - page: result.page, - limit: result.limit, - totalPages: result.totalPages, - }, + meta: buildPaginationMeta(result.total, result.page, result.limit), }); } catch (error) { next(error); diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index e906759..a12421c 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -6,7 +6,7 @@ import AppError from '../utils/AppError'; import asyncHandler from '../utils/asyncHandler'; import { sendSuccess } from '../utils/responseWrapper'; import type { AuthenticatedRequest } from '../middlewares/authMiddleware'; -import { UserRole, UserStatus } from '../interfaces/IUser'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; class UserController { /** @@ -204,37 +204,14 @@ class UserController { */ public listDeletedUsers = asyncHandler( async (req: Request, res: Response, _next: NextFunction): Promise => { - const { - role, - status, - search, - page = '1', - limit = '10', - } = req.query as Record; - - const parsedPage = Math.max(1, parseInt(page as string, 10) || 1); - const parsedLimit = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 10)); - - const filters: Parameters[0] = { - page: parsedPage, - limit: parsedLimit, - }; - - if (role) filters.role = role as UserRole; - if (status) filters.status = status as UserStatus; - if (search) filters.search = search as string; + const { filter, page, limit, sort } = resolveQueryOptions(req); - const result = await userService.getDeletedUsers(filters); + const result = await userService.getDeletedUsers({ filter, page, limit, sort }); res.status(StatusCodes.OK).json({ status: 'success', data: result.data, - pagination: { - total: result.total, - page: result.page, - limit: result.limit, - totalPages: result.totalPages, - }, + pagination: buildPaginationMeta(result.total, result.page, result.limit), }); }, ); diff --git a/src/controllers/webhookController.ts b/src/controllers/webhookController.ts index 7775aa8..f88f6f9 100644 --- a/src/controllers/webhookController.ts +++ b/src/controllers/webhookController.ts @@ -5,6 +5,7 @@ import type { RegisterWebhookInput, UpdateWebhookInput } from '../validators/web import type { IUser } from '../interfaces/IUser'; import AppError from '../utils/AppError'; import { sendSuccess } from '../utils/responseWrapper'; +import { resolveQueryOptions, buildPaginationMeta } from '../middlewares/queryMiddleware'; function requireUser(req: Request): IUser { const user = (req as Request & { user?: IUser }).user; @@ -51,8 +52,19 @@ export const listWebhooks = async ( ): Promise => { try { const user = requireUser(req); - const webhooks = await webhookService.listForMerchant(user._id.toString()); - sendSuccess(res, { webhooks }, 'Webhooks retrieved successfully', StatusCodes.OK); + const { filter, page, limit, sort } = resolveQueryOptions(req); + const result = await webhookService.listForMerchant(user._id.toString(), { + filter, + page, + limit, + sort, + }); + sendSuccess( + res, + { webhooks: result.data, meta: buildPaginationMeta(result.total, result.page, result.limit) }, + 'Webhooks retrieved successfully', + StatusCodes.OK, + ); } catch (error) { next(error); } diff --git a/src/di/container.ts b/src/di/container.ts index 616c29d..db8cc24 100644 --- a/src/di/container.ts +++ b/src/di/container.ts @@ -64,12 +64,13 @@ import * as evidenceService from '../services/evidenceService'; import { indexerService } from '../services/indexerService'; import * as monitorService from '../services/monitorService'; import { idempotencyService } from '../services/idempotency.service'; +import { SdkSorobanRpcClient } from '../services/providers/adapters'; +import { getStorageDriver } from '../services/storage.service'; // ─── Controllers ─────────────────────────────────────────────────────────── // Import controller singleton instances (already instantiated in their modules) import authController from '../controllers/authController'; import { deliveryController } from '../controllers/delivery.controller'; -import * as deliveryCrudController from '../controllers/deliveryCrudController'; import * as deliveryStatusController from '../controllers/deliveryStatusController'; import { driverController } from '../controllers/driverController'; import { driverRatingController } from '../controllers/driverRatingController'; @@ -150,6 +151,11 @@ export function createDIContainer(): AwilixContainer { [TOKENS.indexerService]: asValue(indexerService), [TOKENS.monitorService]: asValue(monitorService), [TOKENS.idempotencyService]: asValue(idempotencyService), + + // ─── External SDK clients behind provider interfaces (issue #222) ───── + [TOKENS.routingProvider]: asValue(routingService), + [TOKENS.sorobanRpcClient]: asValue(new SdkSorobanRpcClient()), + [TOKENS.imagesStorage]: asValue(getStorageDriver()), }); // ─── Register Controllers (Singleton) ────────────────────────────────── @@ -161,7 +167,6 @@ export function createDIContainer(): AwilixContainer { container.register({ [TOKENS.authController]: asValue(authController), [TOKENS.deliveryController]: asValue(deliveryController), - [TOKENS.deliveryCrudController]: asValue(deliveryCrudController), [TOKENS.deliveryStatusController]: asValue(deliveryStatusController), [TOKENS.driverController]: asValue(driverController), [TOKENS.driverRatingController]: asValue(driverRatingController), diff --git a/src/di/tokens.ts b/src/di/tokens.ts index a00e1d6..4f0216d 100644 --- a/src/di/tokens.ts +++ b/src/di/tokens.ts @@ -34,6 +34,11 @@ export const TOKENS = { monitorService: 'monitorService', idempotencyService: 'idempotencyService', + // External SDK clients behind provider interfaces (issue #222) + routingProvider: 'routingProvider', + sorobanRpcClient: 'sorobanRpcClient', + imagesStorage: 'imagesStorage', + // Models (Mongoose schemas) userModel: 'userModel', deliveryModel: 'deliveryModel', @@ -58,7 +63,6 @@ export const TOKENS = { // Controllers authController: 'authController', deliveryController: 'deliveryController', - deliveryCrudController: 'deliveryCrudController', deliveryStatusController: 'deliveryStatusController', driverController: 'driverController', driverRatingController: 'driverRatingController', diff --git a/src/indexer/escrowHandlers.ts b/src/indexer/escrowHandlers.ts index 7fea171..7936d7d 100644 --- a/src/indexer/escrowHandlers.ts +++ b/src/indexer/escrowHandlers.ts @@ -1,10 +1,24 @@ import { rpc as StellarRpc, scValToNative, xdr } from '@stellar/stellar-sdk'; -import { sorobanRpcClient } from '../config/stellar'; import { escrowIndexerConfig } from '../config/escrow'; import { escrowService, EscrowFundedInput } from '../services/escrow.service'; import { escrowIndexerService } from '../services/escrowIndexerService'; import { EscrowResolvedEvent } from './types/escrowEvents'; import logger from '../config/logger'; +import type { ISorobanRpcClient } from '../services/providers/sorobanRpcClient'; +import { defaultSorobanRpcClient } from '../services/providers/adapters'; + +/** + * Soroban RPC client used by the escrow event sync functions. + * + * Defaults to the shared SDK adapter; tests can swap in a fake via + * {@link setEscrowRpcClient} instead of reaching for module mocking. + */ +let sorobanRpcClient: ISorobanRpcClient = defaultSorobanRpcClient; + +/** Replace the RPC client used by the escrow handlers (test seam). */ +export function setEscrowRpcClient(client: ISorobanRpcClient): void { + sorobanRpcClient = client; +} /** * Native representation of the `escrow_funded` event emitted by the escrow diff --git a/src/middlewares/queryMiddleware.ts b/src/middlewares/queryMiddleware.ts index e040976..ad2fc19 100644 --- a/src/middlewares/queryMiddleware.ts +++ b/src/middlewares/queryMiddleware.ts @@ -268,6 +268,28 @@ export const resolveQueryOptions = (req: Request): QueryOptions => filter: {}, }; +/** + * Merges the middleware's `search` `$and` wrapper into a flat filter object + * so services can destructure their whitelisted keys directly. + * + * The combined filter is already a valid Mongoose query; this helper is only + * for services that need to inspect or normalise individual fields (e.g. to + * validate an enum value) before executing the query. + */ +export const flattenQueryFilter = (filter: FilterQuery): Record => { + const { $and, ...rest } = filter as Record; + + if (!Array.isArray($and)) { + return { ...rest }; + } + + const merged = $and.map((part) => + typeof part === 'object' && part !== null ? (part as Record) : {}, + ); + + return { ...rest, ...Object.assign({}, ...merged) }; +}; + /** Derives the pagination metadata returned with every paginated response. */ export const buildPaginationMeta = ( totalItems: number, diff --git a/src/middlewares/rateLimiter.ts b/src/middlewares/rateLimiter.ts index 54be26c..b4e9fb6 100644 --- a/src/middlewares/rateLimiter.ts +++ b/src/middlewares/rateLimiter.ts @@ -3,8 +3,8 @@ import env from '../config/env'; const isTest = env.NODE_ENV === 'test' || !!process.env.JEST_WORKER_ID; -/** Rate-limit ceiling used in test environments so suites are never throttled. */ -const TEST_UNLIMITED_MAX = 100_000; +/** Effectively-unbounded request cap used to disable limiting in tests. */ +const TEST_UNLIMITED_MAX = 1_000_000; /** * Strict rate limiter for authentication endpoints (login, register). diff --git a/src/repositories/BaseRepository.ts b/src/repositories/BaseRepository.ts index 84c935c..44c4eda 100644 --- a/src/repositories/BaseRepository.ts +++ b/src/repositories/BaseRepository.ts @@ -75,13 +75,16 @@ export abstract class BaseRepository implements IRepository< async createMany(data: Partial[], options?: WriteOptions): Promise { if (data.length === 0) return []; - const inserted = await this.model.insertMany(data, { + // With `rawResult: false` the driver resolves to the hydrated documents + // themselves; typing the promise generically keeps that contract explicit + // without an unsafe cast. + const inserted = (await this.model.insertMany(data, { session: options?.session, ordered: options?.ordered ?? true, rawResult: false, - }); + })) as unknown[]; - return inserted as unknown as T[]; + return inserted.filter((doc): doc is T => doc !== null && typeof doc === 'object'); } async findById(id: string, options?: ReadOptions): Promise { diff --git a/src/repositories/DeliveryRepository.ts b/src/repositories/DeliveryRepository.ts index 4bf4d4d..aec6fc5 100644 --- a/src/repositories/DeliveryRepository.ts +++ b/src/repositories/DeliveryRepository.ts @@ -64,7 +64,9 @@ export class DeliveryRepository extends BaseRepository { return new Set( found - .map((doc) => (doc as unknown as { trackingNumber?: string }).trackingNumber) + // `IDelivery` already declares `trackingNumber?: string`; the guard + // narrows the optional field instead of force-casting each document. + .map((doc) => doc.trackingNumber) .filter((value): value is string => typeof value === 'string'), ); } diff --git a/src/repositories/NotificationRepository.ts b/src/repositories/NotificationRepository.ts index aed9e93..465a6f0 100644 --- a/src/repositories/NotificationRepository.ts +++ b/src/repositories/NotificationRepository.ts @@ -1,4 +1,4 @@ -import { Types } from 'mongoose'; +import { Types, FilterQuery, SortOrder } from 'mongoose'; import Notification, { INotification } from '../models/Notification'; import { BaseRepository } from './BaseRepository'; import { Page } from './types'; @@ -17,11 +17,13 @@ export class NotificationRepository extends BaseRepository { /** One page of a user's notification history, newest first. */ async listForUser( - userId: string | Types.ObjectId, + _userId: string | Types.ObjectId, + filter: FilterQuery, page: number, limit: number, + sort: Record = { createdAt: -1 }, ): Promise> { - return this.paginate({ user: userId }, page, limit, { sort: { createdAt: -1 } }); + return this.paginate(filter, page, limit, { sort }); } /** Every notification recorded for a delivery, newest first. */ diff --git a/src/repositories/types.ts b/src/repositories/types.ts index dfed4e4..bada221 100644 --- a/src/repositories/types.ts +++ b/src/repositories/types.ts @@ -1,4 +1,11 @@ -import { ClientSession, FilterQuery, ProjectionType, QueryOptions, UpdateQuery } from 'mongoose'; +import { + ClientSession, + FilterQuery, + ProjectionType, + QueryOptions, + SortOrder, + UpdateQuery, +} from 'mongoose'; /** * Options accepted by every read operation on a repository. @@ -10,8 +17,8 @@ import { ClientSession, FilterQuery, ProjectionType, QueryOptions, UpdateQuery } export interface ReadOptions { /** Field selection, e.g. `'+password'` or `{ password: 1 }`. */ projection?: ProjectionType; - /** Sort specification, e.g. `{ createdAt: -1 }`. */ - sort?: Record; + /** Sort specification, e.g. `{ createdAt: -1 }` or `{ lastModified: 'asc' }`. */ + sort?: Record; /** Number of documents to skip (pagination offset). */ skip?: number; /** Maximum number of documents to return. */ diff --git a/src/routes/delivery.routes.ts b/src/routes/delivery.routes.ts index d714abd..61b2037 100644 --- a/src/routes/delivery.routes.ts +++ b/src/routes/delivery.routes.ts @@ -12,6 +12,19 @@ import requireRole from '../middleware/requireRole'; import { UserRole } from '../interfaces/IUser'; import { estimateFeeSchema } from '../validators/pricingValidator'; import { pricingController } from '../controllers/pricingController'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; + +/** Shared query contract for the delivery list endpoints (docs/query-contract.md). */ +const deliveryListQuery = buildQueryOptions({ + sortableFields: ['createdAt'], + filterableFields: { + status: 'string', + driver: 'string', + }, + searchableFields: ['trackingNumber', 'customer.name', 'customer.phone'], + defaultSort: { createdAt: -1 }, + defaultLimit: 10, +}); const router = Router(); @@ -19,7 +32,7 @@ const router = Router(); router.post( '/fee-estimate', authenticate, - validateRequest({ body: estimateFeeSchema }), + validate({ body: estimateFeeSchema }), pricingController.estimate.bind(pricingController), ); @@ -92,7 +105,7 @@ router.post( deliveryController.create.bind(deliveryController), ); -router.get('/', deliveryController.list.bind(deliveryController)); +router.get('/', deliveryListQuery, deliveryController.list.bind(deliveryController)); /** * @openapi @@ -119,7 +132,11 @@ router.get('/', deliveryController.list.bind(deliveryController)); * schema: * $ref: '#/components/schemas/DeliveryListResponse' */ -router.get('/archived', deliveryController.listArchived.bind(deliveryController)); +router.get( + '/archived', + deliveryListQuery, + deliveryController.listArchived.bind(deliveryController), +); /** * @openapi diff --git a/src/routes/deliveryRoutes.ts b/src/routes/deliveryRoutes.ts index d35622a..dce2138 100644 --- a/src/routes/deliveryRoutes.ts +++ b/src/routes/deliveryRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { deliveryController } from '../controllers/deliveryController'; +import { deliveryController } from '../controllers/delivery.controller'; const router = Router(); diff --git a/src/routes/disputeRoutes.ts b/src/routes/disputeRoutes.ts index 214ab35..6cf7c7f 100644 --- a/src/routes/disputeRoutes.ts +++ b/src/routes/disputeRoutes.ts @@ -3,6 +3,7 @@ import authenticate from '../middleware/authenticate'; import requireRole from '../middleware/requireRole'; import validate from '../middleware/validate'; import { UserRole } from '../interfaces/IUser'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; import { openDispute, getDispute, @@ -33,7 +34,23 @@ router.post('/', authenticate, validate({ body: createDisputeSchema }), openDisp * @access Admin only — the query is not scoped to the caller, so exposing it * to every authenticated user would leak other users' disputes. */ -router.get('/', authenticate, requireRole(UserRole.ADMIN), listDisputes); +router.get( + '/', + authenticate, + requireRole(UserRole.ADMIN), + buildQueryOptions({ + sortableFields: ['createdAt'], + filterableFields: { + status: 'string', + reason: 'string', + raisedBy: 'string', + deliveryId: 'string', + }, + defaultSort: { createdAt: -1 }, + defaultLimit: 10, + }), + listDisputes, +); /** * @route GET /api/v1/disputes/:id diff --git a/src/routes/eventLogRoutes.ts b/src/routes/eventLogRoutes.ts index a3569ba..de9e411 100644 --- a/src/routes/eventLogRoutes.ts +++ b/src/routes/eventLogRoutes.ts @@ -3,6 +3,7 @@ import eventLogController from '../controllers/eventLogController'; import authenticate from '../middleware/authenticate'; import requireRole from '../middleware/requireRole'; import { UserRole } from '../interfaces/IUser'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; const router = Router(); @@ -12,10 +13,16 @@ router.use(authenticate); // Get last processed ledger sequence router.get('/last-processed', eventLogController.getLastProcessedLedger.bind(eventLogController)); -// Get unprocessed events +// Get unprocessed events (paginated) router.get( '/unprocessed', requireRole(UserRole.ADMIN, UserRole.ENTERPRISE), + buildQueryOptions({ + sortableFields: ['createdAt', 'ledgerSequence'], + filterableFields: {}, + defaultSort: { createdAt: 1 }, + defaultLimit: 20, + }), eventLogController.getUnprocessedEvents.bind(eventLogController), ); diff --git a/src/routes/fleetRoutes.ts b/src/routes/fleetRoutes.ts index 68c0924..232cb52 100644 --- a/src/routes/fleetRoutes.ts +++ b/src/routes/fleetRoutes.ts @@ -14,6 +14,18 @@ import { removeMember, } from '../controllers/fleetController'; import { UserRole } from '../interfaces/IUser'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; + +/** Shared query contract for the fleet list endpoint (docs/query-contract.md). */ +const fleetListQuery = buildQueryOptions({ + sortableFields: ['createdAt', 'name'], + filterableFields: { + isActive: 'boolean', + }, + searchableFields: ['name'], + defaultSort: { createdAt: -1 }, + defaultLimit: 10, +}); const router = Router(); @@ -32,7 +44,7 @@ router.post('/', requireRole(UserRole.ENTERPRISE), createFleet); * @desc Get all fleets (paginated) * @access Enterprise/Admin */ -router.get('/', requireRole(UserRole.ENTERPRISE), getAllFleets); +router.get('/', requireRole(UserRole.ENTERPRISE), fleetListQuery, getAllFleets); /** * @route GET /api/v1/fleets/:id diff --git a/src/routes/notificationRoutes.ts b/src/routes/notificationRoutes.ts index ed9b31b..9a47070 100644 --- a/src/routes/notificationRoutes.ts +++ b/src/routes/notificationRoutes.ts @@ -1,10 +1,10 @@ import { Router } from 'express'; import authenticate from '../middleware/authenticate'; import validate from '../middleware/validate'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; import { getPreferences, listNotifications, - listNotificationsSchema, registerDevice, registerDeviceSchema, unregisterDevice, @@ -26,7 +26,19 @@ router.use(authenticate); * @desc Paginated notification history for the authenticated user * @access Private */ -router.get('/', validate({ query: listNotificationsSchema }), listNotifications); +router.get( + '/', + buildQueryOptions({ + sortableFields: ['createdAt'], + filterableFields: { + status: 'string', + event: 'string', + }, + defaultSort: { createdAt: -1 }, + defaultLimit: 20, + }), + listNotifications, +); /** * @route GET /api/v1/notifications/preferences diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index 6fce06e..610e5cf 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -5,6 +5,7 @@ import requireRole from '../middleware/requireRole'; import validate from '../middleware/validate'; import { updateWalletSchema } from '../validators/userValidator'; import { UserRole } from '../interfaces/IUser'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; const router = Router(); @@ -29,6 +30,16 @@ router.get( '/deleted', authMiddleware, requireRole(UserRole.ADMIN), + buildQueryOptions({ + sortableFields: ['deletedAt', 'createdAt'], + filterableFields: { + role: 'string', + status: 'string', + }, + searchableFields: ['email', 'firstName', 'lastName'], + defaultSort: { deletedAt: -1 }, + defaultLimit: 10, + }), userController.listDeletedUsers, ); diff --git a/src/routes/webhookRoutes.ts b/src/routes/webhookRoutes.ts index 4178ebd..9ea42de 100644 --- a/src/routes/webhookRoutes.ts +++ b/src/routes/webhookRoutes.ts @@ -12,6 +12,7 @@ import { rotateWebhookSecret, } from '../controllers/webhookController'; import { registerWebhookSchema, updateWebhookSchema } from '../validators/webhookValidator'; +import { buildQueryOptions } from '../middlewares/queryMiddleware'; const router = Router(); @@ -32,7 +33,18 @@ router.post('/', validate({ body: registerWebhookSchema }), registerWebhook); * @desc List the authenticated merchant's registered webhooks * @access Merchant (enterprise) or admin */ -router.get('/', listWebhooks); +router.get( + '/', + buildQueryOptions({ + sortableFields: ['createdAt'], + filterableFields: { + isActive: 'boolean', + }, + defaultSort: { createdAt: -1 }, + defaultLimit: 20, + }), + listWebhooks, +); /** * @route GET /api/v1/webhooks/:id diff --git a/src/services/authService.ts b/src/services/authService.ts index 3473aa1..a1c410e 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -1,11 +1,51 @@ import jwt from 'jsonwebtoken'; import { StatusCodes } from 'http-status-codes'; import User from '../models/User'; -import { IAuthResponse, ILoginPayload, IUser } from '../interfaces/IUser'; +import { IAuthResponse, ILoginPayload, UserRole, UserStatus } from '../interfaces/IUser'; import AppError from '../utils/AppError'; import logger from '../config/logger'; import env from '../config/env'; +/** + * Public user DTO — the exact JSON shape consumers receive from auth and + * user-profile endpoints. Derived from real Mongoose documents (never cast), + * so schema drift surfaces as a compile error instead of a runtime mismatch. + */ +export interface UserDTO { + id: string; + email: string; + firstName: string; + lastName: string; + role: UserRole; + /** Account lifecycle state — consumers gate suspended/banned accounts on it. */ + status: UserStatus; +} + +/** + * Map a Mongoose user document to the public {@link UserDTO} shape. + * + * The compiler verifies each field against the document type; no `as unknown` + * escape hatch is involved. + */ +export function toUserDTO(user: { + /** Mongoose documents expose `id` as string; lean projections as unknown. */ + id?: unknown; + email: string; + firstName: string; + lastName: string; + role: UserRole; + status: UserStatus; +}): UserDTO { + return { + id: String(user.id), + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + status: user.status, + }; +} + class AuthService { /** * Authenticate a user with email and password, returning a JWT token. @@ -50,13 +90,7 @@ class AuthService { logger.info(`User ${email} logged in successfully`); return { - user: { - id: user.id as string, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - }, + user: toUserDTO(user), token, }; } @@ -102,7 +136,7 @@ class AuthService { lastName: string; email: string; password: string; - }): Promise> { + }): Promise { const existingUser = await User.findOne({ email: payload.email }); if (existingUser) { throw new AppError('Email is already in use', StatusCodes.CONFLICT); @@ -110,17 +144,33 @@ class AuthService { const user = await User.create(payload); - return { - id: user.id as string, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - } as unknown as Partial; + return toUserDTO(user); } - public async getUserById(id: string): Promise { - return User.findById(id).lean().exec() as unknown as IUser | null; + /** + * Fetch a user by id using an explicit projection of the fields the + * {@link UserDTO} contract exposes. The projection keeps the selected + * columns and the DTO in sync: removing a field from one surfaces as a + * type error in the other. + */ + public async getUserById(id: string): Promise { + const user = await User.findById(id) + .select(['email', 'firstName', 'lastName', 'role', 'status']) + .lean<{ + id?: unknown; + email: string; + firstName: string; + lastName: string; + role: UserRole; + status: UserStatus; + }>() + .exec(); + + if (!user) { + return null; + } + + return toUserDTO(user); } } diff --git a/src/services/delivery.service.ts b/src/services/delivery.service.ts index 595b7c6..44740df 100644 --- a/src/services/delivery.service.ts +++ b/src/services/delivery.service.ts @@ -1,4 +1,4 @@ -import { Types } from 'mongoose'; +import { Types, SortOrder } from 'mongoose'; import { Asset, validateAsset } from './currencyService'; import httpStatus from 'http-status-codes'; import Delivery, { IDelivery, DeliveryStatus, ILocation, IPackage } from '../models/Delivery'; @@ -43,13 +43,16 @@ export interface AssignDriverInput { driverId: string; } -export interface DeliveryFilter { - status?: DeliveryStatus; - driver?: string; - search?: string; +/** + * Filter for the delivery list endpoint. The whitelisted fields are supplied + * by the shared query middleware; the service only normalises them. + */ +export type DeliveryFilter = { + filter?: Record; page?: number; limit?: number; -} + sort?: Record; +}; export interface PaginatedResult { data: T[]; @@ -116,32 +119,17 @@ export class DeliveryService { } async list(filters: DeliveryFilter): Promise> { - const { status, driver, search, page = 1, limit = 10 } = filters; + const { filter, page = 1, limit = 10, sort = { createdAt: -1 } } = filters; const query: Record = { // Soft-deleted deliveries are only exposed via GET /deliveries/archived. isDeleted: { $ne: true }, + ...filter, }; - if (status) { - query.status = status; - } - - if (driver) { - query.driver = new Types.ObjectId(driver); - } - - if (search) { - query.$or = [ - { trackingNumber: { $regex: search, $options: 'i' } }, - { 'customer.name': { $regex: search, $options: 'i' } }, - { 'customer.phone': { $regex: search, $options: 'i' } }, - ]; - } - const skip = (page - 1) * limit; const [data, total] = await Promise.all([ - Delivery.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).exec(), + Delivery.find(query).sort(sort).skip(skip).limit(limit).exec(), Delivery.countDocuments(query).exec(), ]); @@ -207,12 +195,16 @@ export class DeliveryService { return delivery.restore(); } - async listArchived(page = 1, limit = 10): Promise> { + async listArchived( + page = 1, + limit = 10, + sort: Record = { deletedAt: -1 }, + ): Promise> { const skip = (page - 1) * limit; const [data, total] = await Promise.all([ Delivery.find({ isDeleted: true }) .setOptions({ includeDeleted: true }) - .sort({ deletedAt: -1 }) + .sort(sort) .skip(skip) .limit(limit) .exec(), diff --git a/src/services/deliveryService.ts b/src/services/deliveryService.ts index 94920e5..063f663 100644 --- a/src/services/deliveryService.ts +++ b/src/services/deliveryService.ts @@ -1,7 +1,10 @@ import { Delivery, DeliveryStatus } from '../models/Delivery'; -import { routingService, ETARequest } from './routingService'; +import { ETARequest } from './providers/routingProvider'; +import type { IRoutingProvider } from './providers/routingProvider'; +import { routingService } from './routingService'; import QRCode from 'qrcode'; import { generateQrToken } from '../utils/qrToken'; +import AppError from '../utils/AppError'; interface DeliveryETARequest { deliveryId: string; @@ -27,6 +30,12 @@ interface DeliveryETAResponse { } class DeliveryService { + private readonly routing: IRoutingProvider; + + constructor(routing: IRoutingProvider = routingService) { + this.routing = routing; + } + async calculateDeliveryETA(request: DeliveryETARequest): Promise { const delivery = await Delivery.findOne({ deliveryId: request.deliveryId }); @@ -50,7 +59,7 @@ class DeliveryService { travelMode: 'driving', }; - const etaResult = await routingService.calculateETA(routingRequest); + const etaResult = await this.routing.calculateETA(routingRequest); delivery.distance = etaResult.distance * 1000; delivery.estimatedDuration = etaResult.estimatedTime * 60; @@ -103,20 +112,17 @@ class DeliveryService { const delivery = await Delivery.findById(deliveryId).lean(); if (!delivery) { - const err = new Error('Delivery not found'); - (err as any).statusCode = 404; - throw err; + throw new AppError('Delivery not found', 404); } // Validate delivery is in a handoff-eligible status // Use EXACT status values from Delivery schema const eligibleStatuses = [DeliveryStatus.IN_PROGRESS]; if (!eligibleStatuses.includes(delivery.status as DeliveryStatus)) { - const err = new Error( + throw new AppError( `Delivery is not eligible for handoff. Current status: ${delivery.status}`, + 400, ); - (err as any).statusCode = 400; - throw err; } // Generate secure token diff --git a/src/services/disputeService.ts b/src/services/disputeService.ts index 2aa45e4..e7b07ba 100644 --- a/src/services/disputeService.ts +++ b/src/services/disputeService.ts @@ -1,5 +1,5 @@ import { StatusCodes } from 'http-status-codes'; -import mongoose from 'mongoose'; +import mongoose, { SortOrder } from 'mongoose'; import Dispute, { DisputeReason, DisputeStatus, IDispute } from '../models/Dispute'; import Delivery, { DeliveryStatus } from '../models/Delivery'; import { sorobanService } from '../blockchain/soroban.service'; @@ -100,43 +100,27 @@ export const getDisputeById = async (id: string): Promise => { return dispute; }; -export const getDisputes = async (filters: DisputeFilter) => { - const { status, raisedBy, deliveryId, reason, page = 1, limit = 10 } = filters; - const query: Record = {}; - - if (status) { - query.status = status; - } - - if (raisedBy) { - if (!mongoose.Types.ObjectId.isValid(raisedBy)) { - throw new AppError('Invalid raisedBy format.', StatusCodes.BAD_REQUEST); - } - query.raisedBy = raisedBy; - } +export const getDisputes = async ( + filters: Pick & { + /** Whitelisted filter produced by the shared query middleware. */ + filter?: Record; + sort?: Record; + }, +) => { + const { filter = {}, page = 1, limit = 10, sort = { createdAt: -1 } } = filters; + const query: Record = { ...filter }; - if (deliveryId) { - if (!mongoose.Types.ObjectId.isValid(deliveryId)) { - throw new AppError('Invalid deliveryId format.', StatusCodes.BAD_REQUEST); - } - query.deliveryId = deliveryId; + if (typeof query.raisedBy === 'string' && !mongoose.Types.ObjectId.isValid(query.raisedBy)) { + throw new AppError('Invalid raisedBy format.', StatusCodes.BAD_REQUEST); } - if (reason) { - if (!Object.values(DisputeReason).includes(reason)) { - throw new AppError('Invalid dispute reason.', StatusCodes.BAD_REQUEST); - } - query.reason = reason; + if (typeof query.deliveryId === 'string' && !mongoose.Types.ObjectId.isValid(query.deliveryId)) { + throw new AppError('Invalid deliveryId format.', StatusCodes.BAD_REQUEST); } const skip = (page - 1) * limit; const [data, total] = await Promise.all([ - Dispute.find(query) - .populate(populateOptions) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit) - .exec(), + Dispute.find(query).populate(populateOptions).sort(sort).skip(skip).limit(limit).exec(), Dispute.countDocuments(query).exec(), ]); diff --git a/src/services/eventLogService.ts b/src/services/eventLogService.ts index 0959573..aa6ba83 100644 --- a/src/services/eventLogService.ts +++ b/src/services/eventLogService.ts @@ -1,5 +1,6 @@ import EventLog, { IEventLog } from '../models/EventLog'; import logger from '../config/logger'; +import type { SortOrder } from 'mongoose'; export class EventLogService { /** @@ -56,12 +57,38 @@ export class EventLogService { /** * Get all unprocessed events */ - async getUnprocessedEvents(): Promise { + async getUnprocessedEvents( + page = 1, + limit = 20, + sort: Record = { createdAt: 1 }, + ): Promise<{ + data: IEventLog[]; + total: number; + page: number; + limit: number; + totalPages: number; + }> { try { - return await EventLog.find({ status: 'pending' }).sort({ createdAt: 1 }).limit(100); + const safePage = Math.max(1, page); + const safeLimit = Math.min(Math.max(1, limit), 100); + const query = { status: 'pending' }; + const skip = (safePage - 1) * safeLimit; + + const [data, total] = await Promise.all([ + EventLog.find(query).sort(sort).skip(skip).limit(safeLimit), + EventLog.countDocuments(query), + ]); + + return { + data, + total, + page: safePage, + limit: safeLimit, + totalPages: Math.ceil(total / safeLimit), + }; } catch (error) { logger.error('Error fetching unprocessed events:', error); - return []; + return { data: [], total: 0, page, limit, totalPages: 0 }; } } diff --git a/src/services/indexerService.ts b/src/services/indexerService.ts index 8909e8e..f18e6d6 100644 --- a/src/services/indexerService.ts +++ b/src/services/indexerService.ts @@ -1,10 +1,9 @@ import EventLog from '../models/EventLog'; import Delivery from '../models/Delivery'; -import { sorobanRpcClient } from '../config/stellar'; import logger from '../config/logger'; -// Issue #212: the live connection handler is the single Socket.IO entry -// point — the old dedicated `sockets/index.ts` server never ran. -import { emitDeliveryStatusUpdated } from '../sockets/connectionHandler'; +import { emitDeliveryStatusUpdated } from '../sockets'; +import type { ISorobanRpcClient } from './providers/sorobanRpcClient'; +import { defaultSorobanRpcClient } from './providers/adapters'; export interface IndexerStatusData { eventType: string; contractId: string; @@ -21,9 +20,15 @@ export interface DeliveryStatusUpdatedEvent { } export class IndexerService { + private readonly rpcClient: ISorobanRpcClient; + + constructor(rpcClient: ISorobanRpcClient = defaultSorobanRpcClient) { + this.rpcClient = rpcClient; + } + public async getIndexerStatus(): Promise { try { - const currentLedgerResponse = await sorobanRpcClient.getLatestLedger(); + const currentLedgerResponse = await this.rpcClient.getLatestLedger(); const currentLedger = currentLedgerResponse.sequence; const logs = await EventLog.find({}).lean(); return logs.map((log) => { diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 962c489..0bbabed 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -1,5 +1,5 @@ import httpStatus from 'http-status-codes'; -import { Types } from 'mongoose'; +import { Types, SortOrder, FilterQuery } from 'mongoose'; import logger from '../config/logger'; import { AppError } from '../utils/AppError'; import { Delivery, DeliveryStatus, IDelivery } from '../models/Delivery'; @@ -388,10 +388,31 @@ export class NotificationService { return preference; } - /** One page of a user's notification history. */ - async listForUser(userId: string, page = 1, limit = 20): Promise> { + /** + * One page of a user's notification history. + * + * `filter`/`sort` come from the shared query middleware whitelist; the + * user scoping is applied here so a client can never read another user's + * notifications, whatever the query string contains. + */ + async listForUser( + userId: string, + options: { + filter?: Record; + page?: number; + limit?: number; + sort?: Record; + } = {}, + ): Promise> { this.assertValidUserId(userId); - return this.notificationRepository.listForUser(userId, page, limit); + const { filter = {}, page = 1, limit = 20, sort = { createdAt: -1 } } = options; + return this.notificationRepository.listForUser( + userId, + { ...filter, user: userId } as FilterQuery, + page, + limit, + sort, + ); } /** Whether the configured push provider can currently send. */ diff --git a/src/services/poolingService.ts b/src/services/poolingService.ts index b012eb7..15599c6 100644 --- a/src/services/poolingService.ts +++ b/src/services/poolingService.ts @@ -13,6 +13,7 @@ import { DriverLocation } from '../models/DriverLocation'; import { driverLocationService, NearbyDriver } from './driverLocationService'; import { deliveryService } from './delivery.service'; import { routingService } from './routingService'; +import type { IRoutingProvider } from './providers/routingProvider'; import { encodeGeohash } from '../utils/geohash'; import logger from '../config/logger'; import env from '../config/env'; @@ -62,9 +63,15 @@ export interface AssignPoolResult { // ─── Service ────────────────────────────────────────────────────────────────── export class PoolingService { + private readonly routing: IRoutingProvider; + private readonly DEFAULT_PROXIMITY_M = 2000; private readonly DEFAULT_MAX_POOL_SIZE = 5; + constructor(routing: IRoutingProvider = routingService) { + this.routing = routing; + } + /** * Find pending/funded deliveries and group them into proximity-based pools. */ @@ -249,7 +256,7 @@ export class PoolingService { for (let i = 0; i < routeSequence.length - 1; i++) { const from = routeSequence[i].coordinates; const to = routeSequence[i + 1].coordinates; - const eta = await routingService.calculateETA({ + const eta = await this.routing.calculateETA({ pickup: from, dropoff: to, travelMode: 'driving', diff --git a/src/services/providers/adapters.ts b/src/services/providers/adapters.ts new file mode 100644 index 0000000..6af7cd7 --- /dev/null +++ b/src/services/providers/adapters.ts @@ -0,0 +1,212 @@ +/** + * Concrete adapters implementing the provider contracts in this directory by + * delegating to the real external SDKs (issue #222). + * + * Keeping the SDK wiring in one place means the rest of the codebase depends + * only on the interfaces (`IRoutingProvider`, `ISorobanRpcClient`, + * `IImagesStorage`), and unit tests can substitute fakes without network + * access or module mocking. + */ + +import axios from 'axios'; +import type { Transaction, FeeBumpTransaction, rpc as StellarRpc } from '@stellar/stellar-sdk'; +import { sorobanRpcClient } from '../../config/stellar'; +import type { Coordinates, ETARequest, ETAResponse, IRoutingProvider } from './routingProvider'; +import type { + ISorobanRpcClient, + PreparedTransaction, + SorobanGetEventsRequest, +} from './sorobanRpcClient'; + +// ─── Google Maps routing adapter ────────────────────────────────────────────── + +const GOOGLE_MAPS_DIRECTIONS_URL = 'https://maps.googleapis.com/maps/api/directions/json'; + +/** Median free-flow speed (km/h) used when no routing provider is configured. */ +const FALLBACK_SPEEDS_KMH: Record, number> = { + driving: 40, + walking: 5, + bicycling: 15, + transit: 25, +}; + +/** + * Google Maps Directions implementation of {@link IRoutingProvider}. + * + * When no API key is configured (or the API call fails) it degrades to a + * great-circle (Haversine) estimate so ETA endpoints keep working without + * external credentials. + */ +export class GoogleMapsRoutingProvider implements IRoutingProvider { + private readonly apiKey: string; + private readonly baseUrl: string; + + constructor(apiKey: string, baseUrl: string = GOOGLE_MAPS_DIRECTIONS_URL) { + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + async calculateETA(request: ETARequest): Promise { + try { + if (this.apiKey) { + return await this.calculateWithGoogleMaps(request); + } + return this.calculateWithHaversine(request); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to calculate ETA:', error); + throw new Error('Failed to calculate delivery ETA'); + } + } + + private async calculateWithGoogleMaps(request: ETARequest): Promise { + const { pickup, dropoff, travelMode = 'driving' } = request; + + const params = { + origin: `${pickup.lat},${pickup.lng}`, + destination: `${dropoff.lat},${dropoff.lng}`, + mode: travelMode, + key: this.apiKey, + units: 'metric', + }; + + const response = await axios.get(this.baseUrl, { params }); + + if (response.data.status !== 'OK') { + throw new Error(`Google Maps API error: ${response.data.status}`); + } + + const route = response.data.routes[0]; + const leg = route.legs[0]; + + return { + estimatedTime: Math.ceil(leg.duration.value / 60), + distance: leg.distance.value / 1000, + durationText: leg.duration.text, + distanceText: leg.distance.text, + route: { + distance: leg.distance.value, + duration: leg.duration.value, + distanceText: leg.distance.text, + durationText: leg.duration.text, + }, + }; + } + + private calculateWithHaversine(request: ETARequest): ETAResponse { + const { pickup, dropoff, travelMode = 'driving' } = request; + + const distance = this.calculateHaversineDistance(pickup, dropoff); + const distanceKm = distance / 1000; + + const speed = FALLBACK_SPEEDS_KMH[travelMode]; + const durationMinutes = (distanceKm / speed) * 60; + + return { + estimatedTime: Math.ceil(durationMinutes), + distance: Math.round(distanceKm * 100) / 100, + durationText: `${Math.ceil(durationMinutes)} mins`, + distanceText: `${Math.round(distanceKm * 100) / 100} km`, + route: { + distance, + duration: durationMinutes * 60, + distanceText: `${Math.round(distanceKm * 100) / 100} km`, + durationText: `${Math.ceil(durationMinutes)} mins`, + }, + }; + } + + /** + * Calculate the great-circle distance between two points using the Haversine + * formula. Handles edge cases including anti-meridian crossings (±180° + * longitude). + * + * @param point1 - First coordinate point + * @param point2 - Second coordinate point + * @returns Distance in meters + */ + private calculateHaversineDistance(point1: Coordinates, point2: Coordinates): number { + const R = 6371000; // Earth's radius in meters + + const lat1Rad = this.toRadians(point1.lat); + const lat2Rad = this.toRadians(point2.lat); + const dLatRad = this.toRadians(point2.lat - point1.lat); + + // Handle anti-meridian edge case: + // When longitude difference exceeds 180°, wrap around the shorter path + let lngDiff = point2.lng - point1.lng; + + // Normalize longitude difference to [-180, 180] + if (lngDiff > 180) { + lngDiff -= 360; + } else if (lngDiff < -180) { + lngDiff += 360; + } + + const dLngRad = this.toRadians(lngDiff); + + // Haversine formula + const a = + Math.sin(dLatRad / 2) * Math.sin(dLatRad / 2) + + Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.sin(dLngRad / 2) * Math.sin(dLngRad / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + private toRadians(degrees: number): number { + return degrees * (Math.PI / 180); + } +} + +// ─── Soroban RPC adapter ────────────────────────────────────────────────────── + +/** + * `@stellar/stellar-sdk` implementation of {@link ISorobanRpcClient}. + * + * A thin delegate over `rpc.Server`: every method maps 1:1 onto the SDK, so + * there is no behaviour to test here — the value is that consumers can accept + * the interface and tests can pass fakes instead. + */ +export class SdkSorobanRpcClient implements ISorobanRpcClient { + private readonly server: StellarRpc.Server; + + constructor(server: StellarRpc.Server = sorobanRpcClient) { + this.server = server; + } + + getAccount(address: string) { + return this.server.getAccount(address); + } + + getHealth() { + return this.server.getHealth(); + } + + getTransaction(hash: string) { + return this.server.getTransaction(hash); + } + + getEvents(request: SorobanGetEventsRequest) { + return this.server.getEvents(request); + } + + getLatestLedger() { + return this.server.getLatestLedger(); + } + + getNetwork() { + return this.server.getNetwork(); + } + + prepareTransaction(tx: Transaction | FeeBumpTransaction): Promise { + return this.server.prepareTransaction(tx); + } + + sendTransaction(tx: Transaction | FeeBumpTransaction) { + return this.server.sendTransaction(tx); + } +} + +/** Shared adapter instance wrapping the configured default `rpc.Server`. */ +export const defaultSorobanRpcClient: ISorobanRpcClient = new SdkSorobanRpcClient(); diff --git a/src/services/providers/imagesStorage.ts b/src/services/providers/imagesStorage.ts new file mode 100644 index 0000000..4b14c99 --- /dev/null +++ b/src/services/providers/imagesStorage.ts @@ -0,0 +1,20 @@ +/** + * Provider-agnostic images/file storage contract (issue #222). + * + * `storage.service.ts` already defines the `StorageDriver` abstraction with + * local-disk and S3 implementations. This module gives that contract its + * canonical, domain-facing name so consumers can depend on `IImagesStorage` + * (consistent with `IPushProvider`/`IRoutingProvider`/`ISorobanRpcClient`) + * and the driver can be registered in the DI container for test doubles. + */ + +import type { StorageDriver } from '../storage.service'; + +/** + * Contract for persisting uploaded images and retrieving their URLs. + * + * Functionally identical to `StorageDriver`; kept as a separate exported name + * so the issue's `IImagesStorage` terminology has a canonical home and new + * consumers can import the contract without reaching into driver internals. + */ +export type IImagesStorage = StorageDriver; diff --git a/src/services/providers/routingProvider.ts b/src/services/providers/routingProvider.ts new file mode 100644 index 0000000..4f1ae8e --- /dev/null +++ b/src/services/providers/routingProvider.ts @@ -0,0 +1,50 @@ +/** + * Provider-agnostic routing/directions contract. + * + * ETA-dependent services (delivery ETA, delivery pooling) depend only on this + * interface, so swapping Google Maps Directions for another routing provider + * (Mapbox, OSRM, …) is a configuration change rather than a rewrite of the + * business logic. It also lets unit tests inject deterministic fakes instead + * of calling the Google Maps API. + */ + +/** A geographic point in decimal degrees. */ +export interface Coordinates { + lat: number; + lng: number; +} + +/** Raw route metrics returned by the underlying directions provider. */ +export interface RouteInfo { + distance: number; + duration: number; + distanceText: string; + durationText: string; +} + +/** Input for an ETA calculation. */ +export interface ETARequest { + pickup: Coordinates; + dropoff: Coordinates; + travelMode?: 'driving' | 'walking' | 'bicycling' | 'transit'; +} + +/** Result of an ETA calculation. */ +export interface ETAResponse { + estimatedTime: number; + distance: number; + durationText: string; + distanceText: string; + route: RouteInfo; +} + +/** + * Contract for a directions/ETA provider. + * + * Implementations must degrade gracefully (e.g. a distance-based fallback) + * when the provider is not configured, and throw when the request cannot be + * served at all. + */ +export interface IRoutingProvider { + calculateETA(request: ETARequest): Promise; +} diff --git a/src/services/providers/sorobanRpcClient.ts b/src/services/providers/sorobanRpcClient.ts new file mode 100644 index 0000000..1bfc10f --- /dev/null +++ b/src/services/providers/sorobanRpcClient.ts @@ -0,0 +1,51 @@ +/** + * Provider-agnostic Soroban RPC contract. + * + * Services that talk to the Stellar network (stellarService, sorobanService, + * transactionService, indexerService, escrow handlers) depend only on this + * interface, so the concrete `rpc.Server` from `@stellar/stellar-sdk` can be + * swapped for a fake in unit tests — no network, no credentials, no module + * mocking. It mirrors the `IPushProvider` pattern used for push notifications. + * + * Method signatures intentionally match `rpc.Server` 1:1 (returning the SDK's + * own parsed response types), so the SDK adapter below is a trivial delegate + * and fakes remain structurally compatible with the real client. + */ + +import type { + Account, + FeeBumpTransaction, + Transaction, + rpc as StellarRpc, +} from '@stellar/stellar-sdk'; + +/** Request shape for contract-event queries (`rpc.Server.GetEventsRequest`). */ +export type SorobanGetEventsRequest = StellarRpc.Server.GetEventsRequest; + +/** + * Transaction returned by `prepareTransaction`. + * Derived from the SDK's own signature so it can never drift. + */ +export type PreparedTransaction = Awaited>; + +/** Methods consumed from the Soroban RPC node, extracted behind an interface. */ +export interface ISorobanRpcClient { + /** Fetch the current sequence/attributes of an on-network account. */ + getAccount(address: string): Promise; + /** Liveness probe of the RPC node. */ + getHealth(): Promise; + /** Fetch a transaction's terminal state by hash. */ + getTransaction(hash: string): Promise; + /** Query contract events emitted from a ledger onwards. */ + getEvents(request: SorobanGetEventsRequest): Promise; + /** Header metadata for the newest ledger on the network. */ + getLatestLedger(): Promise; + /** Metadata (passphrase, protocol version) of the connected network. */ + getNetwork(): Promise; + /** Simulate a transaction and attach footprint/fees/resource data. */ + prepareTransaction(tx: Transaction | FeeBumpTransaction): Promise; + /** Submit a signed transaction envelope to the network. */ + sendTransaction( + tx: Transaction | FeeBumpTransaction, + ): Promise; +} diff --git a/src/services/routingService.ts b/src/services/routingService.ts index 9ff8104..6d0a091 100644 --- a/src/services/routingService.ts +++ b/src/services/routingService.ts @@ -1,162 +1,36 @@ -import axios from 'axios'; -import env from '../config/env'; - -export interface Coordinates { - lat: number; - lng: number; -} - -export interface RouteInfo { - distance: number; - duration: number; - distanceText: string; - durationText: string; -} - -export interface ETARequest { - pickup: Coordinates; - dropoff: Coordinates; - travelMode?: 'driving' | 'walking' | 'bicycling' | 'transit'; -} - -export interface ETAResponse { - estimatedTime: number; - distance: number; - durationText: string; - distanceText: string; - route: RouteInfo; -} +/** + * Routing facade over the injectable {@link IRoutingProvider} (issue #222). + * + * The Google Maps implementation (plus the Haversine fallback) now lives in + * `providers/adapters.ts` behind the `IRoutingProvider` contract, so ETA + * consumers and unit tests can swap in a fake without touching this module. + * The exported types are re-exports of the shared contract to keep existing + * imports (`import { ETARequest } from './routingService'`) working. + */ -class RoutingService { - private readonly apiKey: string; - private readonly baseUrl: string; - - constructor() { - this.apiKey = env.GOOGLE_MAPS_API_KEY ?? ''; - this.baseUrl = 'https://maps.googleapis.com/maps/api/directions/json'; - - if (!this.apiKey) { - // eslint-disable-next-line no-console - console.warn('⚠️ Google Maps API key not configured. Using fallback calculation.'); - } +import env from '../config/env'; +import { GoogleMapsRoutingProvider } from './providers/adapters'; +import type { + Coordinates, + ETARequest, + ETAResponse, + IRoutingProvider, +} from './providers/routingProvider'; + +export type { Coordinates, ETARequest, ETAResponse }; +export { IRoutingProvider }; + +class RoutingService implements IRoutingProvider { + private readonly provider: IRoutingProvider; + + constructor( + provider: IRoutingProvider = new GoogleMapsRoutingProvider(env.GOOGLE_MAPS_API_KEY ?? ''), + ) { + this.provider = provider; } async calculateETA(request: ETARequest): Promise { - try { - if (this.apiKey) { - return await this.calculateWithGoogleMaps(request); - } - return this.calculateWithHaversine(request); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to calculate ETA:', error); - throw new Error('Failed to calculate delivery ETA'); - } - } - - private async calculateWithGoogleMaps(request: ETARequest): Promise { - const { pickup, dropoff, travelMode = 'driving' } = request; - - const params = { - origin: `${pickup.lat},${pickup.lng}`, - destination: `${dropoff.lat},${dropoff.lng}`, - mode: travelMode, - key: this.apiKey, - units: 'metric', - }; - - const response = await axios.get(this.baseUrl, { params }); - - if (response.data.status !== 'OK') { - throw new Error(`Google Maps API error: ${response.data.status}`); - } - - const route = response.data.routes[0]; - const leg = route.legs[0]; - - return { - estimatedTime: Math.ceil(leg.duration.value / 60), - distance: leg.distance.value / 1000, - durationText: leg.duration.text, - distanceText: leg.distance.text, - route: { - distance: leg.distance.value, - duration: leg.duration.value, - distanceText: leg.distance.text, - durationText: leg.duration.text, - }, - }; - } - - private calculateWithHaversine(request: ETARequest): ETAResponse { - const { pickup, dropoff, travelMode = 'driving' } = request; - - const distance = this.calculateHaversineDistance(pickup, dropoff); - const distanceKm = distance / 1000; - - const speeds: Record = { - driving: 40, - walking: 5, - bicycling: 15, - transit: 25, - }; - - const speed = speeds[travelMode] || 40; - const durationMinutes = (distanceKm / speed) * 60; - - return { - estimatedTime: Math.ceil(durationMinutes), - distance: Math.round(distanceKm * 100) / 100, - durationText: `${Math.ceil(durationMinutes)} mins`, - distanceText: `${Math.round(distanceKm * 100) / 100} km`, - route: { - distance: distance, - duration: durationMinutes * 60, - distanceText: `${Math.round(distanceKm * 100) / 100} km`, - durationText: `${Math.ceil(durationMinutes)} mins`, - }, - }; - } - - /** - * Calculate the great-circle distance between two points using the Haversine formula. - * Handles edge cases including anti-meridian crossings (±180° longitude). - * - * @param point1 - First coordinate point - * @param point2 - Second coordinate point - * @returns Distance in meters - */ - private calculateHaversineDistance(point1: Coordinates, point2: Coordinates): number { - const R = 6371000; // Earth's radius in meters - - const lat1Rad = this.toRadians(point1.lat); - const lat2Rad = this.toRadians(point2.lat); - const dLatRad = this.toRadians(point2.lat - point1.lat); - - // Handle anti-meridian edge case: - // When longitude difference exceeds 180°, wrap around the shorter path - let lngDiff = point2.lng - point1.lng; - - // Normalize longitude difference to [-180, 180] - if (lngDiff > 180) { - lngDiff -= 360; - } else if (lngDiff < -180) { - lngDiff += 360; - } - - const dLngRad = this.toRadians(lngDiff); - - // Haversine formula - const a = - Math.sin(dLatRad / 2) * Math.sin(dLatRad / 2) + - Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.sin(dLngRad / 2) * Math.sin(dLngRad / 2); - - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; - } - - private toRadians(degrees: number): number { - return degrees * (Math.PI / 180); + return this.provider.calculateETA(request); } } diff --git a/src/services/stellarService.ts b/src/services/stellarService.ts index e55f401..42ae513 100644 --- a/src/services/stellarService.ts +++ b/src/services/stellarService.ts @@ -10,7 +10,9 @@ import { xdr, } from '@stellar/stellar-sdk'; import { StatusCodes } from 'http-status-codes'; -import { sorobanRpcClient, stellarConfig } from '../config/stellar'; +import { stellarConfig } from '../config/stellar'; +import type { ISorobanRpcClient } from './providers/sorobanRpcClient'; +import { SdkSorobanRpcClient } from './providers/adapters'; import { deliveryService } from './delivery.service'; import { IDelivery } from '../models/Delivery'; import { toStroops } from '../utils/stroops'; @@ -160,6 +162,11 @@ export function isTransientRpcError(error: unknown, kind: AttemptFailureKind = ' ); } +/** Response variant accepted by {@link isBadSeqError} (parsed or raw). */ +type SendTransactionResult = + | StellarRpc.Api.SendTransactionResponse + | StellarRpc.Api.RawSendTransactionResponse; + /** * Inspect a `SendTransactionResponse` or a thrown error and decide whether it * represents a sequence-number mismatch (`tx_bad_seq`). @@ -173,27 +180,31 @@ export function isTransientRpcError(error: unknown, kind: AttemptFailureKind = ' * * We check both paths so the guard is robust to SDK / node differences. */ -function isBadSeqError(responseOrError: StellarRpc.Api.SendTransactionResponse | unknown): boolean { +function isBadSeqError(responseOrError: SendTransactionResult | Error): boolean { // Path 1: resolved response object if ( typeof responseOrError === 'object' && responseOrError !== null && 'status' in responseOrError ) { - const resp = responseOrError as StellarRpc.Api.SendTransactionResponse; + const resp: SendTransactionResult = responseOrError; if (resp.status !== 'ERROR') return false; // Try to decode the error result to confirm it's specifically txBAD_SEQ. - // SDK v13 exposes the decoded `errorResult` (the raw `errorResultXdr` - // string only exists on the raw response variant). + // SDK v13 exposes the decoded `errorResult` on the parsed response, while + // the raw transport variant only carries the base64 `errorResultXdr`. const rawResultXdr = (() => { - const withRaw = resp as unknown as { errorResultXdr?: string }; - if (withRaw.errorResultXdr) return withRaw.errorResultXdr; - try { - return resp.errorResult?.toXDR('base64'); - } catch { - return undefined; + if ('errorResultXdr' in resp && typeof resp.errorResultXdr === 'string') { + return resp.errorResultXdr; + } + if ('errorResult' in resp && resp.errorResult) { + try { + return resp.errorResult.toXDR('base64'); + } catch { + return undefined; + } } + return undefined; })(); if (rawResultXdr) { @@ -247,7 +258,7 @@ function isBadSeqError(responseOrError: StellarRpc.Api.SendTransactionResponse | * Retry attempts are capped by `STELLAR_BAD_SEQ_MAX_RETRIES` (default 3). */ export class StellarService { - private readonly client: StellarRpc.Server; + private readonly client: ISorobanRpcClient; private readonly badSeqMaxRetries: number; private readonly rpcMaxAttempts: number; private readonly rpcBaseDelayMs: number; @@ -255,7 +266,7 @@ export class StellarService { private readonly rpcJitterRatio: number; private readonly rpcTimeoutMs: number; - constructor(client: StellarRpc.Server = sorobanRpcClient) { + constructor(client: ISorobanRpcClient = new SdkSorobanRpcClient()) { this.client = client; this.badSeqMaxRetries = env.STELLAR_BAD_SEQ_MAX_RETRIES; this.rpcMaxAttempts = env.SOROBAN_RPC_MAX_RETRIES; @@ -408,8 +419,10 @@ export class StellarService { // ── Other error ─────────────────────────────────────────────────────── const errXdr = - response.errorResult?.toXDR('base64') ?? - (response as unknown as { errorResultXdr?: string }).errorResultXdr ?? + ('errorResult' in response && response.errorResult + ? response.errorResult.toXDR('base64') + : undefined) ?? + ('errorResultXdr' in response ? response.errorResultXdr : undefined) ?? '(no XDR)'; logger.error( `[StellarService] Submission failed — status=${response.status} ` + @@ -480,7 +493,7 @@ export class StellarService { // ── Private helpers ───────────────────────────────────────────────────────── /** Send a signed XDR to the RPC node. Never throws — returns the response. */ - private async send(signedXdr: string): Promise { + private async send(signedXdr: string): Promise { try { // Deserialise the signed envelope XDR into a Transaction object. const tx = TransactionBuilder.fromXDR( @@ -507,22 +520,22 @@ export class StellarService { ); } - // If the SDK itself throws with bad-seq language surface it as a - // synthetic response object so the caller's isBadSeqError check works. + // If the SDK itself throws with bad-seq language, surface a typed raw + // error response so the caller's isBadSeqError check works without + // falling back to message matching on the submit path. if ( message.toLowerCase().includes('tx_bad_seq') || message.toLowerCase().includes('txbadseq') ) { logger.warn(`[StellarService] sendTransaction threw bad-seq: ${message}`); - return { + const rawResponse: StellarRpc.Api.RawSendTransactionResponse = { status: 'ERROR', hash: '', - errorResultXdr: '', - networkPassphrase: stellarConfig.networkPassphrase, - latestCheckpointLedger: 0, latestLedger: 0, - latestLedgerCloseTime: BigInt(0), - } as unknown as StellarRpc.Api.SendTransactionResponse; + latestLedgerCloseTime: 0, + errorResultXdr: '', + }; + return rawResponse; } logger.error(`[StellarService] sendTransaction threw unexpectedly: ${message}`); @@ -570,11 +583,8 @@ export class StellarService { } if (txResponse.status === StellarRpc.Api.GetTransactionStatus.FAILED) { - // SDK v13 returns the decoded TransactionResult on failure responses. - const resultXdr = - txResponse.resultXdr?.toXDR('base64') ?? - (txResponse as unknown as { resultXdr?: string }).resultXdr ?? - '(no XDR)'; + // The FAILED variant always carries the decoded TransactionResult. + const resultXdr = txResponse.resultXdr.toXDR('base64'); logger.error(`[StellarService] Transaction FAILED — hash=${hash} resultXdr=${resultXdr}`); throw new AppError( `Transaction was submitted but failed on-chain. Result XDR: ${resultXdr}`, diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 4a22b51..8c5324c 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -6,11 +6,10 @@ import { Transaction, TransactionBuilder, nativeToScVal, - rpc as StellarRpc, } from '@stellar/stellar-sdk'; import { StatusCodes } from 'http-status-codes'; import CircuitBreaker from 'opossum'; -import { sorobanRpcClient, stellarConfig } from '../config/stellar'; +import { stellarConfig } from '../config/stellar'; import { deliveryService } from './delivery.service'; import { DeliveryStatus, IDelivery } from '../models/Delivery'; import { fromStroops, toStroops } from '../utils/stroops'; @@ -18,6 +17,8 @@ import AppError from '../utils/AppError'; import logger from '../config/logger'; import env from '../config/env'; import { createCircuitBreaker, fireWithBreaker } from '../utils/circuitBreaker'; +import type { ISorobanRpcClient } from './providers/sorobanRpcClient'; +import { defaultSorobanRpcClient } from './providers/adapters'; /** Input accepted by {@link TransactionService.buildEscrowLockXdr}. */ export interface EscrowLockXdrInput { @@ -77,10 +78,10 @@ const NON_LOCKABLE_STATUSES: ReadonlySet = new Set([ * without waiting for a TCP timeout. */ export class TransactionService { - private readonly client: StellarRpc.Server; + private readonly client: ISorobanRpcClient; private readonly breaker: CircuitBreaker<[() => Promise], unknown>; - constructor(client: StellarRpc.Server = sorobanRpcClient) { + constructor(client: ISorobanRpcClient = defaultSorobanRpcClient) { this.client = client; // A dedicated breaker for transaction-building RPC calls. We use a diff --git a/src/services/userService.ts b/src/services/userService.ts index 35868f5..f0d42f8 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,8 +1,8 @@ import { StatusCodes } from 'http-status-codes'; -import mongoose from 'mongoose'; +import mongoose, { SortOrder } from 'mongoose'; import User from '../models/User'; import DriverProfile from '../models/DriverProfile'; -import Delivery, { IDelivery } from '../models/Delivery'; +import Delivery from '../models/Delivery'; import { IUser, UserRole, UserStatus } from '../interfaces/IUser'; import { AppError } from '../utils/AppError'; import logger from '../config/logger'; @@ -31,6 +31,8 @@ export interface UserFilter { page?: number; limit?: number; includeDeleted?: boolean; + /** Whitelisted filter produced by the shared query middleware. */ + filter?: Record; } export interface PaginatedUserResult { @@ -183,12 +185,11 @@ export class UserService { let driverProfileDeleted = false; let deliveriesDeleted = 0; - // Cascade: soft-delete driver profile + // Cascade: soft-delete driver profile. `softDelete` is declared on the + // schema's methods, so the hydrated document type already carries it. const driverProfile = await DriverProfile.findOne({ userId: id }); if (driverProfile) { - await ( - driverProfile as unknown as { softDelete(userId?: string): Promise } - ).softDelete(userId); + await driverProfile.softDelete(userId); driverProfileDeleted = true; } @@ -207,7 +208,7 @@ export class UserService { .setOptions({ includeDeleted: true }) .exec(); for (const delivery of deliveries) { - await (delivery as unknown as IDelivery).softDelete(userId); + await delivery.softDelete(userId); deliveriesDeleted++; } @@ -257,30 +258,18 @@ export class UserService { /** * List soft-deleted users. */ - async getDeletedUsers(filters: Omit): Promise { - const { page = 1, limit = 10, ...rest } = filters; + async getDeletedUsers( + filters: Omit & { + sort?: Record; + }, + ): Promise { + const { page = 1, limit = 10, filter, sort = { deletedAt: -1 } } = filters; - const query: Record = { isDeleted: true }; - - if (rest.role) { - query.role = rest.role; - } - - if (rest.status) { - query.status = rest.status; - } - - if (rest.search) { - query.$or = [ - { email: { $regex: rest.search, $options: 'i' } }, - { firstName: { $regex: rest.search, $options: 'i' } }, - { lastName: { $regex: rest.search, $options: 'i' } }, - ]; - } + const query: Record = { isDeleted: true, ...filter }; const skip = (page - 1) * limit; const [data, total] = await Promise.all([ - User.find(query).sort({ deletedAt: -1 }).skip(skip).limit(limit).exec(), + User.find(query).sort(sort).skip(skip).limit(limit).exec(), User.countDocuments(query).exec(), ]); diff --git a/src/services/webhookService.ts b/src/services/webhookService.ts index 39b5073..57b1b70 100644 --- a/src/services/webhookService.ts +++ b/src/services/webhookService.ts @@ -27,7 +27,7 @@ import crypto from 'crypto'; import axios from 'axios'; import { StatusCodes } from 'http-status-codes'; -import { Types } from 'mongoose'; +import { Types, SortOrder } from 'mongoose'; import { WebhookSubscription, IWebhookSubscription, @@ -111,9 +111,44 @@ export class WebhookService { } /** List a merchant's registered webhooks, newest first. Secret is never included. */ - async listForMerchant(merchantId: string): Promise { + /** + * One page of the merchant's registered webhooks. + * + * `filter`/`sort` come from the shared query middleware whitelist; scoping + * to the authenticated merchant happens here. + */ + async listForMerchant( + merchantId: string, + options: { + filter?: Record; + page?: number; + limit?: number; + sort?: Record; + } = {}, + ): Promise<{ + data: IWebhookSubscription[]; + total: number; + page: number; + limit: number; + totalPages: number; + }> { this.assertValidObjectId(merchantId, 'merchantId'); - return WebhookSubscription.find({ merchantId }).sort({ createdAt: -1 }); + const { filter = {}, page = 1, limit = 20, sort = { createdAt: -1 } } = options; + + const query = { ...filter, merchantId }; + const skip = (page - 1) * limit; + const [data, total] = await Promise.all([ + WebhookSubscription.find(query).sort(sort).skip(skip).limit(limit), + WebhookSubscription.countDocuments(query), + ]); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; } /** Fetch one webhook, scoped to its owning merchant. */ diff --git a/tests/delivery.test.ts b/tests/delivery.test.ts index 9bb37a6..61507f7 100644 --- a/tests/delivery.test.ts +++ b/tests/delivery.test.ts @@ -135,7 +135,7 @@ describe('Delivery API — GET /api/v1/deliveries', () => { expect(res.status).toBe(200); expect(res.body.data.deliveries).toHaveLength(2); - expect(res.body.data.meta.total).toBe(5); + expect(res.body.data.meta.totalItems).toBe(5); expect(res.body.data.meta.totalPages).toBe(3); }); diff --git a/tests/di.container.test.ts b/tests/di.container.test.ts index 0145e7e..c4a3324 100644 --- a/tests/di.container.test.ts +++ b/tests/di.container.test.ts @@ -161,6 +161,9 @@ describe('DI Container', () => { TOKENS.indexerService, TOKENS.monitorService, TOKENS.idempotencyService, + TOKENS.routingProvider, + TOKENS.sorobanRpcClient, + TOKENS.imagesStorage, ]; services.forEach((token) => { diff --git a/tests/dispute.test.ts b/tests/dispute.test.ts index eee71f4..5082538 100644 --- a/tests/dispute.test.ts +++ b/tests/dispute.test.ts @@ -422,9 +422,9 @@ describe('GET /api/v1/disputes', () => { const res = await request(app).get('/api/v1/disputes').set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); - expect(Array.isArray(res.body.data)).toBe(true); - expect(res.body.data.length).toBeGreaterThanOrEqual(1); - expect(res.body.meta.total).toBeGreaterThanOrEqual(1); + expect(Array.isArray(res.body.data.disputes)).toBe(true); + expect(res.body.data.disputes.length).toBeGreaterThanOrEqual(1); + expect(res.body.data.meta.totalItems).toBeGreaterThanOrEqual(1); }); it('filters disputes by status', async () => { diff --git a/tests/disputeRoutes.test.ts b/tests/disputeRoutes.test.ts index 831f0ce..ca94ed7 100644 --- a/tests/disputeRoutes.test.ts +++ b/tests/disputeRoutes.test.ts @@ -88,8 +88,9 @@ describe('GET /api/v1/disputes', () => { const res = await request(app).get('/api/v1/disputes').set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); - expect(res.body.data).toHaveLength(1); - expect(res.body.data[0].disputeId).toBe('dispute-1'); + expect(res.body.data.disputes).toHaveLength(1); + expect(res.body.data.disputes[0].disputeId).toBe('dispute-1'); + expect(res.body.data.meta.totalItems).toBe(1); }); }); diff --git a/tests/integration/queryContract.test.ts b/tests/integration/queryContract.test.ts new file mode 100644 index 0000000..5db48c8 --- /dev/null +++ b/tests/integration/queryContract.test.ts @@ -0,0 +1,381 @@ +/** + * Integration tests: shared query contract across list endpoints (issue #221). + * + * Every migrated list endpoint must accept `page`, `limit`, `sort` and + * whitelisted filters, and return the exact same pagination meta structure + * built by `buildPaginationMeta` (see docs/query-contract.md). + * + * Real MongoDB via MongoMemoryServer; the app is booted via supertest so the + * actual middleware chain (auth → role → buildQueryOptions → controller) runs. + */ + +import request from 'supertest'; +import mongoose from 'mongoose'; +import jwt from 'jsonwebtoken'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { Express } from 'express'; + +import { Delivery } from '../../src/models/Delivery'; +import User from '../../src/models/User'; +import Dispute from '../../src/models/Dispute'; +import Fleet from '../../src/models/Fleet'; +import Notification from '../../src/models/Notification'; +import { WebhookSubscription } from '../../src/models/WebhookSubscription'; +import EventLog from '../../src/models/EventLog'; +import { NotificationStatus } from '../../src/models/Notification'; +import { DisputeStatus, DisputeReason } from '../../src/models/Dispute'; +import { UserRole } from '../../src/interfaces/IUser'; +import env from '../../src/config/env'; + +jest.mock('../../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +jest.mock('../../src/config/database', () => ({ + connectDatabase: jest.fn(), +})); + +/** The meta shape every list endpoint must return. */ +const META_KEYS = [ + 'totalItems', + 'totalPages', + 'currentPage', + 'limit', + 'hasNextPage', + 'hasPreviousPage', + 'nextPage', + 'previousPage', +] as const; + +let app: Express; +let mongoServer: MongoMemoryServer; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGODB_URI = mongoServer.getUri(); + + const mod = await import('../../src/app'); + app = mod.default; + await mongoose.connect(mongoServer.getUri()); +}); + +afterEach(async () => { + const collections = mongoose.connection.collections; + for (const key of Object.keys(collections)) { + await collections[key].deleteMany({}); + } +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +// ─── Auth helpers ───────────────────────────────────────────────────────────── + +/** + * Sign a token the way the app expects. `authenticate` loads the User from + * the database by `userId`, so tests must create real users first; routes + * guarded by the JWT-only `authMiddleware` additionally read `role` from the + * token payload. Tokens are signed with the already-parsed `env.JWT_SECRET` + * so they verify no matter what secret the runner provided. + */ +const signToken = (userId: string, role?: string): string => + jwt.sign(role ? { userId, role } : { userId }, env.JWT_SECRET, { expiresIn: '1h' }); + +let userSeq = 0; + +const createUser = async (role: UserRole): Promise> => + User.create({ + email: `qc-${role}-${++userSeq}@swiftchain.test`, + password: 'SecretPass123!', + firstName: 'QC', + lastName: role, + role, + status: 'active', + }); + +/** Valid 56-char Stellar address; the suffix keeps fleets below the unique index. */ +const treasury = (i: number): string => `G${'A'.repeat(51)}${String(i).padStart(4, '0')}`; + +/** Assert a pagination payload carries the exact shared meta contract. */ +function expectSharedMeta(meta: unknown): void { + expect(meta).toBeDefined(); + META_KEYS.forEach((key) => { + expect(meta).toHaveProperty(key); + }); +} + +// ─── Deliveries ─────────────────────────────────────────────────────────────── + +describe('GET /api/v1/deliveries', () => { + it('applies page/limit and returns the shared meta', async () => { + for (let i = 0; i < 5; i++) { + await Delivery.create({ + trackingNumber: `QC-00${i}`, + customer: { name: 'QC Customer', phone: '+1000000000' }, + pickup: { address: 'a', city: 'c' }, + dropoff: { address: 'b', city: 'c' }, + package: { description: 'box', weight: 1 }, + status: 'pending', + }); + } + + const res = await request(app).get('/api/v1/deliveries?page=2&limit=2'); + + expect(res.status).toBe(200); + expect(res.body.data.deliveries).toHaveLength(2); + expectSharedMeta(res.body.data.meta); + expect(res.body.data.meta).toMatchObject({ + totalItems: 5, + totalPages: 3, + currentPage: 2, + limit: 2, + hasNextPage: true, + hasPreviousPage: true, + nextPage: 3, + previousPage: 1, + }); + + const last = await request(app).get('/api/v1/deliveries?page=3&limit=2'); + expect(last.status).toBe(200); + expect(last.body.data.meta).toMatchObject({ + currentPage: 3, + hasNextPage: false, + nextPage: null, + previousPage: 2, + }); + }); + + it('supports whitelisted filters, search and sort, rejecting unknown sort fields', async () => { + await Delivery.create({ + trackingNumber: 'QC-FILTER', + customer: { name: 'Filter Me', phone: '+1000000001' }, + pickup: { address: 'a', city: 'c' }, + dropoff: { address: 'b', city: 'c' }, + package: { description: 'box', weight: 1 }, + status: 'pending', + }); + await Delivery.create({ + trackingNumber: 'QC-OTHER', + customer: { name: 'Filter Me', phone: '+1000000001' }, + pickup: { address: 'a', city: 'c' }, + dropoff: { address: 'b', city: 'c' }, + package: { description: 'box', weight: 1 }, + status: 'assigned', + }); + + const filtered = await request(app).get('/api/v1/deliveries?status=pending'); + expect(filtered.status).toBe(200); + expect(filtered.body.data.deliveries).toHaveLength(1); + expect(filtered.body.data.deliveries[0].status).toBe('pending'); + + const searched = await request(app).get('/api/v1/deliveries?search=QC-FILT'); + expect(searched.status).toBe(200); + expect(searched.body.data.deliveries).toHaveLength(1); + + const sorted = await request(app).get('/api/v1/deliveries?sort=-createdAt&limit=1'); + expect(sorted.status).toBe(200); + expectSharedMeta(sorted.body.data.meta); + + const badSort = await request(app).get('/api/v1/deliveries?sort=notAField'); + expect(badSort.status).toBe(400); + + const badPage = await request(app).get('/api/v1/deliveries?page=0'); + expect(badPage.status).toBe(400); + }); +}); + +// ─── Disputes (admin-only list) ─────────────────────────────────────────────── + +describe('GET /api/v1/disputes', () => { + it('paginates, filters and returns the shared meta', async () => { + const admin = await createUser(UserRole.ADMIN); + + for (let i = 0; i < 3; i++) { + await Dispute.create({ + disputeId: `qcd-${i}`, + deliveryId: `qd-${i}`, + openedBy: 'GABC', + raisedBy: 'GABC', + reason: DisputeReason.OTHER, + description: 'QC dispute', + status: DisputeStatus.OPEN, + openedLedger: 10 + i, + }); + } + + const res = await request(app) + .get('/api/v1/disputes?page=1&limit=2&status=open') + .set('Authorization', `Bearer ${signToken(admin._id.toString())}`); + + expect(res.status).toBe(200); + expect(res.body.data.disputes).toHaveLength(2); + expectSharedMeta(res.body.data.meta); + expect(res.body.data.meta).toMatchObject({ totalItems: 3, totalPages: 2, currentPage: 1 }); + }); + + it('rejects a non-whitelisted filter operator', async () => { + const admin = await createUser(UserRole.ADMIN); + + const res = await request(app) + .get('/api/v1/disputes?status[regex]=open') + .set('Authorization', `Bearer ${signToken(admin._id.toString())}`); + + expect(res.status).toBe(400); + }); +}); + +// ─── Users (admin-only deleted list) ────────────────────────────────────────── + +describe('GET /api/v1/users/deleted', () => { + it('paginates soft-deleted users with the shared meta', async () => { + // This route uses the JWT-only authMiddleware, so the role must travel + // inside the token payload rather than being loaded from the database. + const admin = await createUser(UserRole.ADMIN); + + for (let i = 0; i < 3; i++) { + await User.create({ + email: `qc-del-${i}-${userSeq}@swiftchain.test`, + password: 'SecretPass123!', + firstName: 'QC', + lastName: `Deleted${i}`, + role: 'user', + status: 'active', + isDeleted: true, + deletedAt: new Date(), + }); + } + + const res = await request(app) + .get('/api/v1/users/deleted?page=1&limit=2') + .set('Authorization', `Bearer ${signToken(admin._id.toString(), UserRole.ADMIN)}`); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + // The users list keeps its legacy top-level `pagination` key. + expectSharedMeta(res.body.pagination); + expect(res.body.pagination).toMatchObject({ totalItems: 3, totalPages: 2, limit: 2 }); + }); +}); + +// ─── Fleets ─────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/fleets', () => { + it('returns the shared meta instead of the legacy `pages` key', async () => { + const owner = await createUser(UserRole.ENTERPRISE); + + for (let i = 0; i < 2; i++) { + await Fleet.create({ + name: `QC Fleet ${i}`, + treasuryAddress: treasury(i), + ownerId: owner._id, + members: [], + businessMetadata: { + companyName: 'QC Logistics', + contactEmail: 'fleet@qc.test', + }, + isActive: true, + }); + } + + const res = await request(app) + .get('/api/v1/fleets?limit=1&search=Fleet') + .set('Authorization', `Bearer ${signToken(owner._id.toString())}`); + + expect(res.status).toBe(200); + expect(res.body.data.fleets).toHaveLength(1); + expectSharedMeta(res.body.data.meta); + expect(res.body.data.meta).toMatchObject({ totalItems: 2, totalPages: 2 }); + expect(res.body.data.meta).not.toHaveProperty('pages'); + }); +}); + +// ─── Notifications ──────────────────────────────────────────────────────────── + +describe('GET /api/v1/notifications', () => { + it('paginates the user-scoped history with the shared meta', async () => { + const user = await createUser(UserRole.USER); + + for (let i = 0; i < 3; i++) { + await Notification.create({ + user: user._id, + event: 'delivery.assigned', + channel: 'push', + title: `QC ${i}`, + body: 'QC body', + data: {}, + status: NotificationStatus.SENT, + acceptedCount: 1, + rejectedCount: 0, + }); + } + + const res = await request(app) + .get('/api/v1/notifications?page=2&limit=2&status=sent') + .set('Authorization', `Bearer ${signToken(user._id.toString())}`); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expectSharedMeta(res.body.meta); + expect(res.body.meta).toMatchObject({ totalItems: 3, totalPages: 2, currentPage: 2 }); + }); +}); + +// ─── Webhooks ───────────────────────────────────────────────────────────────── + +describe('GET /api/v1/webhooks', () => { + it('paginates merchant webhooks and returns the shared meta', async () => { + const merchant = await createUser(UserRole.ENTERPRISE); + + for (let i = 0; i < 2; i++) { + await WebhookSubscription.create({ + merchantId: merchant._id, + url: `https://example.com/hook-${i}`, + secret: 's3cret-value', + events: ['delivery.assigned'], + isActive: true, + }); + } + + const res = await request(app) + .get('/api/v1/webhooks?limit=1&isActive=true') + .set('Authorization', `Bearer ${signToken(merchant._id.toString())}`); + + expect(res.status).toBe(200); + expect(res.body.data.webhooks).toHaveLength(1); + expectSharedMeta(res.body.data.meta); + expect(res.body.data.meta).toMatchObject({ totalItems: 2, totalPages: 2 }); + }); +}); + +// ─── Event log ──────────────────────────────────────────────────────────────── + +describe('GET /api/v1/eventlog/unprocessed', () => { + it('paginates pending events and returns the shared meta', async () => { + const admin = await createUser(UserRole.ADMIN); + + for (let i = 0; i < 3; i++) { + await EventLog.create({ + eventType: 'delivery', + transactionHash: `0xqc${i}${'0'.repeat(56)}`, + ledgerSequence: 1000 + i, + contractId: 'CQC', + eventData: { amount: 1 }, + status: 'pending', + }); + } + + const res = await request(app) + .get('/api/v1/eventlog/unprocessed?limit=2') + .set('Authorization', `Bearer ${signToken(admin._id.toString())}`); + + expect(res.status).toBe(200); + expect(res.body.data.events).toHaveLength(2); + expectSharedMeta(res.body.data.meta); + expect(res.body.data.meta).toMatchObject({ totalItems: 3, totalPages: 2 }); + }); +}); diff --git a/tests/notificationService.test.ts b/tests/notificationService.test.ts index 688aa98..f654a02 100644 --- a/tests/notificationService.test.ts +++ b/tests/notificationService.test.ts @@ -407,7 +407,7 @@ describe('NotificationService', () => { await service.notifyDeliveryTransition(delivery, DeliveryStatus.IN_PROGRESS); await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); - const page = await service.listForUser(userId, 1, 10); + const page = await service.listForUser(userId, { page: 1, limit: 10 }); expect(page.total).toBe(3); expect(page.data[0].event).toBe(NotificationEvent.DELIVERY_COMPLETED); @@ -420,7 +420,7 @@ describe('NotificationService', () => { await service.notifyDeliveryTransition(delivery, DeliveryStatus.PENDING); await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); - const page = await service.listForUser(userId, 1, 1); + const page = await service.listForUser(userId, { page: 1, limit: 1 }); expect(page.data).toHaveLength(1); expect(page.total).toBe(2); diff --git a/tests/providers.test.ts b/tests/providers.test.ts new file mode 100644 index 0000000..c5dd571 --- /dev/null +++ b/tests/providers.test.ts @@ -0,0 +1,240 @@ +/** + * Unit tests for the external SDK provider interfaces (issue #222). + * + * Everything external is behind an interface now, so these tests inject fakes + * (or mock only the raw SDK transport) and verify: + * - GoogleMapsRoutingProvider: Google path, Haversine fallback and error + * handling — without network calls; + * - SdkSorobanRpcClient: 1:1 delegation onto the underlying rpc.Server; + * - PoolingService: routing injected as a fake, no Google Maps involved; + * - DI container: the provider contracts resolve as singletons. + */ + +import axios from 'axios'; +import { GoogleMapsRoutingProvider, SdkSorobanRpcClient } from '../src/services/providers/adapters'; +import { routingService } from '../src/services/routingService'; +import { PoolingService } from '../src/services/poolingService'; +import type { + IRoutingProvider, + ETARequest, + ETAResponse, +} from '../src/services/providers/routingProvider'; +import type { ISorobanRpcClient } from '../src/services/providers/sorobanRpcClient'; + +jest.mock('axios'); +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +// The stellar config pulls in the SDK's horizon axios client, which cannot be +// constructed under jest once axios is mocked; the adapters only need the +// default client reference, which is never called in these tests. +jest.mock('../src/config/stellar', () => ({ + stellarConfig: { + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + network: 'testnet', + timeoutMs: 10000, + }, + sorobanRpcClient: {}, + createSorobanRpcClient: jest.fn(), +})); + +const mockedAxios = axios as jest.Mocked; + +afterEach(() => { + jest.clearAllMocks(); +}); + +/** Deterministic routing fake returning a fixed ETA. */ +function makeRoutingFake(eta: Partial = {}): IRoutingProvider { + return { + calculateETA: jest.fn().mockResolvedValue({ + estimatedTime: 12, + distance: 5.4, + durationText: '12 mins', + distanceText: '5.4 km', + route: { + distance: 5400, + duration: 720, + distanceText: '5.4 km', + durationText: '12 mins', + }, + ...eta, + }), + }; +} + +// ─── GoogleMapsRoutingProvider ──────────────────────────────────────────────── + +describe('GoogleMapsRoutingProvider', () => { + const baseRequest: ETARequest = { + pickup: { lat: 40.7128, lng: -74.006 }, + dropoff: { lat: 40.7589, lng: -73.9851 }, + }; + + it('is configured for the Google path only when an API key is present', async () => { + const provider = new GoogleMapsRoutingProvider('test-key'); + mockedAxios.get.mockResolvedValue({ + data: { + status: 'OK', + routes: [ + { + legs: [ + { + distance: { value: 5400, text: '5.4 km' }, + duration: { value: 720, text: '12 mins' }, + }, + ], + }, + ], + }, + }); + + const eta = await provider.calculateETA(baseRequest); + + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(eta.estimatedTime).toBe(12); + expect(eta.route.distance).toBe(5400); + }); + + it('falls back to the Haversine estimate when no API key is configured', async () => { + const provider = new GoogleMapsRoutingProvider(''); + + const eta = await provider.calculateETA(baseRequest); + + expect(mockedAxios.get).not.toHaveBeenCalled(); + expect(eta.estimatedTime).toBeGreaterThan(0); + expect(eta.durationText).toMatch(/\d+ mins/); + expect(eta.distanceText).toMatch(/km/); + }); + + it('normalises longitudes across the anti-meridian in the fallback', async () => { + const provider = new GoogleMapsRoutingProvider(''); + + // Fiji → Samoa: the short way is ~1,100 km, not ~19,000 km. + const eta = await provider.calculateETA({ + pickup: { lat: -18.1248, lng: 178.4501 }, + dropoff: { lat: -13.759, lng: -172.1046 }, + }); + + expect(eta.distance).toBeGreaterThan(1000); + expect(eta.distance).toBeLessThan(1300); + }); + + it('wraps provider failures in a generic ETA error', async () => { + const provider = new GoogleMapsRoutingProvider('test-key'); + mockedAxios.get.mockRejectedValue(new Error('boom')); + + await expect(provider.calculateETA(baseRequest)).rejects.toThrow( + 'Failed to calculate delivery ETA', + ); + }); +}); + +// ─── SdkSorobanRpcClient ────────────────────────────────────────────────────── + +describe('SdkSorobanRpcClient', () => { + it('delegates every call 1:1 onto the underlying rpc.Server', async () => { + const server = { + getAccount: jest.fn().mockResolvedValue({ accountId: 'GABC' }), + getHealth: jest.fn().mockResolvedValue({ status: 'healthy' }), + getTransaction: jest.fn().mockResolvedValue({ status: 'NOT_FOUND' }), + getEvents: jest.fn().mockResolvedValue({ events: [], latestLedger: 1, cursor: '' }), + getLatestLedger: jest.fn().mockResolvedValue({ sequence: 42 }), + getNetwork: jest.fn().mockResolvedValue({ passphrase: 'test' }), + prepareTransaction: jest.fn().mockResolvedValue({ sequence: 43 }), + sendTransaction: jest.fn().mockResolvedValue({ status: 'PENDING', hash: 'abc' }), + }; + const client = new SdkSorobanRpcClient(server as never); + const tx = { fake: 'transaction' } as never; + + await client.getAccount('GABC'); + await client.getHealth(); + await client.getTransaction('hash'); + await client.getEvents({ filters: [], startLedger: 1 }); + await client.getLatestLedger(); + await client.getNetwork(); + await client.prepareTransaction(tx); + await client.sendTransaction(tx); + + expect(server.getAccount).toHaveBeenCalledWith('GABC'); + expect(server.getHealth).toHaveBeenCalledTimes(1); + expect(server.getTransaction).toHaveBeenCalledWith('hash'); + expect(server.getEvents).toHaveBeenCalledWith({ filters: [], startLedger: 1 }); + expect(server.getLatestLedger).toHaveBeenCalledTimes(1); + expect(server.getNetwork).toHaveBeenCalledTimes(1); + expect(server.prepareTransaction).toHaveBeenCalledWith(tx); + expect(server.sendTransaction).toHaveBeenCalledWith(tx); + }); + + it('satisfies the ISorobanRpcClient contract', () => { + const server = {} as never; + const client: ISorobanRpcClient = new SdkSorobanRpcClient(server); + expect(client).toBeDefined(); + }); +}); + +// ─── Consumers depending on the interfaces ──────────────────────────────────── + +describe('routing facade', () => { + it('delegates to the injected provider', async () => { + const fake = makeRoutingFake(); + const service = new (routingService.constructor as new ( + p: IRoutingProvider, + ) => typeof routingService)(fake); + + const result = await service.calculateETA({ + pickup: { lat: 1, lng: 1 }, + dropoff: { lat: 2, lng: 2 }, + }); + + expect(fake.calculateETA).toHaveBeenCalledTimes(1); + expect(result.estimatedTime).toBe(12); + }); +}); + +describe('PoolingService with an injected routing fake', () => { + it('sums pool ETA from the provider without any Google Maps involvement', async () => { + const fake = makeRoutingFake({ + estimatedTime: 5, + route: { + distance: 500, + duration: 300, + distanceText: '0.5 km', + durationText: '5 mins', + }, + }); + const pooling = new PoolingService(fake); + + const buildPool = ( + pooling as unknown as { + buildPool: (deliveries: never[]) => Promise<{ + totalEstimatedDistanceMeters: number; + totalEstimatedDurationMinutes: number; + }>; + } + ).buildPool.bind(pooling); + + const summary = await buildPool([ + { + _id: 'd1', + pickupCoordinates: { lat: 1, lng: 1, address: 'A1' }, + dropoffCoordinates: { lat: 1.5, lng: 1.5, address: 'B1' }, + }, + { + _id: 'd2', + pickupCoordinates: { lat: 2, lng: 2, address: 'A2' }, + dropoffCoordinates: { lat: 2.5, lng: 2.5, address: 'B2' }, + }, + ] as never); + + // 2 deliveries → 4 stops (pickup + dropoff each) → 3 legs between them. + expect(fake.calculateETA).toHaveBeenCalledTimes(3); + expect(summary.totalEstimatedDistanceMeters).toBe(1500); + expect(summary.totalEstimatedDurationMinutes).toBe(15); + }); +});