From 2268ef56d8a396ccd7ef877ab6298ae776006abd Mon Sep 17 00:00:00 2001 From: "J.A.R.V.I.S." Date: Thu, 19 Mar 2026 08:09:11 +0000 Subject: [PATCH] feat: Implement dispute flow with database schema and API endpoints --- backend/sql/dispute-schema.sql | 28 +++++++ backend/src/disputes/dispute-service.js | 68 ++++++++++++++++ backend/src/disputes/dispute-service.ts | 67 +++++++++++++++ backend/src/disputes/index.ts | 2 + backend/src/disputes/types.js | 51 ++++++++++++ backend/src/disputes/types.ts | 30 +++++++ backend/src/routes/disputes.js | 103 ++++++++++++++++++++++++ backend/src/routes/disputes.ts | 103 ++++++++++++++++++++++++ backend/src/server.js | 2 + 9 files changed, 454 insertions(+) create mode 100644 backend/sql/dispute-schema.sql create mode 100644 backend/src/disputes/dispute-service.js create mode 100644 backend/src/disputes/dispute-service.ts create mode 100644 backend/src/disputes/index.ts create mode 100644 backend/src/disputes/types.js create mode 100644 backend/src/disputes/types.ts create mode 100644 backend/src/routes/disputes.js create mode 100644 backend/src/routes/disputes.ts diff --git a/backend/sql/dispute-schema.sql b/backend/sql/dispute-schema.sql new file mode 100644 index 0000000..8342140 --- /dev/null +++ b/backend/sql/dispute-schema.sql @@ -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) +); \ No newline at end of file diff --git a/backend/src/disputes/dispute-service.js b/backend/src/disputes/dispute-service.js new file mode 100644 index 0000000..77cc58c --- /dev/null +++ b/backend/src/disputes/dispute-service.js @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/disputes/dispute-service.ts b/backend/src/disputes/dispute-service.ts new file mode 100644 index 0000000..f0c71c5 --- /dev/null +++ b/backend/src/disputes/dispute-service.ts @@ -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): Promise { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + const [rows] = await this.db.query('SELECT * FROM dispute_events WHERE dispute_id = ? ORDER BY created_at ASC', [disputeId]); + return rows as DisputeEvent[]; + } +} \ No newline at end of file diff --git a/backend/src/disputes/index.ts b/backend/src/disputes/index.ts new file mode 100644 index 0000000..073ba43 --- /dev/null +++ b/backend/src/disputes/index.ts @@ -0,0 +1,2 @@ +export * from './dispute-service'; +export * from './types'; \ No newline at end of file diff --git a/backend/src/disputes/types.js b/backend/src/disputes/types.js new file mode 100644 index 0000000..2dee539 --- /dev/null +++ b/backend/src/disputes/types.js @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/disputes/types.ts b/backend/src/disputes/types.ts new file mode 100644 index 0000000..16d759e --- /dev/null +++ b/backend/src/disputes/types.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/routes/disputes.js b/backend/src/routes/disputes.js new file mode 100644 index 0000000..09fe0bd --- /dev/null +++ b/backend/src/routes/disputes.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/disputes.ts b/backend/src/routes/disputes.ts new file mode 100644 index 0000000..959342f --- /dev/null +++ b/backend/src/routes/disputes.ts @@ -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; \ No newline at end of file diff --git a/backend/src/server.js b/backend/src/server.js index 4d0b590..5178f9c 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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, () => {