Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
30 changes: 30 additions & 0 deletions src/controllers/assignmentController.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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);
}
};
69 changes: 69 additions & 0 deletions src/controllers/driverEarningsController.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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);
}
};
73 changes: 73 additions & 0 deletions src/controllers/proofOfDeliveryController.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
try {
const proofOfDelivery = await proofOfDeliveryService.getProofOfDelivery(req.params.id);
sendSuccess(
res,
{ proofOfDelivery },
'Proof of delivery retrieved successfully',
StatusCodes.OK,
);
} catch (error) {
next(error);
}
};
136 changes: 136 additions & 0 deletions src/controllers/webhookController.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, unknown, RegisterWebhookInput>,
res: Response,
next: NextFunction,
): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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);
}
};
Loading