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
50 changes: 50 additions & 0 deletions src/controllers/disputeController.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, unknown, CreateDisputeInput>,
res: Response,
next: NextFunction,
): Promise<void> => {
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);
}
};
61 changes: 61 additions & 0 deletions src/models/Dispute.ts
Original file line number Diff line number Diff line change
@@ -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<IDispute>(
{
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<IDispute>('Dispute', DisputeSchema);

export default Dispute;
export { Dispute };
16 changes: 16 additions & 0 deletions src/routes/disputeRoutes.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 2 additions & 2 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import authRoutes from './authRoutes';
import deliveryCrudRoutes from './delivery.routes';
import deliveryStatusRoutes from './deliveries';
import adminRoutes from './adminRoutes';
import escrowRoutes from './escrowRoutes';
import disputeRoutes from './disputeRoutes';

const router = Router();

router.use('/v1/auth', authRoutes);
router.use('/v1/deliveries', deliveryCrudRoutes);
router.use('/v1/deliveries', deliveryStatusRoutes);
router.use('/v1/admin', adminRoutes);
router.use('/v1/admin/escrows', escrowRoutes);
router.use('/v1/disputes', disputeRoutes);

export default router;
107 changes: 107 additions & 0 deletions src/services/disputeService.ts
Original file line number Diff line number Diff line change
@@ -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<IDispute> => {
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;
};
15 changes: 15 additions & 0 deletions src/validators/disputeValidator.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createDisputeSchema>;
Loading
Loading