diff --git a/src/config/env.ts b/src/config/env.ts index 31c7831..88f807a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -152,6 +152,30 @@ interface EnvConfig { CB_SOROBAN_VOLUME_THRESHOLD: number; /** Per-call timeout (ms) enforced by the breaker. Default: 10000 */ CB_SOROBAN_TIMEOUT_MS: number; + + // ── Merchant webhooks ─────────────────────────────────────────── + /** Per-request timeout (ms) for a webhook POST. Default: 10000 */ + WEBHOOK_REQUEST_TIMEOUT_MS: number; + /** Maximum delivery attempts (including the first) before an attempt is exhausted. Default: 5 */ + WEBHOOK_MAX_RETRIES: number; + /** Base delay (ms) for webhook retry exponential backoff. Default: 30000 */ + WEBHOOK_RETRY_BASE_MS: number; + /** Maximum delay (ms) cap for webhook retry exponential backoff. Default: 3600000 */ + WEBHOOK_RETRY_MAX_MS: number; + /** Cron expression driving the webhook retry sweep. Default: every minute */ + WEBHOOK_RETRY_CRON: string; + /** Maximum due attempts processed per retry sweep tick. Default: 50 */ + WEBHOOK_RETRY_BATCH_SIZE: number; + + // ── Driver assignment ──────────────────────────────────────────── + /** Number of times the search radius doubles before giving up. Default: 3 */ + ASSIGNMENT_RADIUS_EXPANSION_STEPS: number; + /** Cron expression driving the auto-assignment sweep for unassigned funded deliveries. Default: every minute */ + AUTO_ASSIGNMENT_CRON: string; + + // ── Proof of delivery ──────────────────────────────────────────── + /** Maximum accepted proof-of-delivery image size, in MB. Default: 8 */ + PROOF_OF_DELIVERY_MAX_SIZE_MB: number; } const envSchema = z.object({ @@ -261,6 +285,21 @@ const envSchema = z.object({ CB_SOROBAN_RESET_TIMEOUT_MS: z.coerce.number().int().min(1000).default(30000), CB_SOROBAN_VOLUME_THRESHOLD: z.coerce.number().int().min(1).default(5), CB_SOROBAN_TIMEOUT_MS: z.coerce.number().int().min(1000).default(10000), + + // ── Merchant webhooks ─────────────────────────────────────────── + WEBHOOK_REQUEST_TIMEOUT_MS: z.coerce.number().int().min(1000).default(10000), + WEBHOOK_MAX_RETRIES: z.coerce.number().int().min(1).max(20).default(5), + WEBHOOK_RETRY_BASE_MS: z.coerce.number().int().min(1000).default(30000), + WEBHOOK_RETRY_MAX_MS: z.coerce.number().int().min(1000).default(3600000), + WEBHOOK_RETRY_CRON: z.string().trim().min(1).default('* * * * *'), + WEBHOOK_RETRY_BATCH_SIZE: z.coerce.number().int().min(1).max(500).default(50), + + // ── Driver assignment ──────────────────────────────────────────── + ASSIGNMENT_RADIUS_EXPANSION_STEPS: z.coerce.number().int().min(0).max(10).default(3), + AUTO_ASSIGNMENT_CRON: z.string().trim().min(1).default('* * * * *'), + + // ── Proof of delivery ──────────────────────────────────────────── + PROOF_OF_DELIVERY_MAX_SIZE_MB: z.coerce.number().int().min(1).default(8), }); let env: EnvConfig; @@ -296,4 +335,9 @@ if (env.SOROBAN_RPC_RETRY_BASE_MS > env.SOROBAN_RPC_RETRY_MAX_MS) { process.exit(1); } +if (env.WEBHOOK_RETRY_BASE_MS > env.WEBHOOK_RETRY_MAX_MS) { + console.error('❌ WEBHOOK_RETRY_BASE_MS cannot exceed WEBHOOK_RETRY_MAX_MS'); + process.exit(1); +} + export default env; diff --git a/src/controllers/assignmentController.ts b/src/controllers/assignmentController.ts new file mode 100644 index 0000000..ee86a02 --- /dev/null +++ b/src/controllers/assignmentController.ts @@ -0,0 +1,30 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { assignmentService } from '../services/assignmentService'; +import { sendSuccess } from '../utils/responseWrapper'; + +/** + * POST /api/v1/deliveries/:id/assign-nearest-driver + * + * Triggers an on-demand nearest-driver search and assignment for one + * delivery, using the same logic the auto-assignment sweep runs on a + * schedule. Useful for dispatchers retrying a delivery that fell through + * the automatic sweep, or for tests exercising the assignment flow. + */ +export const assignNearestDriver = async ( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +): Promise => { + try { + const result = await assignmentService.assignNearestDriver(req.params.id); + + const message = result.assigned + ? 'Nearest available driver assigned successfully.' + : (result.reason ?? 'No driver could be assigned.'); + + sendSuccess(res, result, message, result.assigned ? StatusCodes.OK : StatusCodes.CONFLICT); + } catch (error) { + next(error); + } +}; diff --git a/src/controllers/driverEarningsController.ts b/src/controllers/driverEarningsController.ts new file mode 100644 index 0000000..3c5c5cc --- /dev/null +++ b/src/controllers/driverEarningsController.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { driverEarningsService, EarningsGroupBy } from '../services/driverEarningsService'; +import { UserRole } from '../interfaces/IUser'; +import type { IUser } from '../interfaces/IUser'; +import AppError from '../utils/AppError'; +import { sendSuccess } from '../utils/responseWrapper'; + +const VALID_GROUP_BY: readonly EarningsGroupBy[] = ['day', 'week', 'month']; + +/** + * GET /api/v1/drivers/:id/earnings + * + * Returns a driver's earnings ledger, aggregated by day/week/month from + * resolved (released) Escrow documents. + * + * A driver may only view their own earnings; an admin may view any + * driver's. + */ +export const getDriverEarnings = async ( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +): Promise => { + try { + const currentUser = (req as Request & { user?: IUser }).user; + if (!currentUser) { + throw new AppError('Authentication required.', StatusCodes.UNAUTHORIZED); + } + + const { id: driverId } = req.params; + driverEarningsService.assertValidDriverId(driverId); + + const isSelf = currentUser._id.toString() === driverId; + const isAdmin = currentUser.role === UserRole.ADMIN; + if (!isSelf && !isAdmin) { + throw new AppError( + 'Access denied. You may only view your own earnings.', + StatusCodes.FORBIDDEN, + ); + } + + const groupByParam = req.query.groupBy as string | undefined; + if (groupByParam && !VALID_GROUP_BY.includes(groupByParam as EarningsGroupBy)) { + throw new AppError('groupBy must be one of: day, week, month.', StatusCodes.BAD_REQUEST); + } + + const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; + const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; + + if (startDate && Number.isNaN(startDate.getTime())) { + throw new AppError('startDate must be a valid date.', StatusCodes.BAD_REQUEST); + } + if (endDate && Number.isNaN(endDate.getTime())) { + throw new AppError('endDate must be a valid date.', StatusCodes.BAD_REQUEST); + } + + const earnings = await driverEarningsService.getDriverEarnings({ + driverId, + groupBy: (groupByParam as EarningsGroupBy) ?? 'day', + startDate, + endDate, + }); + + sendSuccess(res, earnings, 'Driver earnings retrieved successfully', StatusCodes.OK); + } catch (error) { + next(error); + } +}; diff --git a/src/controllers/proofOfDeliveryController.ts b/src/controllers/proofOfDeliveryController.ts new file mode 100644 index 0000000..12e46a6 --- /dev/null +++ b/src/controllers/proofOfDeliveryController.ts @@ -0,0 +1,73 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { proofOfDeliveryService } from '../services/proofOfDeliveryService'; +import type { IUser } from '../interfaces/IUser'; +import AppError from '../utils/AppError'; +import { sendSuccess } from '../utils/responseWrapper'; + +/** + * POST /api/v1/deliveries/:id/proof-of-delivery + * + * Uploads the image a driver captures as evidence of completion. This does + * not itself mark the delivery completed — it unblocks the completion and + * escrow-release transitions, which each check for this record separately. + */ +export const uploadProofOfDeliveryHandler = async ( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +): Promise => { + try { + const currentUser = (req as Request & { user?: IUser }).user; + if (!currentUser) { + throw new AppError('Authentication required.', StatusCodes.UNAUTHORIZED); + } + + const file = (req as Request & { file?: Express.Multer.File }).file; + if (!file) { + throw new AppError('A "file" is required.', StatusCodes.BAD_REQUEST); + } + + const delivery = await proofOfDeliveryService.uploadProofOfDelivery({ + deliveryId: req.params.id, + uploadedBy: currentUser._id.toString(), + originalName: file.originalname, + mimeType: file.mimetype, + buffer: file.buffer, + sizeBytes: file.size, + }); + + sendSuccess( + res, + { delivery }, + 'Proof of delivery uploaded successfully.', + StatusCodes.CREATED, + ); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/v1/deliveries/:id/proof-of-delivery + * + * Returns the proof-of-delivery record for a delivery, or `null` if none + * has been uploaded yet. + */ +export const getProofOfDeliveryHandler = async ( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +): Promise => { + try { + const proofOfDelivery = await proofOfDeliveryService.getProofOfDelivery(req.params.id); + sendSuccess( + res, + { proofOfDelivery }, + 'Proof of delivery retrieved successfully', + StatusCodes.OK, + ); + } catch (error) { + next(error); + } +}; diff --git a/src/controllers/webhookController.ts b/src/controllers/webhookController.ts new file mode 100644 index 0000000..5a9e7eb --- /dev/null +++ b/src/controllers/webhookController.ts @@ -0,0 +1,136 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { webhookService } from '../services/webhookService'; +import type { RegisterWebhookInput, UpdateWebhookInput } from '../validators/webhookValidator'; +import type { IUser } from '../interfaces/IUser'; +import AppError from '../utils/AppError'; +import { sendSuccess } from '../utils/responseWrapper'; + +function requireUser(req: Request): IUser { + const user = (req as Request & { user?: IUser }).user; + if (!user) { + throw new AppError('Authentication required.', StatusCodes.UNAUTHORIZED); + } + return user; +} + +// ─── POST /api/v1/webhooks ────────────────────────────────────── + +export const registerWebhook = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const user = requireUser(req); + + const { webhook, secret } = await webhookService.registerWebhook({ + merchantId: user._id.toString(), + url: req.body.url, + events: req.body.events, + description: req.body.description, + }); + + sendSuccess( + res, + { webhook, secret }, + 'Webhook registered successfully. Store this secret now — it will not be shown again.', + StatusCodes.CREATED, + ); + } catch (error) { + next(error); + } +}; + +// ─── GET /api/v1/webhooks ──────────────────────────────────────── + +export const listWebhooks = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const user = requireUser(req); + const webhooks = await webhookService.listForMerchant(user._id.toString()); + sendSuccess(res, { webhooks }, 'Webhooks retrieved successfully', StatusCodes.OK); + } catch (error) { + next(error); + } +}; + +// ─── GET /api/v1/webhooks/:id ──────────────────────────────────── + +export const getWebhook = async ( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +): Promise => { + try { + const user = requireUser(req); + const webhook = await webhookService.getById(user._id.toString(), req.params.id); + sendSuccess(res, { webhook }, 'Webhook retrieved successfully', StatusCodes.OK); + } catch (error) { + next(error); + } +}; + +// ─── PATCH /api/v1/webhooks/:id ────────────────────────────────── + +export const updateWebhook = async ( + req: Request<{ id: string }, unknown, UpdateWebhookInput>, + res: Response, + next: NextFunction, +): Promise => { + try { + const user = requireUser(req); + const webhook = await webhookService.updateWebhook( + user._id.toString(), + req.params.id, + req.body, + ); + sendSuccess(res, { webhook }, 'Webhook updated successfully', StatusCodes.OK); + } catch (error) { + next(error); + } +}; + +// ─── DELETE /api/v1/webhooks/:id ───────────────────────────────── + +export const deleteWebhook = async ( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +): Promise => { + try { + const user = requireUser(req); + await webhookService.deleteWebhook(user._id.toString(), req.params.id); + sendSuccess(res, null, 'Webhook deleted successfully', StatusCodes.OK); + } catch (error) { + next(error); + } +}; + +// ─── POST /api/v1/webhooks/:id/rotate-secret ───────────────────── + +export const rotateWebhookSecret = async ( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +): Promise => { + try { + const user = requireUser(req); + const { webhook, secret } = await webhookService.rotateSecret( + user._id.toString(), + req.params.id, + ); + + sendSuccess( + res, + { webhook, secret }, + 'Secret rotated successfully. Store this secret now — it will not be shown again.', + StatusCodes.OK, + ); + } catch (error) { + next(error); + } +}; diff --git a/src/jobs/autoAssignmentJob.ts b/src/jobs/autoAssignmentJob.ts new file mode 100644 index 0000000..b8ee477 --- /dev/null +++ b/src/jobs/autoAssignmentJob.ts @@ -0,0 +1,78 @@ +import cron, { ScheduledTask } from 'node-cron'; +import logger from '../config/logger'; +import { assignmentService } from '../services/assignmentService'; +import env from '../config/env'; + +/** + * Cron expression the auto-assignment sweep runs on. Defaults to every + * minute. Override with the `AUTO_ASSIGNMENT_CRON` environment variable. + */ +const AUTO_ASSIGNMENT_CRON = env.AUTO_ASSIGNMENT_CRON; + +let scheduledTask: ScheduledTask | null = null; +let isRunning = false; + +/** + * Runs a single sweep, attempting to assign the nearest available driver to + * every funded delivery that has none yet. + * + * Exported separately from the scheduler so it can be invoked directly + * (e.g. from tests, or an on-demand admin trigger) without waiting for the + * next tick of the cron schedule. + */ +export const runAutoAssignmentSweep = async (): Promise => { + if (isRunning) { + logger.warn('[AutoAssignmentJob] Previous sweep still in progress — skipping this tick.'); + return; + } + + isRunning = true; + try { + const result = await assignmentService.autoAssignPendingDeliveries(); + if (result.attempted > 0) { + logger.info( + `[AutoAssignmentJob] Sweep complete — assigned ${result.assigned}/${result.attempted} delivery(ies).`, + ); + } else { + logger.debug('[AutoAssignmentJob] Sweep complete — no unassigned funded deliveries.'); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + logger.error(`[AutoAssignmentJob] Sweep failed: ${message}`); + } finally { + isRunning = false; + } +}; + +/** + * Starts the recurring background job that auto-assigns nearby drivers to + * unassigned funded deliveries. Safe to call once at process startup. + */ +export const startAutoAssignmentJob = (): ScheduledTask => { + if (scheduledTask) { + return scheduledTask; + } + + if (!cron.validate(AUTO_ASSIGNMENT_CRON)) { + throw new Error(`Invalid AUTO_ASSIGNMENT_CRON expression: "${AUTO_ASSIGNMENT_CRON}"`); + } + + scheduledTask = cron.schedule(AUTO_ASSIGNMENT_CRON, () => { + void runAutoAssignmentSweep(); + }); + + logger.info(`[AutoAssignmentJob] Job scheduled with cron expression "${AUTO_ASSIGNMENT_CRON}"`); + + return scheduledTask; +}; + +/** + * Stops the recurring job, if running. Used during graceful shutdown and in + * tests to avoid leaking timers. + */ +export const stopAutoAssignmentJob = (): void => { + if (scheduledTask) { + scheduledTask.stop(); + scheduledTask = null; + } +}; diff --git a/src/jobs/webhookRetryJob.ts b/src/jobs/webhookRetryJob.ts new file mode 100644 index 0000000..71df673 --- /dev/null +++ b/src/jobs/webhookRetryJob.ts @@ -0,0 +1,75 @@ +import cron, { ScheduledTask } from 'node-cron'; +import logger from '../config/logger'; +import { webhookService } from '../services/webhookService'; +import env from '../config/env'; + +/** + * Cron expression the webhook retry sweep runs on. Defaults to every minute. + * Override with the `WEBHOOK_RETRY_CRON` environment variable. + */ +const WEBHOOK_RETRY_CRON = env.WEBHOOK_RETRY_CRON; + +let scheduledTask: ScheduledTask | null = null; +let isRunning = false; + +/** + * Runs a single retry sweep over due, failed webhook delivery attempts. + * + * Exported separately from the scheduler so it can be invoked directly + * (e.g. from tests, or an on-demand admin trigger) without waiting for the + * next tick of the cron schedule. + */ +export const runWebhookRetrySweep = async (): Promise => { + if (isRunning) { + logger.warn('[WebhookRetryJob] Previous sweep still in progress — skipping this tick.'); + return; + } + + isRunning = true; + try { + const processed = await webhookService.retryDueAttempts(); + if (processed > 0) { + logger.info(`[WebhookRetryJob] Sweep complete — retried ${processed} attempt(s).`); + } else { + logger.debug('[WebhookRetryJob] Sweep complete — no due attempts.'); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + logger.error(`[WebhookRetryJob] Sweep failed: ${message}`); + } finally { + isRunning = false; + } +}; + +/** + * Starts the recurring background job that retries failed webhook + * deliveries. Safe to call once at process startup. + */ +export const startWebhookRetryJob = (): ScheduledTask => { + if (scheduledTask) { + return scheduledTask; + } + + if (!cron.validate(WEBHOOK_RETRY_CRON)) { + throw new Error(`Invalid WEBHOOK_RETRY_CRON expression: "${WEBHOOK_RETRY_CRON}"`); + } + + scheduledTask = cron.schedule(WEBHOOK_RETRY_CRON, () => { + void runWebhookRetrySweep(); + }); + + logger.info(`[WebhookRetryJob] Job scheduled with cron expression "${WEBHOOK_RETRY_CRON}"`); + + return scheduledTask; +}; + +/** + * Stops the recurring job, if running. Used during graceful shutdown and in + * tests to avoid leaking timers. + */ +export const stopWebhookRetryJob = (): void => { + if (scheduledTask) { + scheduledTask.stop(); + scheduledTask = null; + } +}; diff --git a/src/models/Delivery.ts b/src/models/Delivery.ts index 65269b9..4484fee 100644 --- a/src/models/Delivery.ts +++ b/src/models/Delivery.ts @@ -30,6 +30,7 @@ export interface IDelivery extends Document { distance?: number; estimatedDuration?: number; actualDuration?: number; + proofOfDelivery?: IProofOfDelivery; isDeleted?: boolean; deletedAt?: Date | null; deletedBy?: string; @@ -70,6 +71,20 @@ export interface IPackage { requiresSignature?: boolean; } +/** Image evidence a driver uploads to prove a delivery was completed. */ +export interface IProofOfDelivery { + /** Backend-specific object key (S3 key or local relative path). */ + storageKey: string; + /** URL the image can be retrieved from. */ + imageUrl: string; + storageDriver: string; + mimeType: string; + sizeBytes: number; + /** User id (string) of the driver who uploaded the proof. */ + uploadedBy: string; + uploadedAt: Date; +} + const DeliverySchema = new Schema( { deliveryId: { type: String, unique: true, sparse: true }, @@ -105,6 +120,7 @@ const DeliverySchema = new Schema( distance: { type: Number }, estimatedDuration: { type: Number }, actualDuration: { type: Number }, + proofOfDelivery: { type: Schema.Types.Mixed }, isDeleted: { type: Boolean, default: false }, deletedAt: { type: Date, default: null }, deletedBy: { type: String }, diff --git a/src/models/WebhookDeliveryAttempt.ts b/src/models/WebhookDeliveryAttempt.ts new file mode 100644 index 0000000..e573368 --- /dev/null +++ b/src/models/WebhookDeliveryAttempt.ts @@ -0,0 +1,72 @@ +/** + * WebhookDeliveryAttempt.ts + * + * One row per (webhook subscription, delivery event) dispatch. Tracks retry + * state so a crashed process can pick up where it left off — the retry + * sweep (`jobs/webhookRetryJob.ts`) queries this collection directly rather + * than relying on in-memory timers. + */ + +import mongoose, { Schema, Document, Model, Types } from 'mongoose'; +import { WebhookEvent } from './WebhookSubscription'; + +export enum WebhookDeliveryStatus { + PENDING = 'pending', + SUCCESS = 'success', + FAILED = 'failed', + /** Retries exhausted; will not be attempted again automatically. */ + EXHAUSTED = 'exhausted', +} + +export interface IWebhookDeliveryAttempt extends Document { + webhook: Types.ObjectId; + merchantId: Types.ObjectId; + event: WebhookEvent; + delivery: Types.ObjectId; + payload: Record; + status: WebhookDeliveryStatus; + attempts: number; + maxAttempts: number; + lastAttemptAt?: Date; + lastStatusCode?: number; + lastError?: string; + /** When the next retry sweep should pick this attempt up again. Unset once SUCCESS/EXHAUSTED. */ + nextRetryAt?: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface IWebhookDeliveryAttemptModel extends Model {} + +const WebhookDeliveryAttemptSchema = new Schema( + { + webhook: { type: Schema.Types.ObjectId, ref: 'WebhookSubscription', required: true, index: true }, + merchantId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + event: { type: String, enum: Object.values(WebhookEvent), required: true }, + delivery: { type: Schema.Types.ObjectId, ref: 'Delivery', required: true, index: true }, + payload: { type: Schema.Types.Mixed, required: true }, + status: { + type: String, + enum: Object.values(WebhookDeliveryStatus), + default: WebhookDeliveryStatus.PENDING, + required: true, + }, + attempts: { type: Number, default: 0, min: 0 }, + maxAttempts: { type: Number, required: true, min: 1 }, + lastAttemptAt: { type: Date }, + lastStatusCode: { type: Number }, + lastError: { type: String }, + nextRetryAt: { type: Date, default: null }, + }, + { timestamps: true }, +); + +// Retry sweep: due, retryable attempts ordered for a bounded batch scan. +WebhookDeliveryAttemptSchema.index({ status: 1, nextRetryAt: 1 }); + +export const WebhookDeliveryAttempt = mongoose.model< + IWebhookDeliveryAttempt, + IWebhookDeliveryAttemptModel +>('WebhookDeliveryAttempt', WebhookDeliveryAttemptSchema); + +export default WebhookDeliveryAttempt; diff --git a/src/models/WebhookSubscription.ts b/src/models/WebhookSubscription.ts new file mode 100644 index 0000000..3c71c73 --- /dev/null +++ b/src/models/WebhookSubscription.ts @@ -0,0 +1,71 @@ +/** + * WebhookSubscription.ts + * + * A merchant-registered endpoint that receives HTTP POST callbacks when a + * delivery they own changes state. One merchant may register several + * endpoints (e.g. staging + production); each row is independent. + * + * The signing `secret` is generated server-side and never returned again + * after creation/rotation — callers verify the `X-SwiftChain-Signature` + * header against it (see `services/webhookService.ts#verifySignature`). + */ + +import mongoose, { Schema, Document, Model, Types } from 'mongoose'; + +/** Delivery lifecycle events a merchant can subscribe to. */ +export enum WebhookEvent { + DELIVERY_PENDING = 'delivery.pending', + DELIVERY_FUNDED = 'delivery.funded', + DELIVERY_ASSIGNED = 'delivery.assigned', + DELIVERY_IN_PROGRESS = 'delivery.in_progress', + DELIVERY_COMPLETED = 'delivery.completed', + DELIVERY_CANCELLED = 'delivery.cancelled', +} + +export interface IWebhookSubscription extends Document { + /** Owning merchant (the delivery's `sender`). */ + merchantId: Types.ObjectId; + /** HTTPS endpoint the merchant's server exposes for callbacks. */ + url: string; + /** HMAC-SHA256 signing secret. Excluded from queries unless explicitly selected. */ + secret: string; + /** Events this endpoint wants to receive. */ + events: WebhookEvent[]; + /** Toggle without deleting the registration. */ + isActive: boolean; + description?: string; + createdAt: Date; + updatedAt: Date; +} + +export interface IWebhookSubscriptionModel extends Model {} + +const WebhookSubscriptionSchema = new Schema( + { + merchantId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true }, + url: { type: String, required: true, trim: true }, + secret: { type: String, required: true, select: false }, + events: { + type: [String], + enum: Object.values(WebhookEvent), + required: true, + validate: { + validator: (value: WebhookEvent[]): boolean => Array.isArray(value) && value.length > 0, + message: 'At least one event must be selected.', + }, + }, + isActive: { type: Boolean, default: true }, + description: { type: String, trim: true, maxlength: 500 }, + }, + { timestamps: true }, +); + +// Dispatch lookup: active subscriptions for a merchant, filtered by event. +WebhookSubscriptionSchema.index({ merchantId: 1, isActive: 1 }); + +export const WebhookSubscription = mongoose.model( + 'WebhookSubscription', + WebhookSubscriptionSchema, +); + +export default WebhookSubscription; diff --git a/src/routes/assignmentRoutes.ts b/src/routes/assignmentRoutes.ts new file mode 100644 index 0000000..9c36037 --- /dev/null +++ b/src/routes/assignmentRoutes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import authenticate from '../middleware/authenticate'; +import requireRole from '../middleware/requireRole'; +import { UserRole } from '../interfaces/IUser'; +import { assignNearestDriver } from '../controllers/assignmentController'; + +const router = Router(); + +/** + * @route POST /api/v1/deliveries/:id/assign-nearest-driver + * @desc Find and assign the nearest available driver to a funded delivery + * @access Admin only (dispatchers) + */ +router.post( + '/:id/assign-nearest-driver', + authenticate, + requireRole(UserRole.ADMIN), + assignNearestDriver, +); + +export default router; diff --git a/src/routes/driverRoutes.ts b/src/routes/driverRoutes.ts index ecb71be..2185d07 100644 --- a/src/routes/driverRoutes.ts +++ b/src/routes/driverRoutes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { driverController } from '../controllers/driverController'; import { driverLocationController } from '../controllers/driverLocationController'; +import { getDriverEarnings } from '../controllers/driverEarningsController'; import authenticate from '../middleware/authenticate'; import requireRole from '../middleware/requireRole'; import { UserRole } from '../interfaces/IUser'; @@ -72,4 +73,11 @@ router.get( driverLocationController.getDriverLocation.bind(driverLocationController), ); +/** + * @route GET /api/v1/drivers/:id/earnings + * @desc Aggregate a driver's earnings by day/week/month from released escrows + * @access The driver themselves, or an admin + */ +router.get('/:id/earnings', authenticate, getDriverEarnings); + export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 1023ec3..ce61e15 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -14,9 +14,10 @@ import notificationRoutes from './notificationRoutes'; import healthRoutes from './healthRoutes'; import userRoutes from './userRoutes'; import socketMetricsRoutes from './socketMetricsRoutes'; -import bulkDeliveryRoutes from './bulkDeliveryRoutes'; -import notificationRoutes from './notificationRoutes'; import stellarRoutes from './stellar.routes'; +import webhookRoutes from './webhookRoutes'; +import assignmentRoutes from './assignmentRoutes'; +import proofOfDeliveryRoutes from './proofOfDeliveryRoutes'; const router = Router(); @@ -27,6 +28,8 @@ router.use('/v1/deliveries', bulkDeliveryRoutes); router.use('/v1/deliveries', deliveryCrudRoutes); router.use('/v1/deliveries', deliveryEtaRoutes); router.use('/v1/deliveries', deliveryStatusRoutes); +router.use('/v1/deliveries', assignmentRoutes); +router.use('/v1/deliveries', proofOfDeliveryRoutes); router.use('/v1/admin', adminRoutes); router.use('/v1/drivers', driverRoutes); router.use('/v1/fleets', fleetRoutes); @@ -38,5 +41,6 @@ router.use('/v1/health', healthRoutes); router.use('/v1/socket-metrics', socketMetricsRoutes); router.use('/v1/users', userRoutes); router.use('/v1/stellar', stellarRoutes); +router.use('/v1/webhooks', webhookRoutes); export default router; diff --git a/src/routes/proofOfDeliveryRoutes.ts b/src/routes/proofOfDeliveryRoutes.ts new file mode 100644 index 0000000..892a37f --- /dev/null +++ b/src/routes/proofOfDeliveryRoutes.ts @@ -0,0 +1,50 @@ +import { Router } from 'express'; +import multer from 'multer'; +import { StatusCodes } from 'http-status-codes'; +import authenticate from '../middleware/authenticate'; +import { + uploadProofOfDeliveryHandler, + getProofOfDeliveryHandler, +} from '../controllers/proofOfDeliveryController'; +import { ALLOWED_PROOF_MIME_TYPES } from '../services/proofOfDeliveryService'; +import env from '../config/env'; +import AppError from '../utils/AppError'; + +const router = Router(); + +// Buffered in memory and handed to the storage driver inside the service +// layer, matching the evidence-upload pattern in routes/uploadRoutes.ts. +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: env.PROOF_OF_DELIVERY_MAX_SIZE_MB * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (!ALLOWED_PROOF_MIME_TYPES.includes(file.mimetype as (typeof ALLOWED_PROOF_MIME_TYPES)[number])) { + cb( + new AppError( + `Unsupported file type "${file.mimetype}".`, + StatusCodes.UNSUPPORTED_MEDIA_TYPE, + ), + ); + return; + } + cb(null, true); + }, +}); + +router.use(authenticate); + +/** + * @route POST /api/v1/deliveries/:id/proof-of-delivery + * @desc Upload the image proving a delivery was completed + * @access The assigned driver, or an admin + */ +router.post('/:id/proof-of-delivery', upload.single('file'), uploadProofOfDeliveryHandler); + +/** + * @route GET /api/v1/deliveries/:id/proof-of-delivery + * @desc Fetch the proof-of-delivery record for a delivery + * @access Authenticated + */ +router.get('/:id/proof-of-delivery', getProofOfDeliveryHandler); + +export default router; diff --git a/src/routes/webhookRoutes.ts b/src/routes/webhookRoutes.ts new file mode 100644 index 0000000..12ad218 --- /dev/null +++ b/src/routes/webhookRoutes.ts @@ -0,0 +1,65 @@ +import { Router } from 'express'; +import authenticate from '../middleware/authenticate'; +import requireRole from '../middleware/requireRole'; +import validate from '../middleware/validate'; +import { UserRole } from '../interfaces/IUser'; +import { + registerWebhook, + listWebhooks, + getWebhook, + updateWebhook, + deleteWebhook, + rotateWebhookSecret, +} from '../controllers/webhookController'; +import { registerWebhookSchema, updateWebhookSchema } from '../validators/webhookValidator'; + +const router = Router(); + +// Every webhook route is merchant-scoped, so authentication and the +// merchant/admin role gate apply to the whole router. +router.use(authenticate); +router.use(requireRole(UserRole.ENTERPRISE, UserRole.ADMIN)); + +/** + * @route POST /api/v1/webhooks + * @desc Register a new endpoint to receive delivery lifecycle callbacks + * @access Merchant (enterprise) or admin + */ +router.post('/', validate(registerWebhookSchema), registerWebhook); + +/** + * @route GET /api/v1/webhooks + * @desc List the authenticated merchant's registered webhooks + * @access Merchant (enterprise) or admin + */ +router.get('/', listWebhooks); + +/** + * @route GET /api/v1/webhooks/:id + * @desc Get a single webhook registration + * @access Merchant (enterprise) or admin + */ +router.get('/:id', getWebhook); + +/** + * @route PATCH /api/v1/webhooks/:id + * @desc Update a webhook's URL, subscribed events, or active state + * @access Merchant (enterprise) or admin + */ +router.patch('/:id', validate(updateWebhookSchema), updateWebhook); + +/** + * @route DELETE /api/v1/webhooks/:id + * @desc Remove a webhook registration + * @access Merchant (enterprise) or admin + */ +router.delete('/:id', deleteWebhook); + +/** + * @route POST /api/v1/webhooks/:id/rotate-secret + * @desc Issue a new signing secret, invalidating the previous one + * @access Merchant (enterprise) or admin + */ +router.post('/:id/rotate-secret', rotateWebhookSecret); + +export default router; diff --git a/src/server.ts b/src/server.ts index 4a9439a..9e9ae26 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,8 @@ import { TypedServer, } from './sockets/connectionHandler'; import { startEscrowMonitorJob, stopEscrowMonitorJob } from './jobs/escrowMonitor'; +import { startWebhookRetryJob, stopWebhookRetryJob } from './jobs/webhookRetryJob'; +import { startAutoAssignmentJob, stopAutoAssignmentJob } from './jobs/autoAssignmentJob'; import { startEventPoller, stopEventPoller } from './services/eventPoller'; import { initializeRedis, disconnectRedis } from './config/redis'; import env from './config/env'; @@ -52,6 +54,8 @@ httpServer.listen(PORT, () => { if (env.NODE_ENV !== 'test') { startEscrowMonitorJob(); + startWebhookRetryJob(); + startAutoAssignmentJob(); startEventPoller(); } @@ -59,7 +63,9 @@ const gracefulShutdown = (): void => { logger.info('Shutting down gracefully...'); stopEventPoller(); stopEscrowMonitorJob(); - + stopWebhookRetryJob(); + stopAutoAssignmentJob(); + // Disconnect Redis disconnectRedis() .catch((error) => logger.error('Error disconnecting Redis:', error)); diff --git a/src/services/assignmentService.ts b/src/services/assignmentService.ts new file mode 100644 index 0000000..b6e346f --- /dev/null +++ b/src/services/assignmentService.ts @@ -0,0 +1,236 @@ +/** + * assignmentService.ts + * + * Automates driver assignment for a funded delivery: finds the nearest + * available driver via the `DriverLocation` 2dsphere index and assigns them. + * + * ── Race-condition handling ────────────────────────────────────────────────── + * Two problems need independent guards: + * + * 1. Two deliveries racing for the same driver. Solved with an atomic + * claim — `findOneAndUpdate({ driverId, isAvailable: true }, { $set: + * { isAvailable: false, ... } })` — rather than a read-then-write. Mongo + * serializes the update per document, so only one caller's filter can + * match `isAvailable: true` at a time; the loser's `findOneAndUpdate` + * returns `null` and the search simply moves to the next-nearest + * candidate instead of retrying the same driver. + * + * 2. Two requests racing to assign *the same delivery* (e.g. a manual + * retrigger overlapping the auto-assignment sweep). Solved with a + * Redis distributed lock scoped to the delivery id, mirroring the + * pattern `config/redis.ts#withLock` already establishes for escrow + * release. + * + * If a driver is claimed but the subsequent `deliveryService.assignDriver` + * call fails (e.g. the escrow guard rejects it), the claim is rolled back so + * the driver is not stranded as unavailable for a delivery they were never + * actually assigned to. + * + * ── Fallback ────────────────────────────────────────────────────────────────── + * If no driver is claimed within the starting radius, the search radius is + * doubled up to `ASSIGNMENT_RADIUS_EXPANSION_STEPS` times (capped at + * `DRIVER_PROXIMITY_MAX_RADIUS_M`). If every expansion is exhausted, the + * delivery is left in its current status for a later attempt (manual retry + * or the next `autoAssignmentJob` sweep tick) rather than failing hard. + */ + +import { StatusCodes } from 'http-status-codes'; +import { Types } from 'mongoose'; +import Delivery, { IDelivery, DeliveryStatus } from '../models/Delivery'; +import { DriverLocation } from '../models/DriverLocation'; +import { driverLocationService, NearbyDriver } from './driverLocationService'; +import { deliveryService } from './delivery.service'; +import { withLock } from '../config/redis'; +import AppError from '../utils/AppError'; +import logger from '../config/logger'; +import env from '../config/env'; + +// ─── DTOs ────────────────────────────────────────────────────────────────────── + +export interface AssignNearestDriverResult { + assigned: boolean; + delivery?: IDelivery; + driverId?: string; + distanceMeters?: number; + /** Radius, in metres, that finally produced a claimed driver (or the max searched). */ + radiusMeters: number; + /** Populated when `assigned` is false. */ + reason?: string; +} + +// ─── Service ─────────────────────────────────────────────────────────────────── + +export class AssignmentService { + /** + * Find and assign the nearest available driver to a delivery. + * + * @throws {AppError} 400 — invalid delivery id, or the delivery has no + * pickup coordinates to search from. + * @throws {AppError} 404 — delivery not found. + */ + async assignNearestDriver(deliveryId: string): Promise { + if (!Types.ObjectId.isValid(deliveryId)) { + throw new AppError('Invalid delivery ID', StatusCodes.BAD_REQUEST); + } + + return withLock(`assignment:delivery:${deliveryId}`, async () => { + const delivery = await Delivery.findById(deliveryId); + if (!delivery) { + throw new AppError('Delivery not found', StatusCodes.NOT_FOUND); + } + + if (delivery.driverId) { + return { + assigned: false, + radiusMeters: 0, + reason: 'Delivery already has a driver assigned.', + }; + } + + const center = delivery.pickupCoordinates; + if (!center || !Number.isFinite(center.lat) || !Number.isFinite(center.lng)) { + throw new AppError( + 'Delivery has no pickup coordinates to search from.', + StatusCodes.BAD_REQUEST, + ); + } + + return this.searchAndClaim(delivery, center); + }); + } + + /** + * Expand the search radius step by step, attempting to claim the nearest + * candidate at each step, until a driver is claimed and assigned or every + * expansion is exhausted. + */ + private async searchAndClaim( + delivery: IDelivery, + center: { lat: number; lng: number }, + ): Promise { + let radiusMeters = env.DRIVER_PROXIMITY_DEFAULT_RADIUS_M; + const maxRadiusMeters = env.DRIVER_PROXIMITY_MAX_RADIUS_M; + const maxSteps = env.ASSIGNMENT_RADIUS_EXPANSION_STEPS; + + for (let step = 0; step <= maxSteps; step += 1) { + const { drivers } = await driverLocationService.findNearbyDrivers({ + lat: center.lat, + lng: center.lng, + radiusMeters, + availableOnly: true, + status: 'online', + }); + + const claimed = await this.claimFirstAvailable(drivers); + if (claimed) { + try { + const updated = await deliveryService.assignDriver({ + deliveryId: String(delivery._id), + driverId: claimed.driverId, + }); + + logger.info( + `[AssignmentService] Assigned nearest driver — delivery=${String(delivery._id)} ` + + `driver=${claimed.driverId} distance=${claimed.distanceMeters}m radius=${radiusMeters}m`, + ); + + return { + assigned: true, + delivery: updated, + driverId: claimed.driverId, + distanceMeters: claimed.distanceMeters, + radiusMeters, + }; + } catch (error) { + // The delivery could not actually be assigned (e.g. escrow not + // locked) — release the driver so they are not stranded. + await this.releaseClaim(claimed.driverId); + throw error; + } + } + + if (radiusMeters >= maxRadiusMeters) break; + radiusMeters = Math.min(radiusMeters * 2, maxRadiusMeters); + } + + logger.warn( + `[AssignmentService] No driver available for delivery=${String(delivery._id)} ` + + `after searching up to ${radiusMeters}m`, + ); + + return { + assigned: false, + radiusMeters, + reason: 'No available driver was found within the maximum search radius.', + }; + } + + /** + * Walk candidates nearest-first, atomically claiming the first one still + * available. Losing a claim to a concurrent request simply advances to + * the next candidate rather than failing the whole search. + */ + private async claimFirstAvailable(candidates: NearbyDriver[]): Promise { + for (const candidate of candidates) { + const claimed = await DriverLocation.findOneAndUpdate( + { driverId: candidate.driverId, isAvailable: true }, + { $set: { isAvailable: false, status: 'on_delivery' } }, + { new: true }, + ).exec(); + + if (claimed) { + return candidate; + } + // Another request claimed this driver first — try the next nearest. + } + return null; + } + + /** Revert an atomic claim when the follow-up assignment write failed. */ + private async releaseClaim(driverId: string): Promise { + try { + await DriverLocation.findOneAndUpdate( + { driverId }, + { $set: { isAvailable: true, status: 'online' } }, + ).exec(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error( + `[AssignmentService] Failed to release claim for driver=${driverId} after a failed assignment: ${message}`, + ); + } + } + + /** + * Sweep funded deliveries with no driver assigned and attempt to assign + * each one. Used by the auto-assignment cron job; failures on one + * delivery never stop the sweep from processing the rest. + */ + async autoAssignPendingDeliveries(limit = 25): Promise<{ attempted: number; assigned: number }> { + const candidates = await Delivery.find({ + status: DeliveryStatus.FUNDED, + $or: [{ driverId: { $exists: false } }, { driverId: null }, { driverId: '' }], + }) + .sort({ createdAt: 1 }) + .limit(limit); + + let assigned = 0; + + for (const delivery of candidates) { + try { + const result = await this.assignNearestDriver(String(delivery._id)); + if (result.assigned) assigned += 1; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.warn( + `[AssignmentService] Auto-assignment failed for delivery=${String(delivery._id)}: ${message}`, + ); + } + } + + return { attempted: candidates.length, assigned }; + } +} + +export const assignmentService = new AssignmentService(); +export default assignmentService; diff --git a/src/services/delivery.service.ts b/src/services/delivery.service.ts index e5b8259..137d4dd 100644 --- a/src/services/delivery.service.ts +++ b/src/services/delivery.service.ts @@ -6,6 +6,8 @@ import { AppError } from '../utils/AppError'; import logger from '../config/logger'; import { deliveryRepository } from '../repositories/DeliveryRepository'; import { notificationService } from './notificationService'; +import { webhookService } from './webhookService'; +import { proofOfDeliveryService } from './proofOfDeliveryService'; export interface CreateDeliveryInput { trackingNumber: string; @@ -254,6 +256,12 @@ export class DeliveryService { ); } + if (nextStatus === DeliveryStatus.COMPLETED) { + // Proof of delivery must be on record before a delivery can be marked + // completed — this is what ultimately unblocks its escrow release. + await proofOfDeliveryService.assertProofOfDeliveryExists(id); + } + const updated = await deliveryRepository.transitionStatus(id, current.status, nextStatus); if (!updated) { @@ -270,9 +278,10 @@ export class DeliveryService { `${current.status} -> ${nextStatus}`, ); - // Fire-and-forget by design: notification failures are recorded inside the - // notification service and must not roll back a committed transition. + // Fire-and-forget by design: notification/webhook failures are recorded + // inside their own services and must not roll back a committed transition. await notificationService.notifyDeliveryTransition(updated, nextStatus); + await webhookService.dispatchDeliveryEvent(updated, nextStatus); return updated; } diff --git a/src/services/driverEarningsService.ts b/src/services/driverEarningsService.ts new file mode 100644 index 0000000..6e88cb2 --- /dev/null +++ b/src/services/driverEarningsService.ts @@ -0,0 +1,150 @@ +/** + * driverEarningsService.ts + * + * Builds a driver's earnings ledger from resolved (released) Escrow + * documents — the escrow record is the source of truth for what a driver + * was actually paid, rather than deriving an amount from the Delivery + * document. + * + * `Escrow.delivery` references the `Delivery` that owns a `driverId` + * string field, so the aggregation joins the two collections to filter by + * driver before grouping by period. + */ + +import { StatusCodes } from 'http-status-codes'; +import { PipelineStage, Types } from 'mongoose'; +import Escrow, { EscrowStatus } from '../models/Escrow'; +import AppError from '../utils/AppError'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type EarningsGroupBy = 'day' | 'week' | 'month'; + +export interface GetDriverEarningsQuery { + driverId: string; + groupBy?: EarningsGroupBy; + startDate?: Date; + endDate?: Date; +} + +export interface EarningsPeriod { + /** `YYYY-MM-DD`, `YYYY-Www` (ISO week), or `YYYY-MM` depending on `groupBy`. */ + period: string; + totalAmount: number; + deliveryCount: number; +} + +export interface DriverEarningsResult { + driverId: string; + groupBy: EarningsGroupBy; + periods: EarningsPeriod[]; + summary: { + totalAmount: number; + totalDeliveries: number; + }; +} + +interface EarningsAggregateRow { + _id: string; + totalAmount: number; + deliveryCount: number; +} + +// ─── Date-truncation formats per grouping ────────────────────────────────────── + +const DATE_FORMATS: Record = { + day: '%Y-%m-%d', + week: '%G-W%V', + month: '%Y-%m', +}; + +// ─── Service ─────────────────────────────────────────────────────────────────── + +export class DriverEarningsService { + /** + * Aggregate a driver's earnings from released escrows, bucketed by day, + * week, or month. + * + * @throws {AppError} 400 — invalid driverId or date range. + */ + async getDriverEarnings(query: GetDriverEarningsQuery): Promise { + const { driverId } = query; + + if (!driverId || !driverId.trim()) { + throw new AppError('driverId is required.', StatusCodes.BAD_REQUEST); + } + + const groupBy = query.groupBy ?? 'day'; + if (!DATE_FORMATS[groupBy]) { + throw new AppError('groupBy must be one of: day, week, month.', StatusCodes.BAD_REQUEST); + } + + if (query.startDate && query.endDate && query.startDate > query.endDate) { + throw new AppError('startDate must be before endDate.', StatusCodes.BAD_REQUEST); + } + + const releasedAtMatch: Record = {}; + if (query.startDate) releasedAtMatch.$gte = query.startDate; + if (query.endDate) releasedAtMatch.$lte = query.endDate; + + const pipeline: PipelineStage[] = [ + { $match: { status: EscrowStatus.RELEASED } }, + { + $lookup: { + from: 'deliveries', + localField: 'delivery', + foreignField: '_id', + as: 'deliveryDoc', + }, + }, + { $unwind: '$deliveryDoc' }, + { + $match: { + 'deliveryDoc.driverId': driverId, + ...(Object.keys(releasedAtMatch).length > 0 ? { releasedAt: releasedAtMatch } : {}), + }, + }, + { + $group: { + _id: { + $dateToString: { + format: DATE_FORMATS[groupBy], + date: { $ifNull: ['$releasedAt', '$updatedAt'] }, + }, + }, + totalAmount: { $sum: '$amount' }, + deliveryCount: { $sum: 1 }, + }, + }, + { $sort: { _id: 1 } }, + ]; + + const rows = await Escrow.aggregate(pipeline); + + const periods: EarningsPeriod[] = rows.map((row) => ({ + period: row._id, + totalAmount: Math.round(row.totalAmount * 100) / 100, + deliveryCount: row.deliveryCount, + })); + + const summary = periods.reduce( + (acc, period) => ({ + totalAmount: Math.round((acc.totalAmount + period.totalAmount) * 100) / 100, + totalDeliveries: acc.totalDeliveries + period.deliveryCount, + }), + { totalAmount: 0, totalDeliveries: 0 }, + ); + + return { driverId, groupBy, periods, summary }; + } + + /** Validate a Mongo ObjectId supplied as a route param, for callers that need it. */ + assertValidDriverId(driverId: string): void { + if (!Types.ObjectId.isValid(driverId)) { + throw new AppError('Invalid driver ID', StatusCodes.BAD_REQUEST); + } + } +} + +export const driverEarningsService = new DriverEarningsService(); +export default driverEarningsService; diff --git a/src/services/escrow.service.ts b/src/services/escrow.service.ts index 42c8698..7ac863e 100644 --- a/src/services/escrow.service.ts +++ b/src/services/escrow.service.ts @@ -5,6 +5,7 @@ import Delivery, { DeliveryStatus } from '../models/Delivery'; import { AppError } from '../utils/AppError'; import logger from '../config/logger'; import { withLock } from '../config/redis'; +import { proofOfDeliveryService } from './proofOfDeliveryService'; /** Data extracted from an on-chain `escrow_funded` contract event. */ export interface EscrowFundedInput { @@ -171,6 +172,11 @@ export class EscrowService { throw new AppError('Escrow not found', httpStatus.NOT_FOUND); } + // Proof of delivery must be on record before funds can be released — + // this is the enforcement point regardless of which path (API call, + // indexer event) triggers a release. + await proofOfDeliveryService.assertProofOfDeliveryExists(String(escrow.delivery)); + // Check if the escrow is already released if (escrow.lockStatus === EscrowLockStatus.RELEASED) { logger.warn( diff --git a/src/services/proofOfDeliveryService.ts b/src/services/proofOfDeliveryService.ts new file mode 100644 index 0000000..0464e46 --- /dev/null +++ b/src/services/proofOfDeliveryService.ts @@ -0,0 +1,165 @@ +/** + * proofOfDeliveryService.ts + * + * Enforces that a driver uploads photographic evidence before a delivery + * can be marked completed or its escrow released. + * + * The image is written through the same storage driver abstraction used + * for dispute evidence (`services/storage.service.ts`), which currently + * backs onto local disk or S3; the driver interface is what a future IPFS + * backend would implement, so this service does not hard-code S3. + */ + +import { StatusCodes } from 'http-status-codes'; +import { Types } from 'mongoose'; +import Delivery, { IDelivery, DeliveryStatus, IProofOfDelivery } from '../models/Delivery'; +import { getStorageDriver } from './storage.service'; +import env from '../config/env'; +import AppError from '../utils/AppError'; +import logger from '../config/logger'; + +// ─── Constraints ─────────────────────────────────────────────────────────────── + +/** MIME types accepted for proof-of-delivery uploads. */ +export const ALLOWED_PROOF_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const; + +// ─── DTOs ────────────────────────────────────────────────────────────────────── + +export interface UploadProofOfDeliveryInput { + deliveryId: string; + uploadedBy: string; + originalName: string; + mimeType: string; + buffer: Buffer; + sizeBytes: number; +} + +// ─── Service ─────────────────────────────────────────────────────────────────── + +export class ProofOfDeliveryService { + /** + * Validate, persist, and attach a proof-of-delivery image to a delivery. + * + * Business rules enforced here: + * - `deliveryId` must reference an existing, non-terminal delivery. + * - Only the driver assigned to the delivery (or an admin) may upload. + * - MIME type must be an accepted image type. + * - File size must not exceed `PROOF_OF_DELIVERY_MAX_SIZE_MB`. + * + * @throws {AppError} 400 — invalid id or unsupported file. + * @throws {AppError} 403 — the uploader is not the delivery's assigned driver. + * @throws {AppError} 404 — delivery not found. + * @throws {AppError} 409 — delivery is already completed or cancelled. + * @throws {AppError} 413 — file exceeds the configured size limit. + * @throws {AppError} 415 — unsupported MIME type. + */ + async uploadProofOfDelivery(input: UploadProofOfDeliveryInput): Promise { + const { deliveryId, uploadedBy, originalName, mimeType, buffer, sizeBytes } = input; + + if (!Types.ObjectId.isValid(deliveryId)) { + throw new AppError('Invalid delivery ID format.', StatusCodes.BAD_REQUEST); + } + + const delivery = await Delivery.findById(deliveryId); + if (!delivery) { + throw new AppError('Delivery not found.', StatusCodes.NOT_FOUND); + } + + if ( + delivery.status === DeliveryStatus.COMPLETED || + delivery.status === DeliveryStatus.CANCELLED + ) { + throw new AppError( + `Cannot upload proof of delivery for a delivery with status '${delivery.status}'.`, + StatusCodes.CONFLICT, + ); + } + + if (delivery.driverId && delivery.driverId !== uploadedBy) { + throw new AppError( + 'Only the driver assigned to this delivery may upload proof of delivery.', + StatusCodes.FORBIDDEN, + ); + } + + if (!ALLOWED_PROOF_MIME_TYPES.includes(mimeType as (typeof ALLOWED_PROOF_MIME_TYPES)[number])) { + throw new AppError( + `Unsupported file type "${mimeType}". Allowed types: ${ALLOWED_PROOF_MIME_TYPES.join(', ')}.`, + StatusCodes.UNSUPPORTED_MEDIA_TYPE, + ); + } + + const maxBytes = env.PROOF_OF_DELIVERY_MAX_SIZE_MB * 1024 * 1024; + if (sizeBytes > maxBytes) { + throw new AppError( + `File exceeds the maximum allowed size of ${env.PROOF_OF_DELIVERY_MAX_SIZE_MB}MB.`, + StatusCodes.REQUEST_TOO_LONG, + ); + } + + const driver = getStorageDriver(); + const stored = await driver.upload(buffer, `proof-of-delivery/${deliveryId}/${originalName}`, mimeType); + + const proofOfDelivery: IProofOfDelivery = { + storageKey: stored.key, + imageUrl: stored.url, + storageDriver: env.UPLOAD_STORAGE_DRIVER, + mimeType, + sizeBytes, + uploadedBy, + uploadedAt: new Date(), + }; + + delivery.proofOfDelivery = proofOfDelivery; + await delivery.save(); + + logger.info( + `[ProofOfDeliveryService] Uploaded — delivery=${deliveryId} uploadedBy=${uploadedBy} ` + + `key=${stored.key} sizeBytes=${sizeBytes}`, + ); + + return delivery; + } + + /** + * Fetch the proof-of-delivery record for a delivery, if any. + * + * @throws {AppError} 400 — malformed delivery id. + * @throws {AppError} 404 — delivery not found. + */ + async getProofOfDelivery(deliveryId: string): Promise { + if (!Types.ObjectId.isValid(deliveryId)) { + throw new AppError('Invalid delivery ID format.', StatusCodes.BAD_REQUEST); + } + + const delivery = await Delivery.findById(deliveryId).select('proofOfDelivery'); + if (!delivery) { + throw new AppError('Delivery not found.', StatusCodes.NOT_FOUND); + } + + return delivery.proofOfDelivery ?? null; + } + + /** + * Guard used before a delivery is marked completed or its escrow is + * released: throws unless proof of delivery is on record. + * + * @throws {AppError} 422 — no proof of delivery on record. + */ + async assertProofOfDeliveryExists(deliveryId: string): Promise { + if (!Types.ObjectId.isValid(deliveryId)) { + throw new AppError('Invalid delivery ID format.', StatusCodes.BAD_REQUEST); + } + + const delivery = await Delivery.findById(deliveryId).select('proofOfDelivery'); + if (!delivery?.proofOfDelivery) { + throw new AppError( + 'Proof of delivery is required before this delivery can be completed or its escrow released.', + StatusCodes.UNPROCESSABLE_ENTITY, + ); + } + } +} + +export const proofOfDeliveryService = new ProofOfDeliveryService(); +export default proofOfDeliveryService; diff --git a/src/services/webhookService.ts b/src/services/webhookService.ts new file mode 100644 index 0000000..987a152 --- /dev/null +++ b/src/services/webhookService.ts @@ -0,0 +1,412 @@ +/** + * webhookService.ts + * + * Dispatches delivery lifecycle events to merchant-registered HTTP + * endpoints, and owns the registry those endpoints are stored in. + * + * ── Delivery model ────────────────────────────────────────────────────────── + * A dispatch never blocks or fails the delivery-status transition that + * triggered it (mirrors `notificationService`'s fire-and-forget contract). + * Every attempt — success, failure, or skip — is persisted to + * `WebhookDeliveryAttempt` so retries survive a process restart and the + * history is answerable from the database rather than logs. + * + * Failed attempts are retried with exponential backoff (base * 2^(n-1), + * capped, plus jitter) by the sweep in `jobs/webhookRetryJob.ts`, up to + * `WEBHOOK_MAX_RETRIES` attempts, after which the attempt is marked + * `exhausted` and left for the merchant to investigate. + * + * ── Signature verification ────────────────────────────────────────────────── + * Every POST carries `X-SwiftChain-Signature: sha256=`, an + * HMAC-SHA256 of the exact JSON body using the subscription's secret. The + * secret is generated on registration/rotation and never stored or returned + * in plaintext again — only its hash-backed comparisons are needed after + * that point. + */ + +import crypto from 'crypto'; +import axios from 'axios'; +import { StatusCodes } from 'http-status-codes'; +import { Types } from 'mongoose'; +import { + WebhookSubscription, + IWebhookSubscription, + WebhookEvent, +} from '../models/WebhookSubscription'; +import { + WebhookDeliveryAttempt, + IWebhookDeliveryAttempt, + WebhookDeliveryStatus, +} from '../models/WebhookDeliveryAttempt'; +import { IDelivery, DeliveryStatus } from '../models/Delivery'; +import AppError from '../utils/AppError'; +import env from '../config/env'; +import logger from '../config/logger'; + +// ─── DTOs ────────────────────────────────────────────────────────────────────── + +export interface RegisterWebhookInput { + merchantId: string; + url: string; + events: WebhookEvent[]; + description?: string; +} + +export interface UpdateWebhookInput { + url?: string; + events?: WebhookEvent[]; + isActive?: boolean; + description?: string; +} + +/** Registration/rotation result — the only time the plaintext secret is available. */ +export interface WebhookWithSecret { + webhook: IWebhookSubscription; + secret: string; +} + +// ─── Status → event mapping ───────────────────────────────────────────────── + +/** Mirrors `notificationService`'s STATUS_EVENTS: internal statuses raise no event. */ +const STATUS_EVENTS: Partial> = { + [DeliveryStatus.PENDING]: WebhookEvent.DELIVERY_PENDING, + [DeliveryStatus.FUNDED]: WebhookEvent.DELIVERY_FUNDED, + [DeliveryStatus.ASSIGNED]: WebhookEvent.DELIVERY_ASSIGNED, + [DeliveryStatus.IN_PROGRESS]: WebhookEvent.DELIVERY_IN_PROGRESS, + [DeliveryStatus.COMPLETED]: WebhookEvent.DELIVERY_COMPLETED, + [DeliveryStatus.CANCELLED]: WebhookEvent.DELIVERY_CANCELLED, +}; + +// ─── Service ─────────────────────────────────────────────────────────────────── + +export class WebhookService { + // ── Registry ──────────────────────────────────────────────────────────── + + /** Register a new endpoint for a merchant. Generates and returns a one-time secret. */ + async registerWebhook(input: RegisterWebhookInput): Promise { + this.assertValidObjectId(input.merchantId, 'merchantId'); + this.assertValidUrl(input.url); + + if (!input.events || input.events.length === 0) { + throw new AppError('At least one event must be selected.', StatusCodes.BAD_REQUEST); + } + + const secret = this.generateSecret(); + + const webhook = await WebhookSubscription.create({ + merchantId: input.merchantId, + url: input.url, + secret, + events: input.events, + description: input.description, + }); + + logger.info( + `[WebhookService] Registered webhook — merchant=${input.merchantId} id=${String(webhook._id)}`, + ); + + return { webhook, secret }; + } + + /** List a merchant's registered webhooks, newest first. Secret is never included. */ + async listForMerchant(merchantId: string): Promise { + this.assertValidObjectId(merchantId, 'merchantId'); + return WebhookSubscription.find({ merchantId }).sort({ createdAt: -1 }); + } + + /** Fetch one webhook, scoped to its owning merchant. */ + async getById(merchantId: string, id: string): Promise { + this.assertValidObjectId(merchantId, 'merchantId'); + this.assertValidObjectId(id, 'id'); + + const webhook = await WebhookSubscription.findOne({ _id: id, merchantId }); + if (!webhook) { + throw new AppError('Webhook not found.', StatusCodes.NOT_FOUND); + } + return webhook; + } + + /** Update a merchant's webhook. */ + async updateWebhook( + merchantId: string, + id: string, + input: UpdateWebhookInput, + ): Promise { + if (input.url !== undefined) this.assertValidUrl(input.url); + if (input.events !== undefined && input.events.length === 0) { + throw new AppError('At least one event must be selected.', StatusCodes.BAD_REQUEST); + } + + const webhook = await this.getById(merchantId, id); + + if (input.url !== undefined) webhook.url = input.url; + if (input.events !== undefined) webhook.events = input.events; + if (input.isActive !== undefined) webhook.isActive = input.isActive; + if (input.description !== undefined) webhook.description = input.description; + + await webhook.save(); + return webhook; + } + + /** Permanently remove a webhook registration. */ + async deleteWebhook(merchantId: string, id: string): Promise { + const webhook = await this.getById(merchantId, id); + await webhook.deleteOne(); + logger.info(`[WebhookService] Deleted webhook — merchant=${merchantId} id=${id}`); + } + + /** Issue a new signing secret, invalidating the old one immediately. */ + async rotateSecret(merchantId: string, id: string): Promise { + this.assertValidObjectId(merchantId, 'merchantId'); + this.assertValidObjectId(id, 'id'); + + const secret = this.generateSecret(); + const webhook = await WebhookSubscription.findOneAndUpdate( + { _id: id, merchantId }, + { $set: { secret } }, + { new: true }, + ); + + if (!webhook) { + throw new AppError('Webhook not found.', StatusCodes.NOT_FOUND); + } + + logger.info(`[WebhookService] Rotated secret — merchant=${merchantId} id=${id}`); + return { webhook, secret }; + } + + // ── Dispatch ──────────────────────────────────────────────────────────── + + /** + * Notify every active webhook a merchant has registered for this delivery's + * new status. Never throws — a merchant's unreachable server must not roll + * back a delivery transition that already committed. + */ + async dispatchDeliveryEvent(delivery: IDelivery, status: DeliveryStatus): Promise { + const event = STATUS_EVENTS[status]; + if (!event) { + logger.debug(`[WebhookService] Status '${status}' raises no webhook event; skipping`); + return; + } + + const merchantId = delivery.sender ?? delivery.userId; + if (!merchantId || !Types.ObjectId.isValid(String(merchantId))) { + logger.debug( + `[WebhookService] Delivery ${String(delivery._id)} has no identifiable merchant; skipping`, + ); + return; + } + + try { + const subscriptions = await WebhookSubscription.find({ + merchantId, + isActive: true, + events: event, + }).select('+secret'); + + if (subscriptions.length === 0) return; + + const payload = this.buildPayload(delivery, event, status); + + await Promise.all( + subscriptions.map((webhook) => this.createAndSendAttempt(webhook, event, delivery, payload)), + ); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error( + `[WebhookService] Dispatch failed for delivery=${String(delivery._id)}: ${message}`, + ); + } + } + + /** Create the attempt record, then perform the first send. */ + private async createAndSendAttempt( + webhook: IWebhookSubscription, + event: WebhookEvent, + delivery: IDelivery, + payload: Record, + ): Promise { + const attempt = await WebhookDeliveryAttempt.create({ + webhook: webhook._id, + merchantId: webhook.merchantId, + event, + delivery: delivery._id, + payload, + maxAttempts: env.WEBHOOK_MAX_RETRIES, + }); + + await this.sendAttempt(attempt, webhook); + } + + /** + * Perform a single HTTP POST for a delivery attempt and record the + * outcome. Used both for the first send and for retries from the sweep. + */ + async sendAttempt( + attempt: IWebhookDeliveryAttempt, + webhookInput?: IWebhookSubscription, + ): Promise { + const webhook = + webhookInput ?? (await WebhookSubscription.findById(attempt.webhook).select('+secret')); + + if (!webhook || !webhook.isActive) { + attempt.status = WebhookDeliveryStatus.EXHAUSTED; + attempt.lastError = 'Webhook subscription is missing or inactive.'; + attempt.nextRetryAt = null; + await attempt.save(); + return; + } + + const body = JSON.stringify(attempt.payload); + const signature = this.sign(body, webhook.secret); + const attemptNumber = attempt.attempts + 1; + + try { + const response = await axios.post(webhook.url, attempt.payload, { + timeout: env.WEBHOOK_REQUEST_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + 'X-SwiftChain-Event': attempt.event, + 'X-SwiftChain-Delivery-Id': String(attempt.delivery), + 'X-SwiftChain-Attempt-Id': String(attempt._id), + 'X-SwiftChain-Signature': signature, + 'X-SwiftChain-Timestamp': new Date().toISOString(), + }, + validateStatus: (statusCode) => statusCode >= 200 && statusCode < 300, + // Re-serialize would break the signature; axios only serializes + // objects for us, and JSON.stringify is deterministic for the plain + // object payload we build, so `body` above matches what is sent. + transformRequest: [() => body], + }); + + attempt.attempts = attemptNumber; + attempt.status = WebhookDeliveryStatus.SUCCESS; + attempt.lastAttemptAt = new Date(); + attempt.lastStatusCode = response.status; + attempt.lastError = undefined; + attempt.nextRetryAt = null; + await attempt.save(); + + logger.info( + `[WebhookService] Delivered — webhook=${String(webhook._id)} event=${attempt.event} ` + + `attempt=${attemptNumber} status=${response.status}`, + ); + } catch (error) { + const statusCode = axios.isAxiosError(error) ? error.response?.status : undefined; + const message = error instanceof Error ? error.message : 'Unknown error'; + + attempt.attempts = attemptNumber; + attempt.lastAttemptAt = new Date(); + attempt.lastStatusCode = statusCode; + attempt.lastError = message; + + if (attemptNumber >= attempt.maxAttempts) { + attempt.status = WebhookDeliveryStatus.EXHAUSTED; + attempt.nextRetryAt = null; + logger.warn( + `[WebhookService] Exhausted retries — webhook=${String(webhook._id)} ` + + `event=${attempt.event} attempts=${attemptNumber}: ${message}`, + ); + } else { + attempt.status = WebhookDeliveryStatus.FAILED; + attempt.nextRetryAt = this.computeNextRetry(attemptNumber); + logger.warn( + `[WebhookService] Delivery failed, will retry — webhook=${String(webhook._id)} ` + + `event=${attempt.event} attempt=${attemptNumber} nextRetryAt=${attempt.nextRetryAt.toISOString()}: ${message}`, + ); + } + + await attempt.save(); + } + } + + /** + * Retry every due, retryable attempt. Called by the retry sweep job. + * + * @returns Number of attempts processed in this sweep. + */ + async retryDueAttempts(): Promise { + const due = await WebhookDeliveryAttempt.find({ + status: WebhookDeliveryStatus.FAILED, + nextRetryAt: { $lte: new Date() }, + }) + .sort({ nextRetryAt: 1 }) + .limit(env.WEBHOOK_RETRY_BATCH_SIZE); + + if (due.length === 0) return 0; + + await Promise.all(due.map((attempt) => this.sendAttempt(attempt))); + return due.length; + } + + // ── Signing ───────────────────────────────────────────────────────────── + + /** Compute the `sha256=` signature merchants verify against. */ + sign(body: string, secret: string): string { + const digest = crypto.createHmac('sha256', secret).update(body).digest('hex'); + return `sha256=${digest}`; + } + + /** + * Constant-time comparison a merchant's server (or our own tests) can use + * to verify an inbound `X-SwiftChain-Signature` header. + */ + verifySignature(body: string, signatureHeader: string, secret: string): boolean { + const expected = Buffer.from(this.sign(body, secret)); + const actual = Buffer.from(signatureHeader); + + if (expected.length !== actual.length) return false; + return crypto.timingSafeEqual(expected, actual); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private generateSecret(): string { + return crypto.randomBytes(32).toString('hex'); + } + + private buildPayload( + delivery: IDelivery, + event: WebhookEvent, + status: DeliveryStatus, + ): Record { + return { + event, + timestamp: new Date().toISOString(), + data: { + deliveryId: String(delivery._id), + trackingNumber: delivery.trackingNumber, + status, + driverId: delivery.driverId, + }, + }; + } + + /** Exponential backoff with +/-20% jitter, capped at `WEBHOOK_RETRY_MAX_MS`. */ + private computeNextRetry(attemptNumber: number): Date { + const exponential = env.WEBHOOK_RETRY_BASE_MS * Math.pow(2, attemptNumber - 1); + const capped = Math.min(exponential, env.WEBHOOK_RETRY_MAX_MS); + const jitter = capped * 0.2 * (Math.random() * 2 - 1); + return new Date(Date.now() + capped + jitter); + } + + private assertValidObjectId(value: string, field: string): void { + if (!Types.ObjectId.isValid(value)) { + throw new AppError(`${field} must be a valid ObjectId.`, StatusCodes.BAD_REQUEST); + } + } + + private assertValidUrl(url: string): void { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && env.NODE_ENV === 'production') { + throw new Error('non-https'); + } + } catch { + throw new AppError('url must be a valid HTTPS URL.', StatusCodes.BAD_REQUEST); + } + } +} + +export const webhookService = new WebhookService(); +export default webhookService; diff --git a/src/validators/webhookValidator.ts b/src/validators/webhookValidator.ts new file mode 100644 index 0000000..7760141 --- /dev/null +++ b/src/validators/webhookValidator.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { WebhookEvent } from '../models/WebhookSubscription'; + +export const registerWebhookSchema = z.object({ + url: z.url('url must be a valid URL.'), + events: z + .array(z.enum(WebhookEvent, { error: 'Each event must be a valid webhook event.' })) + .min(1, 'At least one event must be selected.'), + description: z.string().trim().max(500).optional(), +}); + +export const updateWebhookSchema = z + .object({ + url: z.url('url must be a valid URL.').optional(), + events: z + .array(z.enum(WebhookEvent, { error: 'Each event must be a valid webhook event.' })) + .min(1, 'At least one event must be selected.') + .optional(), + isActive: z.boolean().optional(), + description: z.string().trim().max(500).optional(), + }) + .refine((data) => Object.keys(data).length > 0, { + message: 'Request body must contain at least one field to update', + }); + +export type RegisterWebhookInput = z.infer; +export type UpdateWebhookInput = z.infer;