From b23726e957b8677716297cbd93d88babc502f215 Mon Sep 17 00:00:00 2001 From: Elizabethxxx Date: Sun, 26 Jul 2026 14:20:44 +0100 Subject: [PATCH] Add open dispute API for delivery issues Allows a delivery's customer or driver to open a dispute before any on-chain dispute workflow runs. - New Dispute model tracking reason, description, evidence, and lifecycle status - disputeService validates the delivery exists and is in an active state (assigned or in progress), restricts the action to delivery participants, and prevents duplicate open disputes on the same delivery - POST /api/v1/disputes, authenticated, request body validated with zod - Stamps the current Soroban ledger sequence onto each dispute via the existing sorobanService for an audit trail - Tests cover the success path plus validation, auth, not-found, conflict, and business-rule failure cases --- src/controllers/disputeController.ts | 50 ++++ src/models/Dispute.ts | 61 +++++ src/routes/disputeRoutes.ts | 16 ++ src/routes/index.ts | 2 + src/services/disputeService.ts | 107 +++++++++ src/validators/disputeValidator.ts | 15 ++ tests/dispute.test.ts | 328 +++++++++++++++++++++++++++ 7 files changed, 579 insertions(+) create mode 100644 src/controllers/disputeController.ts create mode 100644 src/models/Dispute.ts create mode 100644 src/routes/disputeRoutes.ts create mode 100644 src/services/disputeService.ts create mode 100644 src/validators/disputeValidator.ts create mode 100644 tests/dispute.test.ts diff --git a/src/controllers/disputeController.ts b/src/controllers/disputeController.ts new file mode 100644 index 0000000..bd69632 --- /dev/null +++ b/src/controllers/disputeController.ts @@ -0,0 +1,50 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { createDispute } from '../services/disputeService'; +import type { CreateDisputeInput } from '../validators/disputeValidator'; +import type { IUser } from '../interfaces/IUser'; +import AppError from '../utils/AppError'; + +// ─── POST /api/v1/disputes ────────────────────────────────────────────────────── + +/** + * POST /api/v1/disputes + * + * Opens a delivery dispute before any corresponding on-chain dispute + * workflow is executed. `req.body` has already been validated and + * normalized by the `validate(createDisputeSchema)` middleware. + * + * Responds: + * 201 — success, returns the created dispute document. + */ +export const openDispute = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const user = (req as Request & { user?: IUser }).user; + + if (!user) { + throw new AppError('Authentication required.', StatusCodes.UNAUTHORIZED); + } + + const { deliveryId, reason, description, evidenceUrls } = req.body; + + const dispute = await createDispute({ + deliveryId, + raisedBy: user._id.toString(), + reason, + description, + evidenceUrls, + }); + + res.status(StatusCodes.CREATED).json({ + status: 'success', + message: 'Dispute opened successfully.', + data: { dispute }, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/models/Dispute.ts b/src/models/Dispute.ts new file mode 100644 index 0000000..994e55b --- /dev/null +++ b/src/models/Dispute.ts @@ -0,0 +1,61 @@ +import mongoose, { Schema, Document } from 'mongoose'; + +export enum DisputeReason { + DAMAGED_PACKAGE = 'damaged_package', + LATE_DELIVERY = 'late_delivery', + WRONG_ITEM = 'wrong_item', + NON_DELIVERY = 'non_delivery', + OTHER = 'other', +} + +export enum DisputeStatus { + OPEN = 'open', + UNDER_REVIEW = 'under_review', + RESOLVED = 'resolved', + REJECTED = 'rejected', +} + +export interface IDispute extends Document { + deliveryId: string; + raisedBy: string; + reason: DisputeReason; + description: string; + evidenceUrls?: string[]; + status: DisputeStatus; + raisedAtLedger?: number; + resolvedAt?: Date; + resolvedBy?: string; + resolutionNotes?: string; + createdAt: Date; + updatedAt: Date; +} + +const DisputeSchema = new Schema( + { + deliveryId: { type: String, required: true, index: true }, + raisedBy: { type: String, required: true, index: true }, + reason: { + type: String, + enum: Object.values(DisputeReason), + required: true, + }, + description: { type: String, required: true }, + evidenceUrls: { type: [String], default: undefined }, + status: { + type: String, + enum: Object.values(DisputeStatus), + default: DisputeStatus.OPEN, + index: true, + }, + raisedAtLedger: { type: Number }, + resolvedAt: { type: Date }, + resolvedBy: { type: String }, + resolutionNotes: { type: String }, + }, + { timestamps: true }, +); + +const Dispute = mongoose.model('Dispute', DisputeSchema); + +export default Dispute; +export { Dispute }; diff --git a/src/routes/disputeRoutes.ts b/src/routes/disputeRoutes.ts new file mode 100644 index 0000000..0142d34 --- /dev/null +++ b/src/routes/disputeRoutes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import authenticate from '../middleware/authenticate'; +import validate from '../middleware/validate'; +import { openDispute } from '../controllers/disputeController'; +import { createDisputeSchema } from '../validators/disputeValidator'; + +const router = Router(); + +/** + * @route POST /api/v1/disputes + * @desc Open a delivery dispute before any on-chain dispute workflow runs + * @access Authenticated users (delivery customer or driver) + */ +router.post('/', authenticate, validate(createDisputeSchema), openDispute); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index d56bfea..53738e8 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -3,6 +3,7 @@ import authRoutes from './authRoutes'; import deliveryCrudRoutes from './delivery.routes'; import deliveryStatusRoutes from './deliveries'; import adminRoutes from './adminRoutes'; +import disputeRoutes from './disputeRoutes'; const router = Router(); @@ -10,5 +11,6 @@ router.use('/v1/auth', authRoutes); router.use('/v1/deliveries', deliveryCrudRoutes); router.use('/v1/deliveries', deliveryStatusRoutes); router.use('/v1/admin', adminRoutes); +router.use('/v1/disputes', disputeRoutes); export default router; diff --git a/src/services/disputeService.ts b/src/services/disputeService.ts new file mode 100644 index 0000000..c06e2ce --- /dev/null +++ b/src/services/disputeService.ts @@ -0,0 +1,107 @@ +import { StatusCodes } from 'http-status-codes'; +import mongoose from 'mongoose'; +import Dispute, { DisputeReason, DisputeStatus, IDispute } from '../models/Dispute'; +import Delivery, { DeliveryStatus } from '../models/Delivery'; +import { sorobanService } from '../blockchain/soroban.service'; +import AppError from '../utils/AppError'; +import logger from '../config/logger'; + +// ─── Constants ────────────────────────────────────────────────────────────────── + +/** + * Delivery states considered "active" — i.e. a delivery that is actually + * underway and can still be disputed. Deliveries that have not yet been + * assigned, or that have already completed/cancelled, are not eligible. + */ +const ACTIVE_DELIVERY_STATUSES: DeliveryStatus[] = [ + DeliveryStatus.ASSIGNED, + DeliveryStatus.IN_PROGRESS, +]; + +// ─── DTOs ────────────────────────────────────────────────────────────────────── + +export interface CreateDisputeInput { + deliveryId: string; + raisedBy: string; + reason: DisputeReason; + description: string; + evidenceUrls?: string[]; +} + +// ─── Service ─────────────────────────────────────────────────────────────────── + +/** + * Opens a delivery dispute prior to any on-chain dispute workflow. + * + * Business rules enforced here: + * - The referenced delivery must exist. + * - The delivery must currently be in an active state (assigned or + * in-progress) — pending, completed, and cancelled deliveries cannot be + * disputed. + * - Only a participant in the delivery (the customer or the assigned + * driver) may open a dispute against it. + * - A delivery may not have more than one open (unresolved) dispute at a + * time. + */ +export const createDispute = async (input: CreateDisputeInput): Promise => { + const { deliveryId, raisedBy, reason, description, evidenceUrls } = input; + + if (!mongoose.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 (!ACTIVE_DELIVERY_STATUSES.includes(delivery.status)) { + throw new AppError( + `Disputes can only be opened for deliveries that are assigned or in progress. Current status: '${delivery.status}'.`, + StatusCodes.UNPROCESSABLE_ENTITY, + ); + } + + const isParticipant = delivery.userId === raisedBy || delivery.driverId === raisedBy; + if (!isParticipant) { + throw new AppError( + 'Only the customer or driver associated with this delivery may open a dispute.', + StatusCodes.FORBIDDEN, + ); + } + + const existingOpenDispute = await Dispute.findOne({ + deliveryId, + status: { $in: [DisputeStatus.OPEN, DisputeStatus.UNDER_REVIEW] }, + }); + if (existingOpenDispute) { + throw new AppError( + 'An unresolved dispute already exists for this delivery.', + StatusCodes.CONFLICT, + ); + } + + let raisedAtLedger: number | undefined; + try { + raisedAtLedger = await sorobanService.getLatestLedger(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + logger.warn(`[Dispute] Failed to fetch latest Soroban ledger for audit stamp: ${message}`); + } + + const dispute = await Dispute.create({ + deliveryId, + raisedBy, + reason, + description, + evidenceUrls, + status: DisputeStatus.OPEN, + raisedAtLedger, + }); + + logger.info( + `[Dispute] User ${raisedBy} opened dispute ${dispute._id} for delivery ${deliveryId}`, + ); + + return dispute; +}; diff --git a/src/validators/disputeValidator.ts b/src/validators/disputeValidator.ts new file mode 100644 index 0000000..a0cf75c --- /dev/null +++ b/src/validators/disputeValidator.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { DisputeReason } from '../models/Dispute'; + +export const createDisputeSchema = z.object({ + deliveryId: z.string({ error: 'deliveryId is required' }).trim().min(1, 'deliveryId is required'), + reason: z.enum(DisputeReason, { error: 'A valid dispute reason is required' }), + description: z + .string({ error: 'description is required' }) + .trim() + .min(10, 'description must be at least 10 characters') + .max(2000, 'description must be at most 2000 characters'), + evidenceUrls: z.array(z.url('Each evidence entry must be a valid URL')).max(10).optional(), +}); + +export type CreateDisputeInput = z.infer; diff --git a/tests/dispute.test.ts b/tests/dispute.test.ts new file mode 100644 index 0000000..1d201e1 --- /dev/null +++ b/tests/dispute.test.ts @@ -0,0 +1,328 @@ +import request from 'supertest'; +import mongoose from 'mongoose'; +import jwt from 'jsonwebtoken'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import app from '../src/app'; +import User from '../src/models/User'; +import Delivery, { DeliveryStatus } from '../src/models/Delivery'; +import Dispute, { DisputeReason } from '../src/models/Dispute'; +import { UserRole, UserStatus } from '../src/interfaces/IUser'; + +// ─── Module mocks ────────────────────────────────────────────────────────────── + +jest.mock('../src/config/database', () => ({ + connectDatabase: jest.fn(), +})); + +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +})); + +jest.mock('../src/blockchain/soroban.service', () => ({ + sorobanService: { + getLatestLedger: jest.fn().mockResolvedValue(555555), + }, +})); + +// ─── In-memory MongoDB ───────────────────────────────────────────────────────── + +let mongoServer: MongoMemoryServer; + +const SETUP_TIMEOUT = 120_000; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); +}, SETUP_TIMEOUT); + +afterEach(async () => { + const collections = mongoose.connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}, 15_000); + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +const JWT_SECRET = 'test-secret-key'; + +const signToken = (userId: string): string => + jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '1h' }); + +const createUser = async ( + overrides: Partial<{ role: UserRole; status: UserStatus }> = {}, +): Promise> => { + return User.create({ + firstName: 'Test', + lastName: 'User', + email: `user-${Date.now()}-${Math.random()}@example.com`, + password: 'Password123!', + role: overrides.role ?? UserRole.USER, + status: overrides.status ?? UserStatus.ACTIVE, + }); +}; + +const createDelivery = async (overrides: { + userId: string; + driverId?: string; + status?: DeliveryStatus; +}) => { + return Delivery.create({ + userId: overrides.userId, + driverId: overrides.driverId, + status: overrides.status ?? DeliveryStatus.IN_PROGRESS, + pickupCoordinates: { lat: 1, lng: 1, address: 'Pickup' }, + dropoffCoordinates: { lat: 2, lng: 2, address: 'Dropoff' }, + }); +}; + +const validBody = (deliveryId: string) => ({ + deliveryId, + reason: DisputeReason.DAMAGED_PACKAGE, + description: 'The package arrived with visible damage to the packaging and contents.', +}); + +// ─── POST /api/v1/disputes ────────────────────────────────────────────────────── + +describe('POST /api/v1/disputes', () => { + describe('201 - success', () => { + it('opens a dispute for the customer of an active delivery', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const customer = await createUser(); + const driver = await createUser({ role: UserRole.DRIVER }); + const delivery = await createDelivery({ + userId: customer._id.toString(), + driverId: driver._id.toString(), + }); + const token = signToken(customer._id.toString()); + + const res = await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send(validBody(delivery._id.toString())); + + expect(res.status).toBe(201); + expect(res.body.data.dispute.status).toBe('open'); + expect(res.body.data.dispute.deliveryId).toBe(delivery._id.toString()); + expect(res.body.data.dispute.raisedAtLedger).toBe(555555); + }); + + it('opens a dispute for the assigned driver of an active delivery', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const customer = await createUser(); + const driver = await createUser({ role: UserRole.DRIVER }); + const delivery = await createDelivery({ + userId: customer._id.toString(), + driverId: driver._id.toString(), + status: DeliveryStatus.ASSIGNED, + }); + const token = signToken(driver._id.toString()); + + const res = await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send(validBody(delivery._id.toString())); + + expect(res.status).toBe(201); + }); + + it('persists the dispute to the database', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const customer = await createUser(); + const delivery = await createDelivery({ userId: customer._id.toString() }); + const token = signToken(customer._id.toString()); + + await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send(validBody(delivery._id.toString())); + + const disputes = await Dispute.find({ deliveryId: delivery._id.toString() }); + expect(disputes).toHaveLength(1); + expect(disputes[0].raisedBy).toBe(customer._id.toString()); + }); + }); + + describe('400 - validation errors', () => { + it('returns 400 when deliveryId is missing', async () => { + process.env.JWT_SECRET = JWT_SECRET; + const customer = await createUser(); + const token = signToken(customer._id.toString()); + + const res = await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send({ reason: DisputeReason.OTHER, description: 'A description long enough.' }); + + expect(res.status).toBe(400); + }); + + it('returns 400 when reason is invalid', async () => { + process.env.JWT_SECRET = JWT_SECRET; + const customer = await createUser(); + const delivery = await createDelivery({ userId: customer._id.toString() }); + const token = signToken(customer._id.toString()); + + const res = await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send({ + deliveryId: delivery._id.toString(), + reason: 'not_a_real_reason', + description: 'A description long enough.', + }); + + expect(res.status).toBe(400); + }); + + it('returns 400 when description is too short', async () => { + process.env.JWT_SECRET = JWT_SECRET; + const customer = await createUser(); + const delivery = await createDelivery({ userId: customer._id.toString() }); + const token = signToken(customer._id.toString()); + + const res = await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send({ + deliveryId: delivery._id.toString(), + reason: DisputeReason.OTHER, + description: 'short', + }); + + expect(res.status).toBe(400); + }); + + it('returns 400 when deliveryId is not a valid ObjectId', async () => { + process.env.JWT_SECRET = JWT_SECRET; + const customer = await createUser(); + const token = signToken(customer._id.toString()); + + const res = await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send(validBody('not-an-objectid')); + + expect(res.status).toBe(400); + }); + }); + + describe('401 - authentication errors', () => { + it('returns 401 when no Authorization header is provided', async () => { + const customer = await createUser(); + const delivery = await createDelivery({ userId: customer._id.toString() }); + + const res = await request(app) + .post('/api/v1/disputes') + .send(validBody(delivery._id.toString())); + + expect(res.status).toBe(401); + }); + }); + + describe('403 - authorization errors', () => { + it('returns 403 when the requester is not a participant of the delivery', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const customer = await createUser(); + const stranger = await createUser(); + const delivery = await createDelivery({ userId: customer._id.toString() }); + const token = signToken(stranger._id.toString()); + + const res = await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send(validBody(delivery._id.toString())); + + expect(res.status).toBe(403); + }); + }); + + describe('404 - not found', () => { + it('returns 404 when the delivery does not exist', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const customer = await createUser(); + const token = signToken(customer._id.toString()); + const nonExistentId = new mongoose.Types.ObjectId().toString(); + + const res = await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send(validBody(nonExistentId)); + + expect(res.status).toBe(404); + }); + }); + + describe('409 - conflict', () => { + it('returns 409 when an unresolved dispute already exists for the delivery', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const customer = await createUser(); + const delivery = await createDelivery({ userId: customer._id.toString() }); + const token = signToken(customer._id.toString()); + + await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send(validBody(delivery._id.toString())); + + const res = await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send(validBody(delivery._id.toString())); + + expect(res.status).toBe(409); + }); + }); + + describe('422 - business rule violations', () => { + it('returns 422 when the delivery is pending (not yet active)', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const customer = await createUser(); + const delivery = await createDelivery({ + userId: customer._id.toString(), + status: DeliveryStatus.PENDING, + }); + const token = signToken(customer._id.toString()); + + const res = await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send(validBody(delivery._id.toString())); + + expect(res.status).toBe(422); + }); + + it('returns 422 when the delivery is already completed', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const customer = await createUser(); + const delivery = await createDelivery({ + userId: customer._id.toString(), + status: DeliveryStatus.COMPLETED, + }); + const token = signToken(customer._id.toString()); + + const res = await request(app) + .post('/api/v1/disputes') + .set('Authorization', `Bearer ${token}`) + .send(validBody(delivery._id.toString())); + + expect(res.status).toBe(422); + }); + }); +});