diff --git a/backend/src/controllers/disputes.controller.ts b/backend/src/controllers/disputes.controller.ts new file mode 100644 index 00000000..517d6864 --- /dev/null +++ b/backend/src/controllers/disputes.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/backend/src/index.ts b/backend/src/index.ts index 378ba737..565b4ef2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 { @@ -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 // ============================================================================= diff --git a/backend/src/repositories/dispute.repository.ts b/backend/src/repositories/dispute.repository.ts new file mode 100644 index 00000000..986546a5 --- /dev/null +++ b/backend/src/repositories/dispute.repository.ts @@ -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 { + getModelName(): string { + return 'dispute'; + } + + async findByMarketId(marketId: string): Promise { + return await this.prisma.dispute.findMany({ + where: { marketId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findByStatus(status: DisputeStatus): Promise { + 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 { + return await this.prisma.dispute.update({ + where: { id }, + data: { + status, + ...updateData, + }, + }); + } +} diff --git a/backend/src/routes/disputes.routes.ts b/backend/src/routes/disputes.routes.ts new file mode 100644 index 00000000..a1d8de8c --- /dev/null +++ b/backend/src/routes/disputes.routes.ts @@ -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; diff --git a/backend/src/schemas/validation.schemas.ts b/backend/src/schemas/validation.schemas.ts index d3fba634..537d2fdd 100644 --- a/backend/src/schemas/validation.schemas.ts +++ b/backend/src/schemas/validation.schemas.ts @@ -172,3 +172,35 @@ export const distributeCreatorBody = z.object({ } ), }); + +// --- Dispute schemas --- + +export const submitDisputeBody = z.object({ + marketId: z.string().uuid(), + reason: sanitizedString(10, 1000), + evidenceUrl: z.string().url().optional().or(z.literal('')), +}); + +export const reviewDisputeBody = z.object({ + adminNotes: sanitizedString(5, 5000), +}); + +export const resolveDisputeBody = z + .object({ + action: z.enum(['DISMISS', 'RESOLVE_NEW_OUTCOME']), + resolution: sanitizedString(10, 5000), + adminNotes: sanitizedString(5, 5000).optional(), + newWinningOutcome: z.number().int().min(0).max(1).optional(), + }) + .refine( + (data) => { + if (data.action === 'RESOLVE_NEW_OUTCOME' && data.newWinningOutcome === undefined) { + return false; + } + return true; + }, + { + message: 'New winning outcome is required when action is RESOLVE_NEW_OUTCOME', + path: ['newWinningOutcome'], + } + ); diff --git a/backend/src/services/dispute.service.ts b/backend/src/services/dispute.service.ts new file mode 100644 index 00000000..a0bde94e --- /dev/null +++ b/backend/src/services/dispute.service.ts @@ -0,0 +1,150 @@ +// Dispute service - business logic for dispute management +import { DisputeRepository } from '../repositories/dispute.repository.js'; +import { MarketRepository } from '../repositories/market.repository.js'; +import { DisputeStatus, MarketStatus } from '@prisma/client'; +import { logger } from '../utils/logger.js'; + +export class DisputeService { + private disputeRepository: DisputeRepository; + private marketRepository: MarketRepository; + + constructor( + disputeRepo?: DisputeRepository, + marketRepo?: MarketRepository + ) { + this.disputeRepository = disputeRepo || new DisputeRepository(); + this.marketRepository = marketRepo || new MarketRepository(); + } + + /** + * Submit a new dispute for a market + */ + async submitDispute(data: { + marketId: string; + userId: string; + reason: string; + evidenceUrl?: string; + }) { + // Validate market exists + const market = await this.marketRepository.findById(data.marketId); + if (!market) { + throw new Error('Market not found'); + } + + // Only RESOLVED or CLOSED markets can be disputed + // (Open markets can be cancelled, but disputes usually follow a resolution) + if (market.status !== MarketStatus.RESOLVED && market.status !== MarketStatus.CLOSED) { + throw new Error(`Market in ${market.status} status cannot be disputed`); + } + + logger.info('Creating new dispute', { marketId: data.marketId, userId: data.userId }); + + // Create dispute record + const dispute = await this.disputeRepository.create({ + marketId: data.marketId, + userId: data.userId, + reason: data.reason, + evidenceUrl: data.evidenceUrl, + status: DisputeStatus.OPEN, + }); + + // Optionally update market status to DISPUTED to pause further actions + await this.marketRepository.updateMarketStatus(data.marketId, MarketStatus.DISPUTED); + + return dispute; + } + + /** + * Review a dispute (admin only - should be enforced in controller/middleware) + */ + async reviewDispute(disputeId: string, adminNotes: string) { + const dispute = await this.disputeRepository.findById(disputeId); + if (!dispute) { + throw new Error('Dispute not found'); + } + + if (dispute.status !== DisputeStatus.OPEN) { + throw new Error(`Dispute is already ${dispute.status}`); + } + + return await this.disputeRepository.updateStatus(disputeId, DisputeStatus.REVIEWING, { + adminNotes, + }); + } + + /** + * Resolve a dispute (admin only) + * Can either dismiss the dispute or provide a new outcome + */ + async resolveDispute( + disputeId: string, + action: 'DISMISS' | 'RESOLVE_NEW_OUTCOME', + data: { + resolution: string; + adminNotes?: string; + newWinningOutcome?: number; // 0 or 1 + } + ) { + const dispute = await this.disputeRepository.findById(disputeId); + if (!dispute) { + throw new Error('Dispute not found'); + } + + const market = await this.marketRepository.findById(dispute.marketId); + if (!market) { + throw new Error('Market not found'); + } + + if (action === 'DISMISS') { + // Dismiss the dispute, return market to previous status (Resolved if it was resolved before) + // Actually, if it was RESOLVED, it stays RESOLVED. + await this.disputeRepository.updateStatus(disputeId, DisputeStatus.DISMISSED, { + resolution: data.resolution, + adminNotes: data.adminNotes, + resolvedAt: new Date(), + }); + + // Restore market status to RESOLVED + await this.marketRepository.updateMarketStatus(dispute.marketId, MarketStatus.RESOLVED); + } else { + // Resolve with new outcome + if (data.newWinningOutcome === undefined) { + throw new Error('New winning outcome is required for resolution'); + } + + await this.disputeRepository.updateStatus(disputeId, DisputeStatus.RESOLVED, { + resolution: data.resolution, + adminNotes: data.adminNotes, + resolvedAt: new Date(), + }); + + // Update market with new outcome and set status to RESOLVED + await this.marketRepository.updateMarketStatus(dispute.marketId, MarketStatus.RESOLVED, { + resolvedAt: new Date(), + winningOutcome: data.newWinningOutcome, + resolutionSource: `Dispute Resolution: ${data.resolution}`, + }); + + // NOTE: In a real system, we might need to re-settle predictions if they were already settled + // For now, we update the outcome. The settlement logic in MarketService might need to be re-run. + // However, the prompt only asks for resolving the dispute record and market status. + } + + return await this.disputeRepository.findById(disputeId); + } + + async getDisputeDetails(disputeId: string) { + return await this.disputeRepository.findById(disputeId); + } + + async listDisputes(status?: DisputeStatus) { + if (status) { + return await this.disputeRepository.findByStatus(status); + } + return await this.disputeRepository.findMany({ + orderBy: { createdAt: 'desc' }, + }); + } +} + +export const disputeService = new DisputeService(); diff --git a/backend/tests/services/dispute.service.test.ts b/backend/tests/services/dispute.service.test.ts new file mode 100644 index 00000000..cb93f1a6 --- /dev/null +++ b/backend/tests/services/dispute.service.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DisputeService } from '../../src/services/dispute.service.js'; +import { DisputeStatus, MarketStatus } from '@prisma/client'; + +describe('DisputeService Unit Tests', () => { + let disputeService: DisputeService; + let mockDisputeRepository: any; + let mockMarketRepository: any; + + beforeEach(() => { + mockDisputeRepository = { + findById: vi.fn(), + create: vi.fn(), + updateStatus: vi.fn(), + findByStatus: vi.fn(), + findMany: vi.fn(), + }; + mockMarketRepository = { + findById: vi.fn(), + updateMarketStatus: vi.fn(), + }; + + disputeService = new DisputeService(mockDisputeRepository, mockMarketRepository); + + // Mock logger + vi.mock('../../src/utils/logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + })); + }); + + describe('submitDispute', () => { + it('should throw error if market not found', async () => { + mockMarketRepository.findById.mockResolvedValue(null); + await expect( + disputeService.submitDispute({ + marketId: '1', + userId: 'u1', + reason: 'bad outcome', + }) + ).rejects.toThrow('Market not found'); + }); + + it('should throw error if market status is OPEN', async () => { + mockMarketRepository.findById.mockResolvedValue({ status: MarketStatus.OPEN }); + await expect( + disputeService.submitDispute({ + marketId: '1', + userId: 'u1', + reason: 'bad outcome', + }) + ).rejects.toThrow('Market in OPEN status cannot be disputed'); + }); + + it('should create dispute and update market status to DISPUTED', async () => { + mockMarketRepository.findById.mockResolvedValue({ id: '1', status: MarketStatus.RESOLVED }); + mockDisputeRepository.create.mockResolvedValue({ id: 'd1', status: DisputeStatus.OPEN }); + + const result = await disputeService.submitDispute({ + marketId: '1', + userId: 'u1', + reason: 'bad outcome', + }); + + expect(mockDisputeRepository.create).toHaveBeenCalled(); + expect(mockMarketRepository.updateMarketStatus).toHaveBeenCalledWith('1', MarketStatus.DISPUTED); + expect(result.status).toBe(DisputeStatus.OPEN); + }); + }); + + describe('reviewDispute', () => { + it('should update status to REVIEWING', async () => { + mockDisputeRepository.findById.mockResolvedValue({ id: 'd1', status: DisputeStatus.OPEN }); + mockDisputeRepository.updateStatus.mockResolvedValue({ + id: 'd1', + status: DisputeStatus.REVIEWING, + }); + + const result = await disputeService.reviewDispute('d1', 'Checking evidence'); + + expect(mockDisputeRepository.updateStatus).toHaveBeenCalledWith( + 'd1', + DisputeStatus.REVIEWING, + { adminNotes: 'Checking evidence' } + ); + expect(result.status).toBe(DisputeStatus.REVIEWING); + }); + }); + + describe('resolveDispute', () => { + it('should dismiss dispute and restore market status', async () => { + mockDisputeRepository.findById.mockResolvedValue({ id: 'd1', marketId: 'm1' }); + mockMarketRepository.findById.mockResolvedValue({ id: 'm1' }); + mockDisputeRepository.updateStatus.mockResolvedValue({ + id: 'd1', + status: DisputeStatus.DISMISSED, + }); + + await disputeService.resolveDispute('d1', 'DISMISS', { resolution: 'Invalid claim' }); + + expect(mockDisputeRepository.updateStatus).toHaveBeenCalledWith( + 'd1', + DisputeStatus.DISMISSED, + expect.any(Object) + ); + expect(mockMarketRepository.updateMarketStatus).toHaveBeenCalledWith('m1', MarketStatus.RESOLVED); + }); + + it('should resolve with new outcome', async () => { + mockDisputeRepository.findById.mockResolvedValue({ id: 'd1', marketId: 'm1' }); + mockMarketRepository.findById.mockResolvedValue({ id: 'm1' }); + mockDisputeRepository.updateStatus.mockResolvedValue({ + id: 'd1', + status: DisputeStatus.RESOLVED, + }); + + await disputeService.resolveDispute('d1', 'RESOLVE_NEW_OUTCOME', { + resolution: 'Corrected outcome', + newWinningOutcome: 0, + }); + + expect(mockDisputeRepository.updateStatus).toHaveBeenCalledWith( + 'd1', + DisputeStatus.RESOLVED, + expect.any(Object) + ); + expect(mockMarketRepository.updateMarketStatus).toHaveBeenCalledWith( + 'm1', + MarketStatus.RESOLVED, + expect.objectContaining({ winningOutcome: 0 }) + ); + }); + }); +});