diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..bc4d724 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + mongodb-memory-server: set this to true or false + unrs-resolver: set this to true or false diff --git a/src/controllers/deliveryController.ts b/src/controllers/deliveryController.ts index fcd3ef1..6e9aee9 100644 --- a/src/controllers/deliveryController.ts +++ b/src/controllers/deliveryController.ts @@ -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; - 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; - - 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): 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; - 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; - 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; - 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, +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 => { 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); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..3164547 --- /dev/null +++ b/src/middleware/auth.ts @@ -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(); + }; +}; diff --git a/src/models/deliveryModel.ts b/src/models/deliveryModel.ts new file mode 100644 index 0000000..d75f9ee --- /dev/null +++ b/src/models/deliveryModel.ts @@ -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( + { + 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('Delivery', deliverySchema); diff --git a/src/routes/deliveries.ts b/src/routes/deliveries.ts new file mode 100644 index 0000000..b5bf14b --- /dev/null +++ b/src/routes/deliveries.ts @@ -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; diff --git a/src/routes/index.ts b/src/routes/index.ts index c812e87..4d7aef5 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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; diff --git a/src/services/deliveryService.ts b/src/services/deliveryService.ts index bd08742..23a5719 100644 --- a/src/services/deliveryService.ts +++ b/src/services/deliveryService.ts @@ -1,129 +1,52 @@ -import { StatusCodes } from 'http-status-codes'; -import Delivery, { IDelivery, DeliveryStatus } from '../models/Delivery'; -import AppError from '../utils/AppError'; -import logger from '../config/logger'; - -// ─── DTOs ────────────────────────────────────────────────────────────────────── - -export interface AddressInput { - street: string; - city: string; - state: string; - postalCode: string; - country: string; -} - -export interface PartyInput { - name: string; - email: string; - phone: string; - stellarAddress: string; - address: AddressInput; -} - -export interface PackageDetailsInput { - weight: number; - dimensions?: { - length: number; - width: number; - height: number; - }; - description: string; - fragile: boolean; -} - -export interface EscrowInput { - amount: number; - stellarAsset?: string; // defaults to 'XLM' -} +import { isValidObjectId } from 'mongoose'; +import { Delivery, DeliveryDocument, DeliveryStatus } from '../models/deliveryModel'; +import { HttpError } from '../utils/httpError'; + +const statusTransitions: Record = { + pending: ['assigned'], + assigned: ['picked_up'], + picked_up: ['in_transit'], + in_transit: ['delivered'], + delivered: [], +}; -export interface CreateDeliveryInput { - sender: PartyInput; - recipient: PartyInput; - packageDetails: PackageDetailsInput; - escrow: EscrowInput; - estimatedDeliveryDate?: string; // ISO 8601 string from the client - notes?: string; -} +export const allowedStatuses = Object.keys(statusTransitions) as DeliveryStatus[]; -// ─── Helpers ─────────────────────────────────────────────────────────────────── +export const isValidDeliveryStatus = (status: string): status is DeliveryStatus => { + return allowedStatuses.includes(status as DeliveryStatus); +}; -/** - * Generates a unique tracking number in the format SWC--. - * Collision probability is negligible for reasonable traffic volumes. - */ -const generateTrackingNumber = (): string => { - const ts = Date.now().toString(36).toUpperCase(); - const rand = Math.random().toString(36).substring(2, 8).toUpperCase(); - return `SWC-${ts}-${rand}`; +const isValidTransition = (current: DeliveryStatus, next: DeliveryStatus): boolean => { + return statusTransitions[current].includes(next); }; -// ─── Service ─────────────────────────────────────────────────────────────────── +export class DeliveryService { + static async updateDeliveryStatus(id: string, status: DeliveryStatus): Promise { + if (!isValidObjectId(id)) { + throw new HttpError(400, 'Invalid delivery id'); + } -/** - * Creates a new delivery record in MongoDB. - * - * Business rules enforced here (not in the controller): - * - Sender and recipient Stellar addresses must differ. - * - Estimated delivery date, if provided, must be in the future. - * - New deliveries always start with status PENDING. - * - A unique tracking number is generated server-side. - */ -export const createDelivery = async (input: CreateDeliveryInput): Promise => { - const { - sender, - recipient, - packageDetails, - escrow, - estimatedDeliveryDate, - notes, - } = input; + const delivery = await Delivery.findById(id); - // Business rule: sender and recipient cannot share the same Stellar address - if (sender.stellarAddress === recipient.stellarAddress) { - throw new AppError( - 'Sender and recipient Stellar addresses must be different.', - StatusCodes.UNPROCESSABLE_ENTITY, - ); - } + if (!delivery) { + throw new HttpError(404, 'Delivery not found'); + } - // Business rule: estimated delivery date must be in the future - let parsedEstimatedDate: Date | undefined; - if (estimatedDeliveryDate) { - parsedEstimatedDate = new Date(estimatedDeliveryDate); - if (isNaN(parsedEstimatedDate.getTime())) { - throw new AppError( - 'estimatedDeliveryDate must be a valid ISO 8601 date string.', - StatusCodes.BAD_REQUEST, - ); + if (delivery.status === status) { + return delivery; } - if (parsedEstimatedDate <= new Date()) { - throw new AppError( - 'estimatedDeliveryDate must be a future date.', - StatusCodes.UNPROCESSABLE_ENTITY, + + if (!isValidTransition(delivery.status, status)) { + const availableNext = statusTransitions[delivery.status].join(', ') || 'none'; + throw new HttpError( + 400, + `Invalid status transition from '${delivery.status}' to '${status}'. Allowed next statuses: ${availableNext}`, ); } - } - const trackingNumber = generateTrackingNumber(); + delivery.status = status; + await delivery.save(); - logger.info(`Creating new delivery with tracking number: ${trackingNumber}`); - - const delivery = await Delivery.create({ - trackingNumber, - status: DeliveryStatus.PENDING, - sender, - recipient, - packageDetails, - escrow: { - amount: escrow.amount, - stellarAsset: escrow.stellarAsset ?? 'XLM', - }, - estimatedDeliveryDate: parsedEstimatedDate, - notes, - }); - - logger.info(`Delivery created successfully. ID: ${delivery._id}, Tracking: ${trackingNumber}`); - - return delivery; -}; + return delivery; + } +} diff --git a/src/utils/httpError.ts b/src/utils/httpError.ts new file mode 100644 index 0000000..7b3f1b2 --- /dev/null +++ b/src/utils/httpError.ts @@ -0,0 +1,10 @@ +export class HttpError extends Error { + statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + Object.setPrototypeOf(this, HttpError.prototype); + Error.captureStackTrace?.(this, this.constructor); + } +} diff --git a/tests/deliveryStatusUpdate.test.ts b/tests/deliveryStatusUpdate.test.ts new file mode 100644 index 0000000..4ecf63c --- /dev/null +++ b/tests/deliveryStatusUpdate.test.ts @@ -0,0 +1,99 @@ +import request from 'supertest'; +import mongoose from 'mongoose'; +import jwt from 'jsonwebtoken'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { Delivery } from '../src/models/deliveryModel'; + +jest.setTimeout(60000); + +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +})); + +describe('Delivery Status Update API', () => { + let app: any; + let mongoServer: MongoMemoryServer; + const jwtSecret = 'test-secret'; + + const buildToken = (role = 'driver'): string => { + return jwt.sign({ sub: 'test-user-id', role }, jwtSecret, { expiresIn: '1h' }); + }; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGODB_URI = mongoServer.getUri(); + process.env.JWT_SECRET = jwtSecret; + + const imported = await import('../src/app'); + app = imported.default; + }); + + afterAll(async () => { + await mongoose.connection.dropDatabase(); + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + afterEach(async () => { + await Delivery.deleteMany({}); + }); + + it('returns 401 when authorization token is missing', async () => { + const response = await request(app) + .put('/api/v1/deliveries/507f1f77bcf86cd799439011/status') + .send({ status: 'assigned' }); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('status', 'error'); + expect(response.body.message).toMatch(/Authorization header missing or malformed/i); + }); + + it('updates delivery status after a valid transition', async () => { + const delivery = await Delivery.create({ + customerName: 'Alice', + pickupLocation: '123 Market St', + dropoffLocation: '456 Park Ave', + packageDetails: 'Small parcel', + status: 'pending', + }); + + const token = buildToken('driver'); + + const response = await request(app) + .put(`/api/v1/deliveries/${delivery._id}/status`) + .set('Authorization', `Bearer ${token}`) + .send({ status: 'assigned' }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('status', 'success'); + expect(response.body.data).toHaveProperty('status', 'assigned'); + + const updated = await Delivery.findById(delivery._id); + expect(updated).not.toBeNull(); + expect(updated?.status).toBe('assigned'); + }); + + it('returns 400 for invalid status transitions', async () => { + const delivery = await Delivery.create({ + customerName: 'Alice', + pickupLocation: '123 Market St', + dropoffLocation: '456 Park Ave', + packageDetails: 'Small parcel', + status: 'pending', + }); + + const token = buildToken('driver'); + + const response = await request(app) + .put(`/api/v1/deliveries/${delivery._id}/status`) + .set('Authorization', `Bearer ${token}`) + .send({ status: 'delivered' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('status', 'error'); + expect(response.body.message).toMatch(/Invalid status transition/i); + }); +});