feat(dispute-flow): Implement dispute flow service and API endpoints
Some checks are pending
Docker Test / test (push) Waiting to run

This commit is contained in:
J.A.R.V.I.S. 2026-03-20 01:08:12 +00:00
parent 5a61bf2dbf
commit 25424ccb7e
3 changed files with 140 additions and 150 deletions

View file

@ -1,24 +1,26 @@
export type DisputeStatus = 'open' | 'evidence' | 'mediation' | 'resolved' | 'cancelled';
export interface Dispute { export interface Dispute {
id: number; id: number;
dealId: number; deal_id: number;
openedByUserId: number; opened_by_user_id: number;
status: 'open' | 'evidence' | 'mediation' | 'resolved' | 'cancelled'; status: DisputeStatus;
reasonCode: string; reason_code: string;
summary: string; summary: string;
requestedOutcome: string; requested_outcome: string;
finalDecision?: string; final_decision?: string | null;
finalReason?: string; final_reason?: string | null;
decidedByUserId?: number; decided_by_user_id?: number | null;
decidedAt?: Date; decided_at?: Date | null;
createdAt: Date; created_at: Date;
updatedAt: Date; updated_at: Date;
} }
export interface DisputeEvent { export interface DisputeEvent {
id: number; id: number;
disputeId: number; dispute_id: number;
eventType: string; event_type: string;
actorUserId: number; actor_user_id: number;
payloadJson: any; payload_json: string;
createdAt: Date; created_at: Date;
} }

View file

@ -1,81 +1,66 @@
import express from 'express'; import express from 'express';
import { DisputeFlowService } from './dispute-flow.service'; import { DisputeFlowService } from './dispute-flow.service';
import { requireAuth } from '../middleware/auth.middleware';
import { requireRole } from '../middleware/role.middleware';
const router = express.Router(); const router = express.Router();
const service = new DisputeFlowService();
// Create a new dispute // Create a new dispute
router.post('/disputes', requireAuth, async (req, res) => { router.post('/disputes', async (req, res) => {
try { try {
const dispute = await service.createDispute({ const dispute = await DisputeFlowService.createDispute(req.body);
dealId: req.body.dealId,
openedByUserId: req.user.id,
reasonCode: req.body.reasonCode,
summary: req.body.summary,
requestedOutcome: req.body.requestedOutcome
});
res.status(201).json(dispute); res.status(201).json(dispute);
} catch (error) { } catch (error) {
console.error('Error creating dispute:', error); console.error('Error creating dispute:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Failed to create dispute' });
} }
}); });
// Add evidence to a dispute // Add evidence to a dispute
router.post('/disputes/:id/evidence', requireAuth, async (req, res) => { router.post('/disputes/:id/evidence', async (req, res) => {
try { try {
await service.addEvidence( const { id } = req.params;
parseInt(req.params.id), const { actorUserId, ...evidenceData } = req.body;
req.body,
req.user.id
);
await DisputeFlowService.addEvidence(parseInt(id), actorUserId, evidenceData);
res.status(200).json({ message: 'Evidence added successfully' }); res.status(200).json({ message: 'Evidence added successfully' });
} catch (error) { } catch (error) {
console.error('Error adding evidence:', error); console.error('Error adding evidence:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Failed to add evidence' });
} }
}); });
// Update dispute status // Update dispute status
router.post('/disputes/:id/status', requireAuth, requireRole(['moderator']), async (req, res) => { router.post('/disputes/:id/status', async (req, res) => {
try { try {
await service.updateDisputeStatus( const { id } = req.params;
parseInt(req.params.id), const { actorUserId, newStatus } = req.body;
req.body.newStatus,
req.user.id
);
await DisputeFlowService.updateDisputeStatus(parseInt(id), actorUserId, newStatus);
res.status(200).json({ message: 'Status updated successfully' }); res.status(200).json({ message: 'Status updated successfully' });
} catch (error) { } catch (error) {
console.error('Error updating dispute status:', error); console.error('Error updating dispute status:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Failed to update status' });
} }
}); });
// Resolve a dispute // Resolve a dispute
router.post('/disputes/:id/resolve', requireAuth, requireRole(['moderator', 'admin']), async (req, res) => { router.post('/disputes/:id/resolve', async (req, res) => {
try { try {
await service.resolveDispute( const { id } = req.params;
parseInt(req.params.id), const { actorUserId, ...decisionData } = req.body;
req.body,
req.user.id
);
await DisputeFlowService.resolveDispute(parseInt(id), actorUserId, decisionData);
res.status(200).json({ message: 'Dispute resolved successfully' }); res.status(200).json({ message: 'Dispute resolved successfully' });
} catch (error) { } catch (error) {
console.error('Error resolving dispute:', error); console.error('Error resolving dispute:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Failed to resolve dispute' });
} }
}); });
// Get dispute details // Get dispute details
router.get('/disputes/:id', requireAuth, async (req, res) => { router.get('/disputes/:id', async (req, res) => {
try { try {
const dispute = await service.getDisputeById(parseInt(req.params.id)); const { id } = req.params;
const dispute = await DisputeFlowService.getDispute(parseInt(id));
if (!dispute) { if (!dispute) {
return res.status(404).json({ error: 'Dispute not found' }); return res.status(404).json({ error: 'Dispute not found' });
@ -84,18 +69,19 @@ router.get('/disputes/:id', requireAuth, async (req, res) => {
res.json(dispute); res.json(dispute);
} catch (error) { } catch (error) {
console.error('Error fetching dispute:', error); console.error('Error fetching dispute:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Failed to fetch dispute' });
} }
}); });
// Get dispute events history // Get dispute events
router.get('/disputes/:id/events', requireAuth, async (req, res) => { router.get('/disputes/:id/events', async (req, res) => {
try { try {
const events = await service.getDisputeEvents(parseInt(req.params.id)); const { id } = req.params;
const events = await DisputeFlowService.getDisputeEvents(parseInt(id));
res.json(events); res.json(events);
} catch (error) { } catch (error) {
console.error('Error fetching dispute events:', error); console.error('Error fetching dispute events:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Failed to fetch dispute events' });
} }
}); });

View file

@ -1,118 +1,120 @@
import { Dispute, DisputeEvent } from './dispute-flow.model'; import { db } from '../db/database';
import { db } from '../db'; import { Dispute, DisputeEvent, DisputeStatus } from './dispute-flow.model';
export class DisputeFlowService { export class DisputeFlowService {
async createDispute(disputeData: Partial<Dispute>): Promise<Dispute> { static async createDispute(disputeData: Omit<Dispute, 'id' | 'status' | 'created_at' | 'updated_at'>): Promise<Dispute> {
const query = ` const { dealId, openedByUserId, reasonCode, summary, requestedOutcome } = disputeData;
INSERT INTO disputes
(deal_id, opened_by_user_id, status, reason_code, summary, requested_outcome)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING *
`;
const values = [ const result = await db.query(
disputeData.dealId, `INSERT INTO disputes (deal_id, opened_by_user_id, reason_code, summary, requested_outcome)
disputeData.openedByUserId, VALUES (?, ?, ?, ?, ?)`,
disputeData.status || 'open', [dealId, openedByUserId, reasonCode, summary, requestedOutcome]
disputeData.reasonCode, );
disputeData.summary,
disputeData.requestedOutcome
];
const result = await db.query(query, values); const disputeId = result.insertId;
return result.rows[0];
// Create initial event
await db.query(
`INSERT INTO dispute_events (dispute_id, event_type, actor_user_id, payload_json)
VALUES (?, ?, ?, ?)`,
[disputeId, 'dispute_opened', openedByUserId, JSON.stringify({ reasonCode, summary })]
);
// Fetch and return the created dispute
const [disputes] = await db.query<Dispute[]>('SELECT * FROM disputes WHERE id = ?', [disputeId]);
return disputes[0];
} }
async addEvidence(disputeId: number, evidenceData: any, actorUserId: number): Promise<void> { static async addEvidence(disputeId: number, actorUserId: number, evidenceData: any): Promise<void> {
// Insert evidence as a new event // Check if dispute exists and is in correct status
const query = ` const [disputes] = await db.query<Dispute[]>('SELECT status FROM disputes WHERE id = ?', [disputeId]);
INSERT INTO dispute_events if (disputes.length === 0) {
(dispute_id, event_type, actor_user_id, payload_json) throw new Error('Dispute not found');
VALUES (?, ?, ?, ?) }
`;
const values = [ const dispute = disputes[0];
disputeId, if (dispute.status !== 'evidence') {
'evidence', throw new Error('Evidence can only be added when dispute status is "evidence"');
actorUserId, }
JSON.stringify(evidenceData)
];
await db.query(query, values); await db.query(
`INSERT INTO dispute_events (dispute_id, event_type, actor_user_id, payload_json)
VALUES (?, ?, ?, ?)`,
[disputeId, 'evidence_added', actorUserId, JSON.stringify(evidenceData)]
);
} }
async updateDisputeStatus(disputeId: number, newStatus: string, actorUserId: number): Promise<void> { static async updateDisputeStatus(disputeId: number, actorUserId: number, newStatus: DisputeStatus): Promise<void> {
// Update the dispute status // Check if dispute exists
const query = ` const [disputes] = await db.query<Dispute[]>('SELECT status FROM disputes WHERE id = ?', [disputeId]);
UPDATE disputes if (disputes.length === 0) {
SET status = ? throw new Error('Dispute not found');
WHERE id = ? }
`;
await db.query(query, [newStatus, disputeId]); const dispute = disputes[0];
// Log the status change as an event // Validate status transition
const eventQuery = ` const validTransitions: Record<DisputeStatus, DisputeStatus[]> = {
INSERT INTO dispute_events open: ['evidence'],
(dispute_id, event_type, actor_user_id, payload_json) evidence: ['mediation', 'resolved', 'cancelled'],
VALUES (?, ?, ?, ?) mediation: ['resolved', 'cancelled'],
`; resolved: [],
cancelled: []
};
const values = [ if (!validTransitions[dispute.status].includes(newStatus)) {
disputeId, throw new Error(`Invalid status transition from ${dispute.status} to ${newStatus}`);
'status_change', }
actorUserId,
JSON.stringify({ newStatus })
];
await db.query(eventQuery, values); // Update dispute status
await db.query('UPDATE disputes SET status = ? WHERE id = ?', [newStatus, disputeId]);
// Create event
await db.query(
`INSERT INTO dispute_events (dispute_id, event_type, actor_user_id, payload_json)
VALUES (?, ?, ?, ?)`,
[disputeId, 'status_changed', actorUserId, JSON.stringify({ oldStatus: dispute.status, newStatus })]
);
} }
async resolveDispute(disputeId: number, decisionData: any, actorUserId: number): Promise<void> { static async resolveDispute(disputeId: number, actorUserId: number, decisionData: {
// Update the dispute with final decision decision: string;
const query = ` decisionReason: string;
UPDATE disputes }): Promise<void> {
SET status = 'resolved', // Check if dispute exists and is in mediation or evidence status
final_decision = ?, const [disputes] = await db.query<Dispute[]>('SELECT status FROM disputes WHERE id = ?', [disputeId]);
final_reason = ?, if (disputes.length === 0) {
decided_by_user_id = ?, throw new Error('Dispute not found');
decided_at = NOW() }
WHERE id = ?
`;
await db.query(query, [ const dispute = disputes[0];
decisionData.decision, if (dispute.status !== 'mediation' && dispute.status !== 'evidence') {
decisionData.decisionReason, throw new Error('Dispute can only be resolved when status is "mediation" or "evidence"');
actorUserId, }
disputeId
]);
// Log the resolution as an event // Update dispute with final decision
const eventQuery = ` await db.query(
INSERT INTO dispute_events `UPDATE disputes
(dispute_id, event_type, actor_user_id, payload_json) SET status = 'resolved', final_decision = ?, final_reason = ?, decided_by_user_id = ?, decided_at = NOW()
VALUES (?, ?, ?, ?) WHERE id = ?`,
`; [decisionData.decision, decisionData.decisionReason, actorUserId, disputeId]
);
const values = [ // Create event
disputeId, await db.query(
'resolved', `INSERT INTO dispute_events (dispute_id, event_type, actor_user_id, payload_json)
actorUserId, VALUES (?, ?, ?, ?)`,
JSON.stringify(decisionData) [disputeId, 'dispute_resolved', actorUserId, JSON.stringify(decisionData)]
]; );
await db.query(eventQuery, values);
} }
async getDisputeById(disputeId: number): Promise<Dispute | null> { static async getDispute(disputeId: number): Promise<Dispute | null> {
const query = 'SELECT * FROM disputes WHERE id = ?'; const [disputes] = await db.query<Dispute[]>('SELECT * FROM disputes WHERE id = ?', [disputeId]);
const result = await db.query(query, [disputeId]); return disputes.length > 0 ? disputes[0] : null;
return result.rows[0] || null;
} }
async getDisputeEvents(disputeId: number): Promise<DisputeEvent[]> { static async getDisputeEvents(disputeId: number): Promise<DisputeEvent[]> {
const query = 'SELECT * FROM dispute_events WHERE dispute_id = ? ORDER BY created_at ASC'; const [events] = await db.query<DisputeEvent[]>('SELECT * FROM dispute_events WHERE dispute_id = ? ORDER BY created_at ASC', [disputeId]);
const result = await db.query(query, [disputeId]); return events;
return result.rows;
} }
} }