diff --git a/backend/src/dispute-flow/README.md b/backend/src/dispute-flow/README.md new file mode 100644 index 0000000..152e882 --- /dev/null +++ b/backend/src/dispute-flow/README.md @@ -0,0 +1,10 @@ +# Dispute Flow Implementation + +This directory contains the implementation of the dispute flow according to the specification in `docs/dispute-flow.md`. + +## Structure + +- `dispute-flow.service.ts` - Main service logic +- `dispute-flow.routes.ts` - API routes +- `dispute-flow.model.ts` - Database models +- `dispute-flow.tests.ts` - Unit tests \ No newline at end of file diff --git a/backend/src/dispute-flow/dispute-flow.model.ts b/backend/src/dispute-flow/dispute-flow.model.ts new file mode 100644 index 0000000..bbd3183 --- /dev/null +++ b/backend/src/dispute-flow/dispute-flow.model.ts @@ -0,0 +1,59 @@ +import { Knex } from 'knex'; + +export interface Dispute { + id: number; + deal_id: number; + opened_by_user_id: number; + status: 'open' | 'evidence' | 'mediation' | 'resolved' | 'cancelled'; + reason_code: 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; +} + +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()); + }); + } +} \ 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 new file mode 100644 index 0000000..6882177 --- /dev/null +++ b/backend/src/dispute-flow/dispute-flow.service.ts @@ -0,0 +1,95 @@ +import { Knex } from 'knex'; +import { Dispute, DisputeEvent, createDisputeTables } from './dispute-flow.model'; + +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('*'); + + // Log the creation event + await this.logDisputeEvent(dispute.id, 'dispute_created', disputeData.opened_by_user_id, { + ...disputeData, + disputeId: dispute.id + }); + + return dispute; + } + + async getDispute(id: number): Promise { + return await this.knex('disputes').where({ id }).first(); + } + + 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 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, + disputeId + }); + } + + 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 getDisputeEvents(disputeId: number): Promise { + return await this.knex('dispute_events').where({ dispute_id: disputeId }).orderBy('created_at', 'asc'); + } +} \ No newline at end of file