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
4977a213a0
commit
d339c17dc0
4 changed files with 220 additions and 128 deletions
|
|
@ -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<void> {
|
||||
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;
|
||||
}
|
||||
102
backend/src/dispute-flow/dispute-flow.routes.ts
Normal file
102
backend/src/dispute-flow/dispute-flow.routes.ts
Normal file
|
|
@ -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;
|
||||
|
|
@ -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<void> {
|
||||
await createDisputeTables(this.knex);
|
||||
}
|
||||
|
||||
async createDispute(disputeData: Omit<Dispute, 'id' | 'created_at' | 'updated_at'>): Promise<Dispute> {
|
||||
const [dispute] = await this.knex('disputes').insert(disputeData).returning('*');
|
||||
async createDispute(disputeData: Partial<Dispute>): Promise<Dispute> {
|
||||
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<Dispute | null> {
|
||||
return await this.knex('disputes').where({ id }).first();
|
||||
async addEvidence(disputeId: number, evidenceData: any, actorUserId: number): Promise<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<Dispute | null> {
|
||||
const query = 'SELECT * FROM disputes WHERE id = ?';
|
||||
const result = await db.query(query, [disputeId]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async getDisputeEvents(disputeId: number): Promise<DisputeEvent[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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, () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue