diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 8d8f667a..209677b7 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -1,20 +1,22 @@ import { Body, Controller, - Get, Delete, - Patch, + Get, Param, + Patch, Post, Query, Request, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; import { Role } from '../common/enums/role.enum'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { ListFlagsQueryDto } from '../flags/dto/list-flags-query.dto'; +import { ResolveFlagDto } from '../flags/dto/resolve-flag.dto'; import { AdminService } from './admin.service'; import { ActivityLogQueryDto } from './dto/activity-log-query.dto'; import { BanUserDto } from './dto/ban-user.dto'; @@ -100,6 +102,24 @@ export class AdminController { return this.adminService.getUserActivity(id, query); } + @Get('flags') + async listFlags(@Query() query: ListFlagsQueryDto) { + return this.adminService.listFlags(query); + } + + @Patch('flags/:id/resolve') + async resolveFlag( + @Param('id') id: string, + @Body() dto: ResolveFlagDto, + @Request() req: any, + ) { + return this.adminService.resolveFlag( + id, + dto, + (req as { user: { id: string } }).user.id, + ); + } + @Post('markets/:id/resolve') async resolveMarket( @Param('id') id: string, diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index c29f200d..e010aba7 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from '../users/entities/user.entity'; -import { Market } from '../markets/entities/market.entity'; -import { Comment } from '../markets/entities/comment.entity'; -import { Prediction } from '../predictions/entities/prediction.entity'; -import { Competition } from '../competitions/entities/competition.entity'; -import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; import { ActivityLog } from '../analytics/entities/activity-log.entity'; +import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; +import { Competition } from '../competitions/entities/competition.entity'; +import { FlagsModule } from '../flags/flags.module'; +import { Comment } from '../markets/entities/comment.entity'; +import { Market } from '../markets/entities/market.entity'; import { NotificationsModule } from '../notifications/notifications.module'; +import { Prediction } from '../predictions/entities/prediction.entity'; +import { User } from '../users/entities/user.entity'; import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; @@ -22,6 +23,7 @@ import { AdminService } from './admin.service'; CompetitionParticipant, ActivityLog, ]), + FlagsModule, NotificationsModule, ], controllers: [AdminController], diff --git a/backend/src/admin/admin.service.spec.ts b/backend/src/admin/admin.service.spec.ts index d4c10be8..32eb2e3f 100644 --- a/backend/src/admin/admin.service.spec.ts +++ b/backend/src/admin/admin.service.spec.ts @@ -9,9 +9,10 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { AnalyticsService } from '../analytics/analytics.service'; import { ActivityLog } from '../analytics/entities/activity-log.entity'; import { Role } from '../common/enums/role.enum'; +import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; import { Competition } from '../competitions/entities/competition.entity'; +import { FlagsService } from '../flags/flags.service'; import { Comment } from '../markets/entities/comment.entity'; -import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; import { Market } from '../markets/entities/market.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { Prediction } from '../predictions/entities/prediction.entity'; @@ -86,6 +87,13 @@ describe('AdminService.adminResolveMarket', () => { { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, useValue: notificationsService }, { provide: SorobanService, useValue: sorobanService }, + { + provide: FlagsService, + useValue: { + listFlags: jest.fn(), + resolveFlag: jest.fn(), + }, + }, ], }).compile(); @@ -257,6 +265,13 @@ describe('AdminService.featureMarket', () => { { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, useValue: { create: jest.fn() } }, { provide: SorobanService, useValue: { resolveMarket: jest.fn() } }, + { + provide: FlagsService, + useValue: { + listFlags: jest.fn(), + resolveFlag: jest.fn(), + }, + }, ], }).compile(); @@ -349,6 +364,13 @@ describe('AdminService.unfeatureMarket', () => { { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, useValue: { create: jest.fn() } }, { provide: SorobanService, useValue: { resolveMarket: jest.fn() } }, + { + provide: FlagsService, + useValue: { + listFlags: jest.fn(), + resolveFlag: jest.fn(), + }, + }, ], }).compile(); @@ -439,6 +461,13 @@ describe('AdminService.updateUserRole', () => { refundCompetitionParticipant: jest.fn(), }, }, + { + provide: FlagsService, + useValue: { + listFlags: jest.fn(), + resolveFlag: jest.fn(), + }, + }, ], }).compile(); @@ -530,6 +559,13 @@ describe('AdminService.adminCancelCompetition', () => { { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, useValue: notificationsService }, { provide: SorobanService, useValue: sorobanService }, + { + provide: FlagsService, + useValue: { + listFlags: jest.fn(), + resolveFlag: jest.fn(), + }, + }, ], }).compile(); diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index b163a3a0..4899a322 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -7,18 +7,21 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Between } from 'typeorm'; -import { User } from '../users/entities/user.entity'; -import { Market } from '../markets/entities/market.entity'; -import { Comment } from '../markets/entities/comment.entity'; -import { Prediction } from '../predictions/entities/prediction.entity'; -import { Competition } from '../competitions/entities/competition.entity'; -import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; -import { ActivityLog } from '../analytics/entities/activity-log.entity'; +import { Between, Repository } from 'typeorm'; import { AnalyticsService } from '../analytics/analytics.service'; +import { ActivityLog } from '../analytics/entities/activity-log.entity'; +import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; +import { Competition } from '../competitions/entities/competition.entity'; +import { ListFlagsQueryDto } from '../flags/dto/list-flags-query.dto'; +import { ResolveFlagDto } from '../flags/dto/resolve-flag.dto'; +import { FlagsService } from '../flags/flags.service'; +import { Comment } from '../markets/entities/comment.entity'; +import { Market } from '../markets/entities/market.entity'; import { NotificationType } from '../notifications/entities/notification.entity'; import { NotificationsService } from '../notifications/notifications.service'; +import { Prediction } from '../predictions/entities/prediction.entity'; import { SorobanService } from '../soroban/soroban.service'; +import { User } from '../users/entities/user.entity'; import { ActivityLogQueryDto } from './dto/activity-log-query.dto'; import { ListUsersQueryDto } from './dto/list-users-query.dto'; import { @@ -52,6 +55,7 @@ export class AdminService { private readonly analyticsService: AnalyticsService, private readonly notificationsService: NotificationsService, private readonly sorobanService: SorobanService, + private readonly flagsService: FlagsService, ) {} async getStats(): Promise { @@ -247,6 +251,18 @@ export class AdminService { }; } + async listFlags(query: ListFlagsQueryDto) { + return this.flagsService.listFlags(query); + } + + async resolveFlag( + flagId: string, + resolveFlagDto: ResolveFlagDto, + adminId: string, + ) { + return this.flagsService.resolveFlag(flagId, resolveFlagDto, adminId); + } + async adminResolveMarket( id: string, dto: ResolveMarketDto, diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 4f12e8b3..0d86cd27 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -6,25 +6,26 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { LoggerModule } from 'nestjs-pino'; +import { AchievementsModule } from './achievements/achievements.module'; +import { AdminModule } from './admin/admin.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { CommonModule } from './common/common.module'; -import { AdminModule } from './admin/admin.module'; -import { AchievementsModule } from './achievements/achievements.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; import { CompetitionsModule } from './competitions/competitions.module'; import { validate } from './config/env.validation'; +import { FlagsModule } from './flags/flags.module'; import { HealthModule } from './health/health.module'; import { LeaderboardModule } from './leaderboard/leaderboard.module'; import { MarketsModule } from './markets/markets.module'; import { NotificationsModule } from './notifications/notifications.module'; import { PredictionsModule } from './predictions/predictions.module'; +import { SearchModule } from './search/search.module'; import { SeasonsModule } from './seasons/seasons.module'; import { SorobanModule } from './soroban/soroban.module'; -import { SearchModule } from './search/search.module'; import { UsersModule } from './users/users.module'; @Module({ @@ -82,7 +83,7 @@ import { UsersModule } from './users/users.module'; AchievementsModule, SearchModule, CommonModule, - AnalyticsModule, + FlagsModule, ], controllers: [AppController], diff --git a/backend/src/flags/dto/create-flag.dto.ts b/backend/src/flags/dto/create-flag.dto.ts new file mode 100644 index 00000000..d5f7b664 --- /dev/null +++ b/backend/src/flags/dto/create-flag.dto.ts @@ -0,0 +1,21 @@ +import { + IsUUID, + IsEnum, + IsString, + IsOptional, + MaxLength, +} from 'class-validator'; +import { FlagReason } from '../entities/flag.entity'; + +export class CreateFlagDto { + @IsUUID() + market_id: string; + + @IsEnum(FlagReason) + reason: FlagReason; + + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; +} diff --git a/backend/src/flags/dto/list-flags-query.dto.ts b/backend/src/flags/dto/list-flags-query.dto.ts new file mode 100644 index 00000000..d304c377 --- /dev/null +++ b/backend/src/flags/dto/list-flags-query.dto.ts @@ -0,0 +1,32 @@ +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { FlagReason, FlagStatus } from '../entities/flag.entity'; + +export class ListFlagsQueryDto { + @IsOptional() + @IsEnum(FlagStatus) + status?: FlagStatus; + + @IsOptional() + @IsEnum(FlagReason) + reason?: FlagReason; + + @IsOptional() + @IsUUID() + user_id?: string; + + @IsOptional() + @IsString() + page?: string; + + @IsOptional() + @IsString() + limit?: string; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsString() + sortOrder?: string; +} diff --git a/backend/src/flags/dto/resolve-flag.dto.ts b/backend/src/flags/dto/resolve-flag.dto.ts new file mode 100644 index 00000000..57d33ce9 --- /dev/null +++ b/backend/src/flags/dto/resolve-flag.dto.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsString, IsOptional, MaxLength } from 'class-validator'; +import { FlagResolutionAction } from '../entities/flag.entity'; + +export class ResolveFlagDto { + @IsEnum(FlagResolutionAction) + action: FlagResolutionAction; + + @IsOptional() + @IsString() + @MaxLength(1000) + admin_notes?: string; +} diff --git a/backend/src/flags/entities/flag.entity.ts b/backend/src/flags/entities/flag.entity.ts new file mode 100644 index 00000000..4c21de48 --- /dev/null +++ b/backend/src/flags/entities/flag.entity.ts @@ -0,0 +1,108 @@ +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Market } from '../../markets/entities/market.entity'; +import { User } from '../../users/entities/user.entity'; + +export enum FlagStatus { + PENDING = 'pending', + RESOLVED = 'resolved', + DISMISSED = 'dismissed', +} + +export enum FlagReason { + INAPPROPRIATE_CONTENT = 'inappropriate_content', + SPAM = 'spam', + MISINFORMATION = 'misinformation', + HARASSMENT = 'harassment', + HATE_SPEECH = 'hate_speech', + VIOLENCE = 'violence', + COPYRIGHT = 'copyright', + OTHER = 'other', +} + +export enum FlagResolutionAction { + DISMISS = 'dismiss', + REMOVE_MARKET = 'remove_market', + BAN_USER = 'ban_user', +} + +@Entity('flags') +@Index(['market']) +@Index(['user']) +@Index(['status']) +@Index(['reason']) +export class Flag { + @PrimaryGeneratedColumn('uuid') + @IsUUID() + id: string; + + @ManyToOne(() => Market, { onDelete: 'CASCADE' }) + market: Market; + + @Column() + @IsUUID() + market_id: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user: User; + + @Column() + @IsUUID() + user_id: string; + + @Column({ + type: 'enum', + enum: FlagReason, + }) + @IsEnum(FlagReason) + reason: FlagReason; + + @Column({ + type: 'enum', + enum: FlagStatus, + default: FlagStatus.PENDING, + }) + @IsEnum(FlagStatus) + status: FlagStatus; + + @Column('text', { nullable: true }) + @IsOptional() + @IsString() + description: string | null; + + @Column({ + type: 'enum', + enum: FlagResolutionAction, + nullable: true, + }) + @IsOptional() + @IsEnum(FlagResolutionAction) + resolution_action: FlagResolutionAction | null; + + @Column('text', { nullable: true }) + @IsOptional() + @IsString() + admin_notes: string | null; + + @Column({ nullable: true }) + @IsOptional() + @IsUUID() + resolved_by: string | null; + + @ManyToOne(() => User, { nullable: true }) + resolved_by_user: User | null; + + @Column({ type: 'timestamptz', nullable: true }) + @IsOptional() + resolved_at: Date | null; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/flags/flags.controller.spec.ts b/backend/src/flags/flags.controller.spec.ts new file mode 100644 index 00000000..c9b708ec --- /dev/null +++ b/backend/src/flags/flags.controller.spec.ts @@ -0,0 +1,107 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FlagsController } from './flags.controller'; +import { FlagsService } from './flags.service'; +import { Flag, FlagStatus, FlagReason } from './entities/flag.entity'; +import { CreateFlagDto } from './dto/create-flag.dto'; +import { ListFlagsQueryDto } from './dto/list-flags-query.dto'; + +describe('FlagsController', () => { + let controller: FlagsController; + let flagsService: FlagsService; + + const mockFlag: Flag = { + id: 'flag-1', + market_id: 'market-1', + user_id: 'user-1', + reason: FlagReason.INAPPROPRIATE_CONTENT, + status: FlagStatus.PENDING, + description: 'This is inappropriate', + resolution_action: null, + admin_notes: null, + resolved_by: null, + resolved_by_user: null, + resolved_at: null, + created_at: new Date(), + market: null as any, + user: null as any, + }; + + const mockUser = { id: 'user-1' }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FlagsController], + providers: [ + { + provide: FlagsService, + useValue: { + createFlag: jest.fn(), + listFlags: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(FlagsController); + flagsService = module.get(FlagsService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('createFlag', () => { + it('should create a flag', async () => { + const createFlagDto: CreateFlagDto = { + market_id: 'market-1', + reason: FlagReason.INAPPROPRIATE_CONTENT, + description: 'This is inappropriate', + }; + + const mockRequest = { user: mockUser }; + + jest.spyOn(flagsService, 'createFlag').mockResolvedValue(mockFlag); + + const result = await controller.createFlag( + createFlagDto, + mockRequest as any, + ); + + expect(flagsService.createFlag).toHaveBeenCalledWith( + 'user-1', + createFlagDto, + ); + expect(result).toEqual(mockFlag); + }); + }); + + describe('getMyFlags', () => { + it('should return user flags', async () => { + const query: ListFlagsQueryDto = { + page: '1', + limit: '10', + }; + + const mockRequest = { user: mockUser }; + const mockResponse = { + data: [mockFlag], + meta: { + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + }; + + jest.spyOn(flagsService, 'listFlags').mockResolvedValue(mockResponse); + + const result = await controller.getMyFlags(mockRequest as any, query); + + expect(flagsService.listFlags).toHaveBeenCalledWith({ + ...query, + user_id: 'user-1', + }); + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/backend/src/flags/flags.controller.ts b/backend/src/flags/flags.controller.ts new file mode 100644 index 00000000..50dfc339 --- /dev/null +++ b/backend/src/flags/flags.controller.ts @@ -0,0 +1,35 @@ +import { + Controller, + Post, + Get, + Body, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { FlagsService } from './flags.service'; +import { CreateFlagDto } from './dto/create-flag.dto'; +import { ListFlagsQueryDto } from './dto/list-flags-query.dto'; + +@Controller('flags') +@UseGuards(JwtAuthGuard) +export class FlagsController { + constructor(private readonly flagsService: FlagsService) {} + + @Post() + async createFlag(@Body() createFlagDto: CreateFlagDto, @Request() req: any) { + return this.flagsService.createFlag( + (req as { user: { id: string } }).user.id, + createFlagDto, + ); + } + + @Get('my-flags') + async getMyFlags(@Request() req: any, @Query() query: ListFlagsQueryDto) { + return this.flagsService.listFlags({ + ...query, + user_id: (req as { user: { id: string } }).user.id, + }); + } +} diff --git a/backend/src/flags/flags.module.ts b/backend/src/flags/flags.module.ts new file mode 100644 index 00000000..3c0d5229 --- /dev/null +++ b/backend/src/flags/flags.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Flag } from './entities/flag.entity'; +import { User } from '../users/entities/user.entity'; +import { Market } from '../markets/entities/market.entity'; +import { FlagsService } from './flags.service'; +import { FlagsController } from './flags.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Flag, User, Market])], + controllers: [FlagsController], + providers: [FlagsService], + exports: [FlagsService], +}) +export class FlagsModule {} diff --git a/backend/src/flags/flags.service.spec.ts b/backend/src/flags/flags.service.spec.ts new file mode 100644 index 00000000..c1a75705 --- /dev/null +++ b/backend/src/flags/flags.service.spec.ts @@ -0,0 +1,375 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { Market } from '../markets/entities/market.entity'; +import { User } from '../users/entities/user.entity'; +import { + Flag, + FlagReason, + FlagResolutionAction, + FlagStatus, +} from './entities/flag.entity'; +import { FlagsService } from './flags.service'; + +describe('FlagsService', () => { + let service: FlagsService; + let flagsRepository: Repository; + let usersRepository: Repository; + let marketsRepository: Repository; + let analyticsService: AnalyticsService; + + const mockUser: User = { + id: 'user-1', + stellar_address: 'test@example.com', + username: 'testuser', + avatar_url: null, + total_predictions: 0, + correct_predictions: 0, + total_staked_stroops: '0', + total_winnings_stroops: '0', + reputation_score: 0, + season_points: 0, + role: 'user', + is_banned: false, + ban_reason: null, + banned_at: null, + banned_by: null, + created_at: new Date(), + updated_at: new Date(), + }; + + const mockMarket: Market = { + id: 'market-1', + on_chain_market_id: 'chain-market-1', + creator: mockUser, + title: 'Test Market', + description: 'Test Description', + category: 'test', + outcome_options: ['yes', 'no'], + end_time: new Date(), + resolution_time: new Date(), + is_resolved: false, + resolved_outcome: null, + is_public: true, + is_cancelled: false, + total_pool_stroops: '1000', + participant_count: 0, + created_at: new Date(), + }; + + const createMockFlag = (): Flag => ({ + id: 'flag-1', + market: mockMarket, + market_id: 'market-1', + user: mockUser, + user_id: 'user-1', + reason: FlagReason.INAPPROPRIATE_CONTENT, + status: FlagStatus.PENDING, + description: 'This is inappropriate', + resolution_action: null, + admin_notes: null, + resolved_by: null, + resolved_by_user: null, + resolved_at: null, + created_at: new Date(), + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FlagsService, + { + provide: getRepositoryToken(Flag), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + { + provide: getRepositoryToken(User), + useValue: { + update: jest.fn(), + }, + }, + { + provide: getRepositoryToken(Market), + useValue: { + findOne: jest.fn(), + update: jest.fn(), + }, + }, + { + provide: AnalyticsService, + useValue: { + logActivity: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(FlagsService); + flagsRepository = module.get>(getRepositoryToken(Flag)); + usersRepository = module.get>(getRepositoryToken(User)); + marketsRepository = module.get>( + getRepositoryToken(Market), + ); + analyticsService = module.get(AnalyticsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('createFlag', () => { + it('should create a flag successfully', async () => { + const createFlagDto = { + market_id: 'market-1', + reason: FlagReason.INAPPROPRIATE_CONTENT, + description: 'This is inappropriate', + }; + + jest.spyOn(marketsRepository, 'findOne').mockResolvedValue(mockMarket); + jest.spyOn(flagsRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(flagsRepository, 'create').mockReturnValue(createMockFlag()); + jest.spyOn(flagsRepository, 'save').mockResolvedValue(createMockFlag()); + + const result = await service.createFlag('user-1', createFlagDto); + + expect(result).toEqual( + expect.objectContaining({ + id: 'flag-1', + market_id: 'market-1', + user_id: 'user-1', + reason: FlagReason.INAPPROPRIATE_CONTENT, + description: 'This is inappropriate', + status: FlagStatus.PENDING, + }), + ); + expect(marketsRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'market-1' }, + }); + expect(flagsRepository.create).toHaveBeenCalledWith({ + ...createFlagDto, + user_id: 'user-1', + }); + expect(flagsRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + market_id: 'market-1', + user_id: 'user-1', + reason: FlagReason.INAPPROPRIATE_CONTENT, + description: 'This is inappropriate', + status: FlagStatus.PENDING, + }), + ); + expect(analyticsService.logActivity).toHaveBeenCalledWith( + 'user-1', + 'MARKET_FLAGGED', + { + market_id: 'market-1', + reason: FlagReason.INAPPROPRIATE_CONTENT, + }, + ); + }); + + it('should throw NotFoundException if market does not exist', async () => { + const createFlagDto = { + market_id: 'non-existent-market', + reason: FlagReason.INAPPROPRIATE_CONTENT, + }; + + jest.spyOn(marketsRepository, 'findOne').mockResolvedValue(null); + + await expect(service.createFlag('user-1', createFlagDto)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw ForbiddenException if user already flagged the market', async () => { + const createFlagDto = { + market_id: 'market-1', + reason: FlagReason.INAPPROPRIATE_CONTENT, + }; + + jest.spyOn(marketsRepository, 'findOne').mockResolvedValue(mockMarket); + jest + .spyOn(flagsRepository, 'findOne') + .mockResolvedValue(createMockFlag()); + + await expect(service.createFlag('user-1', createFlagDto)).rejects.toThrow( + ForbiddenException, + ); + }); + }); + + describe('listFlags', () => { + it('should list flags with filters', async () => { + const query = { + page: '1', + limit: '10', + status: FlagStatus.PENDING, + reason: FlagReason.INAPPROPRIATE_CONTENT, + }; + + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[createMockFlag()], 1]), + }; + + jest + .spyOn(flagsRepository, 'createQueryBuilder') + .mockReturnValue(mockQueryBuilder as any); + + const result = await service.listFlags(query); + + expect(result).toEqual({ + data: [createMockFlag()], + meta: { + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + }); + }); + }); + + describe('resolveFlag', () => { + it('should resolve a flag with dismiss action', async () => { + const resolveFlagDto = { + action: FlagResolutionAction.DISMISS, + admin_notes: 'No action needed', + }; + + jest + .spyOn(flagsRepository, 'findOne') + .mockResolvedValue(createMockFlag()); + jest.spyOn(flagsRepository, 'save').mockResolvedValue({ + ...createMockFlag(), + status: FlagStatus.DISMISSED, + resolution_action: FlagResolutionAction.DISMISS, + admin_notes: 'No action needed', + resolved_by: 'admin-1', + resolved_at: new Date(), + }); + + const result = await service.resolveFlag( + 'flag-1', + resolveFlagDto, + 'admin-1', + ); + + expect(result.status).toBe(FlagStatus.DISMISSED); + expect(result.resolution_action).toBe(FlagResolutionAction.DISMISS); + expect(result.resolved_by).toBe('admin-1'); + expect(analyticsService.logActivity).toHaveBeenCalledWith( + 'admin-1', + 'FLAG_RESOLVED', + { + flag_id: 'flag-1', + action: FlagResolutionAction.DISMISS, + market_id: 'market-1', + user_id: 'user-1', + }, + ); + }); + + it('should resolve a flag with remove market action', async () => { + const resolveFlagDto = { + action: FlagResolutionAction.REMOVE_MARKET, + admin_notes: 'Market removed', + }; + + jest + .spyOn(flagsRepository, 'findOne') + .mockResolvedValue(createMockFlag()); + jest.spyOn(marketsRepository, 'update').mockResolvedValue({} as any); + jest.spyOn(flagsRepository, 'save').mockResolvedValue({ + ...createMockFlag(), + status: FlagStatus.RESOLVED, + resolution_action: FlagResolutionAction.REMOVE_MARKET, + admin_notes: 'Market removed', + resolved_by: 'admin-1', + resolved_at: new Date(), + }); + + const result = await service.resolveFlag( + 'flag-1', + resolveFlagDto, + 'admin-1', + ); + + expect(marketsRepository.update).toHaveBeenCalledWith('market-1', { + is_cancelled: true, + }); + expect(result.resolution_action).toBe(FlagResolutionAction.REMOVE_MARKET); + }); + + it('should resolve a flag with ban user action', async () => { + const resolveFlagDto = { + action: FlagResolutionAction.BAN_USER, + admin_notes: 'User banned', + }; + + jest + .spyOn(flagsRepository, 'findOne') + .mockResolvedValue(createMockFlag()); + jest.spyOn(usersRepository, 'update').mockResolvedValue({} as any); + jest.spyOn(flagsRepository, 'save').mockResolvedValue({ + ...createMockFlag(), + status: FlagStatus.RESOLVED, + resolution_action: FlagResolutionAction.BAN_USER, + admin_notes: 'User banned', + resolved_by: 'admin-1', + resolved_at: new Date(), + }); + + const result = await service.resolveFlag( + 'flag-1', + resolveFlagDto, + 'admin-1', + ); + + expect(usersRepository.update).toHaveBeenCalledWith('user-1', { + is_banned: true, + ban_reason: 'Banned due to flagged content: inappropriate_content', + banned_at: expect.any(Date), + banned_by: 'admin-1', + }); + expect(result.resolution_action).toBe(FlagResolutionAction.BAN_USER); + }); + + it('should throw NotFoundException if flag does not exist', async () => { + const resolveFlagDto = { + action: FlagResolutionAction.DISMISS, + }; + + jest.spyOn(flagsRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.resolveFlag('non-existent-flag', resolveFlagDto, 'admin-1'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw ForbiddenException if flag is already resolved', async () => { + const resolveFlagDto = { + action: FlagResolutionAction.DISMISS, + }; + + const resolvedFlag = { ...createMockFlag(), status: FlagStatus.RESOLVED }; + + jest.spyOn(flagsRepository, 'findOne').mockResolvedValue(resolvedFlag); + + await expect( + service.resolveFlag('flag-1', resolveFlagDto, 'admin-1'), + ).rejects.toThrow(ForbiddenException); + }); + }); +}); diff --git a/backend/src/flags/flags.service.ts b/backend/src/flags/flags.service.ts new file mode 100644 index 00000000..7f3172c4 --- /dev/null +++ b/backend/src/flags/flags.service.ts @@ -0,0 +1,168 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { Market } from '../markets/entities/market.entity'; +import { User } from '../users/entities/user.entity'; +import { CreateFlagDto } from './dto/create-flag.dto'; +import { ListFlagsQueryDto } from './dto/list-flags-query.dto'; +import { ResolveFlagDto } from './dto/resolve-flag.dto'; +import { Flag, FlagResolutionAction, FlagStatus } from './entities/flag.entity'; + +@Injectable() +export class FlagsService { + constructor( + @InjectRepository(Flag) + private readonly flagsRepository: Repository, + @InjectRepository(User) + private readonly usersRepository: Repository, + @InjectRepository(Market) + private readonly marketsRepository: Repository, + private readonly analyticsService: AnalyticsService, + ) {} + + async createFlag( + userId: string, + createFlagDto: CreateFlagDto, + ): Promise { + const market = await this.marketsRepository.findOne({ + where: { id: createFlagDto.market_id }, + }); + if (!market) { + throw new NotFoundException('Market not found'); + } + + const existingFlag = await this.flagsRepository.findOne({ + where: { + user_id: userId, + market_id: createFlagDto.market_id, + status: FlagStatus.PENDING, + }, + }); + + if (existingFlag) { + throw new ForbiddenException('You have already flagged this market'); + } + + const flag = this.flagsRepository.create({ + ...createFlagDto, + user_id: userId, + }); + + const savedFlag = await this.flagsRepository.save(flag); + + await this.analyticsService.logActivity(userId, 'MARKET_FLAGGED', { + market_id: createFlagDto.market_id, + reason: createFlagDto.reason, + }); + + return savedFlag; + } + + async listFlags(query: ListFlagsQueryDto) { + const { + page = 1, + limit = 10, + status, + reason, + user_id, + sortBy = 'created_at', + sortOrder = 'DESC', + } = query; + const skip = (Number(page) - 1) * Number(limit); + + const queryBuilder = this.flagsRepository + .createQueryBuilder('flag') + .leftJoinAndSelect('flag.market', 'market') + .leftJoinAndSelect('flag.user', 'user') + .leftJoinAndSelect('flag.resolved_by_user', 'resolvedByUser'); + + if (status) { + queryBuilder.andWhere('flag.status = :status', { status }); + } + + if (reason) { + queryBuilder.andWhere('flag.reason = :reason', { reason }); + } + + if (user_id) { + queryBuilder.andWhere('flag.user_id = :user_id', { user_id }); + } + + queryBuilder + .orderBy(`flag.${sortBy}`, sortOrder as 'ASC' | 'DESC') + .skip(skip) + .take(Number(limit)); + + const [flags, total] = await queryBuilder.getManyAndCount(); + + return { + data: flags, + meta: { + total, + page: Number(page), + limit: Number(limit), + totalPages: Math.ceil(total / Number(limit)), + }, + }; + } + + async resolveFlag( + flagId: string, + resolveFlagDto: ResolveFlagDto, + adminId: string, + ): Promise { + const flag = await this.flagsRepository.findOne({ + where: { id: flagId }, + relations: ['market', 'user'], + }); + + if (!flag) { + throw new NotFoundException('Flag not found'); + } + + if (flag.status !== FlagStatus.PENDING) { + throw new ForbiddenException('Flag has already been resolved'); + } + + flag.status = FlagStatus.RESOLVED; + flag.resolution_action = resolveFlagDto.action; + flag.admin_notes = resolveFlagDto.admin_notes || null; + flag.resolved_by = adminId; + flag.resolved_at = new Date(); + + switch (resolveFlagDto.action) { + case FlagResolutionAction.DISMISS: + flag.status = FlagStatus.DISMISSED; + break; + case FlagResolutionAction.REMOVE_MARKET: + await this.marketsRepository.update(flag.market_id, { + is_cancelled: true, + }); + break; + case FlagResolutionAction.BAN_USER: + await this.usersRepository.update(flag.user_id, { + is_banned: true, + ban_reason: `Banned due to flagged content: ${flag.reason}`, + banned_at: new Date(), + banned_by: adminId, + }); + break; + } + + const savedFlag = await this.flagsRepository.save(flag); + + await this.analyticsService.logActivity(adminId, 'FLAG_RESOLVED', { + flag_id: flagId, + action: resolveFlagDto.action, + market_id: flag.market_id, + user_id: flag.user_id, + }); + + return savedFlag; + } +} diff --git a/backend/src/migrations/1774670001000-CreateFlagEntity.ts b/backend/src/migrations/1774670001000-CreateFlagEntity.ts new file mode 100644 index 00000000..4cd10968 --- /dev/null +++ b/backend/src/migrations/1774670001000-CreateFlagEntity.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateFlagEntity1774670001000 implements MigrationInterface { + name = 'CreateFlagEntity1774670001000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "flags" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "market_id" uuid NOT NULL, "user_id" uuid NOT NULL, "reason" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'pending', "description" text, "resolution_action" character varying, "admin_notes" text, "resolved_by" uuid, "resolved_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_f8a1c3e4b2d6a7f8c9e0d1a2b3" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_flags_market_id" ON "flags" ("market_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_flags_user_id" ON "flags" ("user_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_flags_status" ON "flags" ("status")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_flags_reason" ON "flags" ("reason")`, + ); + await queryRunner.query( + `ALTER TABLE "flags" ADD CONSTRAINT "FK_flags_market_id" FOREIGN KEY ("market_id") REFERENCES "markets"("id") ON DELETE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "flags" ADD CONSTRAINT "FK_flags_user_id" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "flags" ADD CONSTRAINT "FK_flags_resolved_by" FOREIGN KEY ("resolved_by") REFERENCES "users"("id") ON DELETE SET NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "flags" DROP CONSTRAINT "FK_flags_resolved_by"`, + ); + await queryRunner.query( + `ALTER TABLE "flags" DROP CONSTRAINT "FK_flags_user_id"`, + ); + await queryRunner.query( + `ALTER TABLE "flags" DROP CONSTRAINT "FK_flags_market_id"`, + ); + await queryRunner.query(`DROP INDEX "public"."IDX_flags_reason"`); + await queryRunner.query(`DROP INDEX "public"."IDX_flags_status"`); + await queryRunner.query(`DROP INDEX "public"."IDX_flags_user_id"`); + await queryRunner.query(`DROP INDEX "public"."IDX_flags_market_id"`); + await queryRunner.query(`DROP TABLE "flags"`); + } +}