diff --git a/backend/src/dispute/dispute.controller.ts b/backend/src/dispute/dispute.controller.ts new file mode 100644 index 0000000..9d04145 --- /dev/null +++ b/backend/src/dispute/dispute.controller.ts @@ -0,0 +1,64 @@ +import { + Body, + Controller, + Get, + NotFoundException, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { Request } from 'express'; +import { DisputeService } from './dispute.service'; +import { CreateDisputeDto } from './dto/create-dispute.dto'; +import { ResolveDisputeDto } from './dto/resolve-dispute.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { User } from '../users/entities/user.entity'; + +@Controller('disputes') +export class DisputeController { + constructor(private readonly disputeService: DisputeService) {} + + @Post() + @UseGuards(JwtAuthGuard) + async create( + @Body() dto: CreateDisputeDto, + @Req() req: Request & { user?: User }, + ) { + const user = req.user!; + return this.disputeService.create(user.id, dto); + } + + @Get(':id') + @UseGuards(JwtAuthGuard) + async getById(@Param('id') id: string) { + const dispute = await this.disputeService.findById(id); + if (!dispute) throw new NotFoundException('Dispute not found'); + return dispute; + } + + @Get('document/:documentId') + @UseGuards(JwtAuthGuard) + async getByDocument(@Param('documentId') documentId: string) { + return this.disputeService.findByDocument(documentId); + } + + @Get() + @UseGuards(JwtAuthGuard) + async getMyDisputes(@Req() req: Request & { user?: User }) { + const user = req.user!; + return this.disputeService.findByUser(user.id); + } + + @Patch(':id/resolve') + @UseGuards(JwtAuthGuard) + async resolve( + @Param('id') id: string, + @Body() dto: ResolveDisputeDto, + @Req() req: Request & { user?: User }, + ) { + const user = req.user!; + return this.disputeService.resolve(id, dto, user.id); + } +} diff --git a/backend/src/dispute/dispute.module.ts b/backend/src/dispute/dispute.module.ts new file mode 100644 index 0000000..1d9ef5e --- /dev/null +++ b/backend/src/dispute/dispute.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Dispute } from './entities/dispute.entity'; +import { DisputeReason } from './entities/dispute-reason.entity'; +import { DisputeController } from './dispute.controller'; +import { DisputeService } from './dispute.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Dispute, DisputeReason])], + controllers: [DisputeController], + providers: [DisputeService], + exports: [DisputeService], +}) +export class DisputeModule {} diff --git a/backend/src/dispute/dispute.service.ts b/backend/src/dispute/dispute.service.ts new file mode 100644 index 0000000..52a59f4 --- /dev/null +++ b/backend/src/dispute/dispute.service.ts @@ -0,0 +1,65 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Dispute, DisputeStatus } from './entities/dispute.entity'; +import { CreateDisputeDto } from './dto/create-dispute.dto'; +import { ResolveDisputeDto } from './dto/resolve-dispute.dto'; + +@Injectable() +export class DisputeService { + constructor( + @InjectRepository(Dispute) + private readonly disputeRepository: Repository, + ) {} + + create(userId: string, dto: CreateDisputeDto): Promise { + const dispute = this.disputeRepository.create({ + raisedById: userId, + documentId: dto.documentId, + reason: dto.reason, + description: dto.description, + }); + return this.disputeRepository.save(dispute); + } + + findById(id: string): Promise { + return this.disputeRepository.findOne({ + where: { id }, + relations: ['raisedBy', 'document'], + }); + } + + findByDocument(documentId: string): Promise { + return this.disputeRepository.find({ + where: { documentId }, + relations: ['raisedBy'], + order: { createdAt: 'DESC' }, + }); + } + + findByUser(userId: string): Promise { + return this.disputeRepository.find({ + where: { raisedById: userId }, + relations: ['document'], + order: { createdAt: 'DESC' }, + }); + } + + async resolve( + id: string, + dto: ResolveDisputeDto, + resolvedById: string, + ): Promise { + const dispute = await this.findById(id); + if (!dispute) throw new NotFoundException('Dispute not found'); + + await this.disputeRepository.update(id, { + status: dto.status, + resolutionAction: dto.action, + resolutionNote: dto.note, + resolvedById, + }); + + return this.findById(id) as Promise; + } +} diff --git a/backend/src/dispute/dto/create-dispute.dto.ts b/backend/src/dispute/dto/create-dispute.dto.ts new file mode 100644 index 0000000..814e4f6 --- /dev/null +++ b/backend/src/dispute/dto/create-dispute.dto.ts @@ -0,0 +1,13 @@ +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class CreateDisputeDto { + @IsUUID() + documentId: string; + + @IsString() + reason: string; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/backend/src/dispute/dto/resolve-dispute.dto.ts b/backend/src/dispute/dto/resolve-dispute.dto.ts new file mode 100644 index 0000000..707676d --- /dev/null +++ b/backend/src/dispute/dto/resolve-dispute.dto.ts @@ -0,0 +1,14 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { DisputeStatus, ResolutionAction } from '../entities/dispute.entity'; + +export class ResolveDisputeDto { + @IsEnum(DisputeStatus) + status: DisputeStatus.RESOLVED | DisputeStatus.DISMISSED; + + @IsEnum(ResolutionAction) + action: ResolutionAction; + + @IsOptional() + @IsString() + note?: string; +} diff --git a/backend/src/dispute/entities/dispute.entity.ts b/backend/src/dispute/entities/dispute.entity.ts new file mode 100644 index 0000000..487a19f --- /dev/null +++ b/backend/src/dispute/entities/dispute.entity.ts @@ -0,0 +1,74 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Document } from '../../documents/entities/document.entity'; + +export enum DisputeStatus { + OPEN = 'open', + UNDER_REVIEW = 'under_review', + RESOLVED = 'resolved', + DISMISSED = 'dismissed', +} + +export enum ResolutionAction { + NONE = 'none', + DOCUMENT_UPDATED = 'document_updated', + OWNERSHIP_TRANSFERRED = 'ownership_transferred', + COMPENSATION_ISSUED = 'compensation_issued', +} + +@Entity('disputes') +export class Dispute { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'raised_by_id' }) + raisedById: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + raisedBy: User; + + @Column({ name: 'document_id' }) + documentId: string; + + @ManyToOne(() => Document, { onDelete: 'CASCADE' }) + document: Document; + + @Column() + reason: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ + type: 'enum', + enum: DisputeStatus, + default: DisputeStatus.OPEN, + }) + status: DisputeStatus; + + @Column({ + type: 'enum', + enum: ResolutionAction, + default: ResolutionAction.NONE, + }) + resolutionAction: ResolutionAction; + + @Column({ type: 'text', nullable: true }) + resolutionNote?: string; + + @Column({ name: 'resolved_by_id', nullable: true }) + resolvedById?: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +}