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
3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
allowBuilds:
mongodb-memory-server: set this to true or false
unrs-resolver: set this to true or false
155 changes: 27 additions & 128 deletions src/controllers/deliveryController.ts
Original file line number Diff line number Diff line change
@@ -1,142 +1,41 @@
import { Request, Response, NextFunction } from 'express';
import { StatusCodes } from 'http-status-codes';
import * as deliveryService from '../services/deliveryService';
import { CreateDeliveryInput } from '../services/deliveryService';
import AppError from '../utils/AppError';

// ─── Request body type ─────────────────────────────────────────────────────────

type CreateDeliveryBody = CreateDeliveryInput;

// ─── Validation helpers ────────────────────────────────────────────────────────

/**
* Returns a list of missing required field paths for an address object.
*/
const validateAddress = (address: unknown, prefix: string): string[] => {
const errors: string[] = [];
const required = ['street', 'city', 'state', 'postalCode', 'country'] as const;

if (typeof address !== 'object' || address === null) {
return [`${prefix} is required and must be an object`];
}

const addr = address as Record<string, unknown>;
for (const field of required) {
if (!addr[field] || typeof addr[field] !== 'string') {
errors.push(`${prefix}.${field} is required`);
}
}
return errors;
};

/**
* Returns a list of missing / invalid fields for a sender or recipient object.
*/
const validateParty = (party: unknown, role: 'sender' | 'recipient'): string[] => {
const errors: string[] = [];

if (typeof party !== 'object' || party === null) {
return [`${role} is required and must be an object`];
}

const p = party as Record<string, unknown>;

if (!p.name || typeof p.name !== 'string') errors.push(`${role}.name is required`);
if (!p.email || typeof p.email !== 'string') errors.push(`${role}.email is required`);
if (!p.phone || typeof p.phone !== 'string') errors.push(`${role}.phone is required`);
if (!p.stellarAddress || typeof p.stellarAddress !== 'string') {
errors.push(`${role}.stellarAddress is required`);
}

errors.push(...validateAddress(p.address, `${role}.address`));

return errors;
};

/**
* Validates the top-level request body and returns an array of error messages.
* An empty array means the body is valid.
*/
const validateCreateDeliveryBody = (body: Partial<CreateDeliveryBody>): string[] => {
const errors: string[] = [];

errors.push(...validateParty(body.sender, 'sender'));
errors.push(...validateParty(body.recipient, 'recipient'));

// packageDetails
if (typeof body.packageDetails !== 'object' || body.packageDetails === null) {
errors.push('packageDetails is required and must be an object');
} else {
// Cast through unknown to allow dynamic key access on the typed sub-object
const pkg = body.packageDetails as unknown as Record<string, unknown>;
if (typeof pkg['weight'] !== 'number' || (pkg['weight'] as number) < 0) {
errors.push('packageDetails.weight must be a non-negative number');
}
if (!pkg['description'] || typeof pkg['description'] !== 'string') {
errors.push('packageDetails.description is required');
}
if (typeof pkg['fragile'] !== 'boolean') {
errors.push('packageDetails.fragile must be a boolean');
}
if (pkg['dimensions'] !== undefined) {
const dims = pkg['dimensions'] as Record<string, unknown>;
for (const dim of ['length', 'width', 'height']) {
if (typeof dims[dim] !== 'number' || (dims[dim] as number) < 0) {
errors.push(`packageDetails.dimensions.${dim} must be a non-negative number`);
}
}
}
}

// escrow
if (typeof body.escrow !== 'object' || body.escrow === null) {
errors.push('escrow is required and must be an object');
} else {
// Cast through unknown to allow dynamic key access on the typed sub-object
const escrow = body.escrow as unknown as Record<string, unknown>;
if (typeof escrow['amount'] !== 'number' || (escrow['amount'] as number) < 0) {
errors.push('escrow.amount must be a non-negative number');
}
if (escrow['stellarAsset'] !== undefined && typeof escrow['stellarAsset'] !== 'string') {
errors.push('escrow.stellarAsset must be a string');
}
}

return errors;
};

// ─── Controller ────────────────────────────────────────────────────────────────

/**
* POST /api/v1/deliveries
*
* Creates a new delivery record (off-chain metadata only).
* The on-chain Soroban contract interaction happens in a subsequent step.
*/
export const createDelivery = async (
req: Request<unknown, unknown, CreateDeliveryBody>,
import { DeliveryService, allowedStatuses, isValidDeliveryStatus } from '../services/deliveryService';
import { HttpError } from '../utils/httpError';

interface UpdateDeliveryStatusRequest extends Request {
params: {
id: string;
};
body: {
status?: string;
};
}

export const updateDeliveryStatus = async (
req: UpdateDeliveryStatusRequest,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const validationErrors = validateCreateDeliveryBody(req.body);
const { id } = req.params;
const { status } = req.body;

if (!status || typeof status !== 'string') {
throw new HttpError(400, 'Status is required and must be a string');
}

if (validationErrors.length > 0) {
throw new AppError(
`Validation failed: ${validationErrors.join('; ')}`,
StatusCodes.BAD_REQUEST,
if (!isValidDeliveryStatus(status)) {
throw new HttpError(
400,
`Status must be one of: ${allowedStatuses.join(', ')}`,
);
}

const delivery = await deliveryService.createDelivery(req.body);
const delivery = await DeliveryService.updateDeliveryStatus(id, status);

res.status(StatusCodes.CREATED).json({
res.status(200).json({
status: 'success',
message: 'Delivery created successfully',
data: {
delivery,
},
data: delivery,
});
} catch (error) {
next(error);
Expand Down
54 changes: 54 additions & 0 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextFunction, Request, Response } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';
import { HttpError } from '../utils/httpError';

const jwtSecret = process.env.JWT_SECRET || 'changeme';

export interface AuthenticatedRequest extends Request {
user?: JwtPayload & {
role?: string;
sub?: string;
};
}

export const authenticate = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
): void => {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
next(new HttpError(401, 'Authorization header missing or malformed'));
return;
}

const token = authHeader.split(' ')[1];

try {
const payload = jwt.verify(token, jwtSecret);

if (typeof payload === 'string') {
next(new HttpError(401, 'Invalid authorization token'));
return;
}

req.user = payload;
next();
} catch (error) {
next(new HttpError(401, 'Invalid or expired authorization token'));
}
};

export const authorize = (allowedRoles: string[] = ['driver', 'admin']) => {
return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
const role = req.user?.role;

if (!role || !allowedRoles.includes(role)) {
next(new HttpError(403, 'Insufficient permissions to perform this action'));
return;
}

next();
};
};
35 changes: 35 additions & 0 deletions src/models/deliveryModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Document, model, Schema } from 'mongoose';

export type DeliveryStatus = 'pending' | 'assigned' | 'picked_up' | 'in_transit' | 'delivered';

export interface DeliveryDocument extends Document {
customerName: string;
pickupLocation: string;
dropoffLocation: string;
packageDetails: string;
status: DeliveryStatus;
assignedDriver?: string;
createdAt: Date;
updatedAt: Date;
}

const deliverySchema = new Schema<DeliveryDocument>(
{
customerName: { type: String, required: true, trim: true },
pickupLocation: { type: String, required: true, trim: true },
dropoffLocation: { type: String, required: true, trim: true },
packageDetails: { type: String, required: true, trim: true },
status: {
type: String,
enum: ['pending', 'assigned', 'picked_up', 'in_transit', 'delivered'],
default: 'pending',
required: true,
},
assignedDriver: { type: String, default: null },
},
{
timestamps: true,
},
);

export const Delivery = model<DeliveryDocument>('Delivery', deliverySchema);
9 changes: 9 additions & 0 deletions src/routes/deliveries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Router } from 'express';
import { authenticate, authorize } from '../middleware/auth';
import { updateDeliveryStatus } from '../controllers/deliveryController';

const router = Router();

router.put('/:id/status', authenticate, authorize(['driver', 'admin']), updateDeliveryStatus);

export default router;
7 changes: 1 addition & 6 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { Router } from 'express';
import deliveryRoutes from './deliveryRoutes';
import adminRoutes from './adminRoutes';
import deliveryRoutes from './deliveries';

const router = Router();

// Define your routes here
// router.use('/auth', authRoutes);
// router.use('/users', userRoutes);
router.use('/deliveries', deliveryRoutes);
router.use('/admin', adminRoutes);

export default router;
Loading