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
138 changes: 138 additions & 0 deletions backend/src/controllers/disputes.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Disputes Controller
import { Response } from 'express';
import { AuthenticatedRequest } from '../types/auth.types.js';
import { DisputeService } from '../services/dispute.service.js';
import { logger } from '../utils/logger.js';
import { DisputeStatus } from '@prisma/client';

class DisputesController {
private disputeService: DisputeService;

constructor() {
this.disputeService = new DisputeService();
}

/**
* POST /api/disputes - Submit a new dispute
*/
async submitDispute(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}

const { marketId, reason, evidenceUrl } = req.body;

if (!marketId || !reason) {
res.status(400).json({ error: 'Market ID and reason are required' });
return;
}

const dispute = await this.disputeService.submitDispute({
marketId,
userId,
reason,
evidenceUrl,
});

res.status(201).json(dispute);
} catch (error: any) {
logger.error('Error submitting dispute', { error: error.message });
res.status(error.message.includes('not found') ? 404 : 400).json({
error: error.message,
});
}
}

/**
* PATCH /api/disputes/:disputeId/review - Review a dispute (Admin only)
*/
async reviewDispute(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { disputeId } = req.params;
const { adminNotes } = req.body;

if (!adminNotes) {
res.status(400).json({ error: 'Admin notes are required' });
return;
}

const dispute = await this.disputeService.reviewDispute(disputeId as string, adminNotes);
res.status(200).json(dispute);
} catch (error: any) {
logger.error('Error reviewing dispute', { error: error.message });
res.status(error.message.includes('not found') ? 404 : 400).json({
error: error.message,
});
}
}

/**
* PATCH /api/disputes/:disputeId/resolve - Resolve a dispute (Admin only)
*/
async resolveDispute(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { disputeId } = req.params;
const { action, resolution, adminNotes, newWinningOutcome } = req.body;

if (!action || !['DISMISS', 'RESOLVE_NEW_OUTCOME'].includes(action)) {
res.status(400).json({ error: 'Valid action (DISMISS or RESOLVE_NEW_OUTCOME) is required' });
return;
}

if (!resolution) {
res.status(400).json({ error: 'Resolution details are required' });
return;
}

const dispute = await this.disputeService.resolveDispute(disputeId as string, action, {
resolution,
adminNotes,
newWinningOutcome,
});

res.status(200).json(dispute);
} catch (error: any) {
logger.error('Error resolving dispute', { error: error.message });
res.status(error.message.includes('not found') ? 404 : 400).json({
error: error.message,
});
}
}

/**
* GET /api/disputes/:disputeId - Get dispute details
*/
async getDispute(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { disputeId } = req.params;
const dispute = await this.disputeService.getDisputeDetails(disputeId as string);

if (!dispute) {
res.status(404).json({ error: 'Dispute not found' });
return;
}

res.status(200).json(dispute);
} catch (error: any) {
res.status(400).json({ error: error.message });
}
}

/**
* GET /api/disputes - List disputes
*/
async listDisputes(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const status = req.query.status as DisputeStatus | undefined;
const disputes = await this.disputeService.listDisputes(status);
res.status(200).json(disputes);
} catch (error: any) {
res.status(400).json({ error: error.message });
}
}
}

export const disputesController = new DisputesController();
4 changes: 4 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import referralsRoutes from './routes/referrals.routes.js';
import leaderboardRoutes from './routes/leaderboard.routes.js';
import notificationsRoutes from './routes/notifications.routes.js';
import walletRoutes from './routes/wallet.routes.js';
import disputeRoutes from './routes/disputes.routes.js';

// Import Redis initialization
import {
Expand Down Expand Up @@ -233,6 +234,9 @@ app.use('/api/notifications', notificationsRoutes);
// Wallet routes (USDC withdraw)
app.use('/api/wallet', walletRoutes);

// Dispute routes
app.use('/api/disputes', disputeRoutes);

// =============================================================================
// ERROR HANDLING - UPDATED WITH NEW ERROR HANDLER
// =============================================================================
Expand Down
41 changes: 41 additions & 0 deletions backend/src/repositories/dispute.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Dispute repository - data access layer for disputes
import { Dispute, DisputeStatus } from '@prisma/client';
import { BaseRepository } from './base.repository.js';

export class DisputeRepository extends BaseRepository<Dispute> {
getModelName(): string {
return 'dispute';
}

async findByMarketId(marketId: string): Promise<Dispute[]> {
return await this.prisma.dispute.findMany({
where: { marketId },
orderBy: { createdAt: 'desc' },
});
}

async findByStatus(status: DisputeStatus): Promise<Dispute[]> {
return await this.prisma.dispute.findMany({
where: { status },
orderBy: { createdAt: 'desc' },
});
}

async updateStatus(
id: string,
status: DisputeStatus,
updateData?: {
resolution?: string;
adminNotes?: string;
resolvedAt?: Date;
}
): Promise<Dispute> {
return await this.prisma.dispute.update({
where: { id },
data: {
status,
...updateData,
},
});
}
}
191 changes: 191 additions & 0 deletions backend/src/routes/disputes.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Disputes routes
import { Router } from 'express';
import { disputesController } from '../controllers/disputes.controller.js';
import { requireAuth } from '../middleware/auth.middleware.js';
import { requireAdmin } from '../middleware/admin.middleware.js';
import { validate } from '../middleware/validation.middleware.js';
import {
submitDisputeBody,
reviewDisputeBody,
resolveDisputeBody,
} from '../schemas/validation.schemas.js';

const router: Router = Router();

/**
* @swagger
* tags:
* name: Disputes
* description: Market dispute management
*/

/**
* @swagger
* /api/disputes:
* post:
* summary: Submit a new dispute
* tags: [Disputes]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - marketId
* - reason
* properties:
* marketId:
* type: string
* reason:
* type: string
* evidenceUrl:
* type: string
* responses:
* 201:
* description: Dispute submitted
* 400:
* description: Bad request
* 401:
* description: Unauthorized
*/
router.post(
'/',
requireAuth,
validate({ body: submitDisputeBody }),
(req, res) => disputesController.submitDispute(req, res)
);

/**
* @swagger
* /api/disputes:
* get:
* summary: List all disputes
* tags: [Disputes]
* parameters:
* - name: status
* in: query
* schema:
* type: string
* enum: [OPEN, REVIEWING, RESOLVED, DISMISSED]
* responses:
* 200:
* description: List of disputes
*/
router.get(
'/',
(req, res) => disputesController.listDisputes(req, res)
);

/**
* @swagger
* /api/disputes/{disputeId}:
* get:
* summary: Get dispute details
* tags: [Disputes]
* parameters:
* - name: disputeId
* in: path
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Dispute details
* 404:
* description: Dispute not found
*/
router.get(
'/:disputeId',
(req, res) => disputesController.getDispute(req, res)
);

/**
* @swagger
* /api/disputes/{disputeId}/review:
* patch:
* summary: Review a dispute (Admin only)
* tags: [Disputes]
* security:
* - bearerAuth: []
* parameters:
* - name: disputeId
* in: path
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - adminNotes
* properties:
* adminNotes:
* type: string
* responses:
* 200:
* description: Dispute updated to REVIEWING
* 403:
* description: Forbidden
*/
router.patch(
'/:disputeId/review',
requireAuth,
requireAdmin,
validate({ body: reviewDisputeBody }),
(req, res) => disputesController.reviewDispute(req, res)
);

/**
* @swagger
* /api/disputes/{disputeId}/resolve:
* patch:
* summary: Resolve a dispute (Admin only)
* tags: [Disputes]
* security:
* - bearerAuth: []
* parameters:
* - name: disputeId
* in: path
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - action
* - resolution
* properties:
* action:
* type: string
* enum: [DISMISS, RESOLVE_NEW_OUTCOME]
* resolution:
* type: string
* adminNotes:
* type: string
* newWinningOutcome:
* type: number
* responses:
* 200:
* description: Dispute resolved
* 403:
* description: Forbidden
*/
router.patch(
'/:disputeId/resolve',
requireAuth,
requireAdmin,
validate({ body: resolveDisputeBody }),
(req, res) => disputesController.resolveDispute(req, res)
);

export default router;
Loading
Loading