feat: Implement dispute flow with database schema and API endpoints
This commit is contained in:
parent
431ced05b5
commit
2268ef56d8
9 changed files with 454 additions and 0 deletions
28
backend/sql/dispute-schema.sql
Normal file
28
backend/sql/dispute-schema.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CREATE TABLE IF NOT EXISTS disputes (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
deal_id BIGINT NOT NULL,
|
||||
opened_by_user_id BIGINT NOT NULL,
|
||||
status ENUM('open','evidence','mediation','resolved','cancelled') NOT NULL DEFAULT 'open',
|
||||
reason_code VARCHAR(64) NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
requested_outcome VARCHAR(64) NOT NULL,
|
||||
final_decision VARCHAR(64) NULL,
|
||||
final_reason TEXT NULL,
|
||||
decided_by_user_id BIGINT NULL,
|
||||
decided_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (deal_id) REFERENCES deals(id),
|
||||
FOREIGN KEY (opened_by_user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dispute_events (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
dispute_id BIGINT NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
actor_user_id BIGINT NOT NULL,
|
||||
payload_json JSON NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (dispute_id) REFERENCES disputes(id),
|
||||
FOREIGN KEY (actor_user_id) REFERENCES users(id)
|
||||
);
|
||||
68
backend/src/disputes/dispute-service.js
Normal file
68
backend/src/disputes/dispute-service.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { DB } from '../db/index.js';
|
||||
|
||||
export class DisputeService {
|
||||
constructor(db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async createDispute(disputeData) {
|
||||
const { deal_id, opened_by_user_id, reason_code, summary, requested_outcome } = disputeData;
|
||||
|
||||
const result = await this.db.query(
|
||||
`INSERT INTO disputes (deal_id, opened_by_user_id, reason_code, summary, requested_outcome)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[deal_id, opened_by_user_id, reason_code, summary, requested_outcome]
|
||||
);
|
||||
|
||||
const disputeId = result.insertId;
|
||||
return await this.getDisputeById(disputeId);
|
||||
}
|
||||
|
||||
async getDisputeById(id) {
|
||||
const [rows] = await this.db.query('SELECT * FROM disputes WHERE id = ?', [id]);
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
}
|
||||
|
||||
async updateDisputeStatus(disputeId, status, updatedByUserId) {
|
||||
await this.db.query(
|
||||
'UPDATE disputes SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[status, disputeId]
|
||||
);
|
||||
|
||||
// Log the status change as an event
|
||||
await this.logDisputeEvent(disputeId, 'status_change', updatedByUserId, {
|
||||
old_status: 'open',
|
||||
new_status: status
|
||||
});
|
||||
}
|
||||
|
||||
async addEvidence(disputeId, userId, evidenceData) {
|
||||
// Log the evidence addition as an event
|
||||
await this.logDisputeEvent(disputeId, 'evidence_added', userId, evidenceData);
|
||||
}
|
||||
|
||||
async resolveDispute(disputeId, resolvedByUserId, decision, reason) {
|
||||
await this.db.query(
|
||||
`UPDATE disputes
|
||||
SET status = 'resolved', final_decision = ?, final_reason = ?, decided_by_user_id = ?, decided_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[decision, reason, resolvedByUserId, disputeId]
|
||||
);
|
||||
|
||||
// Log the resolution as an event
|
||||
await this.logDisputeEvent(disputeId, 'resolved', resolvedByUserId, { decision, reason });
|
||||
}
|
||||
|
||||
async logDisputeEvent(disputeId, eventType, actorUserId, payload) {
|
||||
await this.db.query(
|
||||
`INSERT INTO dispute_events (dispute_id, event_type, actor_user_id, payload_json)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[disputeId, eventType, actorUserId, JSON.stringify(payload)]
|
||||
);
|
||||
}
|
||||
|
||||
async getDisputeEvents(disputeId) {
|
||||
const [rows] = await this.db.query('SELECT * FROM dispute_events WHERE dispute_id = ? ORDER BY created_at ASC', [disputeId]);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
67
backend/src/disputes/dispute-service.ts
Normal file
67
backend/src/disputes/dispute-service.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { DB } from '../db';
|
||||
import { Dispute, DisputeEvent, DisputeStatus, DisputeReasonCode, DisputeOutcome } from './types';
|
||||
|
||||
export class DisputeService {
|
||||
constructor(private db: DB) {}
|
||||
|
||||
async createDispute(disputeData: Omit<Dispute, 'id' | 'status' | 'created_at' | 'updated_at'>): Promise<Dispute> {
|
||||
const { deal_id, opened_by_user_id, reason_code, summary, requested_outcome } = disputeData;
|
||||
|
||||
const result = await this.db.query(
|
||||
`INSERT INTO disputes (deal_id, opened_by_user_id, reason_code, summary, requested_outcome)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[deal_id, opened_by_user_id, reason_code, summary, requested_outcome]
|
||||
);
|
||||
|
||||
const disputeId = result.insertId;
|
||||
return await this.getDisputeById(disputeId);
|
||||
}
|
||||
|
||||
async getDisputeById(id: number): Promise<Dispute | null> {
|
||||
const [rows] = await this.db.query('SELECT * FROM disputes WHERE id = ?', [id]);
|
||||
return rows.length > 0 ? rows[0] as Dispute : null;
|
||||
}
|
||||
|
||||
async updateDisputeStatus(disputeId: number, status: DisputeStatus, updatedByUserId: number): Promise<void> {
|
||||
await this.db.query(
|
||||
'UPDATE disputes SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[status, disputeId]
|
||||
);
|
||||
|
||||
// Log the status change as an event
|
||||
await this.logDisputeEvent(disputeId, 'status_change', updatedByUserId, {
|
||||
old_status: 'open',
|
||||
new_status: status
|
||||
});
|
||||
}
|
||||
|
||||
async addEvidence(disputeId: number, userId: number, evidenceData: any): Promise<void> {
|
||||
// Log the evidence addition as an event
|
||||
await this.logDisputeEvent(disputeId, 'evidence_added', userId, evidenceData);
|
||||
}
|
||||
|
||||
async resolveDispute(disputeId: number, resolvedByUserId: number, decision: DisputeOutcome, reason: string): Promise<void> {
|
||||
await this.db.query(
|
||||
`UPDATE disputes
|
||||
SET status = 'resolved', final_decision = ?, final_reason = ?, decided_by_user_id = ?, decided_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[decision, reason, resolvedByUserId, disputeId]
|
||||
);
|
||||
|
||||
// Log the resolution as an event
|
||||
await this.logDisputeEvent(disputeId, 'resolved', resolvedByUserId, { decision, reason });
|
||||
}
|
||||
|
||||
async logDisputeEvent(disputeId: number, eventType: string, actorUserId: number, payload: any): Promise<void> {
|
||||
await this.db.query(
|
||||
`INSERT INTO dispute_events (dispute_id, event_type, actor_user_id, payload_json)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[disputeId, eventType, actorUserId, JSON.stringify(payload)]
|
||||
);
|
||||
}
|
||||
|
||||
async getDisputeEvents(disputeId: number): Promise<DisputeEvent[]> {
|
||||
const [rows] = await this.db.query('SELECT * FROM dispute_events WHERE dispute_id = ? ORDER BY created_at ASC', [disputeId]);
|
||||
return rows as DisputeEvent[];
|
||||
}
|
||||
}
|
||||
2
backend/src/disputes/index.ts
Normal file
2
backend/src/disputes/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './dispute-service';
|
||||
export * from './types';
|
||||
51
backend/src/disputes/types.js
Normal file
51
backend/src/disputes/types.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export const DisputeStatus = {
|
||||
OPEN: 'open',
|
||||
EVIDENCE: 'evidence',
|
||||
MEDIATION: 'mediation',
|
||||
RESOLVED: 'resolved',
|
||||
CANCELLED: 'cancelled'
|
||||
};
|
||||
|
||||
export const DisputeReasonCode = {
|
||||
NO_SHOW: 'NO_SHOW',
|
||||
QUALITY_ISSUE: 'QUALITY_ISSUE',
|
||||
PAYMENT_DISPUTE: 'PAYMENT_DISPUTE',
|
||||
ABUSE: 'ABUSE',
|
||||
OTHER: 'OTHER'
|
||||
};
|
||||
|
||||
export const DisputeOutcome = {
|
||||
REFUND: 'refund',
|
||||
PARTIAL_REFUND: 'partial_refund',
|
||||
COMPLETE_WORK: 'complete_work',
|
||||
OTHER: 'other'
|
||||
};
|
||||
|
||||
export class Dispute {
|
||||
constructor(id, deal_id, opened_by_user_id, status, reason_code, summary, requested_outcome, final_decision = null, final_reason = null, decided_by_user_id = null, decided_at = null, created_at, updated_at) {
|
||||
this.id = id;
|
||||
this.deal_id = deal_id;
|
||||
this.opened_by_user_id = opened_by_user_id;
|
||||
this.status = status;
|
||||
this.reason_code = reason_code;
|
||||
this.summary = summary;
|
||||
this.requested_outcome = requested_outcome;
|
||||
this.final_decision = final_decision;
|
||||
this.final_reason = final_reason;
|
||||
this.decided_by_user_id = decided_by_user_id;
|
||||
this.decided_at = decided_at;
|
||||
this.created_at = created_at;
|
||||
this.updated_at = updated_at;
|
||||
}
|
||||
}
|
||||
|
||||
export class DisputeEvent {
|
||||
constructor(id, dispute_id, event_type, actor_user_id, payload_json, created_at) {
|
||||
this.id = id;
|
||||
this.dispute_id = dispute_id;
|
||||
this.event_type = event_type;
|
||||
this.actor_user_id = actor_user_id;
|
||||
this.payload_json = payload_json;
|
||||
this.created_at = created_at;
|
||||
}
|
||||
}
|
||||
30
backend/src/disputes/types.ts
Normal file
30
backend/src/disputes/types.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export type DisputeStatus = 'open' | 'evidence' | 'mediation' | 'resolved' | 'cancelled';
|
||||
|
||||
export type DisputeReasonCode = 'NO_SHOW' | 'QUALITY_ISSUE' | 'PAYMENT_DISPUTE' | 'ABUSE' | 'OTHER';
|
||||
|
||||
export type DisputeOutcome = 'refund' | 'partial_refund' | 'complete_work' | 'other';
|
||||
|
||||
export interface Dispute {
|
||||
id: number;
|
||||
deal_id: number;
|
||||
opened_by_user_id: number;
|
||||
status: DisputeStatus;
|
||||
reason_code: DisputeReasonCode;
|
||||
summary: string;
|
||||
requested_outcome: DisputeOutcome;
|
||||
final_decision?: DisputeOutcome | null;
|
||||
final_reason?: string | null;
|
||||
decided_by_user_id?: number | null;
|
||||
decided_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DisputeEvent {
|
||||
id: number;
|
||||
dispute_id: number;
|
||||
event_type: string;
|
||||
actor_user_id: number;
|
||||
payload_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
103
backend/src/routes/disputes.js
Normal file
103
backend/src/routes/disputes.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import express from 'express';
|
||||
import { DisputeService } from '../disputes/dispute-service.js';
|
||||
import { DB } from '../db/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
const disputeService = new DisputeService(new DB());
|
||||
|
||||
// Create a new dispute
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { deal_id, opened_by_user_id, reason_code, summary, requested_outcome } = req.body;
|
||||
|
||||
const dispute = await disputeService.createDispute({
|
||||
deal_id,
|
||||
opened_by_user_id,
|
||||
reason_code,
|
||||
summary,
|
||||
requested_outcome
|
||||
});
|
||||
|
||||
res.status(201).json(dispute);
|
||||
} catch (error) {
|
||||
console.error('Error creating dispute:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get dispute by ID
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const dispute = await disputeService.getDisputeById(parseInt(id));
|
||||
|
||||
if (!dispute) {
|
||||
return res.status(404).json({ error: 'Dispute not found' });
|
||||
}
|
||||
|
||||
res.json(dispute);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dispute:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update dispute status
|
||||
router.post('/:id/status', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, updated_by_user_id } = req.body;
|
||||
|
||||
await disputeService.updateDisputeStatus(parseInt(id), status, updated_by_user_id);
|
||||
|
||||
res.json({ message: 'Dispute status updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error updating dispute status:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add evidence to a dispute
|
||||
router.post('/:id/evidence', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { user_id, evidence_data } = req.body;
|
||||
|
||||
await disputeService.addEvidence(parseInt(id), user_id, evidence_data);
|
||||
|
||||
res.json({ message: 'Evidence added successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error adding evidence:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve a dispute
|
||||
router.post('/:id/resolve', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { resolved_by_user_id, decision, reason } = req.body;
|
||||
|
||||
await disputeService.resolveDispute(parseInt(id), resolved_by_user_id, decision, reason);
|
||||
|
||||
res.json({ message: 'Dispute resolved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error resolving dispute:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get dispute events
|
||||
router.get('/:id/events', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const events = await disputeService.getDisputeEvents(parseInt(id));
|
||||
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dispute events:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
103
backend/src/routes/disputes.ts
Normal file
103
backend/src/routes/disputes.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import express, { Request, Response } from 'express';
|
||||
import { DisputeService } from '../disputes/dispute-service';
|
||||
import { DB } from '../db';
|
||||
|
||||
const router = express.Router();
|
||||
const disputeService = new DisputeService(new DB());
|
||||
|
||||
// Create a new dispute
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { deal_id, opened_by_user_id, reason_code, summary, requested_outcome } = req.body;
|
||||
|
||||
const dispute = await disputeService.createDispute({
|
||||
deal_id,
|
||||
opened_by_user_id,
|
||||
reason_code,
|
||||
summary,
|
||||
requested_outcome
|
||||
});
|
||||
|
||||
res.status(201).json(dispute);
|
||||
} catch (error) {
|
||||
console.error('Error creating dispute:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get dispute by ID
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const dispute = await disputeService.getDisputeById(parseInt(id));
|
||||
|
||||
if (!dispute) {
|
||||
return res.status(404).json({ error: 'Dispute not found' });
|
||||
}
|
||||
|
||||
res.json(dispute);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dispute:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update dispute status
|
||||
router.post('/:id/status', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, updated_by_user_id } = req.body;
|
||||
|
||||
await disputeService.updateDisputeStatus(parseInt(id), status, updated_by_user_id);
|
||||
|
||||
res.json({ message: 'Dispute status updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error updating dispute status:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add evidence to a dispute
|
||||
router.post('/:id/evidence', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { user_id, evidence_data } = req.body;
|
||||
|
||||
await disputeService.addEvidence(parseInt(id), user_id, evidence_data);
|
||||
|
||||
res.json({ message: 'Evidence added successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error adding evidence:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve a dispute
|
||||
router.post('/:id/resolve', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { resolved_by_user_id, decision, reason } = req.body;
|
||||
|
||||
await disputeService.resolveDispute(parseInt(id), resolved_by_user_id, decision, reason);
|
||||
|
||||
res.json({ message: 'Dispute resolved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error resolving dispute:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get dispute events
|
||||
router.get('/:id/events', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const events = await disputeService.getDisputeEvents(parseInt(id));
|
||||
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dispute events:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -7,6 +7,7 @@ import reviewRoutes from './routes/reviews.js';
|
|||
import addressRoutes from './routes/addresses.js';
|
||||
import contactRoutes from './routes/contacts.js';
|
||||
import profileRoutes from './routes/profile.js';
|
||||
import disputeRoutes from './routes/disputes.js';
|
||||
// import { requestLogger } from './middleware/logger.js'; // Temporarily removed for compatibility
|
||||
import { rateLimit, authRateLimit } from '../middleware/rateLimit.cjs';
|
||||
import { requireRole } from '../middleware/role.middleware.js';
|
||||
|
|
@ -50,6 +51,7 @@ app.use('/reviews', rateLimit({ max: 50 }), reviewRoutes);
|
|||
app.use('/addresses', rateLimit({ max: 50 }), addressRoutes);
|
||||
app.use('/contacts', rateLimit({ max: 50 }), contactRoutes);
|
||||
app.use('/profile', rateLimit({ max: 50 }), profileRoutes);
|
||||
app.use('/disputes', rateLimit({ max: 50 }), disputeRoutes);
|
||||
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
app.listen(port, () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue