feat(dispute-flow): Implement dispute flow service and API endpoints
Some checks are pending
Docker Test / test (push) Waiting to run
Some checks are pending
Docker Test / test (push) Waiting to run
This commit is contained in:
parent
5a61bf2dbf
commit
25424ccb7e
3 changed files with 140 additions and 150 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
@ -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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
|
||||||
disputeId,
|
|
||||||
'evidence',
|
|
||||||
actorUserId,
|
|
||||||
JSON.stringify(evidenceData)
|
|
||||||
];
|
|
||||||
|
|
||||||
await db.query(query, values);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateDisputeStatus(disputeId: number, newStatus: string, actorUserId: number): Promise<void> {
|
const dispute = disputes[0];
|
||||||
// Update the dispute status
|
if (dispute.status !== 'evidence') {
|
||||||
const query = `
|
throw new Error('Evidence can only be added when dispute status is "evidence"');
|
||||||
UPDATE disputes
|
|
||||||
SET status = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`;
|
|
||||||
|
|
||||||
await db.query(query, [newStatus, disputeId]);
|
|
||||||
|
|
||||||
// Log the status change as an event
|
|
||||||
const eventQuery = `
|
|
||||||
INSERT INTO dispute_events
|
|
||||||
(dispute_id, event_type, actor_user_id, payload_json)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`;
|
|
||||||
|
|
||||||
const values = [
|
|
||||||
disputeId,
|
|
||||||
'status_change',
|
|
||||||
actorUserId,
|
|
||||||
JSON.stringify({ newStatus })
|
|
||||||
];
|
|
||||||
|
|
||||||
await db.query(eventQuery, values);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveDispute(disputeId: number, decisionData: any, actorUserId: number): Promise<void> {
|
await db.query(
|
||||||
// Update the dispute with final decision
|
`INSERT INTO dispute_events (dispute_id, event_type, actor_user_id, payload_json)
|
||||||
const query = `
|
VALUES (?, ?, ?, ?)`,
|
||||||
UPDATE disputes
|
[disputeId, 'evidence_added', actorUserId, JSON.stringify(evidenceData)]
|
||||||
SET status = 'resolved',
|
);
|
||||||
final_decision = ?,
|
|
||||||
final_reason = ?,
|
|
||||||
decided_by_user_id = ?,
|
|
||||||
decided_at = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
`;
|
|
||||||
|
|
||||||
await db.query(query, [
|
|
||||||
decisionData.decision,
|
|
||||||
decisionData.decisionReason,
|
|
||||||
actorUserId,
|
|
||||||
disputeId
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Log the resolution as an event
|
|
||||||
const eventQuery = `
|
|
||||||
INSERT INTO dispute_events
|
|
||||||
(dispute_id, event_type, actor_user_id, payload_json)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`;
|
|
||||||
|
|
||||||
const values = [
|
|
||||||
disputeId,
|
|
||||||
'resolved',
|
|
||||||
actorUserId,
|
|
||||||
JSON.stringify(decisionData)
|
|
||||||
];
|
|
||||||
|
|
||||||
await db.query(eventQuery, values);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDisputeById(disputeId: number): Promise<Dispute | null> {
|
static async updateDisputeStatus(disputeId: number, actorUserId: number, newStatus: DisputeStatus): Promise<void> {
|
||||||
const query = 'SELECT * FROM disputes WHERE id = ?';
|
// Check if dispute exists
|
||||||
const result = await db.query(query, [disputeId]);
|
const [disputes] = await db.query<Dispute[]>('SELECT status FROM disputes WHERE id = ?', [disputeId]);
|
||||||
return result.rows[0] || null;
|
if (disputes.length === 0) {
|
||||||
|
throw new Error('Dispute not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDisputeEvents(disputeId: number): Promise<DisputeEvent[]> {
|
const dispute = disputes[0];
|
||||||
const query = 'SELECT * FROM dispute_events WHERE dispute_id = ? ORDER BY created_at ASC';
|
|
||||||
const result = await db.query(query, [disputeId]);
|
// Validate status transition
|
||||||
return result.rows;
|
const validTransitions: Record<DisputeStatus, DisputeStatus[]> = {
|
||||||
|
open: ['evidence'],
|
||||||
|
evidence: ['mediation', 'resolved', 'cancelled'],
|
||||||
|
mediation: ['resolved', 'cancelled'],
|
||||||
|
resolved: [],
|
||||||
|
cancelled: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!validTransitions[dispute.status].includes(newStatus)) {
|
||||||
|
throw new Error(`Invalid status transition from ${dispute.status} to ${newStatus}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 })]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async resolveDispute(disputeId: number, actorUserId: number, decisionData: {
|
||||||
|
decision: string;
|
||||||
|
decisionReason: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
// Check if dispute exists and is in mediation or evidence status
|
||||||
|
const [disputes] = await db.query<Dispute[]>('SELECT status FROM disputes WHERE id = ?', [disputeId]);
|
||||||
|
if (disputes.length === 0) {
|
||||||
|
throw new Error('Dispute not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispute = disputes[0];
|
||||||
|
if (dispute.status !== 'mediation' && dispute.status !== 'evidence') {
|
||||||
|
throw new Error('Dispute can only be resolved when status is "mediation" or "evidence"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update dispute with final decision
|
||||||
|
await db.query(
|
||||||
|
`UPDATE disputes
|
||||||
|
SET status = 'resolved', final_decision = ?, final_reason = ?, decided_by_user_id = ?, decided_at = NOW()
|
||||||
|
WHERE id = ?`,
|
||||||
|
[decisionData.decision, decisionData.decisionReason, actorUserId, disputeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create event
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO dispute_events (dispute_id, event_type, actor_user_id, payload_json)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
[disputeId, 'dispute_resolved', actorUserId, JSON.stringify(decisionData)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getDispute(disputeId: number): Promise<Dispute | null> {
|
||||||
|
const [disputes] = await db.query<Dispute[]>('SELECT * FROM disputes WHERE id = ?', [disputeId]);
|
||||||
|
return disputes.length > 0 ? disputes[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getDisputeEvents(disputeId: number): Promise<DisputeEvent[]> {
|
||||||
|
const [events] = await db.query<DisputeEvent[]>('SELECT * FROM dispute_events WHERE dispute_id = ? ORDER BY created_at ASC', [disputeId]);
|
||||||
|
return events;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue