diff --git a/backend/lint.txt b/backend/lint.txt new file mode 100644 index 00000000..2e5c14b8 Binary files /dev/null and b/backend/lint.txt differ diff --git a/backend/lint2.txt b/backend/lint2.txt new file mode 100644 index 00000000..30d41e28 --- /dev/null +++ b/backend/lint2.txt @@ -0,0 +1,23 @@ + +> backend@0.0.1 lint C:\Users\DELL\Desktop\Arena\InsightArena\backend +> eslint "{src,apps,libs,test}/**/*.ts" --fix + + +C:\Users\DELL\Desktop\Arena\InsightArena\backend\src\admin\admin.service.ts + 35:24 error 'SystemConfigValues' is an 'error' type that acts as 'any' and overrides all other types in this union type @typescript-eslint/no-redundant-type-constituents + 50:23 warning Unsafe argument of type error typed assigned to a parameter of type `EntityClassOrSchema` @typescript-eslint/no-unsafe-argument + 61:11 error Unsafe assignment of an `any` value @typescript-eslint/no-unsafe-assignment + 64:15 error Unsafe member access .key on a type that cannot be resolved @typescript-eslint/no-unsafe-member-access + 65:45 error The type of computed name [row.key] cannot be resolved @typescript-eslint/no-unsafe-member-access + 65:49 error Unsafe member access .key on a type that cannot be resolved @typescript-eslint/no-unsafe-member-access + 65:60 error Unsafe member access .value on a type that cannot be resolved @typescript-eslint/no-unsafe-member-access + 69:5 error Unsafe assignment of an `any` value @typescript-eslint/no-unsafe-assignment + 77:36 warning Unsafe argument of type error typed assigned to a parameter of type `{ [s: string]: unknown; } | ArrayLike` @typescript-eslint/no-unsafe-argument + +C:\Users\DELL\Desktop\Arena\InsightArena\backend\src\markets\markets.service.spec.ts + 315:7 warning Unsafe argument of type `any` assigned to a parameter of type `SelectQueryBuilder` @typescript-eslint/no-unsafe-argument + 363:7 warning Unsafe argument of type `any` assigned to a parameter of type `SelectQueryBuilder` @typescript-eslint/no-unsafe-argument + +??? 11 problems (7 errors, 4 warnings) + +???ELIFECYCLE??? Command failed with exit code 1. diff --git a/backend/lint_final.json b/backend/lint_final.json new file mode 100644 index 00000000..58d5cb91 Binary files /dev/null and b/backend/lint_final.json differ diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 209677b7..fb5f14be 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -25,6 +25,7 @@ import { ModerateCommentDto } from './dto/moderate-comment.dto'; import { ReportQueryDto } from './dto/report-query.dto'; import { ResolveMarketDto } from './dto/resolve-market.dto'; import { StatsResponseDto } from './dto/stats-response.dto'; +import { UpdateSystemConfigDto } from './dto/system-config.dto'; import { UpdateUserRoleDto } from './dto/update-user-role.dto'; @Controller('admin') @@ -161,4 +162,17 @@ export class AdminController { async getActivityReport(@Query() query: ReportQueryDto) { return this.adminService.getActivityReport(query); } + + @Get('config') + async getConfig() { + return this.adminService.getConfig(); + } + + @Patch('config') + async updateConfig(@Body() dto: UpdateSystemConfigDto, @Request() req: any) { + return this.adminService.updateConfig( + dto, + (req as { user: { id: string } }).user.id, + ); + } } diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index e010aba7..2aa0fbb7 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -11,6 +11,7 @@ import { Prediction } from '../predictions/entities/prediction.entity'; import { User } from '../users/entities/user.entity'; import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; +import { SystemConfig } from './entities/system-config.entity'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { AdminService } from './admin.service'; Competition, CompetitionParticipant, ActivityLog, + SystemConfig, ]), FlagsModule, NotificationsModule, diff --git a/backend/src/admin/admin.service.spec.ts b/backend/src/admin/admin.service.spec.ts index 32eb2e3f..1d4af991 100644 --- a/backend/src/admin/admin.service.spec.ts +++ b/backend/src/admin/admin.service.spec.ts @@ -19,6 +19,7 @@ import { Prediction } from '../predictions/entities/prediction.entity'; import { SorobanService } from '../soroban/soroban.service'; import { User } from '../users/entities/user.entity'; import { AdminService } from './admin.service'; +import { SystemConfig } from './entities/system-config.entity'; import { ResolveMarketDto } from './dto/resolve-market.dto'; const mockRepo = () => ({ @@ -84,6 +85,7 @@ describe('AdminService.adminResolveMarket', () => { useValue: mockRepo(), }, { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, + { provide: getRepositoryToken(SystemConfig), useValue: mockRepo() }, { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, useValue: notificationsService }, { provide: SorobanService, useValue: sorobanService }, @@ -262,6 +264,7 @@ describe('AdminService.featureMarket', () => { useValue: mockRepo(), }, { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, + { provide: getRepositoryToken(SystemConfig), useValue: mockRepo() }, { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, useValue: { create: jest.fn() } }, { provide: SorobanService, useValue: { resolveMarket: jest.fn() } }, @@ -361,6 +364,7 @@ describe('AdminService.unfeatureMarket', () => { useValue: mockRepo(), }, { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, + { provide: getRepositoryToken(SystemConfig), useValue: mockRepo() }, { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, useValue: { create: jest.fn() } }, { provide: SorobanService, useValue: { resolveMarket: jest.fn() } }, @@ -449,6 +453,7 @@ describe('AdminService.updateUserRole', () => { useValue: mockRepo(), }, { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, + { provide: getRepositoryToken(SystemConfig), useValue: mockRepo() }, { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, @@ -556,6 +561,7 @@ describe('AdminService.adminCancelCompetition', () => { useValue: participantsRepo, }, { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, + { provide: getRepositoryToken(SystemConfig), useValue: mockRepo() }, { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, useValue: notificationsService }, { provide: SorobanService, useValue: sorobanService }, diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 4899a322..be92e575 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -32,10 +32,16 @@ import { import { ResolveMarketDto } from './dto/resolve-market.dto'; import { StatsResponseDto } from './dto/stats-response.dto'; import { UpdateUserRoleDto } from './dto/update-user-role.dto'; - +import { SystemConfig } from './entities/system-config.entity'; +import { + DEFAULT_CONFIG, + SystemConfigValues, + UpdateSystemConfigDto, +} from './dto/system-config.dto'; @Injectable() export class AdminService { private readonly logger = new Logger(AdminService.name); + private configCache: SystemConfigValues | null = null; constructor( @InjectRepository(User) @@ -52,12 +58,52 @@ export class AdminService { private readonly competitionParticipantsRepository: Repository, @InjectRepository(ActivityLog) private readonly activityLogsRepository: Repository, + @InjectRepository(SystemConfig) + private readonly systemConfigRepository: Repository, private readonly analyticsService: AnalyticsService, private readonly notificationsService: NotificationsService, private readonly sorobanService: SorobanService, private readonly flagsService: FlagsService, ) {} + async getConfig(): Promise { + if (this.configCache) return this.configCache; + + const rows = await this.systemConfigRepository.find(); + const config = { ...DEFAULT_CONFIG }; + + for (const row of rows) { + if (row.key in config) { + (config as Record)[row.key] = row.value; + } + } + + this.configCache = config; + return config; + } + + async updateConfig( + dto: UpdateSystemConfigDto, + adminId: string, + ): Promise { + const updates = Object.entries(dto).filter(([, v]) => v !== undefined); + + for (const [key, value] of updates) { + await this.systemConfigRepository.save({ + key, + value: value as unknown, + }); + } + + this.configCache = null; + + await this.analyticsService.logActivity(adminId, 'SYSTEM_CONFIG_UPDATED', { + updated_keys: updates.map(([k]) => k), + }); + + return this.getConfig(); + } + async getStats(): Promise { const now = new Date(); const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); diff --git a/backend/src/admin/dto/system-config.dto.ts b/backend/src/admin/dto/system-config.dto.ts new file mode 100644 index 00000000..f1f5bb51 --- /dev/null +++ b/backend/src/admin/dto/system-config.dto.ts @@ -0,0 +1,49 @@ +import { IsNumber, IsBoolean, IsOptional, Min, Max } from 'class-validator'; + +export class UpdateSystemConfigDto { + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + platform_fee_percent?: number; + + @IsOptional() + @IsNumber() + @Min(0) + min_stake_stroops?: number; + + @IsOptional() + @IsNumber() + @Min(1) + max_markets_per_user?: number; + + @IsOptional() + @IsBoolean() + maintenance_mode?: boolean; + + @IsOptional() + @IsBoolean() + feature_competitions?: boolean; + + @IsOptional() + @IsBoolean() + feature_leaderboard?: boolean; +} + +export interface SystemConfigValues { + platform_fee_percent: number; + min_stake_stroops: number; + max_markets_per_user: number; + maintenance_mode: boolean; + feature_competitions: boolean; + feature_leaderboard: boolean; +} + +export const DEFAULT_CONFIG: SystemConfigValues = { + platform_fee_percent: 2, + min_stake_stroops: 1000000, + max_markets_per_user: 10, + maintenance_mode: false, + feature_competitions: true, + feature_leaderboard: true, +}; diff --git a/backend/src/admin/entities/system-config.entity.ts b/backend/src/admin/entities/system-config.entity.ts new file mode 100644 index 00000000..a67dfad9 --- /dev/null +++ b/backend/src/admin/entities/system-config.entity.ts @@ -0,0 +1,13 @@ +import { Entity, PrimaryColumn, Column, UpdateDateColumn } from 'typeorm'; + +@Entity('system_config') +export class SystemConfig { + @PrimaryColumn() + key: string; + + @Column('jsonb') + value: unknown; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/admin/system-config.service.spec.ts b/backend/src/admin/system-config.service.spec.ts new file mode 100644 index 00000000..0a71bb57 --- /dev/null +++ b/backend/src/admin/system-config.service.spec.ts @@ -0,0 +1,111 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { Competition } from '../competitions/entities/competition.entity'; +import { Market } from '../markets/entities/market.entity'; +import { Comment } from '../markets/entities/comment.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 { ActivityLog } from '../analytics/entities/activity-log.entity'; +import { AdminService } from './admin.service'; +import { SystemConfig } from './entities/system-config.entity'; +import { DEFAULT_CONFIG } from './dto/system-config.dto'; + +const mockRepo = () => ({ + findOne: jest.fn(), + save: jest.fn(), + find: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), +}); + +describe('AdminService - system config', () => { + let service: AdminService; + let configRepo: ReturnType; + let analyticsService: jest.Mocked>; + + const adminId = 'admin-1'; + + beforeEach(async () => { + configRepo = mockRepo(); + analyticsService = { logActivity: jest.fn().mockResolvedValue({}) }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminService, + { provide: getRepositoryToken(User), useValue: mockRepo() }, + { provide: getRepositoryToken(Market), useValue: mockRepo() }, + { provide: getRepositoryToken(Comment), useValue: mockRepo() }, + { provide: getRepositoryToken(Prediction), useValue: mockRepo() }, + { provide: getRepositoryToken(Competition), useValue: mockRepo() }, + { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, + { provide: getRepositoryToken(SystemConfig), useValue: configRepo }, + { provide: AnalyticsService, useValue: analyticsService }, + { provide: NotificationsService, useValue: { create: jest.fn() } }, + { provide: SorobanService, useValue: { resolveMarket: jest.fn() } }, + ], + }).compile(); + + service = module.get(AdminService); + }); + + describe('getConfig', () => { + it('returns defaults when no rows exist', async () => { + configRepo.find.mockResolvedValue([]); + const config = await service.getConfig(); + expect(config).toEqual(DEFAULT_CONFIG); + }); + + it('merges stored values over defaults', async () => { + configRepo.find.mockResolvedValue([ + { key: 'platform_fee_percent', value: 5 }, + { key: 'maintenance_mode', value: true }, + ]); + const config = await service.getConfig(); + expect(config.platform_fee_percent).toBe(5); + expect(config.maintenance_mode).toBe(true); + expect(config.min_stake_stroops).toBe(DEFAULT_CONFIG.min_stake_stroops); + }); + + it('returns cached value on second call', async () => { + configRepo.find.mockResolvedValue([]); + await service.getConfig(); + await service.getConfig(); + expect(configRepo.find).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateConfig', () => { + it('saves each provided key and invalidates cache', async () => { + configRepo.find.mockResolvedValue([]); + configRepo.save.mockResolvedValue({}); + + await service.updateConfig({ platform_fee_percent: 3 }, adminId); + + expect(configRepo.save).toHaveBeenCalledWith({ + key: 'platform_fee_percent', + value: 3, + }); + expect(analyticsService.logActivity).toHaveBeenCalledWith( + adminId, + 'SYSTEM_CONFIG_UPDATED', + expect.objectContaining({ updated_keys: ['platform_fee_percent'] }), + ); + }); + + it('does not save keys with undefined values', async () => { + configRepo.find.mockResolvedValue([]); + configRepo.save.mockResolvedValue({}); + + await service.updateConfig({ maintenance_mode: true }, adminId); + + expect(configRepo.save).toHaveBeenCalledTimes(1); + expect(configRepo.save).toHaveBeenCalledWith({ + key: 'maintenance_mode', + value: true, + }); + }); + }); +}); diff --git a/backend/src/flags/flags.service.spec.ts b/backend/src/flags/flags.service.spec.ts index c1a75705..3e07334e 100644 --- a/backend/src/flags/flags.service.spec.ts +++ b/backend/src/flags/flags.service.spec.ts @@ -51,14 +51,17 @@ describe('FlagsService', () => { end_time: new Date(), resolution_time: new Date(), is_resolved: false, - resolved_outcome: null, + resolved_outcome: null as any, is_public: true, is_cancelled: false, total_pool_stroops: '1000', participant_count: 0, - created_at: new Date(), + is_featured: false, + featured_at: null, + created_at: new Date('2026-04-03T23:00:00.000Z'), }; + const mockDate = new Date('2026-04-03T23:00:00.000Z'); const createMockFlag = (): Flag => ({ id: 'flag-1', market: mockMarket, @@ -73,7 +76,7 @@ describe('FlagsService', () => { resolved_by: null, resolved_by_user: null, resolved_at: null, - created_at: new Date(), + created_at: mockDate, }); beforeEach(async () => { diff --git a/backend/src/migrations/1774600000000-CreateSystemConfigEntity.ts b/backend/src/migrations/1774600000000-CreateSystemConfigEntity.ts new file mode 100644 index 00000000..4d7012b7 --- /dev/null +++ b/backend/src/migrations/1774600000000-CreateSystemConfigEntity.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSystemConfigEntity1774600000000 implements MigrationInterface { + name = 'CreateSystemConfigEntity1774600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "system_config" ("key" character varying NOT NULL, "value" jsonb NOT NULL, "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_system_config" PRIMARY KEY ("key"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "system_config"`); + } +} diff --git a/backend/test_out.txt b/backend/test_out.txt new file mode 100644 index 00000000..e8bf651f Binary files /dev/null and b/backend/test_out.txt differ