From 76b7365dc3d69fa8349f434d332cbe7ffc607a6c Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Thu, 19 Mar 2026 05:19:47 +0530 Subject: [PATCH] feat: implement user reporting system with dedicated module, service, controller, DTOs, and Prisma schema updates. --- prisma/schema.prisma | 35 +++++++ src/app.module.ts | 2 + src/user-reports/dto/create-report.dto.ts | 24 +++++ src/user-reports/dto/index.ts | 2 + src/user-reports/dto/update-report.dto.ts | 14 +++ src/user-reports/index.ts | 3 + src/user-reports/user-reports.controller.ts | 87 ++++++++++++++++ src/user-reports/user-reports.module.ts | 12 +++ src/user-reports/user-reports.service.ts | 104 ++++++++++++++++++++ 9 files changed, 283 insertions(+) create mode 100644 src/user-reports/dto/create-report.dto.ts create mode 100644 src/user-reports/dto/index.ts create mode 100644 src/user-reports/dto/update-report.dto.ts create mode 100644 src/user-reports/index.ts create mode 100644 src/user-reports/user-reports.controller.ts create mode 100644 src/user-reports/user-reports.module.ts create mode 100644 src/user-reports/user-reports.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1377b30..df9aa53 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -156,6 +156,10 @@ model User { subscriptions AgentSubscription[] payments Payment[] + // Reports + reportsSubmitted UserReport[] @relation("ReportsSubmitted") + reportsReceived UserReport[] @relation("ReportsReceived") + @@index([email]) @@index([role]) @@index([status]) @@ -702,3 +706,34 @@ model Payment { @@index([status]) @@map("payments") } + +// ============================================ +// USER REPORTS +// ============================================ +enum ReportStatus { + PENDING + REVIEWED + RESOLVED + DISMISSED +} + +model UserReport { + id String @id @default(uuid()) + reporterId String + reportedUserId String + conversationId String? + reason String + description String? + status ReportStatus @default(PENDING) + adminNotes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + reporter User @relation("ReportsSubmitted", fields: [reporterId], references: [id], onDelete: Cascade) + reportedUser User @relation("ReportsReceived", fields: [reportedUserId], references: [id], onDelete: Cascade) + + @@index([reporterId]) + @@index([reportedUserId]) + @@index([status]) + @@map("user_reports") +} diff --git a/src/app.module.ts b/src/app.module.ts index 822640b..ed27d4c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,7 @@ import { CmsModule } from './cms'; import { NotificationsModule } from './notifications'; import { TestimonialsModule } from './testimonials'; import { SupportChatModule } from './support-chat'; +import { UserReportsModule } from './user-reports'; import { StripeModule } from './stripe'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -64,6 +65,7 @@ import { AppService } from './app.service'; NotificationsModule, TestimonialsModule, SupportChatModule, + UserReportsModule, StripeModule, ], controllers: [AppController], diff --git a/src/user-reports/dto/create-report.dto.ts b/src/user-reports/dto/create-report.dto.ts new file mode 100644 index 0000000..168efab --- /dev/null +++ b/src/user-reports/dto/create-report.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; + +export class CreateReportDto { + @ApiProperty({ description: 'ID of the user being reported' }) + @IsUUID() + @IsNotEmpty() + reportedUserId: string; + + @ApiPropertyOptional({ description: 'Conversation ID where the issue occurred' }) + @IsUUID() + @IsOptional() + conversationId?: string; + + @ApiProperty({ description: 'Reason for the report', example: 'Spam' }) + @IsString() + @IsNotEmpty() + reason: string; + + @ApiPropertyOptional({ description: 'Additional details about the report' }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/src/user-reports/dto/index.ts b/src/user-reports/dto/index.ts new file mode 100644 index 0000000..6b9cec2 --- /dev/null +++ b/src/user-reports/dto/index.ts @@ -0,0 +1,2 @@ +export { CreateReportDto } from './create-report.dto'; +export { UpdateReportDto } from './update-report.dto'; diff --git a/src/user-reports/dto/update-report.dto.ts b/src/user-reports/dto/update-report.dto.ts new file mode 100644 index 0000000..4bc1326 --- /dev/null +++ b/src/user-reports/dto/update-report.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsEnum } from 'class-validator'; +import { ReportStatus } from '@prisma/client'; + +export class UpdateReportDto { + @ApiProperty({ enum: ReportStatus, description: 'New status for the report' }) + @IsEnum(ReportStatus) + status: ReportStatus; + + @ApiPropertyOptional({ description: 'Admin notes about the report' }) + @IsString() + @IsOptional() + adminNotes?: string; +} diff --git a/src/user-reports/index.ts b/src/user-reports/index.ts new file mode 100644 index 0000000..dc66dd1 --- /dev/null +++ b/src/user-reports/index.ts @@ -0,0 +1,3 @@ +export { UserReportsModule } from './user-reports.module'; +export { UserReportsService } from './user-reports.service'; +export { UserReportsController } from './user-reports.controller'; diff --git a/src/user-reports/user-reports.controller.ts b/src/user-reports/user-reports.controller.ts new file mode 100644 index 0000000..4a983d9 --- /dev/null +++ b/src/user-reports/user-reports.controller.ts @@ -0,0 +1,87 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger'; +import { UserReportsService } from './user-reports.service'; +import { CreateReportDto, UpdateReportDto } from './dto'; +import { JwtAuthGuard } from '../auth/guards'; +import { CurrentUser } from '../auth/decorators'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole, ReportStatus } from '@prisma/client'; + +@ApiTags('User Reports') +@Controller('user-reports') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth('JWT-auth') +export class UserReportsController { + constructor(private readonly userReportsService: UserReportsService) {} + + // ========================================== + // USER: SUBMIT A REPORT + // ========================================== + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Submit a report against a user' }) + async createReport( + @CurrentUser('id') reporterId: string, + @Body() dto: CreateReportDto, + ) { + return this.userReportsService.createReport(reporterId, dto); + } + + // ========================================== + // ADMIN: GET ALL REPORTS + // ========================================== + @Get('admin/all') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Admin: Get all user reports' }) + @ApiQuery({ name: 'status', required: false, enum: ReportStatus }) + async getAllReports(@Query('status') status?: ReportStatus) { + return this.userReportsService.getAllReports(status); + } + + // ========================================== + // ADMIN: GET REPORT COUNTS + // ========================================== + @Get('admin/counts') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Admin: Get report counts by status' }) + async getReportCounts() { + return this.userReportsService.getReportCounts(); + } + + // ========================================== + // ADMIN: GET SINGLE REPORT + // ========================================== + @Get('admin/:id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Admin: Get a specific report' }) + @ApiParam({ name: 'id', description: 'Report ID' }) + async getReport(@Param('id') id: string) { + return this.userReportsService.getReportById(id); + } + + // ========================================== + // ADMIN: UPDATE REPORT STATUS + // ========================================== + @Patch('admin/:id') + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Admin: Update report status and notes' }) + @ApiParam({ name: 'id', description: 'Report ID' }) + async updateReport( + @Param('id') id: string, + @Body() dto: UpdateReportDto, + ) { + return this.userReportsService.updateReport(id, dto.status, dto.adminNotes); + } +} diff --git a/src/user-reports/user-reports.module.ts b/src/user-reports/user-reports.module.ts new file mode 100644 index 0000000..f4d43c9 --- /dev/null +++ b/src/user-reports/user-reports.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { UserReportsController } from './user-reports.controller'; +import { UserReportsService } from './user-reports.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [UserReportsController], + providers: [UserReportsService], + exports: [UserReportsService], +}) +export class UserReportsModule {} diff --git a/src/user-reports/user-reports.service.ts b/src/user-reports/user-reports.service.ts new file mode 100644 index 0000000..594020a --- /dev/null +++ b/src/user-reports/user-reports.service.ts @@ -0,0 +1,104 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { ReportStatus } from '@prisma/client'; +import { CreateReportDto } from './dto'; + +@Injectable() +export class UserReportsService { + constructor(private readonly prisma: PrismaService) {} + + private readonly reportInclude = { + reporter: { + select: { + id: true, + email: true, + userProfile: { select: { firstName: true, lastName: true } }, + agentProfile: { select: { firstName: true, lastName: true } }, + }, + }, + reportedUser: { + select: { + id: true, + email: true, + userProfile: { select: { firstName: true, lastName: true } }, + agentProfile: { select: { firstName: true, lastName: true } }, + }, + }, + }; + + async createReport(reporterId: string, dto: CreateReportDto) { + if (reporterId === dto.reportedUserId) { + throw new BadRequestException('You cannot report yourself'); + } + + // Check if user already reported this user recently (within 24h) + const recentReport = await this.prisma.userReport.findFirst({ + where: { + reporterId, + reportedUserId: dto.reportedUserId, + createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }, + }, + }); + + if (recentReport) { + throw new BadRequestException('You have already reported this user recently'); + } + + return this.prisma.userReport.create({ + data: { + reporterId, + reportedUserId: dto.reportedUserId, + conversationId: dto.conversationId, + reason: dto.reason, + description: dto.description, + }, + include: this.reportInclude, + }); + } + + async getAllReports(status?: ReportStatus) { + const where = status ? { status } : {}; + return this.prisma.userReport.findMany({ + where, + include: this.reportInclude, + orderBy: { createdAt: 'desc' }, + }); + } + + async getReportById(id: string) { + const report = await this.prisma.userReport.findUnique({ + where: { id }, + include: this.reportInclude, + }); + + if (!report) { + throw new NotFoundException('Report not found'); + } + + return report; + } + + async updateReport(id: string, status: ReportStatus, adminNotes?: string) { + const report = await this.prisma.userReport.findUnique({ where: { id } }); + if (!report) { + throw new NotFoundException('Report not found'); + } + + return this.prisma.userReport.update({ + where: { id }, + data: { status, adminNotes }, + include: this.reportInclude, + }); + } + + async getReportCounts() { + const [pending, reviewed, resolved, dismissed, total] = await Promise.all([ + this.prisma.userReport.count({ where: { status: ReportStatus.PENDING } }), + this.prisma.userReport.count({ where: { status: ReportStatus.REVIEWED } }), + this.prisma.userReport.count({ where: { status: ReportStatus.RESOLVED } }), + this.prisma.userReport.count({ where: { status: ReportStatus.DISMISSED } }), + this.prisma.userReport.count(), + ]); + return { pending, reviewed, resolved, dismissed, total }; + } +}