From d339c17dc0ac42b03a7fdd5733c914668cb826f8 Mon Sep 17 00:00:00 2001 From: "J.A.R.V.I.S." Date: Thu, 19 Mar 2026 12:08:00 +0000 Subject: [PATCH] feat(dispute-flow): Implement dispute flow service and API endpoints --- .../src/dispute-flow/dispute-flow.model.ts | 65 ++----- .../src/dispute-flow/dispute-flow.routes.ts | 102 ++++++++++ .../src/dispute-flow/dispute-flow.service.ts | 179 ++++++++++-------- backend/src/server.js | 2 + 4 files changed, 220 insertions(+), 128 deletions(-) create mode 100644 backend/src/dispute-flow/dispute-flow.routes.ts diff --git a/backend/src/dispute-flow/dispute-flow.model.ts b/backend/src/dispute-flow/dispute-flow.model.ts index bbd3183..79b2ee4 100644 --- a/backend/src/dispute-flow/dispute-flow.model.ts +++ b/backend/src/dispute-flow/dispute-flow.model.ts @@ -1,59 +1,24 @@ -import { Knex } from 'knex'; - export interface Dispute { id: number; - deal_id: number; - opened_by_user_id: number; + dealId: number; + openedByUserId: number; status: 'open' | 'evidence' | 'mediation' | 'resolved' | 'cancelled'; - reason_code: string; + reasonCode: string; summary: string; - requested_outcome: string; - final_decision?: string; - final_reason?: string; - decided_by_user_id?: number; - decided_at?: Date; - created_at: Date; - updated_at: Date; + requestedOutcome: string; + finalDecision?: string; + finalReason?: string; + decidedByUserId?: number; + decidedAt?: Date; + createdAt: Date; + updatedAt: Date; } export interface DisputeEvent { id: number; - dispute_id: number; - event_type: string; - actor_user_id: number; - payload_json: any; - created_at: Date; -} - -export async function createDisputeTables(knex: Knex): Promise { - const disputeExists = await knex.schema.hasTable('disputes'); - if (!disputeExists) { - await knex.schema.createTable('disputes', (table) => { - table.increments('id').primary(); - table.integer('deal_id').notNullable().references('deals.id'); - table.integer('opened_by_user_id').notNullable().references('users.id'); - table.enu('status', ['open', 'evidence', 'mediation', 'resolved', 'cancelled']).notNullable().defaultTo('open'); - table.string('reason_code', 64).notNullable(); - table.text('summary').notNullable(); - table.string('requested_outcome', 64).notNullable(); - table.string('final_decision', 64); - table.text('final_reason'); - table.integer('decided_by_user_id').references('users.id'); - table.timestamp('decided_at'); - table.timestamp('created_at').defaultTo(knex.fn.now()); - table.timestamp('updated_at').defaultTo(knex.fn.now()); - }); - } - - const disputeEventExists = await knex.schema.hasTable('dispute_events'); - if (!disputeEventExists) { - await knex.schema.createTable('dispute_events', (table) => { - table.increments('id').primary(); - table.integer('dispute_id').notNullable().references('disputes.id'); - table.string('event_type', 64).notNullable(); - table.integer('actor_user_id').notNullable().references('users.id'); - table.json('payload_json').notNullable(); - table.timestamp('created_at').defaultTo(knex.fn.now()); - }); - } + disputeId: number; + eventType: string; + actorUserId: number; + payloadJson: any; + createdAt: Date; } \ No newline at end of file diff --git a/backend/src/dispute-flow/dispute-flow.routes.ts b/backend/src/dispute-flow/dispute-flow.routes.ts new file mode 100644 index 0000000..c5d6499 --- /dev/null +++ b/backend/src/dispute-flow/dispute-flow.routes.ts @@ -0,0 +1,102 @@ +import express from 'express'; +import { DisputeFlowService } from './dispute-flow.service'; +import { requireAuth } from '../middleware/auth.middleware'; +import { requireRole } from '../middleware/role.middleware'; + +const router = express.Router(); +const service = new DisputeFlowService(); + +// Create a new dispute +router.post('/disputes', requireAuth, async (req, res) => { + try { + const dispute = await service.createDispute({ + 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); + } catch (error) { + console.error('Error creating dispute:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Add evidence to a dispute +router.post('/disputes/:id/evidence', requireAuth, async (req, res) => { + try { + await service.addEvidence( + parseInt(req.params.id), + req.body, + req.user.id + ); + + res.status(200).json({ message: 'Evidence added successfully' }); + } catch (error) { + console.error('Error adding evidence:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Update dispute status +router.post('/disputes/:id/status', requireAuth, requireRole(['moderator']), async (req, res) => { + try { + await service.updateDisputeStatus( + parseInt(req.params.id), + req.body.newStatus, + req.user.id + ); + + res.status(200).json({ message: 'Status updated successfully' }); + } catch (error) { + console.error('Error updating dispute status:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Resolve a dispute +router.post('/disputes/:id/resolve', requireAuth, requireRole(['moderator', 'admin']), async (req, res) => { + try { + await service.resolveDispute( + parseInt(req.params.id), + req.body, + req.user.id + ); + + res.status(200).json({ message: 'Dispute resolved successfully' }); + } catch (error) { + console.error('Error resolving dispute:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Get dispute details +router.get('/disputes/:id', requireAuth, async (req, res) => { + try { + const dispute = await service.getDisputeById(parseInt(req.params.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' }); + } +}); + +// Get dispute events history +router.get('/disputes/:id/events', requireAuth, async (req, res) => { + try { + const events = await service.getDisputeEvents(parseInt(req.params.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/dispute-flow/dispute-flow.service.ts b/backend/src/dispute-flow/dispute-flow.service.ts index 6882177..10cad66 100644 --- a/backend/src/dispute-flow/dispute-flow.service.ts +++ b/backend/src/dispute-flow/dispute-flow.service.ts @@ -1,95 +1,118 @@ -import { Knex } from 'knex'; -import { Dispute, DisputeEvent, createDisputeTables } from './dispute-flow.model'; +import { Dispute, DisputeEvent } from './dispute-flow.model'; +import { db } from '../db'; export class DisputeFlowService { - constructor(private knex: Knex) {} - - async init(): Promise { - await createDisputeTables(this.knex); - } - - async createDispute(disputeData: Omit): Promise { - const [dispute] = await this.knex('disputes').insert(disputeData).returning('*'); + async createDispute(disputeData: Partial): Promise { + const query = ` + INSERT INTO disputes + (deal_id, opened_by_user_id, status, reason_code, summary, requested_outcome) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING * + `; - // Log the creation event - await this.logDisputeEvent(dispute.id, 'dispute_created', disputeData.opened_by_user_id, { - ...disputeData, - disputeId: dispute.id - }); - - return dispute; + const values = [ + disputeData.dealId, + disputeData.openedByUserId, + disputeData.status || 'open', + disputeData.reasonCode, + disputeData.summary, + disputeData.requestedOutcome + ]; + + const result = await db.query(query, values); + return result.rows[0]; } - async getDispute(id: number): Promise { - return await this.knex('disputes').where({ id }).first(); + async addEvidence(disputeId: number, evidenceData: any, actorUserId: number): Promise { + // Insert evidence as a new event + const query = ` + INSERT INTO dispute_events + (dispute_id, event_type, actor_user_id, payload_json) + VALUES (?, ?, ?, ?) + `; + + const values = [ + disputeId, + 'evidence', + actorUserId, + JSON.stringify(evidenceData) + ]; + + await db.query(query, values); } - async updateDisputeStatus(disputeId: number, status: Dispute['status'], updatedByUserId: number): Promise { - const dispute = await this.getDispute(disputeId); - if (!dispute) { - throw new Error(`Dispute with id ${disputeId} not found`); - } - - await this.knex('disputes') - .where({ id: disputeId }) - .update({ status, updated_at: this.knex.fn.now() }); - - // Log the status change event - await this.logDisputeEvent(disputeId, 'status_changed', updatedByUserId, { - oldStatus: dispute.status, - newStatus: status - }); + async updateDisputeStatus(disputeId: number, newStatus: string, actorUserId: number): Promise { + // Update the dispute status + const query = ` + 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 addEvidence(disputeId: number, evidenceData: any, uploadedByUserId: number): Promise { - const dispute = await this.getDispute(disputeId); - if (!dispute) { - throw new Error(`Dispute with id ${disputeId} not found`); - } - - // Log the evidence upload event - await this.logDisputeEvent(disputeId, 'evidence_added', uploadedByUserId, { - ...evidenceData, + async resolveDispute(disputeId: number, decisionData: any, actorUserId: number): Promise { + // Update the dispute with final decision + const query = ` + UPDATE disputes + 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 resolveDispute(disputeId: number, decision: string, reason: string, resolvedByUserId: number): Promise { - const dispute = await this.getDispute(disputeId); - if (!dispute) { - throw new Error(`Dispute with id ${disputeId} not found`); - } - - await this.knex('disputes') - .where({ id: disputeId }) - .update({ - status: 'resolved', - final_decision: decision, - final_reason: reason, - decided_by_user_id: resolvedByUserId, - decided_at: this.knex.fn.now(), - updated_at: this.knex.fn.now() - }); - - // Log the resolution event - await this.logDisputeEvent(disputeId, 'dispute_resolved', resolvedByUserId, { - decision, - reason, - disputeId - }); - } - - async logDisputeEvent(disputeId: number, eventType: string, actorUserId: number, payload: any): Promise { - await this.knex('dispute_events').insert({ - dispute_id: disputeId, - event_type: eventType, - actor_user_id: actorUserId, - payload_json: payload, - created_at: this.knex.fn.now() - }); + async getDisputeById(disputeId: number): Promise { + const query = 'SELECT * FROM disputes WHERE id = ?'; + const result = await db.query(query, [disputeId]); + return result.rows[0] || null; } async getDisputeEvents(disputeId: number): Promise { - return await this.knex('dispute_events').where({ dispute_id: disputeId }).orderBy('created_at', 'asc'); + const query = 'SELECT * FROM dispute_events WHERE dispute_id = ? ORDER BY created_at ASC'; + const result = await db.query(query, [disputeId]); + return result.rows; } } \ No newline at end of file diff --git a/backend/src/server.js b/backend/src/server.js index 5178f9c..3038d20 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -8,6 +8,7 @@ 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 disputeFlowRoutes from './dispute-flow/dispute-flow.routes.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'; @@ -52,6 +53,7 @@ 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); +app.use('/disputes-flow', rateLimit({ max: 50 }), disputeFlowRoutes); const port = Number(process.env.PORT || 3000); app.listen(port, () => {