From 182f4aae165cfd16ee28d20061688b8734562d40 Mon Sep 17 00:00:00 2001 From: Fahri Can Date: Thu, 16 Apr 2026 15:12:27 +0300 Subject: [PATCH] first (part 3: src directory) --- src/app.controller.spec.ts | 22 + src/app.controller.ts | 12 + src/app.module.ts | 255 +++ src/app.service.ts | 8 + src/common/base/base.controller.ts | 128 ++ src/common/base/base.service.ts | 165 ++ src/common/base/index.ts | 2 + src/common/decorators/index.ts | 60 + src/common/dto/pagination.dto.ts | 65 + src/common/filters/global-exception.filter.ts | 109 ++ .../interceptors/response.interceptor.ts | 147 ++ .../interceptors/sanitize.interceptor.ts | 48 + src/common/queues/queue.module.ts | 31 + src/common/types/api-response.type.ts | 96 ++ src/common/utils/image.util.ts | 59 + src/config/configuration.ts | 58 + src/config/env.validation.ts | 107 ++ src/database/database.module.ts | 9 + src/database/prisma.service.ts | 134 ++ src/example/feeder.js | 276 ++++ src/i18n/en/auth.json | 6 + src/i18n/en/common.json | 13 + src/i18n/en/errors.json | 14 + src/i18n/en/predictions.json | 41 + src/i18n/en/validation.json | 23 + src/i18n/tr/auth.json | 6 + src/i18n/tr/common.json | 13 + src/i18n/tr/errors.json | 14 + src/i18n/tr/predictions.json | 48 + src/i18n/tr/validation.json | 23 + src/main.ts | 119 ++ src/modules/admin/admin.controller.ts | 280 ++++ src/modules/admin/admin.module.ts | 7 + src/modules/admin/dto/admin.dto.ts | 71 + src/modules/analysis/analysis.controller.ts | 100 ++ src/modules/analysis/analysis.module.ts | 13 + src/modules/analysis/analysis.service.ts | 152 ++ .../analysis/dto/analysis-request.dto.ts | 16 + src/modules/auth/auth.controller.ts | 78 + src/modules/auth/auth.module.ts | 37 + src/modules/auth/auth.service.ts | 248 +++ src/modules/auth/dto/auth.dto.ts | 70 + src/modules/auth/guards/auth.guards.ts | 142 ++ src/modules/auth/guards/index.ts | 1 + src/modules/auth/strategies/jwt.strategy.ts | 37 + src/modules/coupons/coupons.controller.ts | 238 +++ src/modules/coupons/coupons.module.ts | 16 + src/modules/coupons/coupons.service.ts | 38 + .../coupons/dto/coupons-request.dto.ts | 76 + .../coupons/services/smart-coupon.service.ts | 248 +++ .../coupons/services/user-coupon.service.ts | 189 +++ .../feeder/feeder-persistence.service.ts | 987 +++++++++++ src/modules/feeder/feeder-scraper.service.ts | 746 +++++++++ .../feeder/feeder-transformer.service.ts | 359 ++++ src/modules/feeder/feeder.module.ts | 22 + src/modules/feeder/feeder.service.ts | 994 +++++++++++ src/modules/feeder/feeder.types.ts | 533 ++++++ src/modules/gemini/gemini.config.ts | 7 + src/modules/gemini/gemini.module.ts | 18 + src/modules/gemini/gemini.service.ts | 240 +++ src/modules/gemini/index.ts | 3 + src/modules/health/health.controller.ts | 44 + src/modules/health/health.module.ts | 11 + src/modules/leagues/leagues.controller.ts | 152 ++ src/modules/leagues/leagues.module.ts | 12 + src/modules/leagues/leagues.service.ts | 173 ++ src/modules/matches/dto/index.ts | 219 +++ src/modules/matches/matches.controller.ts | 130 ++ src/modules/matches/matches.module.ts | 12 + src/modules/matches/matches.service.ts | 703 ++++++++ src/modules/predictions/dto/index.ts | 471 ++++++ .../dto/predictions-request.dto.ts | 63 + .../predictions/dto/smart-coupon.dto.ts | 64 + .../predictions/predictions.controller.ts | 169 ++ src/modules/predictions/predictions.module.ts | 37 + .../predictions/predictions.service.ts | 1166 +++++++++++++ .../queues/predictions.processor.spec.ts | 81 + .../queues/predictions.processor.ts | 123 ++ .../predictions/queues/predictions.queue.ts | 41 + .../predictions/queues/predictions.types.ts | 29 + .../services/ai-feature-store.service.ts | 114 ++ .../caption-generator.service.ts | 109 ++ .../social-poster/dto/prediction-card.dto.ts | 60 + .../social-poster/image-renderer.service.ts | 462 ++++++ src/modules/social-poster/meta.service.ts | 180 ++ .../social-poster/social-poster.controller.ts | 25 + .../social-poster/social-poster.module.ts | 31 + .../social-poster/social-poster.service.ts | 395 +++++ src/modules/social-poster/twitter.service.ts | 87 + src/modules/spor-toto/dto/spor-toto.dto.ts | 256 +++ .../services/toto-analytics.service.ts | 190 +++ .../services/toto-combinatorics.service.ts | 156 ++ .../services/toto-fetcher.service.ts | 124 ++ .../services/toto-prediction.service.ts | 795 +++++++++ src/modules/spor-toto/spor-toto.controller.ts | 259 +++ src/modules/spor-toto/spor-toto.module.ts | 24 + src/modules/spor-toto/spor-toto.service.ts | 462 ++++++ src/modules/users/dto/user.dto.ts | 103 ++ src/modules/users/users.controller.ts | 105 ++ src/modules/users/users.module.ts | 10 + src/modules/users/users.service.ts | 199 +++ src/scripts/backtest-accuracy.ts | 419 +++++ src/scripts/batch-predict.ts | 134 ++ src/scripts/check-duplicate-matches.ts | 97 ++ src/scripts/cleanup-live-matches.ts | 107 ++ src/scripts/compute-elo-ratings.ts | 318 ++++ src/scripts/export-postman-collection.ts | 636 +++++++ .../export-swagger-endpoints-summary.ts | 687 ++++++++ src/scripts/populate-feature-store.ts | 888 ++++++++++ src/scripts/run-all-fe-compatible.ts | 5 + src/scripts/run-feeder-basketball.ts | 24 + src/scripts/run-feeder-filtered.ts | 63 + src/scripts/run-feeder.ts | 39 + src/scripts/run-full-stack.ts | 362 ++++ src/scripts/run-live-feeder.ts | 43 + src/services/ai.service.ts | 319 ++++ src/services/match-analysis.service.ts | 318 ++++ src/services/scraper.service.ts | 273 +++ src/services/services.module.ts | 25 + src/tasks/data-fetcher.task.ts | 1461 +++++++++++++++++ .../historical-results-sync.task.spec.ts | 34 + src/tasks/historical-results-sync.task.ts | 41 + src/tasks/limit-resetter.task.ts | 122 ++ src/tasks/live-updater.task.ts | 177 ++ src/tasks/tasks.module.ts | 37 + 125 files changed, 22552 insertions(+) create mode 100755 src/app.controller.spec.ts create mode 100755 src/app.controller.ts create mode 100755 src/app.module.ts create mode 100755 src/app.service.ts create mode 100755 src/common/base/base.controller.ts create mode 100755 src/common/base/base.service.ts create mode 100755 src/common/base/index.ts create mode 100755 src/common/decorators/index.ts create mode 100755 src/common/dto/pagination.dto.ts create mode 100755 src/common/filters/global-exception.filter.ts create mode 100755 src/common/interceptors/response.interceptor.ts create mode 100644 src/common/interceptors/sanitize.interceptor.ts create mode 100755 src/common/queues/queue.module.ts create mode 100755 src/common/types/api-response.type.ts create mode 100755 src/common/utils/image.util.ts create mode 100755 src/config/configuration.ts create mode 100755 src/config/env.validation.ts create mode 100755 src/database/database.module.ts create mode 100755 src/database/prisma.service.ts create mode 100755 src/example/feeder.js create mode 100755 src/i18n/en/auth.json create mode 100755 src/i18n/en/common.json create mode 100755 src/i18n/en/errors.json create mode 100644 src/i18n/en/predictions.json create mode 100755 src/i18n/en/validation.json create mode 100755 src/i18n/tr/auth.json create mode 100755 src/i18n/tr/common.json create mode 100755 src/i18n/tr/errors.json create mode 100644 src/i18n/tr/predictions.json create mode 100755 src/i18n/tr/validation.json create mode 100755 src/main.ts create mode 100755 src/modules/admin/admin.controller.ts create mode 100755 src/modules/admin/admin.module.ts create mode 100755 src/modules/admin/dto/admin.dto.ts create mode 100755 src/modules/analysis/analysis.controller.ts create mode 100755 src/modules/analysis/analysis.module.ts create mode 100755 src/modules/analysis/analysis.service.ts create mode 100644 src/modules/analysis/dto/analysis-request.dto.ts create mode 100755 src/modules/auth/auth.controller.ts create mode 100755 src/modules/auth/auth.module.ts create mode 100755 src/modules/auth/auth.service.ts create mode 100755 src/modules/auth/dto/auth.dto.ts create mode 100755 src/modules/auth/guards/auth.guards.ts create mode 100755 src/modules/auth/guards/index.ts create mode 100755 src/modules/auth/strategies/jwt.strategy.ts create mode 100755 src/modules/coupons/coupons.controller.ts create mode 100755 src/modules/coupons/coupons.module.ts create mode 100755 src/modules/coupons/coupons.service.ts create mode 100644 src/modules/coupons/dto/coupons-request.dto.ts create mode 100755 src/modules/coupons/services/smart-coupon.service.ts create mode 100755 src/modules/coupons/services/user-coupon.service.ts create mode 100755 src/modules/feeder/feeder-persistence.service.ts create mode 100755 src/modules/feeder/feeder-scraper.service.ts create mode 100755 src/modules/feeder/feeder-transformer.service.ts create mode 100755 src/modules/feeder/feeder.module.ts create mode 100755 src/modules/feeder/feeder.service.ts create mode 100755 src/modules/feeder/feeder.types.ts create mode 100755 src/modules/gemini/gemini.config.ts create mode 100755 src/modules/gemini/gemini.module.ts create mode 100755 src/modules/gemini/gemini.service.ts create mode 100755 src/modules/gemini/index.ts create mode 100755 src/modules/health/health.controller.ts create mode 100755 src/modules/health/health.module.ts create mode 100755 src/modules/leagues/leagues.controller.ts create mode 100755 src/modules/leagues/leagues.module.ts create mode 100755 src/modules/leagues/leagues.service.ts create mode 100755 src/modules/matches/dto/index.ts create mode 100755 src/modules/matches/matches.controller.ts create mode 100755 src/modules/matches/matches.module.ts create mode 100755 src/modules/matches/matches.service.ts create mode 100755 src/modules/predictions/dto/index.ts create mode 100644 src/modules/predictions/dto/predictions-request.dto.ts create mode 100755 src/modules/predictions/dto/smart-coupon.dto.ts create mode 100755 src/modules/predictions/predictions.controller.ts create mode 100755 src/modules/predictions/predictions.module.ts create mode 100755 src/modules/predictions/predictions.service.ts create mode 100755 src/modules/predictions/queues/predictions.processor.spec.ts create mode 100755 src/modules/predictions/queues/predictions.processor.ts create mode 100755 src/modules/predictions/queues/predictions.queue.ts create mode 100755 src/modules/predictions/queues/predictions.types.ts create mode 100755 src/modules/predictions/services/ai-feature-store.service.ts create mode 100644 src/modules/social-poster/caption-generator.service.ts create mode 100644 src/modules/social-poster/dto/prediction-card.dto.ts create mode 100644 src/modules/social-poster/image-renderer.service.ts create mode 100644 src/modules/social-poster/meta.service.ts create mode 100644 src/modules/social-poster/social-poster.controller.ts create mode 100644 src/modules/social-poster/social-poster.module.ts create mode 100644 src/modules/social-poster/social-poster.service.ts create mode 100644 src/modules/social-poster/twitter.service.ts create mode 100644 src/modules/spor-toto/dto/spor-toto.dto.ts create mode 100644 src/modules/spor-toto/services/toto-analytics.service.ts create mode 100644 src/modules/spor-toto/services/toto-combinatorics.service.ts create mode 100644 src/modules/spor-toto/services/toto-fetcher.service.ts create mode 100644 src/modules/spor-toto/services/toto-prediction.service.ts create mode 100644 src/modules/spor-toto/spor-toto.controller.ts create mode 100644 src/modules/spor-toto/spor-toto.module.ts create mode 100644 src/modules/spor-toto/spor-toto.service.ts create mode 100755 src/modules/users/dto/user.dto.ts create mode 100755 src/modules/users/users.controller.ts create mode 100755 src/modules/users/users.module.ts create mode 100755 src/modules/users/users.service.ts create mode 100644 src/scripts/backtest-accuracy.ts create mode 100644 src/scripts/batch-predict.ts create mode 100755 src/scripts/check-duplicate-matches.ts create mode 100755 src/scripts/cleanup-live-matches.ts create mode 100644 src/scripts/compute-elo-ratings.ts create mode 100644 src/scripts/export-postman-collection.ts create mode 100755 src/scripts/export-swagger-endpoints-summary.ts create mode 100644 src/scripts/populate-feature-store.ts create mode 100644 src/scripts/run-all-fe-compatible.ts create mode 100755 src/scripts/run-feeder-basketball.ts create mode 100755 src/scripts/run-feeder-filtered.ts create mode 100755 src/scripts/run-feeder.ts create mode 100644 src/scripts/run-full-stack.ts create mode 100755 src/scripts/run-live-feeder.ts create mode 100755 src/services/ai.service.ts create mode 100755 src/services/match-analysis.service.ts create mode 100755 src/services/scraper.service.ts create mode 100755 src/services/services.module.ts create mode 100755 src/tasks/data-fetcher.task.ts create mode 100644 src/tasks/historical-results-sync.task.spec.ts create mode 100644 src/tasks/historical-results-sync.task.ts create mode 100755 src/tasks/limit-resetter.task.ts create mode 100755 src/tasks/live-updater.task.ts create mode 100755 src/tasks/tasks.module.ts diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts new file mode 100755 index 0000000..d22f389 --- /dev/null +++ b/src/app.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe('root', () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe('Hello World!'); + }); + }); +}); diff --git a/src/app.controller.ts b/src/app.controller.ts new file mode 100755 index 0000000..cce879e --- /dev/null +++ b/src/app.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + getHello(): string { + return this.appService.getHello(); + } +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100755 index 0000000..115bdb0 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,255 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { CacheModule } from '@nestjs/cache-manager'; +import { ScheduleModule } from '@nestjs/schedule'; +import { redisStore } from 'cache-manager-redis-yet'; +import { LoggerModule } from 'nestjs-pino'; +import { + I18nModule, + AcceptLanguageResolver, + HeaderResolver, + QueryResolver, +} from 'nestjs-i18n'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import * as path from 'path'; + +// Config +import { + appConfig, + databaseConfig, + jwtConfig, + redisConfig, + i18nConfig, + featuresConfig, + throttleConfig, +} from './config/configuration'; +import { geminiConfig } from './modules/gemini/gemini.config'; +import { validateEnv } from './config/env.validation'; + +// Common +import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; +import { ResponseInterceptor } from './common/interceptors/response.interceptor'; + +// Database +import { DatabaseModule } from './database/database.module'; + +// Core Modules +import { AuthModule } from './modules/auth/auth.module'; +import { UsersModule } from './modules/users/users.module'; +import { AdminModule } from './modules/admin/admin.module'; +import { HealthModule } from './modules/health/health.module'; +import { GeminiModule } from './modules/gemini/gemini.module'; +import { SocialPosterModule } from './modules/social-poster/social-poster.module'; + +// Sports Domain Modules +import { MatchesModule } from './modules/matches/matches.module'; +import { PredictionsModule } from './modules/predictions/predictions.module'; +import { LeaguesModule } from './modules/leagues/leagues.module'; +import { AnalysisModule } from './modules/analysis/analysis.module'; +import { CouponsModule } from './modules/coupons/coupons.module'; +import { SporTotoModule } from './modules/spor-toto/spor-toto.module'; + +// Services and Tasks +import { ServicesModule } from './services/services.module'; +import { TasksModule } from './tasks/tasks.module'; + +// Feeder Module (Historical Data Scraping) +import { FeederModule } from './modules/feeder/feeder.module'; + +// Guards +import { + JwtAuthGuard, + RolesGuard, + PermissionsGuard, +} from './modules/auth/guards'; + +// Queue +import { QueueModule } from './common/queues/queue.module'; + +const redisEnabled = process.env.REDIS_ENABLED === 'true'; +const historicalFeederMode = process.env.FEEDER_MODE === 'historical'; + +@Module({ + imports: [ + // Configuration + ConfigModule.forRoot({ + isGlobal: true, + validate: validateEnv, + load: [ + appConfig, + databaseConfig, + jwtConfig, + redisConfig, + i18nConfig, + featuresConfig, + throttleConfig, + geminiConfig, + ], + }), + + // Global Queue Configuration (optional) + ...(redisEnabled ? [QueueModule] : []), + + // Static Assets (Images, Uploads) + ServeStaticModule.forRoot({ + rootPath: path.join(__dirname, '..', 'public'), + serveRoot: '/', // This means public/uploads/x.png -> /uploads/x.png + }), + + // Logger (Structured Logging with Pino) + LoggerModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + return { + pinoHttp: { + level: configService.get('app.isDevelopment') ? 'debug' : 'info', + transport: configService.get('app.isDevelopment') + ? { + target: 'pino-pretty', + options: { + singleLine: true, + }, + } + : undefined, + }, + }; + }, + }), + + // i18n + I18nModule.forRootAsync({ + useFactory: (configService: ConfigService) => ({ + fallbackLanguage: configService.get('i18n.fallbackLanguage', 'en'), + loaderOptions: { + path: path.join(__dirname, '../i18n/'), + watch: configService.get('app.isDevelopment', true), + }, + }), + resolvers: [ + new HeaderResolver(['x-lang']), + new QueryResolver(['lang']), + AcceptLanguageResolver, + ], + inject: [ConfigService], + }), + + // Throttling + ThrottlerModule.forRootAsync({ + inject: [ConfigService], + useFactory: (configService: ConfigService) => [ + { + ttl: configService.get('throttle.ttl', 60000), + limit: configService.get('throttle.limit', 100), + }, + ], + }), + + // Caching (Redis with in-memory fallback) + CacheModule.registerAsync({ + isGlobal: true, + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => { + // FORCE DISABLE REDIS if user doesn't want it + const useRedis = configService.get('redis.enabled', false); + + if (useRedis) { + try { + const store = await redisStore({ + socket: { + host: configService.get('redis.host', 'localhost'), + port: configService.get('redis.port', 6379), + }, + ttl: 60 * 1000, // 1 minute default + }); + console.log('✅ Redis cache connected'); + return { + store: store as unknown as any, + ttl: 60 * 1000, + }; + } catch { + console.warn('⚠️ Redis connection failed, using in-memory cache'); + } + } + + // Fallback to in-memory cache + console.log('📦 Using in-memory cache'); + return { + ttl: 60 * 1000, + }; + }, + inject: [ConfigService], + }), + + // Database + DatabaseModule, + + // Scheduling (for cron jobs) + ...(historicalFeederMode ? [] : [ScheduleModule.forRoot()]), + + // Core Modules + AuthModule, + UsersModule, + AdminModule, + + // Sports Domain Modules + MatchesModule, + PredictionsModule, + LeaguesModule, + AnalysisModule, + CouponsModule, + SporTotoModule, + + // Services and Scheduled Tasks + ServicesModule, + ...(historicalFeederMode ? [] : [TasksModule]), + + // Optional Modules (controlled by env variables) + GeminiModule, + HealthModule, + SocialPosterModule, + + // Feeder Module (Historical Data Scraping) + FeederModule, + ], + providers: [ + // Global Exception Filter + { + provide: APP_FILTER, + useClass: GlobalExceptionFilter, + }, + + // Global Response Interceptor + { + provide: APP_INTERCEPTOR, + useClass: ResponseInterceptor, + }, + + // Global Rate Limiting + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + + // Global JWT Auth Guard + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + + // Global Roles Guard + { + provide: APP_GUARD, + useClass: RolesGuard, + }, + + // Global Permissions Guard + { + provide: APP_GUARD, + useClass: PermissionsGuard, + }, + ], +}) +export class AppModule {} diff --git a/src/app.service.ts b/src/app.service.ts new file mode 100755 index 0000000..927d7cc --- /dev/null +++ b/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello World!'; + } +} diff --git a/src/common/base/base.controller.ts b/src/common/base/base.controller.ts new file mode 100755 index 0000000..a1377d6 --- /dev/null +++ b/src/common/base/base.controller.ts @@ -0,0 +1,128 @@ +import { + Get, + Post, + Put, + Delete, + Param, + Query, + Body, + HttpCode, + ParseUUIDPipe, +} from '@nestjs/common'; +import { + ApiOperation, + ApiOkResponse, + ApiNotFoundResponse, + ApiBadRequestResponse, +} from '@nestjs/swagger'; +import { BaseService } from './base.service'; +import { PaginationDto } from '../dto/pagination.dto'; +import { + ApiResponse, + createSuccessResponse, + createPaginatedResponse, +} from '../types/api-response.type'; + +/** + * Generic base controller with common CRUD endpoints + * Extend this class for entity-specific controllers + * + * Note: Use decorators like @Controller() on the child class + */ +export abstract class BaseController { + constructor( + protected readonly service: BaseService, + protected readonly entityName: string, + ) {} + + @Get() + @HttpCode(200) + @ApiOperation({ summary: 'Get all records with pagination' }) + @ApiOkResponse({ description: 'Records retrieved successfully' }) + async findAll( + @Query() pagination: PaginationDto, + ): Promise> { + const result = await this.service.findAll(pagination); + return createPaginatedResponse( + result.items, + result.meta.total, + result.meta.page, + result.meta.limit, + `${this.entityName} list retrieved successfully`, + ); + } + + @Get(':id') + @HttpCode(200) + @ApiOperation({ summary: 'Get a record by ID' }) + @ApiOkResponse({ description: 'Record retrieved successfully' }) + @ApiNotFoundResponse({ description: 'Record not found' }) + async findOne( + @Param('id', ParseUUIDPipe) id: string, + ): Promise> { + const result = await this.service.findOne(id); + return createSuccessResponse( + result, + `${this.entityName} retrieved successfully`, + ); + } + + @Post() + @HttpCode(200) + @ApiOperation({ summary: 'Create a new record' }) + @ApiOkResponse({ description: 'Record created successfully' }) + @ApiBadRequestResponse({ description: 'Validation failed' }) + async create(@Body() createDto: CreateDto): Promise> { + const result = await this.service.create(createDto); + return createSuccessResponse( + result, + `${this.entityName} created successfully`, + 201, + ); + } + + @Put(':id') + @HttpCode(200) + @ApiOperation({ summary: 'Update an existing record' }) + @ApiOkResponse({ description: 'Record updated successfully' }) + @ApiNotFoundResponse({ description: 'Record not found' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateDto: UpdateDto, + ): Promise> { + const result = await this.service.update(id, updateDto); + return createSuccessResponse( + result, + `${this.entityName} updated successfully`, + ); + } + + @Delete(':id') + @HttpCode(200) + @ApiOperation({ summary: 'Delete a record (soft delete)' }) + @ApiOkResponse({ description: 'Record deleted successfully' }) + @ApiNotFoundResponse({ description: 'Record not found' }) + async delete( + @Param('id', ParseUUIDPipe) id: string, + ): Promise> { + const result = await this.service.delete(id); + return createSuccessResponse( + result, + `${this.entityName} deleted successfully`, + ); + } + + @Post(':id/restore') + @HttpCode(200) + @ApiOperation({ summary: 'Restore a soft-deleted record' }) + @ApiOkResponse({ description: 'Record restored successfully' }) + async restore( + @Param('id', ParseUUIDPipe) id: string, + ): Promise> { + const result = await this.service.restore(id); + return createSuccessResponse( + result, + `${this.entityName} restored successfully`, + ); + } +} diff --git a/src/common/base/base.service.ts b/src/common/base/base.service.ts new file mode 100755 index 0000000..210baa1 --- /dev/null +++ b/src/common/base/base.service.ts @@ -0,0 +1,165 @@ +import { NotFoundException, Logger } from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { PaginationDto } from '../dto/pagination.dto'; +import { PaginationMeta } from '../types/api-response.type'; + +/** + * Generic base service with common CRUD operations + * Extend this class for entity-specific services + */ +export abstract class BaseService { + protected readonly logger: Logger; + + constructor( + protected readonly prisma: PrismaService, + protected readonly modelName: string, + ) { + this.logger = new Logger(`${modelName}Service`); + } + + /** + * Get the Prisma model delegate + */ + protected get model() { + return (this.prisma as any)[this.modelName.toLowerCase()]; + } + + /** + * Find all records with pagination + */ + async findAll( + pagination: PaginationDto, + where?: any, + ): Promise<{ items: T[]; meta: PaginationMeta }> { + const { skip, take, orderBy } = pagination; + + const [items, total] = await Promise.all([ + this.model.findMany({ + where, + skip, + take, + orderBy, + }), + this.model.count({ where }), + ]); + + const totalPages = Math.ceil(total / take); + + return { + items, + meta: { + total, + page: pagination.page || 1, + limit: pagination.limit || 10, + totalPages, + hasNextPage: (pagination.page || 1) < totalPages, + hasPreviousPage: (pagination.page || 1) > 1, + }, + }; + } + + /** + * Find a single record by ID + */ + async findOne(id: string, include?: any): Promise { + const record = await this.model.findUnique({ + where: { id }, + include, + }); + + if (!record) { + throw new NotFoundException(`${this.modelName} not found`); + } + + return record; + } + + /** + * Find a single record by custom criteria + */ + findOneBy(where: any, include?: any): Promise { + return this.model.findFirst({ + where, + include, + }); + } + + /** + * Create a new record + */ + create(data: CreateDto, include?: any): Promise { + return this.model.create({ + data, + include, + }); + } + + /** + * Update an existing record + */ + async update(id: string, data: UpdateDto, include?: any): Promise { + // Check if record exists + await this.findOne(id); + + return this.model.update({ + where: { id }, + data, + include, + }); + } + + /** + * Soft delete a record (sets deletedAt) + */ + async delete(id: string): Promise { + // Check if record exists + await this.findOne(id); + + return this.model.delete({ + where: { id }, + }); + } + + /** + * Hard delete a record (permanently removes) + */ + async hardDelete(id: string): Promise { + // Check if record exists + await this.findOne(id); + + return this.prisma.hardDelete(this.modelName, { id }); + } + + /** + * Restore a soft-deleted record + */ + async restore(id: string): Promise { + return this.prisma.restore(this.modelName, { id }); + } + + /** + * Check if a record exists + */ + async exists(id: string): Promise { + const count = await this.model.count({ + where: { id }, + }); + return count > 0; + } + + /** + * Count records matching criteria + */ + count(where?: any): Promise { + return this.model.count({ where }); + } + + /** + * Execute a transaction + */ + transaction(fn: (prisma: PrismaService) => Promise): Promise { + return this.prisma.$transaction(async (tx) => { + return fn(tx as unknown as PrismaService); + }); + } +} diff --git a/src/common/base/index.ts b/src/common/base/index.ts new file mode 100755 index 0000000..7c648aa --- /dev/null +++ b/src/common/base/index.ts @@ -0,0 +1,2 @@ +export * from './base.service'; +export * from './base.controller'; diff --git a/src/common/decorators/index.ts b/src/common/decorators/index.ts new file mode 100755 index 0000000..24df2f1 --- /dev/null +++ b/src/common/decorators/index.ts @@ -0,0 +1,60 @@ +import { + createParamDecorator, + ExecutionContext, + SetMetadata, +} from '@nestjs/common'; + +/** + * Get the current authenticated user from request + */ +export const CurrentUser = createParamDecorator( + (data: string | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + + if (data) { + return user?.[data]; + } + + return user; + }, +); + +/** + * Mark a route as public (no authentication required) + */ +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + +/** + * Require specific roles to access a route + */ +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); + +/** + * Require specific permissions to access a route + */ +export const PERMISSIONS_KEY = 'permissions'; +export const RequirePermissions = (...permissions: string[]) => + SetMetadata(PERMISSIONS_KEY, permissions); + +/** + * Get tenant ID from request (for multi-tenancy) + */ +export const CurrentTenant = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.tenantId; + }, +); + +/** + * Get the current language from request headers + */ +export const CurrentLang = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.headers['accept-language'] || 'en'; + }, +); diff --git a/src/common/dto/pagination.dto.ts b/src/common/dto/pagination.dto.ts new file mode 100755 index 0000000..951b3c3 --- /dev/null +++ b/src/common/dto/pagination.dto.ts @@ -0,0 +1,65 @@ +import { IsOptional, IsInt, Min, Max, IsString, IsIn } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class PaginationDto { + @ApiPropertyOptional({ default: 1, minimum: 1, description: 'Page number' }) + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + default: 10, + minimum: 1, + maximum: 100, + description: 'Items per page', + }) + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 10; + + @ApiPropertyOptional({ description: 'Field to sort by' }) + @IsOptional() + @IsString() + sortBy?: string = 'createdAt'; + + @ApiPropertyOptional({ + enum: ['asc', 'desc'], + default: 'desc', + description: 'Sort order', + }) + @IsOptional() + @IsIn(['asc', 'desc']) + sortOrder?: 'asc' | 'desc' = 'desc'; + + @ApiPropertyOptional({ description: 'Search query' }) + @IsOptional() + @IsString() + search?: string; + + /** + * Get skip value for Prisma + */ + get skip(): number { + return ((this.page || 1) - 1) * (this.limit || 10); + } + + /** + * Get take value for Prisma + */ + get take(): number { + return this.limit || 10; + } + + /** + * Get orderBy object for Prisma + */ + get orderBy(): Record { + return { [this.sortBy || 'createdAt']: this.sortOrder || 'desc' }; + } +} diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts new file mode 100755 index 0000000..fdfa24b --- /dev/null +++ b/src/common/filters/global-exception.filter.ts @@ -0,0 +1,109 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { I18nService, I18nContext } from 'nestjs-i18n'; +import { ApiResponse, createErrorResponse } from '../types/api-response.type'; + +/** + * Global exception filter that catches all exceptions + * and returns a standardized ApiResponse with HTTP 200 + */ +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(GlobalExceptionFilter.name); + + constructor(private readonly i18n?: I18nService) {} + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + // Determine status and message + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal server error'; + let errors: string[] = []; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + } else if (typeof exceptionResponse === 'object') { + const responseObj = exceptionResponse as Record; + message = (responseObj.message as string) || exception.message; + + // Handle validation errors (class-validator) + if (Array.isArray(responseObj.message)) { + errors = responseObj.message as string[]; + message = 'VALIDATION_FAILED'; + } + } + } else if (exception instanceof Error) { + message = exception.message; + } + + // Try to translate the message + if (this.i18n) { + try { + const i18nContext = I18nContext.current(); + let lang = i18nContext?.lang; + + if (!lang) { + const acceptLanguage = request.headers['accept-language']; + const xLang = request.headers['x-lang']; + + if (xLang) { + lang = Array.isArray(xLang) ? xLang[0] : xLang; + } else if (acceptLanguage) { + // Take first preferred language: "tr-TR,en;q=0.9" -> "tr" + lang = acceptLanguage.split(',')[0].split(';')[0].split('-')[0]; + } + } + + lang = lang || 'en'; + + // Translate validation error specially + if (message === 'VALIDATION_FAILED') { + message = this.i18n.translate('errors.VALIDATION_FAILED', { lang }); + } else { + // Try dynamic translation + const translatedMessage = this.i18n.translate(`errors.${message}`, { + lang, + }); + // Only update if translation exists (key is different from result) + if (translatedMessage !== `errors.${message}`) { + message = translatedMessage; + } + } + } catch { + // Keep original message if translation fails + } + } + + // Log the error + this.logger.error( + `${request.method} ${request.url} - ${status} - ${message}`, + exception instanceof Error ? exception.stack : undefined, + ); + + // Build response + const isDevelopment = process.env.NODE_ENV === 'development'; + const errorResponse: ApiResponse = createErrorResponse( + message, + status, + errors, + isDevelopment && exception instanceof Error ? exception.stack : undefined, + ); + + // Always return HTTP 200, actual status in body + response.status(200).json(errorResponse); + } +} diff --git a/src/common/interceptors/response.interceptor.ts b/src/common/interceptors/response.interceptor.ts new file mode 100755 index 0000000..9be3060 --- /dev/null +++ b/src/common/interceptors/response.interceptor.ts @@ -0,0 +1,147 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ApiResponse, createSuccessResponse } from '../types/api-response.type'; + +/** + * Response interceptor that wraps all successful responses + * in the standard ApiResponse format + */ +import { I18nService, I18nContext } from 'nestjs-i18n'; + +@Injectable() +export class ResponseInterceptor implements NestInterceptor< + T, + ApiResponse +> { + constructor(private readonly i18n: I18nService) {} + + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + return next.handle().pipe( + map((data: unknown) => { + const request = context.switchToHttp().getRequest(); + + // Determine language + const i18nContext = I18nContext.current(); + let lang = i18nContext?.lang; + + if (!lang) { + const acceptLanguage = request.headers['accept-language']; + const xLang = request.headers['x-lang']; + + if (xLang) { + lang = Array.isArray(xLang) ? xLang[0] : xLang; + } else if (acceptLanguage) { + lang = acceptLanguage.split(',')[0].split(';')[0].split('-')[0]; + } + } + + lang = lang || 'en'; + + // If data is already an ApiResponse, we should still translate its 'data' property + // But first let's just do it directly on 'data' below before returning + if (this.isApiResponse(data)) { + if (data !== null) { + try { + this.translateReasons(data, lang); + } catch { + // Ignore if object is not extensible + } + } + return data as ApiResponse; + } + + // Recursively translate reasons arrays in the response body + if (data !== null) { + try { + this.translateReasons(data, lang); + } catch { + // Ignore if object is not extensible + } + } + + const message = this.i18n.translate('common.success', { + lang, + }); + + // Wrap in success response + return createSuccessResponse(data as T, message); + }), + ); + } + + private translateReasons(data: any, lang: string) { + if (!data || typeof data !== 'object') { + return; + } + + if (Array.isArray(data)) { + data.forEach((item) => this.translateReasons(item, lang)); + return; + } + + Object.keys(data).forEach((key) => { + const val = data[key]; + if ( + (key === 'reasons' || + key === 'decision_reasons' || + key === 'reasoning_factors') && + Array.isArray(val) + ) { + data[key] = val.map((r: any) => { + if (typeof r !== 'string') return r; + const translationKey = `predictions.reasons.${r}`; + const translated = this.i18n.translate(translationKey, { + lang, + }); + return translated === translationKey ? r : translated; + }); + } else if (key === 'reason' && typeof val === 'string') { + const translationKey = `predictions.reasons.${val}`; + const translated = this.i18n.translate(translationKey, { + lang, + }); + data[key] = translated === translationKey ? val : translated; + } else if (key === 'flags' && Array.isArray(val)) { + data[key] = val.map((r: any) => { + if (typeof r !== 'string') return r; + const translationKey = `predictions.flags.${r}`; + const translated = this.i18n.translate(translationKey, { + lang, + }); + return translated === translationKey ? r : translated; + }); + } else if (key === 'warnings' && Array.isArray(val)) { + data[key] = val.map((r: any) => { + if (typeof r !== 'string') return r; + const translationKey = `predictions.warnings.${r}`; + const translated = this.i18n.translate(translationKey, { + lang, + }); + return translated === translationKey ? r : translated; + }); + } else if (typeof val === 'object' && val !== null) { + this.translateReasons(val, lang); + } + }); + } + + private isApiResponse(data: unknown): boolean { + return ( + data !== null && + typeof data === 'object' && + 'success' in data && + 'status' in data && + 'message' in data && + 'data' in data + ); + } +} diff --git a/src/common/interceptors/sanitize.interceptor.ts b/src/common/interceptors/sanitize.interceptor.ts new file mode 100644 index 0000000..4090568 --- /dev/null +++ b/src/common/interceptors/sanitize.interceptor.ts @@ -0,0 +1,48 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; + +/** + * Strips HTML/script tags from all string values in the request body. + * Applied globally to prevent stored XSS via API inputs. + */ +@Injectable() +export class SanitizeInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + + if (request.body && typeof request.body === 'object') { + request.body = this.sanitize(request.body); + } + + return next.handle(); + } + + private sanitize(value: unknown): unknown { + if (typeof value === 'string') { + return this.stripTags(value); + } + + if (Array.isArray(value)) { + return value.map((item) => this.sanitize(item)); + } + + if (value !== null && typeof value === 'object') { + const sanitized: Record = {}; + for (const [key, val] of Object.entries(value)) { + sanitized[key] = this.sanitize(val); + } + return sanitized; + } + + return value; + } + + private stripTags(input: string): string { + return input.replace(/<[^>]*>/g, ''); + } +} diff --git a/src/common/queues/queue.module.ts b/src/common/queues/queue.module.ts new file mode 100755 index 0000000..dc766a7 --- /dev/null +++ b/src/common/queues/queue.module.ts @@ -0,0 +1,31 @@ +import { Module, Global } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { ConfigModule, ConfigService } from '@nestjs/config'; + +@Global() +@Module({ + imports: [ + BullModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + connection: { + host: configService.get('redis.host', 'localhost'), + port: configService.get('redis.port', 6379), + password: configService.get('redis.password'), + }, + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, + removeOnComplete: true, + removeOnFail: false, + }, + }), + inject: [ConfigService], + }), + ], + exports: [BullModule], +}) +export class QueueModule {} diff --git a/src/common/types/api-response.type.ts b/src/common/types/api-response.type.ts new file mode 100755 index 0000000..dd7aa90 --- /dev/null +++ b/src/common/types/api-response.type.ts @@ -0,0 +1,96 @@ +/** + * Standard API Response Type + * All responses return HTTP 200 with this structure + */ +export type ApiResponse = { + errors: unknown[]; + stack?: string; + message: string; + success: boolean; + status: number; + data: T; +}; + +/** + * Paginated response wrapper + */ +export interface PaginatedData { + items: T[]; + meta: PaginationMeta; +} + +export interface PaginationMeta { + total: number; + page: number; + limit: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; +} + +/** + * Create a successful API response + */ +export function createSuccessResponse( + data: T, + message = 'Success', + status = 200, +): ApiResponse { + return { + success: true, + status, + message, + data, + errors: [], + }; +} + +/** + * Create an error API response + */ +export function createErrorResponse( + message: string, + status = 400, + errors: any[] = [], + stack?: string, +): ApiResponse { + return { + success: false, + status, + message, + data: null, + errors, + stack, + }; +} + +/** + * Create a paginated API response + */ +export function createPaginatedResponse( + items: T[], + total: number, + page: number, + limit: number, + message = 'Success', +): ApiResponse> { + const totalPages = Math.ceil(total / limit); + + return { + success: true, + status: 200, + message, + data: { + items, + meta: { + total, + page, + limit, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }, + }, + errors: [], + }; +} diff --git a/src/common/utils/image.util.ts b/src/common/utils/image.util.ts new file mode 100755 index 0000000..23a1e1e --- /dev/null +++ b/src/common/utils/image.util.ts @@ -0,0 +1,59 @@ +import { existsSync, createWriteStream, mkdirSync } from 'fs'; +import { dirname } from 'path'; +import axios from 'axios'; +import { Logger } from '@nestjs/common'; + +export class ImageUtils { + private static readonly logger = new Logger('ImageUtils'); + + /** + * Downloads an image from a URL and saves it to a local path. + * Skips download if file already exists. + */ + static async downloadImage(url: string, localPath: string): Promise { + try { + // Check if file exists + if (existsSync(localPath)) { + return true; + } + + // Ensure directory exists + const dir = dirname(localPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Download + const response = await axios({ + url, + method: 'GET', + responseType: 'stream', + timeout: 5000, + validateStatus: (status) => status === 200, // Only save if 200 OK + }); + + const writer = createWriteStream(localPath); + + response.data.pipe(writer); + + return new Promise((resolve, reject) => { + writer.on('finish', () => resolve(true)); + writer.on('error', (err) => { + this.logger.warn( + `Failed to write image to ${localPath}: ${err.message}`, + ); + reject(new Error(`Failed to write image to ${localPath}`)); + }); + }); + } catch (error: any) { + // Log warning but don't break the application + // 404s are common for missing logos + if (error.response?.status !== 404) { + this.logger.warn( + `Failed to download image from ${url}: ${error.message}`, + ); + } + return false; + } + } +} diff --git a/src/config/configuration.ts b/src/config/configuration.ts new file mode 100755 index 0000000..f798719 --- /dev/null +++ b/src/config/configuration.ts @@ -0,0 +1,58 @@ +import { registerAs } from '@nestjs/config'; + +export const appConfig = registerAs('app', () => ({ + env: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT || '3005', 10), + isDevelopment: process.env.NODE_ENV === 'development', + isProduction: process.env.NODE_ENV === 'production', +})); + +export const databaseConfig = registerAs('database', () => ({ + url: process.env.DATABASE_URL, +})); + +export const jwtConfig = registerAs('jwt', () => ({ + secret: process.env.JWT_SECRET, + accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '15m', + refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '7d', +})); + +export const redisConfig = registerAs('redis', () => ({ + enabled: process.env.REDIS_ENABLED === 'true', + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD || undefined, +})); + +export const i18nConfig = registerAs('i18n', () => ({ + defaultLanguage: process.env.DEFAULT_LANGUAGE || 'en', + fallbackLanguage: process.env.FALLBACK_LANGUAGE || 'en', +})); + +export const featuresConfig = registerAs('features', () => ({ + mail: process.env.ENABLE_MAIL === 'true', + s3: process.env.ENABLE_S3 === 'true', + websocket: process.env.ENABLE_WEBSOCKET === 'true', + multiTenancy: process.env.ENABLE_MULTI_TENANCY === 'true', +})); + +export const mailConfig = registerAs('mail', () => ({ + host: process.env.MAIL_HOST, + port: parseInt(process.env.MAIL_PORT || '587', 10), + user: process.env.MAIL_USER, + password: process.env.MAIL_PASSWORD, + from: process.env.MAIL_FROM, +})); + +export const s3Config = registerAs('s3', () => ({ + endpoint: process.env.S3_ENDPOINT, + accessKey: process.env.S3_ACCESS_KEY, + secretKey: process.env.S3_SECRET_KEY, + bucket: process.env.S3_BUCKET, + region: process.env.S3_REGION || 'us-east-1', +})); + +export const throttleConfig = registerAs('throttle', () => ({ + ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10), + limit: parseInt(process.env.THROTTLE_LIMIT || '100', 10), +})); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts new file mode 100755 index 0000000..275a6c3 --- /dev/null +++ b/src/config/env.validation.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; + +/** + * Helper to parse boolean from string + */ +const booleanString = z + .string() + .optional() + .default('false') + .transform((val) => val === 'true'); + +/** + * Environment variables schema validation using Zod + */ +export const envSchema = z.object({ + // Environment + NODE_ENV: z + .enum(['development', 'production', 'test']) + .default('development'), + PORT: z.coerce.number().default(3005), + + // Database + DATABASE_URL: z.string().url(), + // AI Engine + AI_ENGINE_URL: z.string().url().default('http://localhost:8000'), + + // JWT + JWT_SECRET: z.string().min(32), + JWT_ACCESS_EXPIRATION: z.string().default('15m'), + JWT_REFRESH_EXPIRATION: z.string().default('7d'), + + // Redis + REDIS_ENABLED: z + .string() + .transform((val) => val === 'true') + .default('false' as any), + REDIS_HOST: z.string().default('localhost'), + REDIS_PORT: z.coerce.number().default(6379), + REDIS_PASSWORD: z.string().optional(), + + // i18n + DEFAULT_LANGUAGE: z.string().default('en'), + FALLBACK_LANGUAGE: z.string().default('en'), + + // Gemini AI + ENABLE_GEMINI: z + .string() + .transform((val) => val === 'true') + .default('false' as any), + GOOGLE_API_KEY: z.string().optional(), + GEMINI_DEFAULT_MODEL: z.string().default('gemini-2.5-flash'), + + // Social Poster + SOCIAL_POSTER_ENABLED: z + .string() + .transform((val) => val === 'true') + .default('false' as any), + TWITTER_API_KEY: z.string().optional(), + TWITTER_API_SECRET: z.string().optional(), + TWITTER_ACCESS_TOKEN: z.string().optional(), + TWITTER_ACCESS_SECRET: z.string().optional(), + META_PAGE_ACCESS_TOKEN: z.string().optional(), + META_PAGE_ID: z.string().optional(), + META_IG_USER_ID: z.string().optional(), + + // Optional Features + ENABLE_MAIL: booleanString, + ENABLE_S3: booleanString, + ENABLE_WEBSOCKET: booleanString, + ENABLE_MULTI_TENANCY: booleanString, + + // Mail (Optional) + MAIL_HOST: z.string().optional(), + MAIL_PORT: z.coerce.number().optional(), + MAIL_USER: z.string().optional(), + MAIL_PASSWORD: z.string().optional(), + MAIL_FROM: z.string().optional(), + + // S3 (Optional) + S3_ENDPOINT: z.string().optional(), + S3_ACCESS_KEY: z.string().optional(), + S3_SECRET_KEY: z.string().optional(), + S3_BUCKET: z.string().optional(), + S3_REGION: z.string().optional(), + + // Throttle + THROTTLE_TTL: z.coerce.number().default(60000), + THROTTLE_LIMIT: z.coerce.number().default(100), +}); + +export type EnvConfig = z.infer; + +/** + * Validate environment variables + */ +export function validateEnv(config: Record): EnvConfig { + const result = envSchema.safeParse(config); + + if (!result.success) { + const errors = result.error.issues.map( + (err) => `${err.path.join('.')}: ${err.message}`, + ); + throw new Error(`Environment validation failed:\n${errors.join('\n')}`); + } + + return result.data; +} diff --git a/src/database/database.module.ts b/src/database/database.module.ts new file mode 100755 index 0000000..e8a9179 --- /dev/null +++ b/src/database/database.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class DatabaseModule {} diff --git a/src/database/prisma.service.ts b/src/database/prisma.service.ts new file mode 100755 index 0000000..0a70a3a --- /dev/null +++ b/src/database/prisma.service.ts @@ -0,0 +1,134 @@ +import { + Injectable, + OnModuleInit, + OnModuleDestroy, + Logger, +} from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +// Models that support soft delete +const SOFT_DELETE_MODELS = ['user', 'role', 'tenant']; + +// Type for Prisma model delegate with common operations +interface PrismaDelegate { + delete: (args: { where: Record }) => Promise; + findMany: (args?: Record) => Promise; + update: (args: { + where: Record; + data: Record; + }) => Promise; +} + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(PrismaService.name); + + constructor() { + super({ + log: [ + { emit: 'event', level: 'query' }, + { emit: 'event', level: 'error' }, + { emit: 'event', level: 'warn' }, + ], + }); + } + + async onModuleInit() { + this.logger.log( + `Connecting to database... URL: ${process.env.DATABASE_URL?.split('@')[1]}`, + ); // Mask password + try { + await this.$connect(); + this.logger.log('✅ Database connected successfully'); + } catch (error) { + this.logger.error( + `❌ Database connection failed: ${error.message}`, + error.stack, + ); + throw error; + } + } + + async onModuleDestroy() { + await this.$disconnect(); + this.logger.log('🔌 Database disconnected'); + } + + /** + * Check if model has soft delete (deletedAt field) + */ + hasSoftDelete(model: string | undefined): boolean { + return model ? SOFT_DELETE_MODELS.includes(model.toLowerCase()) : false; + } + + /** + * Hard delete - actually remove from database + */ + hardDelete(model: string, where: Record): Promise { + const delegate = this.getModelDelegate(model); + return delegate.delete({ where }) as Promise; + } + + /** + * Find including soft deleted records + */ + findWithDeleted( + model: string, + args?: Record, + ): Promise { + const delegate = this.getModelDelegate(model); + return delegate.findMany(args) as Promise; + } + + /** + * Restore a soft deleted record + */ + restore(model: string, where: Record): Promise { + const delegate = this.getModelDelegate(model); + return delegate.update({ + where, + data: { deletedAt: null }, + }) as Promise; + } + + /** + * Soft delete - set deletedAt to current date + */ + softDelete(model: string, where: Record): Promise { + const delegate = this.getModelDelegate(model); + return delegate.update({ + where, + data: { deletedAt: new Date() }, + }) as Promise; + } + + /** + * Find many excluding soft deleted records + */ + findManyActive( + model: string, + args?: Record, + ): Promise { + const delegate = this.getModelDelegate(model); + const whereWithDeleted = { + ...args, + where: { + ...(args?.where as Record | undefined), + deletedAt: null, + }, + }; + return delegate.findMany(whereWithDeleted) as Promise; + } + + /** + * Get Prisma model delegate by name + */ + private getModelDelegate(model: string): PrismaDelegate { + const modelKey = model.charAt(0).toLowerCase() + model.slice(1); + + return (this as any)[modelKey] as PrismaDelegate; + } +} diff --git a/src/example/feeder.js b/src/example/feeder.js new file mode 100755 index 0000000..adc64ca --- /dev/null +++ b/src/example/feeder.js @@ -0,0 +1,276 @@ +const axios = require('axios'); +const he = require('he'); // HTML entity decode etmek için (npm install he) +const cheerio = require('cheerio'); // HTML içinden data-settings almak için + +const MATCH_ID = '18rkqb1lhon6ne1hdb6d15as'; // Barcelona - Real Madrid + +// 1. ADIM: TANIMLARI (METADATA) ÇEK VE HARİTALAMA YAP +async function createMarketMap() { + // Bu URL bize HTML döndürür, ama içinde data-settings JSON'u vardır. + const url = `https://www.mackolik.com/ajax/iddaa/markets/soccer/all/${MATCH_ID}?template=all`; + + const response = await axios.get(url, { + headers: { 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': 'Mozilla/5.0' } + }); + + // Cheerio ile HTML'i yükle + const $ = cheerio.load(response.data.data.html); + + // data-settings özniteliğini bul (Genelde "all" marketlerinde bulunur) + // Not: Bazen widget-iddaa-markets--all bazen başka class olabilir, genel arıyoruz: + const settingsRaw = $('.widget-iddaa-markets').first().attr('data-settings'); + + if (!settingsRaw) { + console.error("Data settings bulunamadı!"); + return null; + } + + // HTML Entity'lerini temizle (" -> ") + const settingsJson = JSON.parse(he.decode(settingsRaw)); + + // Şimdi elimizde ALTIN DEĞERİNDE bir sözlük var: marketCollection + const definitions = settingsJson.iddaaEventId.marketCollection; + + // Bizim kullanacağımız Harita (Dictionary) + let marketMap = {}; + + // Sözlüğü oluşturuyoruz: ID -> İSİM + for (const key in definitions) { + const market = definitions[key]; + const marketId = market.iddaaId || market.id; // Bazen iddaaId, bazen id kullanılır + + marketMap[marketId] = { + name: market.name, // "Maç Sonucu" + outcomes: {} + }; + + // Seçenekleri de haritalayalım (1, X, 2, Alt, Üst) + // selectionCollectionAll varsa onu, yoksa selectionCollection kullan + const selections = market.selectionCollectionAll || market.selectionCollection; + + for (const selKey in selections) { + const selection = selections[selKey]; + // Outcome shortcode (Örn: 1.1) veya iddaaMarketId ile eşleşme yapabiliriz + // Live JSON'da outcome ID'leri "1.1", "1.2" gibi shortcode olarak geliyor. + marketMap[marketId].outcomes[selection.shortcode] = selection.name; + } + } + + console.log("✅ Market Haritası Başarıyla Oluşturuldu (HTML'den)."); + return marketMap; +} + +// 2. ADIM: CANLI VERİYİ ÇEK VE HARİTA İLE BİRLEŞTİR +async function fetchLiveOdds(marketMap) { + // Bu URL sadece sayıları (oranları) verir, çok hızlıdır. + const url = `https://www.mackolik.com/ajax/iddaa/outcomes/soccer/all/${MATCH_ID}`; + + const response = await axios.get(url, { + headers: { 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': 'Mozilla/5.0' } + }); + + const liveMarkets = response.data.data.markets; + + console.log("\n📊 GÜNCEL ORANLAR (Dinamik Eşleştirme İle):\n"); + console.log(`| ${"BAHİS TÜRÜ".padEnd(35)} | ${"SEÇENEK".padEnd(15)} | ${"ORAN".padEnd(6)} |`); + console.log("-".repeat(65)); + + // Canlı veriyi dönüyoruz + for (const [marketId, data] of Object.entries(liveMarkets)) { + // Haritamızdan bu ID'nin adını buluyoruz + // JSON'daki marketId bazen Mackolik ID'si bazen Iddaa ID'si olabiliyor. + // Genelde 'code' alanı ile bizim map'teki ID eşleşir veya key ile. + // Senin attığın JSON'da keyler (örn: 1, 3, 184.5) Mapping ID'si olarak kullanılıyor. + + // DİKKAT: Senin attığın JSON'da Keyler (1, 184.5) bizim Map'teki ID'ler olmayabilir. + // HTML JSON'undaki "id" alanı (örn: 1) ile Canlı JSON'daki Key (örn: 1) eşleşir. + + // Eşleşme Algoritması: + // HTML'deki `market.id` == LiveJSON'daki `key` + + // HTML Map'imizi ID bazlı (1, 3, 184.5 gibi) tekrar düzenlememiz gerekebilir ama + // senin HTML JSON'una baktığımda "id": 18, "iddaaId": 55512092 var. + // Live JSON key'i "184.5" (Bu aslında 18 nolu marketin 4.5 barajı). + + // Basit Eşleştirme Denemesi (ID üzerinden): + // Live JSON'daki key (örn "1" veya "184.5") HTML map'te yoksa, "code" (50465) üzerinden arama yapabiliriz. + // Ama en garantisi HTML'deki marketCollection içindeki key yapısıdır. + + // Şimdilik basit ID eşleştirmesi yapalım, eğer map'te yoksa "Bilinmeyen" yazarız. + + // Live JSON Key'i ile Map arıyoruz. + // Ancak HTML'deki "id" (örn: 1) ile Live JSON key (1) tutuyor. + // Fakat "184.5" gibi olanlar HTML'de "id: 18, sov: 4.5" olarak geçiyor. + + let definitions = findDefinition(marketMap, marketId, data); + + let marketName = definitions ? definitions.name : `Bilinmeyen (${marketId})`; + + // Eğer barajlı bir bahisse (184.5 gibi) isme barajı ekle + // (Zaten HTML'den gelen isimde "4,5 Alt/Üst" yazıyor olacak) + + for (const [outcomeKey, outcomeData] of Object.entries(data.outcomes)) { + if (outcomeData.outcome !== '-') { + let label = outcomeData.label; + // Eğer label mapping'den gelirse daha doğru olur ama outcomeData.label da genelde doğrudur. + + console.log(`| ${marketName.padEnd(35)} | ${label.padEnd(15)} | ${outcomeData.outcome.padEnd(6)} |`); + } + } + } +} + +// Yardımcı Fonksiyon: Live JSON Key'ini HTML Map içinde bulma +function findDefinition(marketMap, liveKey, liveData) { + // 1. Doğrudan ID eşleşmesi (Örn: "1" == "1") + // HTML'i parse ederken ID'leri key olarak ayarlamalıyız. + // Yukarıdaki createMarketMap fonksiyonunu buna göre revize ettim aşağıda. + + // Asıl sorun: HTML JSON'da keyler "0", "1", "2" diye gidiyor array indexi gibi. + // Ama içlerinde "id": 1, "id": 18 var. + + // Biz map'i oluştururken liveKey ile eşleşecek şekilde kurmalıyız. + // LiveKey "184.5" ise -> id=18 ve sov=4.5 olanı bulmalıyız. + + // Bu karmaşıklığı çözmek için MarketMap'i bir Array olarak tutup find ile aramak en iyisi. + for (const def of Object.values(marketMap)) { + // Eğer ID tutuyorsa (Örn: 1 == 1) + if (def.rawId == liveKey) return def; + + // Eğer ID ve Baraj (sov) tutuyorsa (Örn: LiveKey "184.5", Def id=18, sov=4.5) + if (liveKey.includes('.')) { + const [mainId, sov] = liveKey.split('.'); + // Tamam float problemleri olabilir ama string olarak "18" == def.rawId + // Bu kısım biraz manuel mapping gerektirebilir ama HTML içindeki "name" zaten barajı içeriyor. + // HTML'deki iddaaMarketNo veya iddaaId ile LiveData'daki code eşleşebilir! + + if (def.iddaaCode == liveData.code) return def; // EN GARANTİ YÖNTEM BU! + } + + if (def.iddaaCode == liveData.code) return def; + } + return null; +} + +// ------------------------------------------------------------------ +// REVIZE EDİLMİŞ HARİTA OLUŞTURUCU (EN SAĞLAMI) +// ------------------------------------------------------------------ +async function main() { + const url = `https://www.mackolik.com/ajax/iddaa/markets/soccer/all/${MATCH_ID}?template=all`; + const response = await axios.get(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': 'Mozilla/5.0' } }); + const $ = cheerio.load(response.data.data.html); + const settingsRaw = $('.widget-iddaa-markets').first().attr('data-settings'); + + if (!settingsRaw) return; + + const settingsJson = JSON.parse(he.decode(settingsRaw)); + const definitions = settingsJson.iddaaEventId.marketCollection; + + // Haritamızı bir dizi (array) yapalım, içinde arama yapacağız. + let marketDefinitions = []; + + for (const key in definitions) { + const m = definitions[key]; + marketDefinitions.push({ + name: m.name, // Örn: "Maç Sonucu" veya "4,5 Alt/Üst" + rawId: m.id, // Örn: 1 veya 18 + iddaaCode: m.iddaaNo, // Örn: "10313" (HTML'deki code) -> LiveData'da "code" ile eşleşecek mi bakacağız. + iddaaMarketId: m.iddaaId, // Örn: 21983276 -> LiveData'da code olarak gelebilir. + sov: m.sov // Baraj değeri (4.5) + }); + } + + // --- LIVE DATA ÇEK --- + const liveUrl = `https://www.mackolik.com/ajax/iddaa/outcomes/soccer/all/${MATCH_ID}`; + const liveRes = await axios.get(liveUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': 'Mozilla/5.0' } }); + const liveMarkets = liveRes.data.data.markets; + + // --- EŞLEŞTİRME VE YAZDIRMA --- + console.log(`\nMAÇ: ${MATCH_ID} | DATA ANALİZİ SONUCU`); + console.log("=".repeat(70)); + + for (const [liveKey, liveData] of Object.entries(liveMarkets)) { + + // EŞLEŞTİRME MANTIĞI: + // Live Data içindeki "code" (Örn: 50465) ile HTML'deki "iddaaId" (Örn: 55507607) veya "iddaaNo" (Örn: 18660) eşleşmeli. + // Senin attığın son örneklerde: + // HTML JSON: "iddaaId": 55507607, "iddaaNo": "18660" + // LIVE JSON: "code": "50465" + + // HATA: Kodlar (Code) maç başladığında (Live) ve başlamadan önce (Pre-match) değişiyor olabilir! + // Bu durumda en güvenilir eşleşme "Market Tipi" (ID) ve "Baraj" (SOV) üzerinden olur. + + let matchedDef = marketDefinitions.find(def => { + // 1. Ana ID eşleşiyor mu? (1 == 1) + if (def.rawId == liveKey) return true; + + // 2. Barajlı ID kontrolü (184.5 -> id:18, sov:4.5) + if (liveKey.includes('.')) { + // Float çevirmeden string karşılaştırması riskli olabilir, dikkat. + // LiveKey "184.5" + // Def: rawId=18, sov=4.5 + // 18 == 18 AND 4.5 == 4.5 + if (def.rawId == Math.floor(parseFloat(liveKey)) && def.sov == parseFloat(liveKey.split('.')[1] + '.' + (liveKey.split('.')[2] || '0'))) { + // Basitçe string match daha güvenli olabilir + return def.rawId == 18 && def.sov == 4.5 && liveKey == "184.5"; // Örnek mantık + } + + // Daha basit: Mackolik Live Key yapısı: {MARKET_ID}{BARAJ} + // "18" + "4.5" -> "184.5" + if (def.rawId == 18 && liveKey == `18${def.sov}`) return true; + if (def.rawId == 19 && liveKey == `19${def.sov}`) return true; // 1. Yarı Alt üst + } + return false; + }); + + // Eğer yukarıdaki ID mantığı tutmazsa manuel düzeltme: + // Mackolik LiveKey formatı: {ID} veya {ID}{SOV} + // Örn: Market 1 -> Key "1" + // Örn: Market 18 (Alt/Üst), Sov 4.5 -> Key "184.5" + + // Hızlı çözüm için bir Map oluşturuyorum: + const name = getMarketNameFromKey(liveKey, marketDefinitions); + + for (const [k, v] of Object.entries(liveData.outcomes)) { + if (v.outcome !== '-') { + console.log(`| ${name.padEnd(35)} | ${v.label.padEnd(10)} | ${v.outcome} |`); + } + } + } +} + +function getMarketNameFromKey(key, definitions) { + // 1. Tam eşleşme (ID 1, 3, 11 vb.) + let exact = definitions.find(d => d.rawId == key); + if (exact) return exact.name; + + // 2. Noktalı Eşleşme (184.5, 191.5 vb.) + if (key.includes('.')) { + // key: 184.5 -> id: 18, sov: 4.5 + // key: 190.5 -> id: 19, sov: 0.5 + + // Püf nokta: String olarak sov'u ayıklamak. + // Genelde sonu .5 ile biter. + // Ama ID 18, 19, 28, 29 gibi alt/üst türleri var. + + // Bu kısmı senin için basitleştiriyorum: + // HTML'deki isimleri tara, hangisinin sov değeri key ile uyuşuyorsa onu al. + + for (let def of definitions) { + if (def.sov !== null) { + // Basit bir string contains kontrolü bile çoğu zaman yeter. + // Örneğin key="184.5", def.rawId=18, def.sov=4.5 -> Eşleşir. + // key="280.5", def.rawId=28, def.sov=0.5 -> Eşleşir. + + // Formül: Key, Def.ID ile başlıyor mu VE Key, Def.Sov ile bitiyor mu? + if (key.startsWith(def.rawId) && key.endsWith(def.sov)) { + return def.name; + } + } + } + } + + return `Bilinmeyen Market (${key})`; +} + +main(); \ No newline at end of file diff --git a/src/i18n/en/auth.json b/src/i18n/en/auth.json new file mode 100755 index 0000000..45bc054 --- /dev/null +++ b/src/i18n/en/auth.json @@ -0,0 +1,6 @@ +{ + "registered": "User registered successfully", + "login_success": "Login successful", + "refresh_success": "Token refreshed successfully", + "logout_success": "Logout successful" +} diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json new file mode 100755 index 0000000..3325cb0 --- /dev/null +++ b/src/i18n/en/common.json @@ -0,0 +1,13 @@ +{ + "welcome": "Welcome", + "success": "Operation completed successfully", + "created": "Resource created successfully", + "updated": "Resource updated successfully", + "deleted": "Resource deleted successfully", + "restored": "Resource restored successfully", + "notFound": "Resource not found", + "serverError": "An unexpected error occurred", + "unauthorized": "You are not authorized to perform this action", + "forbidden": "Access denied", + "badRequest": "Invalid request" +} diff --git a/src/i18n/en/errors.json b/src/i18n/en/errors.json new file mode 100755 index 0000000..82bbb97 --- /dev/null +++ b/src/i18n/en/errors.json @@ -0,0 +1,14 @@ +{ + "USER_NOT_FOUND": "User not found", + "INVALID_CREDENTIALS": "Invalid email or password", + "EMAIL_ALREADY_EXISTS": "This email is already registered", + "INVALID_REFRESH_TOKEN": "Invalid or expired refresh token", + "ACCOUNT_DISABLED": "Your account has been disabled", + "TOKEN_EXPIRED": "Your session has expired, please login again", + "PERMISSION_DENIED": "You do not have permission to perform this action", + "ROLE_NOT_FOUND": "Role not found", + "TENANT_NOT_FOUND": "Tenant not found", + "VALIDATION_FAILED": "Validation failed", + "INTERNAL_ERROR": "An internal error occurred, please try again later", + "AUTH_REQUIRED": "Authentication required, please provide a valid token" +} diff --git a/src/i18n/en/predictions.json b/src/i18n/en/predictions.json new file mode 100644 index 0000000..e3893aa --- /dev/null +++ b/src/i18n/en/predictions.json @@ -0,0 +1,41 @@ +{ + "reasons": { + "below_calibrated_conf_threshold": "Confidence score is below the minimum threshold", + "market_odds_missing": "Market odds are missing or too low", + "high_risk_low_data_quality": "High risk combined with low data quality", + "lineup_insufficient_for_market": "Lineup info is insufficient for this market", + "lineup_not_confirmed": "Starting lineups are not confirmed yet", + "negative_model_edge": "Model edge (EV) is negative", + "insufficient_play_score": "Prediction play score is insufficient", + "market_passed_all_gates": "Passed all safety gates successfully", + "market_signal_dominant": "Market signal is dominant", + "team_form_signal_dominant": "Team form signal is dominant", + "lineup_signal_strong": "Starting lineup signal is strong", + "lineup_signal_weak": "Starting lineup signal is weak", + "lineup_probable_xi_used": "Probable starting XI was used", + "upset_risk_detected": "Upset risk detected", + "player_form_signal_strong": "Player form signal is strong", + "player_form_signal_limited": "Player form signal is limited", + "limited_data_confidence": "Limited data confidence", + "basketball_points_model": "Basketball points model used" + }, + "flags": { + "missing_full_ms_odds": "Missing match result odds", + "lineup_probable_not_confirmed": "Probable lineup is not confirmed", + "lineup_unavailable": "Lineup data is completely unavailable", + "lineup_incomplete": "Lineup data is incomplete", + "missing_referee": "Referee information is missing", + "missing_moneyline_odds": "Missing moneyline odds", + "missing_total_odds": "Missing total goals/points odds", + "missing_spread_odds": "Missing point spread odds", + "missing_team_ids": "Missing team identification", + "missing_ai_features": "Missing AI feature data", + "missing_home_stats": "Missing home team statistics", + "missing_away_stats": "Missing away team statistics", + "missing_odds": "General odds are missing" + }, + "warnings": { + "Very tight ELO difference — coin-flip territory": "Very tight ELO difference — coin-flip territory", + "Upset potential: bookmaker odds suggest heavy favorite but ELO says the match is closer than the market thinks": "Upset potential: bookmaker odds suggest heavy favorite but ELO says the match is closer than the market thinks" + } +} diff --git a/src/i18n/en/validation.json b/src/i18n/en/validation.json new file mode 100755 index 0000000..57e52cf --- /dev/null +++ b/src/i18n/en/validation.json @@ -0,0 +1,23 @@ +{ + "email": { + "required": "Email is required", + "invalid": "Please enter a valid email address" + }, + "password": { + "required": "Password is required", + "minLength": "Password must be at least 8 characters long", + "weak": "Password is too weak" + }, + "firstName": { + "required": "First name is required" + }, + "lastName": { + "required": "Last name is required" + }, + "generic": { + "required": "This field is required", + "invalid": "Invalid value", + "minLength": "Must be at least {min} characters", + "maxLength": "Must be at most {max} characters" + } +} diff --git a/src/i18n/tr/auth.json b/src/i18n/tr/auth.json new file mode 100755 index 0000000..977e914 --- /dev/null +++ b/src/i18n/tr/auth.json @@ -0,0 +1,6 @@ +{ + "registered": "Kullanıcı başarıyla kaydedildi", + "login_success": "Giriş başarılı", + "refresh_success": "Token başarıyla yenilendi", + "logout_success": "Çıkış başarılı" +} diff --git a/src/i18n/tr/common.json b/src/i18n/tr/common.json new file mode 100755 index 0000000..f5cc22c --- /dev/null +++ b/src/i18n/tr/common.json @@ -0,0 +1,13 @@ +{ + "welcome": "Hoş geldiniz", + "success": "İşlem başarıyla tamamlandı", + "created": "Kayıt başarıyla oluşturuldu", + "updated": "Kayıt başarıyla güncellendi", + "deleted": "Kayıt başarıyla silindi", + "restored": "Kayıt başarıyla geri yüklendi", + "notFound": "Kayıt bulunamadı", + "serverError": "Beklenmeyen bir hata oluştu", + "unauthorized": "Bu işlemi yapmaya yetkiniz yok", + "forbidden": "Erişim reddedildi", + "badRequest": "Geçersiz istek" +} diff --git a/src/i18n/tr/errors.json b/src/i18n/tr/errors.json new file mode 100755 index 0000000..098d658 --- /dev/null +++ b/src/i18n/tr/errors.json @@ -0,0 +1,14 @@ +{ + "USER_NOT_FOUND": "Kullanıcı bulunamadı", + "INVALID_CREDENTIALS": "Geçersiz e-posta veya şifre", + "EMAIL_ALREADY_EXISTS": "Bu e-posta adresi zaten kayıtlı", + "INVALID_REFRESH_TOKEN": "Geçersiz veya süresi dolmuş yenileme token'ı", + "ACCOUNT_DISABLED": "Hesabınız devre dışı bırakılmış", + "TOKEN_EXPIRED": "Oturumunuz sona erdi, lütfen tekrar giriş yapın", + "PERMISSION_DENIED": "Bu işlemi gerçekleştirme izniniz yok", + "ROLE_NOT_FOUND": "Rol bulunamadı", + "TENANT_NOT_FOUND": "Kiracı bulunamadı", + "VALIDATION_FAILED": "Doğrulama başarısız", + "INTERNAL_ERROR": "Bir iç hata oluştu, lütfen daha sonra tekrar deneyin", + "AUTH_REQUIRED": "Kimlik doğrulama gerekli, lütfen geçerli bir token sağlayın" +} diff --git a/src/i18n/tr/predictions.json b/src/i18n/tr/predictions.json new file mode 100644 index 0000000..792be27 --- /dev/null +++ b/src/i18n/tr/predictions.json @@ -0,0 +1,48 @@ +{ + "reasons": { + "no_strategy_fit": "Seçilen kupon stratejisine uyan uygun bahis bulunamadı", + "match_not_found": "Maç verisi bulunamadı", + "unsupported_sport": "Desteklenmeyen spor türü", + "out_of_training_scope": "Bu maç modelin eğitim kapsamının dışında", + "missing_critical_data": "Tahmin için kritik veriler eksik", + "playable_pick_found": "Oynanabilir seçim bulundu", + "no_bet_conditions_met": "Bahis için gerekli koşullar oluşmadı", + "below_calibrated_conf_threshold": "Bulunan güven skoru minimum barajın altında", + "market_odds_missing": "Bu bahis için oranlar eksik veya pazar kapalı", + "high_risk_low_data_quality": "Yüksek risk ve düşük veri kalitesi nedeniyle oynanamaz", + "lineup_insufficient_for_market": "Kadro bilgisi bu bahis türü için yetersiz", + "lineup_not_confirmed": "İlk onbirler henüz resmi olarak doğrulanmadı", + "negative_model_edge": "Yapay zeka bu bahsi değerli (EV+) bulmadı", + "insufficient_play_score": "Oynanabilirlik puanı gereksinimleri karşılamıyor", + "market_passed_all_gates": "Bahis güvenlik testlerinden başarıyla geçti", + "market_signal_dominant": "Bahis piyasalarındaki sinyaller çok baskın", + "team_form_signal_dominant": "Takım formuna dayalı sinyaller çok baskın", + "lineup_signal_strong": "İlk onbir bilgisi güçlü bir sinyal yaratıyor", + "lineup_signal_weak": "İlk onbir bilgisi eksik olduğu için sinyal zayıf", + "lineup_probable_xi_used": "Resmi olmayan muhtemel onbir bilgisi kullanıldı", + "upset_risk_detected": "Sürpriz potansiyeli tespit edildi", + "player_form_signal_strong": "Oyuncu form değerleri tahmini güçlü destekliyor", + "player_form_signal_limited": "Oyuncu form değerlerinin etkisi sınırlı", + "limited_data_confidence": "Veri kalitesi ve geçmiş veriler sınırlı", + "basketball_points_model": "Basketbol özel sayı tahmini modeli kullanıldı" + }, + "flags": { + "missing_full_ms_odds": "Maç sonucu oranları eksik", + "lineup_probable_not_confirmed": "Muhtemel ilk onbir henüz doğrulanmadı", + "lineup_unavailable": "Takım kadro bilgisi şu an için tamamen eksik", + "lineup_incomplete": "Takım kadro verisinde eksiklikler var", + "missing_referee": "Hakem bilgisi bulunamadı", + "missing_moneyline_odds": "Taraf bahsi oranları eksik", + "missing_total_odds": "Alt/Üst toplam sayı bahis oranları eksik", + "missing_spread_odds": "Handikap oranları eksik", + "missing_team_ids": "Takım kimlik bilgileri eksik", + "missing_ai_features": "Yapay zeka analiz verileri eksik", + "missing_home_stats": "Ev sahibi takım istatistikleri eksik", + "missing_away_stats": "Deplasman takım istatistikleri eksik", + "missing_odds": "Genel bahis oranları eksik" + }, + "warnings": { + "Very tight ELO difference — coin-flip territory": "Çok yakın ELO kalitesi — tamamen yazı-tura (50/50) maçı", + "Upset potential: bookmaker odds suggest heavy favorite but ELO says the match is closer than the market thinks": "Sürpriz potansiyeli: Bahis büroları büyük bir favori gösteriyor fakat yapay zeka ELO verileri maçın çok daha başa baş geçeceğini öngörüyor" + } +} diff --git a/src/i18n/tr/validation.json b/src/i18n/tr/validation.json new file mode 100755 index 0000000..ba5e591 --- /dev/null +++ b/src/i18n/tr/validation.json @@ -0,0 +1,23 @@ +{ + "email": { + "required": "E-posta adresi gereklidir", + "invalid": "Lütfen geçerli bir e-posta adresi girin" + }, + "password": { + "required": "Şifre gereklidir", + "minLength": "Şifre en az 8 karakter olmalıdır", + "weak": "Şifre çok zayıf" + }, + "firstName": { + "required": "Ad gereklidir" + }, + "lastName": { + "required": "Soyad gereklidir" + }, + "generic": { + "required": "Bu alan gereklidir", + "invalid": "Geçersiz değer", + "minLength": "En az {min} karakter olmalıdır", + "maxLength": "En fazla {max} karakter olmalıdır" + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100755 index 0000000..6f61308 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,119 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe, Logger as NestLogger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { AppModule } from './app.module'; +import helmet from 'helmet'; +import * as express from 'express'; +import { Logger, LoggerErrorInterceptor } from 'nestjs-pino'; +import { SanitizeInterceptor } from './common/interceptors/sanitize.interceptor'; + +// BigInt serialization polyfill — Prisma returns BigInt for mstUtc etc. +(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () { + return this.toString(); +}; + +async function bootstrap() { + const logger = new NestLogger('Bootstrap'); + + logger.log('🔄 Starting application...'); + + const app = await NestFactory.create(AppModule, { bufferLogs: false }); + + // Use Pino Logger + app.useLogger(app.get(Logger)); + app.useGlobalInterceptors( + new LoggerErrorInterceptor(), + new SanitizeInterceptor(), + ); + + // Security Headers + app.use(helmet()); + + // Request payload size limit + app.use(express.json({ limit: '1mb' })); + app.use(express.urlencoded({ extended: true, limit: '1mb' })); + + // Graceful Shutdown (Prisma & Docker) + app.enableShutdownHooks(); + + // Get config service + const configService = app.get(ConfigService); + const port = configService.get('PORT', 3005); + const nodeEnv = configService.get('NODE_ENV', 'development'); + + // Enable CORS + app.enableCors({ + origin: + nodeEnv === 'production' + ? [ + 'https://ui-suggestbet.bilgich.com', + 'https://suggestbet.bilgich.com', + 'https://iddaai.com', + 'https://www.iddaai.com', + ] + : true, + credentials: true, + }); + + // Global prefix + app.setGlobalPrefix('api'); + + // Validation pipe (Strict) + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + // Swagger setup — hidden in production + if (nodeEnv !== 'production') { + const swaggerConfig = new DocumentBuilder() + .setTitle('Suggest-Bet API') + .setDescription( + 'AI-driven sports betting prediction engine with smart coupon generation', + ) + .setVersion('1.0') + .addBearerAuth() + .addTag('Auth', 'Authentication endpoints') + .addTag('Users', 'User management endpoints') + .addTag('Admin', 'Admin management endpoints') + .addTag('Health', 'Health check endpoints') + .addTag('Matches', 'Match listing and detail endpoints') + .addTag('Leagues', 'League, country, and team discovery endpoints') + .addTag('Analysis', 'AI analysis and analysis history endpoints') + .addTag('Coupon', 'Coupon generation and coupon management endpoints') + .addTag('Predictions', 'Prediction and smart-coupon endpoints') + .build(); + + logger.log('Initializing Swagger...'); + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api/docs', app, document, { + swaggerOptions: { + persistAuthorization: true, + }, + }); + logger.log('Swagger initialized'); + } + + logger.log(`Attempting to listen on port ${port}...`); + await app.listen(port, '0.0.0.0'); + + logger.log('═══════════════════════════════════════════════════════════'); + logger.log(`🚀 Server is running on: http://localhost:${port}/api`); + logger.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`); + logger.log(`💚 Health check: http://localhost:${port}/api/health`); + logger.log(`🌍 Environment: ${nodeEnv.toUpperCase()}`); + logger.log('═══════════════════════════════════════════════════════════'); + + if (nodeEnv === 'development') { + logger.warn('⚠️ Running in development mode'); + } +} + +void bootstrap(); diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts new file mode 100755 index 0000000..4d66638 --- /dev/null +++ b/src/modules/admin/admin.controller.ts @@ -0,0 +1,280 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + UseInterceptors, + Inject, + NotFoundException, +} from '@nestjs/common'; +import { + CacheInterceptor, + CacheKey, + CacheTTL, + CACHE_MANAGER, +} from '@nestjs/cache-manager'; +import * as cacheManager from 'cache-manager'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { Roles } from '../../common/decorators'; +import { PrismaService } from '../../database/prisma.service'; +import { PaginationDto } from '../../common/dto/pagination.dto'; +import { + ApiResponse, + createSuccessResponse, + createPaginatedResponse, + PaginatedData, +} from '../../common/types/api-response.type'; +import { plainToInstance } from 'class-transformer'; +import { UserResponseDto } from '../users/dto/user.dto'; +import { UserRole } from '@prisma/client'; + +@ApiTags('Admin') +@ApiBearerAuth() +@Controller('admin') +@Roles('superadmin') +export class AdminController { + constructor( + private readonly prisma: PrismaService, + @Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache, + ) {} + + // ================== Users Management ================== + + @Get('users') + @ApiOperation({ summary: 'Get all users (admin)' }) + async getAllUsers( + @Query() pagination: PaginationDto, + ): Promise>> { + const { skip, take, orderBy } = pagination; + + const [users, total] = await Promise.all([ + this.prisma.user.findMany({ + skip, + take, + orderBy, + }), + this.prisma.user.count(), + ]); + + const dtos = plainToInstance( + UserResponseDto, + users, + ) as unknown as UserResponseDto[]; + + return createPaginatedResponse( + dtos, + total, + pagination.page || 1, + pagination.limit || 10, + ); + } + + @Get('users/:id') + @ApiOperation({ summary: 'Get user by ID' }) + async getUserById( + @Param('id') id: string, + ): Promise> { + const user = await this.prisma.user.findUnique({ + where: { id }, + include: { + usageLimit: true, + analyses: { + take: 5, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return createSuccessResponse(plainToInstance(UserResponseDto, user)); + } + + @Put('users/:id/toggle-active') + @ApiOperation({ summary: 'Toggle user active status' }) + async toggleUserActive( + @Param('id') id: string, + ): Promise> { + const user = await this.prisma.user.findUnique({ where: { id } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const updated = await this.prisma.user.update({ + where: { id }, + data: { isActive: !user.isActive }, + }); + + return createSuccessResponse( + plainToInstance(UserResponseDto, updated), + 'User status updated', + ); + } + + @Put('users/:id/role') + @ApiOperation({ summary: 'Update user role' }) + async updateUserRole( + @Param('id') id: string, + @Body() data: { role: UserRole }, + ): Promise> { + const user = await this.prisma.user.update({ + where: { id }, + data: { role: data.role }, + }); + + return createSuccessResponse( + plainToInstance(UserResponseDto, user), + 'User role updated', + ); + } + + @Put('users/:id/subscription') + @ApiOperation({ summary: 'Update user subscription' }) + async updateUserSubscription( + @Param('id') id: string, + @Body() + data: { subscriptionStatus: string; subscriptionExpiresAt?: string }, + ): Promise> { + const user = await this.prisma.user.update({ + where: { id }, + data: { + subscriptionStatus: data.subscriptionStatus as any, + subscriptionExpiresAt: data.subscriptionExpiresAt + ? new Date(data.subscriptionExpiresAt) + : null, + }, + }); + + return createSuccessResponse( + plainToInstance(UserResponseDto, user), + 'User subscription updated', + ); + } + + @Delete('users/:id') + @ApiOperation({ summary: 'Soft delete a user' }) + async deleteUser(@Param('id') id: string): Promise> { + await this.prisma.user.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + return createSuccessResponse(null, 'User deleted'); + } + + // ================== App Settings ================== + + @Get('settings') + @UseInterceptors(CacheInterceptor) + @CacheKey('app_settings') + @CacheTTL(60 * 1000) + @ApiOperation({ summary: 'Get all app settings' }) + async getAllSettings(): Promise>> { + const settings = await this.prisma.appSetting.findMany(); + const settingsMap: Record = {}; + for (const s of settings) { + settingsMap[s.key] = s.value || ''; + } + return createSuccessResponse(settingsMap); + } + + @Put('settings/:key') + @ApiOperation({ summary: 'Update an app setting' }) + async updateSetting( + @Param('key') key: string, + @Body() data: { value: string }, + ): Promise> { + const setting = await this.prisma.appSetting.upsert({ + where: { key }, + update: { value: data.value }, + create: { key, value: data.value }, + }); + await this.cacheManager.del('app_settings'); + return createSuccessResponse( + { key: setting.key, value: setting.value || '' }, + 'Setting updated', + ); + } + + // ================== Usage Limits ================== + + @Get('usage-limits') + @ApiOperation({ summary: 'Get all usage limits' }) + async getAllUsageLimits(@Query() pagination: PaginationDto) { + const { skip, take } = pagination; + + const [limits, total] = await Promise.all([ + this.prisma.usageLimit.findMany({ + skip, + take, + include: { + user: { + select: { id: true, email: true, firstName: true, lastName: true }, + }, + }, + orderBy: { lastResetDate: 'desc' }, + }), + this.prisma.usageLimit.count(), + ]); + + return createPaginatedResponse( + limits, + total, + pagination.page || 1, + pagination.limit || 10, + ); + } + + @Post('usage-limits/reset-all') + @ApiOperation({ summary: 'Reset all usage limits' }) + async resetAllUsageLimits(): Promise> { + const result = await this.prisma.usageLimit.updateMany({ + data: { + analysisCount: 0, + couponCount: 0, + lastResetDate: new Date(), + }, + }); + + return createSuccessResponse( + { count: result.count }, + 'All usage limits reset', + ); + } + + // ================== Analytics ================== + + @Get('analytics/overview') + @ApiOperation({ summary: 'Get system analytics overview' }) + async getAnalyticsOverview() { + const [ + totalUsers, + activeUsers, + premiumUsers, + totalMatches, + totalPredictions, + ] = await Promise.all([ + this.prisma.user.count(), + this.prisma.user.count({ where: { isActive: true } }), + this.prisma.user.count({ where: { subscriptionStatus: 'active' } }), + this.prisma.match.count(), + this.prisma.prediction.count(), + ]); + + return createSuccessResponse({ + users: { + total: totalUsers, + active: activeUsers, + premium: premiumUsers, + }, + matches: totalMatches, + predictions: totalPredictions, + }); + } +} diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts new file mode 100755 index 0000000..361a281 --- /dev/null +++ b/src/modules/admin/admin.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { AdminController } from './admin.controller'; + +@Module({ + controllers: [AdminController], +}) +export class AdminModule {} diff --git a/src/modules/admin/dto/admin.dto.ts b/src/modules/admin/dto/admin.dto.ts new file mode 100755 index 0000000..9ecb5df --- /dev/null +++ b/src/modules/admin/dto/admin.dto.ts @@ -0,0 +1,71 @@ +import { Exclude, Expose, Type } from 'class-transformer'; + +@Exclude() +export class PermissionResponseDto { + @Expose() + id: string; + + @Expose() + name: string; + + @Expose() + description: string | null; + + @Expose() + resource: string; + + @Expose() + action: string; + + @Expose() + createdAt: Date; + + @Expose() + updatedAt: Date; +} + +@Exclude() +export class RoleResponseDto { + @Expose() + id: string; + + @Expose() + name: string; + + @Expose() + description: string | null; + + @Expose() + @Type(() => PermissionResponseDto) + permissions?: PermissionResponseDto[]; + + @Expose() + createdAt: Date; + + @Expose() + updatedAt: Date; +} + +@Exclude() +export class UserRoleResponseDto { + @Expose() + userId: string; + + @Expose() + roleId: string; + + @Expose() + createdAt: Date; +} + +@Exclude() +export class RolePermissionResponseDto { + @Expose() + roleId: string; + + @Expose() + permissionId: string; + + @Expose() + createdAt: Date; +} diff --git a/src/modules/analysis/analysis.controller.ts b/src/modules/analysis/analysis.controller.ts new file mode 100755 index 0000000..35821d0 --- /dev/null +++ b/src/modules/analysis/analysis.controller.ts @@ -0,0 +1,100 @@ +import { + Controller, + Post, + Get, + Body, + HttpCode, + HttpStatus, + ForbiddenException, +} from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, +} from '@nestjs/swagger'; +import { AnalysisService } from './analysis.service'; +import { AnalyzeMatchesDto } from './dto/analysis-request.dto'; +import { CurrentUser } from '../../common/decorators'; + +@ApiTags('Analysis') +@ApiBearerAuth() +@Controller('analysis') +export class AnalysisController { + constructor(private readonly analysisService: AnalysisService) {} + + /** + * POST /analysis/analyze-matches + * Analyze multiple matches (coupon generation) + */ + @Post('analyze-matches') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Analyze multiple matches for coupon' }) + @ApiResponse({ status: 200, description: 'Analysis successful' }) + @ApiResponse({ status: 400, description: 'Invalid input' }) + @ApiResponse({ status: 429, description: 'Usage limit exceeded' }) + async analyzeMatches( + @CurrentUser() user: any, + @Body() dto: AnalyzeMatchesDto, + ) { + const { matchIds } = dto; + + // Check usage limit + const isCoupon = matchIds.length > 1; + const canProceed = await this.analysisService.checkUsageLimit( + user.id, + isCoupon, + matchIds.length, + ); + + if (!canProceed) { + throw new ForbiddenException('You have exceeded your daily usage limit'); + } + + // Run analysis + const result = await this.analysisService.analyzeCoupon(matchIds, user.id); + + if (!result) { + return { + success: false, + message: 'None of the provided matches could be analyzed successfully', + }; + } + + // Record usage + await this.analysisService.recordUsage(user.id, isCoupon); + + return { + success: true, + data: result, + }; + } + + /** + * POST /analysis/analyze (alias for /analyze-matches - frontend compatibility) + */ + @Post('analyze') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Analyze multiple matches for coupon (alias)', + deprecated: true, + }) + async analyzeMatchesAlias( + @CurrentUser() user: any, + @Body() dto: AnalyzeMatchesDto, + ) { + return this.analyzeMatches(user, dto); + } + + /** + * GET /analysis/history + * Get user's analysis history + */ + @Get('history') + @ApiOperation({ summary: 'Get analysis history' }) + @ApiResponse({ status: 200, description: 'History retrieved' }) + async getHistory(@CurrentUser() user: any) { + const history = await this.analysisService.getAnalysisHistory(user.id); + return { success: true, data: history }; + } +} diff --git a/src/modules/analysis/analysis.module.ts b/src/modules/analysis/analysis.module.ts new file mode 100755 index 0000000..003644d --- /dev/null +++ b/src/modules/analysis/analysis.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AnalysisController } from './analysis.controller'; +import { AnalysisService } from './analysis.service'; +import { DatabaseModule } from '../../database/database.module'; +import { ServicesModule } from '../../services/services.module'; + +@Module({ + imports: [DatabaseModule, ServicesModule], + controllers: [AnalysisController], + providers: [AnalysisService], + exports: [AnalysisService], +}) +export class AnalysisModule {} diff --git a/src/modules/analysis/analysis.service.ts b/src/modules/analysis/analysis.service.ts new file mode 100755 index 0000000..1224f4a --- /dev/null +++ b/src/modules/analysis/analysis.service.ts @@ -0,0 +1,152 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { + MatchAnalysisService, + AnalysisResult, +} from '../../services/match-analysis.service'; + +@Injectable() +export class AnalysisService { + private readonly logger = new Logger(AnalysisService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly matchAnalysisService: MatchAnalysisService, + ) {} + + /** + * Analyze multiple matches (coupon) + */ + async analyzeCoupon(matchIds: string[], userId: string): Promise { + this.logger.log(`Analyzing ${matchIds.length} matches for coupon`); + + const results: AnalysisResult[] = []; + + for (const matchId of matchIds) { + try { + // Get match from DB + const match = await this.prisma.match.findFirst({ + where: { + OR: [{ id: matchId }], + }, + include: { + league: true, + homeTeam: true, + awayTeam: true, + }, + }); + + // Try live match if not found + const liveMatch = !match + ? await this.prisma.liveMatch.findUnique({ + where: { id: matchId }, + }) + : null; + + const targetMatch = match || liveMatch; + if (!targetMatch) { + this.logger.warn(`Match not found: ${matchId}`); + continue; + } + + // Build URL for analysis + const sport = (targetMatch as any).sport || 'football'; + const slug = (targetMatch as any).matchSlug || matchId; + const url = `https://www.mackolik.com/${sport === 'basketball' ? 'basketbol/mac' : 'mac'}/${slug}/${matchId}`; + + // Run analysis + const result = await this.matchAnalysisService.analyzeMatch( + url, + userId, + ); + results.push(result); + } catch (err: any) { + this.logger.warn(`Analysis failed for ${matchId}: ${err.message}`); + } + } + + if (results.length === 0) { + return null; + } + + // Combine results into coupon format + return { + totalMatches: matchIds.length, + analyzedMatches: results.length, + matches: results.map((r) => ({ + matchDetails: r.matchDetails, + predictions: r.aiAnalysis?.predictions || [], + recommendedBets: r.aiAnalysis?.recommendedBets || [], + confidence: r.aiAnalysis?.confidenceScore || 0, + })), + generatedAt: new Date().toISOString(), + }; + } + + /** + * Check user usage limit + */ + async checkUsageLimit( + userId: string, + isCoupon: boolean, + matchCount: number, + ): Promise { + const usageLimit = await this.prisma.usageLimit.findUnique({ + where: { userId }, + }); + + if (!usageLimit) { + // Create default limit + await this.prisma.usageLimit.create({ + data: { + userId, + analysisCount: 0, + couponCount: 0, + lastResetDate: new Date(), + }, + }); + return true; + } + + // Check limits (default: 10 analyses, 3 coupons per day) + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + const isPremium = user?.subscriptionStatus === 'active'; + + const maxAnalyses = isPremium ? 50 : 10; + const maxCoupons = isPremium ? 10 : 3; + + if (isCoupon) { + return usageLimit.couponCount < maxCoupons; + } + + return usageLimit.analysisCount + matchCount <= maxAnalyses; + } + + /** + * Record usage + */ + async recordUsage(userId: string, isCoupon: boolean): Promise { + if (isCoupon) { + await this.prisma.usageLimit.update({ + where: { userId }, + data: { couponCount: { increment: 1 } }, + }); + } else { + await this.prisma.usageLimit.update({ + where: { userId }, + data: { analysisCount: { increment: 1 } }, + }); + } + } + + /** + * Get user analysis history + */ + async getAnalysisHistory(userId: string, limit: number = 20) { + return this.prisma.analysis.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } +} diff --git a/src/modules/analysis/dto/analysis-request.dto.ts b/src/modules/analysis/dto/analysis-request.dto.ts new file mode 100644 index 0000000..d1a0c10 --- /dev/null +++ b/src/modules/analysis/dto/analysis-request.dto.ts @@ -0,0 +1,16 @@ +import { IsArray, IsString, ArrayMinSize, ArrayMaxSize } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class AnalyzeMatchesDto { + @ApiProperty({ + description: 'List of match IDs to analyze', + example: ['match-1', 'match-2'], + minItems: 1, + maxItems: 20, + }) + @IsArray() + @IsString({ each: true }) + @ArrayMinSize(1) + @ArrayMaxSize(20) + matchIds: string[]; +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100755 index 0000000..046302d --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,78 @@ +import { Controller, Post, Body, HttpCode } from '@nestjs/common'; +import { I18n, I18nContext } from 'nestjs-i18n'; +import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger'; +import { AuthService } from './auth.service'; +import { + RegisterDto, + LoginDto, + RefreshTokenDto, + TokenResponseDto, +} from './dto/auth.dto'; +import { Public } from '../../common/decorators'; +import { + ApiResponse, + createSuccessResponse, +} from '../../common/types/api-response.type'; + +@ApiTags('Auth') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('register') + @Public() + @HttpCode(200) + @ApiOperation({ summary: 'Register a new user' }) + @ApiOkResponse({ + description: 'User registered successfully', + type: TokenResponseDto, + }) + async register( + @Body() dto: RegisterDto, + @I18n() i18n: I18nContext, + ): Promise> { + const result = await this.authService.register(dto); + return createSuccessResponse(result, i18n.t('auth.registered'), 201); + } + + @Post('login') + @Public() + @HttpCode(200) + @ApiOperation({ summary: 'Login with email and password' }) + @ApiOkResponse({ description: 'Login successful', type: TokenResponseDto }) + async login( + @Body() dto: LoginDto, + @I18n() i18n: I18nContext, + ): Promise> { + const result = await this.authService.login(dto); + return createSuccessResponse(result, i18n.t('auth.login_success')); + } + + @Post('refresh') + @Public() + @HttpCode(200) + @ApiOperation({ summary: 'Refresh access token' }) + @ApiOkResponse({ + description: 'Token refreshed successfully', + type: TokenResponseDto, + }) + async refreshToken( + @Body() dto: RefreshTokenDto, + @I18n() i18n: I18nContext, + ): Promise> { + const result = await this.authService.refreshToken(dto.refreshToken); + return createSuccessResponse(result, i18n.t('auth.refresh_success')); + } + + @Post('logout') + @HttpCode(200) + @ApiOperation({ summary: 'Logout and invalidate refresh token' }) + @ApiOkResponse({ description: 'Logout successful' }) + async logout( + @Body() dto: RefreshTokenDto, + @I18n() i18n: I18nContext, + ): Promise> { + await this.authService.logout(dto.refreshToken); + return createSuccessResponse(null, i18n.t('auth.logout_success')); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100755 index 0000000..a573328 --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { JwtAuthGuard, RolesGuard, PermissionsGuard } from './guards'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (configService: ConfigService): JwtModuleOptions => { + const expiresIn = + configService.get('JWT_ACCESS_EXPIRATION') || '15m'; + return { + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: expiresIn as any, + }, + }; + }, + }), + ], + controllers: [AuthController], + providers: [ + AuthService, + JwtStrategy, + JwtAuthGuard, + RolesGuard, + PermissionsGuard, + ], + exports: [AuthService, JwtAuthGuard, RolesGuard, PermissionsGuard], +}) +export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100755 index 0000000..2f398e3 --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -0,0 +1,248 @@ +import { + Injectable, + UnauthorizedException, + ConflictException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import * as bcrypt from 'bcrypt'; +import * as crypto from 'crypto'; +import { PrismaService } from '../../database/prisma.service'; +import { RegisterDto, LoginDto, TokenResponseDto } from './dto/auth.dto'; +import { User, UserRole } from '@prisma/client'; + +export interface JwtPayload { + sub: string; + email: string; + role: string; + tenantId?: string; +} + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + /** + * Register a new user + */ + async register(dto: RegisterDto): Promise { + // Check if email already exists + const existingUser = await this.prisma.user.findUnique({ + where: { email: dto.email }, + }); + + if (existingUser) { + throw new ConflictException('EMAIL_ALREADY_EXISTS'); + } + + // Hash password + const hashedPassword = await this.hashPassword(dto.password); + + // Create user with default role + const user = await this.prisma.user.create({ + data: { + email: dto.email, + passwordHash: hashedPassword, + firstName: dto.firstName, + lastName: dto.lastName, + role: UserRole.user, + }, + }); + + // Create usage limit for user + await this.prisma.usageLimit.create({ + data: { + userId: user.id, + analysisCount: 0, + couponCount: 0, + lastResetDate: new Date(), + }, + }); + + return this.generateTokens(user); + } + + /** + * Login with email and password + */ + async login(dto: LoginDto): Promise { + // Find user by email + const user = await this.prisma.user.findUnique({ + where: { email: dto.email }, + }); + + if (!user) { + throw new UnauthorizedException('INVALID_CREDENTIALS'); + } + + // Verify password + const isPasswordValid = await this.comparePassword( + dto.password, + user.passwordHash, + ); + + if (!isPasswordValid) { + throw new UnauthorizedException('INVALID_CREDENTIALS'); + } + + if (!user.isActive) { + throw new UnauthorizedException('ACCOUNT_DISABLED'); + } + + return this.generateTokens(user); + } + + /** + * Refresh access token using refresh token + */ + async refreshToken(refreshToken: string): Promise { + // Find refresh token + const storedToken = await this.prisma.refreshToken.findUnique({ + where: { token: refreshToken }, + include: { + user: true, + }, + }); + + if (!storedToken) { + throw new UnauthorizedException('INVALID_REFRESH_TOKEN'); + } + + if (storedToken.expiresAt < new Date()) { + // Delete expired token + await this.prisma.refreshToken.delete({ + where: { id: storedToken.id }, + }); + throw new UnauthorizedException('INVALID_REFRESH_TOKEN'); + } + + // Delete old refresh token + await this.prisma.refreshToken.delete({ + where: { id: storedToken.id }, + }); + + return this.generateTokens(storedToken.user); + } + + /** + * Logout - invalidate refresh token + */ + async logout(refreshToken: string): Promise { + await this.prisma.refreshToken.deleteMany({ + where: { token: refreshToken }, + }); + } + + /** + * Validate user by ID (used by JWT strategy) + */ + async validateUser(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user || !user.isActive) { + return null; + } + + // Remove password from user object + const { passwordHash: _, ...result } = user; + return result; + } + + /** + * Generate access and refresh tokens + */ + private async generateTokens(user: User): Promise { + const payload: JwtPayload = { + sub: user.id, + email: user.email, + role: user.role, + tenantId: undefined, + }; + + // Generate access token + const accessToken = this.jwtService.sign(payload, { + expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'), + }); + + // Generate refresh token + const refreshTokenValue = crypto.randomUUID(); + const refreshExpiration = this.parseExpiration( + this.configService.get('JWT_REFRESH_EXPIRATION', '7d'), + ); + + // Store refresh token + await this.prisma.refreshToken.create({ + data: { + token: refreshTokenValue, + userId: user.id, + expiresAt: new Date(Date.now() + refreshExpiration), + }, + }); + + return { + accessToken, + refreshToken: refreshTokenValue, + expiresIn: + this.parseExpiration( + this.configService.get('JWT_ACCESS_EXPIRATION', '15m'), + ) / 1000, // Convert to seconds + user: { + id: user.id, + email: user.email, + firstName: user.firstName || undefined, + lastName: user.lastName || undefined, + roles: [user.role], // Single role as array for backwards compatibility + }, + }; + } + + /** + * Hash password using bcrypt + */ + private async hashPassword(password: string): Promise { + const saltRounds = 12; + return bcrypt.hash(password, saltRounds); + } + + /** + * Compare password with hash + */ + private async comparePassword( + password: string, + hashedPassword: string, + ): Promise { + return bcrypt.compare(password, hashedPassword); + } + + /** + * Parse expiration string to milliseconds + */ + private parseExpiration(expiration: string): number { + const match = expiration.match(/^(\d+)([smhd])$/); + if (!match) { + return 15 * 60 * 1000; // Default 15 minutes + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case 's': + return value * 1000; + case 'm': + return value * 60 * 1000; + case 'h': + return value * 60 * 60 * 1000; + case 'd': + return value * 24 * 60 * 60 * 1000; + default: + return 15 * 60 * 1000; + } + } +} diff --git a/src/modules/auth/dto/auth.dto.ts b/src/modules/auth/dto/auth.dto.ts new file mode 100755 index 0000000..b11504d --- /dev/null +++ b/src/modules/auth/dto/auth.dto.ts @@ -0,0 +1,70 @@ +import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RegisterDto { + @ApiProperty({ example: 'user@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'password123', minLength: 8 }) + @IsString() + @MinLength(8) + password: string; + + @ApiPropertyOptional({ example: 'John' }) + @IsOptional() + @IsString() + firstName?: string; + + @ApiPropertyOptional({ example: 'Doe' }) + @IsOptional() + @IsString() + lastName?: string; +} + +export class LoginDto { + @ApiProperty({ example: 'user@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'password123' }) + @IsString() + password: string; +} + +export class RefreshTokenDto { + @ApiProperty() + @IsString() + refreshToken: string; +} + +export class UserInfoDto { + @ApiProperty() + id: string; + + @ApiProperty() + email: string; + + @ApiProperty({ required: false }) + firstName?: string; + + @ApiProperty({ required: false }) + lastName?: string; + + @ApiProperty() + roles: string[]; +} + +export class TokenResponseDto { + @ApiProperty() + accessToken: string; + + @ApiProperty() + refreshToken: string; + + @ApiProperty() + expiresIn: number; + + @ApiProperty({ type: UserInfoDto }) + user: UserInfoDto; +} diff --git a/src/modules/auth/guards/auth.guards.ts b/src/modules/auth/guards/auth.guards.ts new file mode 100755 index 0000000..bdb001f --- /dev/null +++ b/src/modules/auth/guards/auth.guards.ts @@ -0,0 +1,142 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Request } from 'express'; +import { + IS_PUBLIC_KEY, + ROLES_KEY, + PERMISSIONS_KEY, +} from '../../../common/decorators'; + +interface AuthenticatedUser { + id: string; + email: string; + roles: string[]; + permissions: string[]; +} + +/** + * JWT Auth Guard - Validates JWT token + */ +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + if (request?.method === 'OPTIONS') { + return true; + } + + // Check if route is public + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } + + handleRequest( + err: Error | null, + user: TUser | false, + info: any, + ): TUser { + if (err || !user) { + if (info?.name === 'TokenExpiredError') { + throw new UnauthorizedException('TOKEN_EXPIRED'); + } + throw err || new UnauthorizedException('AUTH_REQUIRED'); + } + return user; + } +} + +/** + * Roles Guard - Check if user has required roles + */ +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const req = context.switchToHttp().getRequest(); + if (req?.method === 'OPTIONS') { + return true; + } + + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const user = req.user as AuthenticatedUser | undefined; + + if (!user || !user.roles) { + return false; + } + + const hasRole = requiredRoles.some((role) => user.roles.includes(role)); + if (!hasRole) { + throw new ForbiddenException('PERMISSION_DENIED'); + } + + return true; + } +} + +/** + * Permissions Guard - Check if user has required permissions + */ +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const req = context.switchToHttp().getRequest(); + if (req?.method === 'OPTIONS') { + return true; + } + + const requiredPermissions = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredPermissions || requiredPermissions.length === 0) { + return true; + } + + const user = req.user as AuthenticatedUser | undefined; + + if (!user || !user.permissions) { + return false; + } + + const hasPermission = requiredPermissions.every((permission) => + user.permissions.includes(permission), + ); + + if (!hasPermission) { + throw new ForbiddenException('PERMISSION_DENIED'); + } + + return true; + } +} diff --git a/src/modules/auth/guards/index.ts b/src/modules/auth/guards/index.ts new file mode 100755 index 0000000..4916ad7 --- /dev/null +++ b/src/modules/auth/guards/index.ts @@ -0,0 +1 @@ +export * from './auth.guards'; diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts new file mode 100755 index 0000000..a873612 --- /dev/null +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { AuthService, JwtPayload } from '../auth.service'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private readonly configService: ConfigService, + private readonly authService: AuthService, + ) { + const secret = configService.get('JWT_SECRET'); + if (!secret) { + throw new Error('JWT_SECRET is not defined'); + } + + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: secret, + }); + } + + async validate(payload: JwtPayload) { + const user = await this.authService.validateUser(payload.sub); + + if (!user) { + return null; + } + + return { + ...user, + role: payload.role, + }; + } +} diff --git a/src/modules/coupons/coupons.controller.ts b/src/modules/coupons/coupons.controller.ts new file mode 100755 index 0000000..f81d45a --- /dev/null +++ b/src/modules/coupons/coupons.controller.ts @@ -0,0 +1,238 @@ +import { + Controller, + Post, + Get, + Body, + Query, + HttpCode, + HttpStatus, + UseGuards, + Req, + Logger, +} from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, +} from '@nestjs/swagger'; +import { CouponsService } from './coupons.service'; +import { MatchesService } from '../matches/matches.service'; +import { SmartCouponService } from './services/smart-coupon.service'; +import { + UserCouponService, + CreateCouponDto, +} from './services/user-coupon.service'; +import { + AnalyzeMatchDto, + DailyBankoDto, + SuggestCouponDto, +} from './dto/coupons-request.dto'; +import { Public } from '../../common/decorators'; +import { JwtAuthGuard } from '../auth/guards/auth.guards'; // Assuming standard guard +import { Sport } from '../matches/dto'; + +@ApiTags('Coupon') +@Controller('coupon') +export class CouponsController { + private readonly logger = new Logger(CouponsController.name); + + constructor( + private readonly couponsService: CouponsService, + private readonly smartCouponService: SmartCouponService, + private readonly userCouponService: UserCouponService, + private readonly matchesService: MatchesService, + ) {} + + /** + * POST /coupon/analyze-match + * Analyze a single match with V20+ single-match package + */ + @Post('analyze-match') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Analyze single match with V20 model' }) + @ApiResponse({ status: 200, description: 'Match analysis' }) + async analyzeMatch(@Body() dto: AnalyzeMatchDto) { + const analysis = await this.smartCouponService.analyzeMatch(dto.matchId); + if (!analysis) { + return { success: false, message: 'Analiz yapılamadı.' }; + } + return { success: true, data: analysis }; + } + + /** + * POST /coupon/analyze (alias for /analyze-match - frontend compatibility) + */ + @Post('analyze') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Analyze single match with V20 model (alias)', + deprecated: true, + }) + async analyzeMatchAlias(@Body() dto: AnalyzeMatchDto) { + return this.analyzeMatch(dto); + } + + /** + * POST /coupon + * Alias for /coupon/create - frontend compatibility + */ + @Post() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create and save a user coupon (alias)' }) + async createCouponAlias(@Body() dto: CreateCouponDto, @Req() req: any) { + return this.createCoupon(dto, req); + } + + /** + * POST /coupon/daily-banko + * Generate a high-confidence banko combo (2 matches) + */ + @Post('daily-banko') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Generate a high-confidence banko combo (2 matches)', + }) + async getDailyBanko(@Body() dto: DailyBankoDto) { + // If no match IDs provided, fetch from system (top 50 upcoming) + let candidateMatches = dto.matchIds || []; + if (candidateMatches.length === 0) { + candidateMatches = await this.matchesService.findUpcomingMatches( + Sport.FOOTBALL, + 20, + ); + this.logger.debug( + `Auto-fetched ${candidateMatches.length} matches for daily-banko`, + ); + } else { + candidateMatches = await this.matchesService.filterUpcomingMatchIds( + candidateMatches, + Sport.FOOTBALL, + ); + this.logger.debug( + `Sanitized candidate matches for daily-banko: ${candidateMatches.length}`, + ); + } + + if (candidateMatches.length === 0) { + return { + success: false, + message: 'Kupon için uygun, henüz baÅŸlamamış maç bulunamadı.', + }; + } + + const coupon = + await this.smartCouponService.generateDailyBankoCoupon(candidateMatches); + if (!coupon) { + return { + success: false, + message: 'Kriterlere uygun (80%+ güvenli) yeterli maç bulunamadı.', + }; + } + return { success: true, data: coupon }; + } + + /** + * POST /coupon/suggest + * Generate Smart Coupon + */ + @Post('suggest') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Suggest Smart Coupon' }) + @ApiResponse({ status: 200, description: 'Smart Coupon generated' }) + async suggestCoupon(@Body() dto: SuggestCouponDto) { + // If no match IDs provided, fetch from system (top 50 upcoming) + let candidateMatches = dto.matchIds || []; + if (candidateMatches.length === 0) { + candidateMatches = await this.matchesService.findUpcomingMatches( + Sport.FOOTBALL, + 20, + ); + this.logger.debug( + `Auto-fetched ${candidateMatches.length} matches for suggest`, + ); + } else { + candidateMatches = await this.matchesService.filterUpcomingMatchIds( + candidateMatches, + Sport.FOOTBALL, + ); + this.logger.debug( + `Sanitized candidate matches for suggest: ${candidateMatches.length}`, + ); + } + + if (candidateMatches.length === 0) { + return { + success: false, + message: 'Tahmin için uygun, henüz baÅŸlamamış maç bulunamadı.', + }; + } + + const coupon = await this.smartCouponService.getSmartCoupon( + candidateMatches, + dto.strategy, + { + maxMatches: dto.maxMatches, + minConfidence: dto.minConfidence, + }, + ); + if (!coupon) { + return { success: false, message: 'Kupon oluşturulamadı.' }; + } + return { success: true, data: coupon }; + } + + // ============================================ + // USER COUPON ENDPOINTS (NEW) + // ============================================ + + /** + * POST /coupon/create + * Save a user generated coupon + */ + @Post('create') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create and save a user coupon' }) + async createCoupon(@Body() dto: CreateCouponDto, @Req() req: any) { + // req.user is populated by JwtAuthGuard + const coupon = await this.userCouponService.createCoupon(req.user, dto); + return { success: true, data: coupon }; + } + + /** + * GET /coupon/my-stats + * Get user betting statistics (ROI, Win Rate) + */ + @Get('my-stats') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get user betting statistics' }) + async getUserStats(@Req() req: any) { + const stats = await this.userCouponService.getUserStatistics(req.user.id); + return { success: true, data: stats }; + } + + /** + * GET /coupon/history + * Get coupon history (Public/System coupons) + */ + @Get('history') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get coupon history' }) + @ApiResponse({ status: 200, description: 'History retrieved' }) + async getHistory(@Query('limit') limit?: string) { + // eslint-disable-next-line @typescript-eslint/await-thenable + const results = await this.couponsService.getCouponHistory( + Number(limit) || 10, + ); + return { success: true, data: results }; + } +} diff --git a/src/modules/coupons/coupons.module.ts b/src/modules/coupons/coupons.module.ts new file mode 100755 index 0000000..82738f5 --- /dev/null +++ b/src/modules/coupons/coupons.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { CouponsController } from './coupons.controller'; +import { SmartCouponService } from './services/smart-coupon.service'; +import { UserCouponService } from './services/user-coupon.service'; +import { CouponsService } from './coupons.service'; +import { DatabaseModule } from '../../database/database.module'; +import { ServicesModule } from '../../services/services.module'; +import { MatchesModule } from '../matches/matches.module'; + +@Module({ + imports: [DatabaseModule, ServicesModule, MatchesModule], + controllers: [CouponsController], + providers: [CouponsService, SmartCouponService, UserCouponService], + exports: [CouponsService, SmartCouponService, UserCouponService], +}) +export class CouponsModule {} diff --git a/src/modules/coupons/coupons.service.ts b/src/modules/coupons/coupons.service.ts new file mode 100755 index 0000000..c934960 --- /dev/null +++ b/src/modules/coupons/coupons.service.ts @@ -0,0 +1,38 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { AiService } from '../../services/ai.service'; +// [REMOVED V16 IMPORTS] + +export type RiskLevel = 'banko' | 'safe' | 'value'; + +export interface CouponMatch { + matchId: string; + matchName: string; + prediction: string; + confidence: number; + odd: number; +} + +export interface GeneratedCoupon { + id: string; + matches: CouponMatch[]; + totalOdd: number; + riskLevel: RiskLevel; + generatedAt: string; +} + +@Injectable() +export class CouponsService { + private readonly logger = new Logger(CouponsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly aiService: AiService, + ) {} + /** + * Legacy history/history methods... + */ + getCouponHistory(_limit: number = 10) { + return []; + } +} diff --git a/src/modules/coupons/dto/coupons-request.dto.ts b/src/modules/coupons/dto/coupons-request.dto.ts new file mode 100644 index 0000000..20c3ea6 --- /dev/null +++ b/src/modules/coupons/dto/coupons-request.dto.ts @@ -0,0 +1,76 @@ +import { + IsArray, + IsString, + IsOptional, + IsNotEmpty, + IsNumber, + IsEnum, + ArrayMaxSize, + Min, + Max, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum CouponStrategyEnum { + SAFE = 'SAFE', + BALANCED = 'BALANCED', + AGGRESSIVE = 'AGGRESSIVE', + VALUE = 'VALUE', + MIRACLE = 'MIRACLE', +} + +export class AnalyzeMatchDto { + @ApiProperty({ description: 'Match ID to analyze' }) + @IsString() + @IsNotEmpty() + matchId: string; +} + +export class DailyBankoDto { + @ApiPropertyOptional({ + description: 'Optional match IDs — system fetches if empty', + example: ['match-1', 'match-2'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ArrayMaxSize(50) + matchIds?: string[]; +} + +export class SuggestCouponDto { + @ApiPropertyOptional({ + description: 'Match IDs — system fetches if empty', + example: ['match-1', 'match-2'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ArrayMaxSize(50) + matchIds?: string[]; + + @ApiPropertyOptional({ + enum: CouponStrategyEnum, + default: CouponStrategyEnum.BALANCED, + }) + @IsOptional() + @IsEnum(CouponStrategyEnum) + strategy?: CouponStrategyEnum; + + @ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(20) + maxMatches?: number; + + @ApiPropertyOptional({ + description: 'Minimum confidence threshold (0-100)', + example: 60, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + minConfidence?: number; +} diff --git a/src/modules/coupons/services/smart-coupon.service.ts b/src/modules/coupons/services/smart-coupon.service.ts new file mode 100755 index 0000000..240cf8b --- /dev/null +++ b/src/modules/coupons/services/smart-coupon.service.ts @@ -0,0 +1,248 @@ +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import axios from 'axios'; +import { GeminiService } from '../../gemini/gemini.service'; + +export type PredictionRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; +export type PredictionDataQuality = 'HIGH' | 'MEDIUM' | 'LOW'; +export type BetGrade = 'A' | 'B' | 'C' | 'PASS'; + +export interface PredictionPickRow { + market: string; + pick: string; + probability: number; + confidence: number; + odds: number; + raw_confidence: number; + calibrated_confidence: number; + min_required_confidence: number; + edge: number; + play_score: number; + playable: boolean; + bet_grade: BetGrade; + stake_units: number; + decision_reasons: string[]; +} + +export interface PredictionBetSummaryRow { + market: string; + pick: string; + raw_confidence: number; + calibrated_confidence: number; + bet_grade: BetGrade; + playable: boolean; + stake_units: number; + play_score: number; + reasons: string[]; +} + +export interface SingleMatchPredictionPackage { + model_version: string; + match_info: { + match_id: string; + match_name: string; + home_team: string; + away_team: string; + league: string; + match_date_ms: number; + }; + data_quality: { + label: PredictionDataQuality; + score: number; + flags: string[]; + home_lineup_count: number; + away_lineup_count: number; + }; + risk: { + level: PredictionRiskLevel; + score: number; + is_surprise_risk: boolean; + surprise_type: string | null; + warnings: string[]; + }; + engine_breakdown: { + team: number; + player: number; + odds: number; + referee: number; + }; + main_pick: PredictionPickRow | null; + value_pick: PredictionPickRow | null; + bet_advice: { + playable: boolean; + suggested_stake_units: number; + reason: string; + }; + bet_summary: PredictionBetSummaryRow[]; + supporting_picks: PredictionPickRow[]; + aggressive_pick: { + market: string; + pick: string; + probability: number; + confidence: number; + odds: number | null; + } | null; + scenario_top5: Array<{ + score: string; + prob: number; + [key: string]: unknown; + }>; + score_prediction: { + ft: string; + ht: string; + xg_home: number; + xg_away: number; + xg_total: number; + }; + market_board: Record; + reasoning_factors: string[]; + ai_commentary?: string | null; +} + +export interface SmartCouponResult { + strategy: string; + generated_at: string; + match_count: number; + bets: Array<{ + match_id: string; + match_name: string; + market: string; + pick: string; + probability: number; + confidence: number; + odds: number; + risk_level: PredictionRiskLevel; + data_quality: PredictionDataQuality; + }>; + total_odds: number; + expected_win_rate: number; + rejected_matches: Array<{ + match_id: string; + reason: string; + threshold?: number; + }>; +} + +@Injectable() +export class SmartCouponService { + private readonly logger = new Logger(SmartCouponService.name); + private readonly aiEngineUrl: string; + + constructor(private readonly geminiService: GeminiService) { + this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000'; + } + + async analyzeMatch(matchId: string): Promise { + let prediction: SingleMatchPredictionPackage; + try { + const response = await axios.post( + `${this.aiEngineUrl}/v20plus/analyze/${matchId}`, + ); + prediction = response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const detail = error.response?.data?.detail || error.message; + throw new HttpException( + `AI analyze failed: ${detail}`, + error.response?.status || HttpStatus.SERVICE_UNAVAILABLE, + ); + } + throw new HttpException( + 'AI analyze failed', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + // Generate AI commentary (non-blocking — fail-safe) + prediction.ai_commentary = await this.generateMatchCommentary(prediction); + return prediction; + } + + private async generateMatchCommentary( + prediction: SingleMatchPredictionPackage, + ): Promise { + if (!this.geminiService.isAvailable()) { + return null; + } + + try { + const result = await this.geminiService.generateText( + JSON.stringify(prediction, null, 2), + { + model: 'gemini-2.0-flash', + temperature: 0.7, + maxTokens: 600, + systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT, + }, + ); + return result.text || null; + } catch (error) { + this.logger.warn('AI commentary generation failed, skipping', error); + return null; + } + } + + async generateDailyBankoCoupon( + matchIds: string[], + ): Promise { + if (matchIds.length === 0) { + return null; + } + + return this.getSmartCoupon(matchIds, 'SAFE', { + maxMatches: 2, + minConfidence: 78, + }); + } + + async getSmartCoupon( + matchIds: string[], + strategy: + | 'SAFE' + | 'BALANCED' + | 'AGGRESSIVE' + | 'VALUE' + | 'MIRACLE' = 'BALANCED', + options: { maxMatches?: number; minConfidence?: number } = {}, + ): Promise { + try { + const response = await axios.post( + `${this.aiEngineUrl}/v20plus/coupon`, + { + match_ids: matchIds, + strategy, + max_matches: options.maxMatches, + min_confidence: options.minConfidence, + }, + ); + return response.data; + } catch (error) { + this.logger.error('Failed to generate smart coupon', error); + if (axios.isAxiosError(error)) { + const detail = error.response?.data?.detail || error.message; + throw new HttpException( + `Coupon generation failed: ${detail}`, + error.response?.status || HttpStatus.SERVICE_UNAVAILABLE, + ); + } + throw new HttpException( + 'Coupon generation failed', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } +} + +const MATCH_COMMENTARY_SYSTEM_PROMPT = `Sen uzman bir futbol bahis analistisin. Sana verilen model çıktısını analiz edip kısa, net ve aksiyon odaklı Türkçe bir yorum yaz. + +Kurallar: +- Max 3-4 kısa paragraf, gereksiz uzatma +- Playable olan marketleri ve nedenlerini açıkla +- Edge pozitif olan marketleri vurgula (bahisçiden daha iyi biliyoruz) +- Tüm edge'ler negatifse "trap maç" olarak uyar +- xG ve skor senaryolarına göre strateji öner +- Bahis grade'lerini açıkla: A = güvenli, B = iyi, PASS = oynama +- Data quality ve risk seviyesini yorumla (kadro onaylı mı, probable XI mi) +- "Ben olsam..." formatında kişisel tavsiye ver +- Emoji kullan: ⚽ ✅ ⚠️ 🎯 ❌ 💰 +- Markdown formatı KULLANMA, düz metin yaz +- Bahis terminolojisi kullan: edge, value, implied odds, xG`; diff --git a/src/modules/coupons/services/user-coupon.service.ts b/src/modules/coupons/services/user-coupon.service.ts new file mode 100755 index 0000000..0eb4be2 --- /dev/null +++ b/src/modules/coupons/services/user-coupon.service.ts @@ -0,0 +1,189 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../../database/prisma.service'; +import { User, UserCoupon, Match } from '@prisma/client'; + +export class CreateCouponDto { + strategy: string; // 'SAFE', 'VALUE', 'CUSTOM' + items: { + matchId: string; + selection: string; // 'MS 1', '2.5 UST' + odd: number; + }[]; + isPublic?: boolean; +} + +export interface UserStatsDto { + totalCoupons: number; + wonCoupons: number; + winRate: number; // Percentage + totalInvested: number; // Unit based (1 unit per coupon) + totalReturn: number; + roi: number; // Return on Investment % +} + +@Injectable() +export class UserCouponService { + private readonly logger = new Logger(UserCouponService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Kullanıcı için yeni bir kupon oluşturur ve kaydeder. + */ + async createCoupon(user: User, dto: CreateCouponDto): Promise { + const totalOdds = dto.items.reduce((acc, item) => acc * item.odd, 1); + + const coupon = await this.prisma.userCoupon.create({ + data: { + userId: user.id, + strategy: dto.strategy, + totalOdds: parseFloat(totalOdds.toFixed(2)), + isPublic: dto.isPublic || false, + status: 'PENDING', + couponItems: { + create: dto.items.map((item) => ({ + matchId: item.matchId, + selection: item.selection, + oddAtTime: item.odd, + })), + }, + }, + include: { + couponItems: true, + }, + }); + + this.logger.log( + `Coupon created for user ${user.email} with odds ${totalOdds}`, + ); + return coupon; + } + + /** + * Bekleyen kuponların sonuçlarını kontrol eder ve günceller. + * Bu metod bir Cron Job tarafından periyodik olarak çağrılmalıdır. + */ + async updatePendingCoupons(): Promise { + // Sadece bitmiş (FT) maçları içeren PENDING kuponları çek + const pendingCoupons = await this.prisma.userCoupon.findMany({ + where: { status: 'PENDING' }, + include: { + couponItems: { + include: { match: true }, + }, + }, + }); + + for (const coupon of pendingCoupons) { + let isCouponWon = true; + let isCouponLost = false; + let allMatchesFinished = true; + + for (const item of coupon.couponItems) { + if (item.match.status !== 'FT') { + allMatchesFinished = false; + break; // Henüz bitmemiş maç var, kuponu güncelleme + } + + const isItemWon = this.checkSelection(item.selection, item.match); + + // Sonucu item bazında güncelle + if (item.isCorrect !== isItemWon) { + await this.prisma.userCouponItem.update({ + where: { id: item.id }, + data: { isCorrect: isItemWon }, + }); + } + + if (!isItemWon) { + isCouponLost = true; + isCouponWon = false; + } + } + + if (isCouponLost) { + await this.prisma.userCoupon.update({ + where: { id: coupon.id }, + data: { status: 'LOST' }, + }); + } else if (allMatchesFinished && isCouponWon) { + await this.prisma.userCoupon.update({ + where: { id: coupon.id }, + data: { status: 'WON' }, + }); + } + } + } + + /** + * Basit bir kural seti ile bahsin tutup tutmadığını kontrol eder. + * Gerçek dünyada bu daha karmaşık bir 'BetSettlementService' olmalıdır. + */ + private checkSelection(selection: string, match: Match): boolean { + const home = match.scoreHome ?? 0; + const away = match.scoreAway ?? 0; + const total = home + away; + + switch (selection) { + case 'MS 1': + return home > away; + case 'MS X': + return home === away; + case 'MS 2': + return away > home; + case '1.5 UST': + return total > 1.5; + case '2.5 UST': + return total > 2.5; + case '3.5 UST': + return total > 3.5; + case '2.5 ALT': + return total < 2.5; + case 'KG VAR': + return home > 0 && away > 0; + case 'KG YOK': + return home === 0 || away === 0; + default: + return false; // Bilinmeyen market + } + } + + /** + * Kullanıcının bahis performans istatistiklerini getirir. + */ + async getUserStatistics(userId: string): Promise { + const coupons = await this.prisma.userCoupon.findMany({ + where: { + userId, + status: { in: ['WON', 'LOST'] }, + }, + }); + + const totalCoupons = coupons.length; + if (totalCoupons === 0) { + return { + totalCoupons: 0, + wonCoupons: 0, + winRate: 0, + totalInvested: 0, + totalReturn: 0, + roi: 0, + }; + } + + const wonCoupons = coupons.filter((c) => c.status === 'WON'); + const totalInvested = totalCoupons; // Her kupona 1 birim yatırıldığını varsayıyoruz + const totalReturn = wonCoupons.reduce((acc, c) => acc + c.totalOdds, 0); + const winRate = (wonCoupons.length / totalCoupons) * 100; + const roi = ((totalReturn - totalInvested) / totalInvested) * 100; + + return { + totalCoupons, + wonCoupons: wonCoupons.length, + winRate: parseFloat(winRate.toFixed(2)), + totalInvested, + totalReturn: parseFloat(totalReturn.toFixed(2)), + roi: parseFloat(roi.toFixed(2)), + }; + } +} diff --git a/src/modules/feeder/feeder-persistence.service.ts b/src/modules/feeder/feeder-persistence.service.ts new file mode 100755 index 0000000..8a12ad3 --- /dev/null +++ b/src/modules/feeder/feeder-persistence.service.ts @@ -0,0 +1,987 @@ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/** + * Feeder Persistence Service - Senior Level Implementation + * Database operations using Prisma + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { + Sport, + MatchSummary, + Competition, + TransformedPlayer, + MatchParticipation, + TransformedMatchStats, + MatchOfficial, + ParsedMatchHeader, + BasketballPlayerStats, + DbEventPayload, + DbMarketPayload, + BasketballTeamStats, +} from './feeder.types'; +import { ImageUtils } from '../../common/utils/image.util'; + +@Injectable() +export class FeederPersistenceService { + private readonly logger = new Logger(FeederPersistenceService.name); + + constructor(private readonly prisma: PrismaService) {} + + // ============================================ + // HELPER FUNCTIONS + // ============================================ + private safeString(value: any): string | null { + return value === null || value === undefined || value === '' + ? null + : String(value); + } + + private safeInt(value: any): number | null { + const num = parseInt(String(value), 10); + return isNaN(num) ? null : num; + } + + private safeFloat(value: any): number | null { + const num = parseFloat(String(value)); + return isNaN(num) ? null : num; + } + + private mapPositionToEnum(position: string | null): any { + if (!position) return null; + const pos = position.toLowerCase(); + if (pos.includes('kaleci') || pos.includes('goalkeeper')) + return 'goalkeeper'; + if (pos.includes('defans') || pos.includes('defender')) return 'defender'; + if (pos.includes('orta saha') || pos.includes('midfielder')) + return 'midfielder'; + if (pos.includes('forvet') || pos.includes('striker')) return 'striker'; + return null; + } + + // ============================================ + // ODDS HELPER (TRANSACTION SAFE) + // ============================================ + private async saveOddsInTransaction( + tx: any, + matchId: string, + oddsArray: DbMarketPayload[], + ): Promise { + if (oddsArray.length === 0) return; + + const existingCategories = await tx.oddCategory.findMany({ + where: { matchId }, + include: { selections: true }, + }); + + for (const market of oddsArray) { + if (!market || !market.name || !market.selectionCollection) continue; + + let category = existingCategories.find((c) => c.name === market.name); + + if (!category) { + category = await tx.oddCategory.create({ + data: { + matchId, + categoryJsonId: this.safeInt(market.id), + name: market.name, + }, + include: { selections: true }, + }); + existingCategories.push(category); + } + + for (const s of market.selectionCollection) { + if (!s || s.odd === '-' || s.odd === '') continue; + + const sName = this.safeString(s.name); + const sValue = this.safeString(s.odd); + const sPos = this.safeString(s.position); + + if (!sName || !sValue) continue; + + const existingSel = category.selections.find( + (sel) => sel.name === sName, + ); + + if (existingSel) { + if (existingSel.oddValue !== sValue) { + const oldVal = parseFloat(existingSel.oddValue || '0'); + const newVal = parseFloat(sValue); + + if (!isNaN(oldVal) && !isNaN(newVal)) { + await tx.oddsHistory.create({ + data: { + selectionId: existingSel.dbId, + matchId: matchId, + previousValue: oldVal, + newValue: newVal, + }, + }); + } + + await tx.oddSelection.update({ + where: { dbId: existingSel.dbId }, + data: { oddValue: sValue, position: sPos }, + }); + } + } else { + const newSel = await tx.oddSelection.create({ + data: { + categoryId: category.dbId, + name: sName, + oddValue: sValue, + position: sPos, + }, + }); + category.selections.push(newSel); + } + } + } + } + + // ============================================ + // MAIN SAVE FUNCTION + // ============================================ + async saveMatch( + sport: Sport, + matchId: string, + matchSummary: MatchSummary, + league: Competition, + homeTeamId: string, + awayTeamId: string, + headerData: ParsedMatchHeader | null, + playersMap: Map, + participationData: MatchParticipation[], + eventData: DbEventPayload[], + stats: TransformedMatchStats | null, + basketballTeamStats: BasketballTeamStats | null, + basketballPlayerStats: Partial[], + oddsArray: DbMarketPayload[], + officialsData: MatchOfficial[], + ): Promise { + // START IMAGE DOWNLOADS (NON-BLOCKING) + const imageDownloads: Promise[] = []; + + const leagueId = this.safeString(league.id); + if (leagueId) { + const logoUrl = `https://file.mackolikfeeds.com/areas/${leagueId}`; + const localPath = `public/uploads/competitions/${leagueId}.png`; + imageDownloads.push( + ImageUtils.downloadImage(logoUrl, localPath) + .then(() => void 0) + .catch((err) => { + this.logger.error( + `Failed to download league logo ${leagueId}: ${err}`, + ); + }), + ); + } + + const teamsToUpsert = [ + { + id: homeTeamId, + name: matchSummary.homeTeam?.name || 'Unknown', + slug: matchSummary.homeTeam?.slug || homeTeamId, + sport: sport, + }, + { + id: awayTeamId, + name: matchSummary.awayTeam?.name || 'Unknown', + slug: matchSummary.awayTeam?.slug || awayTeamId, + sport: sport, + }, + ]; + + for (const team of teamsToUpsert) { + const teamLogoUrl = `https://file.mackolikfeeds.com/teams/${team.id}`; + const teamLocalPath = `public/uploads/teams/${team.id}.png`; + imageDownloads.push( + ImageUtils.downloadImage(teamLogoUrl, teamLocalPath) + .then(() => void 0) + .catch((err) => { + this.logger.error( + `Failed to download team logo ${team.id}: ${err}`, + ); + }), + ); + } + + // DATABASE TRANSACTION + try { + await this.prisma.$transaction( + async (tx) => { + // 1. Save Country + const countryId = this.safeString(league.country?.id); + if (countryId) { + try { + await tx.country.upsert({ + where: { id: countryId }, + update: {}, + create: { + id: countryId, + name: league.country.name || 'Unknown', + }, + }); + } catch (error: any) { + if (error.code !== 'P2002') throw error; + } + } + + // 2. Save League (Handle ID changes by checking unique constraint) + let finalLeagueId = this.safeString(league.id); + if (finalLeagueId && countryId) { + const leagueName = league.name || 'Unknown'; + + // Check if league exists by unique constraint (name + country + sport) + const existingLeague = await tx.league.findUnique({ + where: { + name_countryId_sport: { + name: leagueName, + countryId: countryId, + sport: sport, + }, + }, + }); + + if (existingLeague) { + // If exists with different ID, use existing ID to prevent constraint errors + finalLeagueId = existingLeague.id; + } else { + // Create new league + await tx.league.create({ + data: { + id: finalLeagueId, + name: leagueName, + countryId: countryId, + sport: sport, + competitionSlug: league.competitionSlug, + logoUrl: `/uploads/competitions/${finalLeagueId}.png`, + }, + }); + } + } + + // 3. Save Teams (BULK OPTIMIZED) + const existingTeams = await tx.team.findMany({ + where: { + id: { in: [homeTeamId, awayTeamId] }, + }, + select: { id: true }, + }); + + const existingTeamIds = new Set(existingTeams.map((t) => t.id)); + const teamsToCreate = teamsToUpsert.filter( + (t) => !existingTeamIds.has(t.id), + ); + const teamsToUpdate = teamsToUpsert.filter((t) => + existingTeamIds.has(t.id), + ); + + if (teamsToCreate.length > 0) { + await tx.team.createMany({ + data: teamsToCreate.map((t) => ({ + ...t, + logoUrl: `/uploads/teams/${t.id}.png`, + })), + skipDuplicates: true, + }); + } + + for (const team of teamsToUpdate) { + await tx.team.update({ + where: { id: team.id }, + data: { + name: team.name, + logoUrl: `/uploads/teams/${team.id}.png`, + }, + }); + } + + // 4. Save Match + const finalScoreHome = + headerData?.scoreHome ?? this.safeInt(matchSummary.score?.home); + const finalScoreAway = + headerData?.scoreAway ?? this.safeInt(matchSummary.score?.away); + const htScoreHome = + headerData?.htScoreHome ?? + this.safeInt(matchSummary.score?.ht?.home); + const htScoreAway = + headerData?.htScoreAway ?? + this.safeInt(matchSummary.score?.ht?.away); + + let status = 'NS'; + if (headerData?.matchStatus) { + if ( + headerData.matchStatus === 'postGame' || + headerData.matchStatus === 'post' + ) { + status = 'FT'; + } else if ( + headerData.matchStatus === 'live' || + headerData.matchStatus === 'liveGame' + ) { + status = 'LIVE'; + } + } + + // Handle Postponed Matches (ERT) + if (matchSummary.statusBoxContent === 'ERT') { + status = 'POSTPONED'; + } + + if ( + status === 'NS' && + finalScoreHome !== null && + finalScoreAway !== null + ) { + status = 'FT'; + } + + await tx.match.upsert({ + where: { id: matchId }, + update: { + scoreHome: finalScoreHome, + scoreAway: finalScoreAway, + htScoreHome: htScoreHome, + htScoreAway: htScoreAway, + status: status, + state: headerData?.matchStatus || null, + }, + create: { + id: matchId, + leagueId: finalLeagueId || undefined, + homeTeamId: homeTeamId, + awayTeamId: awayTeamId, + sport: sport, + matchName: matchSummary.matchName, + matchSlug: matchSummary.matchSlug, + mstUtc: BigInt(matchSummary.mstUtc || 0), + status: status, + state: headerData?.matchStatus || null, + scoreHome: finalScoreHome, + scoreAway: finalScoreAway, + htScoreHome: htScoreHome, + htScoreAway: htScoreAway, + winner: matchSummary.winner || null, + iddaaCode: this.safeString(matchSummary.iddaaCode), + }, + }); + + // 5. Save Players (BULK OPTIMIZED) + const playersArray = Array.from(playersMap.values()); + if (playersArray.length > 0) { + const existingPlayers = await tx.player.findMany({ + where: { + id: { in: playersArray.map((p) => p.id) }, + }, + select: { id: true }, + }); + + const existingPlayerIds = new Set(existingPlayers.map((p) => p.id)); + const playersToCreate = playersArray.filter( + (p) => !existingPlayerIds.has(p.id), + ); + const playersToUpdate = playersArray.filter((p) => + existingPlayerIds.has(p.id), + ); + + if (playersToCreate.length > 0) { + await tx.player.createMany({ + data: playersToCreate.map((p) => ({ + id: p.id, + name: p.name, + slug: p.slug, + })), + skipDuplicates: true, + }); + } + + if (playersToUpdate.length > 0) { + await Promise.all( + playersToUpdate.map((p) => + tx.player.update({ + where: { id: p.id }, + data: { name: p.name }, + }), + ), + ); + } + } + + // 6. Save Participation + if (participationData.length > 0) { + await tx.matchPlayerParticipation.deleteMany({ + where: { matchId: matchId }, + }); + + await tx.matchPlayerParticipation.createMany({ + data: participationData.map((p) => ({ + matchId: p.matchId, + playerId: p.playerId, + teamId: p.teamId, + position: this.mapPositionToEnum(p.position), + shirtNumber: p.shirtNumber, + isStarting: p.isStarting, + })), + skipDuplicates: true, + }); + } + + // 7. Save Events + if (eventData.length > 0) { + await tx.matchPlayerEvents.deleteMany({ + where: { matchId: matchId }, + }); + + await tx.matchPlayerEvents.createMany({ + data: eventData.map((e) => ({ + matchId: e.match_id, + playerId: e.player_id, + teamId: e.team_id, + eventType: e.event_type, + eventSubtype: e.event_subtype, + timeMinute: e.time_minute, + timeSeconds: e.time_seconds, + periodId: e.period_id, + assistPlayerId: e.assist_player_id, + scoreAfter: e.score_after, + playerOutId: e.player_out_id, + position: e.position, + })), + skipDuplicates: true, + }); + } + + // 8. Save Team Stats (Football) + if (stats && sport === 'football') { + const statsRows = [ + { + matchId, + teamId: homeTeamId, + possessionPercentage: stats.home.possesionPercentage, + shotsOnTarget: stats.home.shotsOnTarget, + shotsOffTarget: stats.home.shotsOffTarget, + totalShots: + (stats.home.shotsOnTarget || 0) + + (stats.home.shotsOffTarget || 0) || null, + totalPasses: stats.home.totalPasses, + corners: stats.home.corners, + fouls: stats.home.fouls, + offsides: stats.home.offsides, + }, + { + matchId, + teamId: awayTeamId, + possessionPercentage: stats.away.possesionPercentage, + shotsOnTarget: stats.away.shotsOnTarget, + shotsOffTarget: stats.away.shotsOffTarget, + totalShots: + (stats.away.shotsOnTarget || 0) + + (stats.away.shotsOffTarget || 0) || null, + totalPasses: stats.away.totalPasses, + corners: stats.away.corners, + fouls: stats.away.fouls, + offsides: stats.away.offsides, + }, + ]; + + for (const row of statsRows) { + await tx.footballTeamStats.upsert({ + where: { + matchId_teamId: { matchId: row.matchId, teamId: row.teamId }, + }, + update: row, + create: row, + }); + } + } + + // 8b. Save Team Stats (Basketball) + if (basketballTeamStats && sport === 'basketball') { + const teams = [ + { id: homeTeamId, data: basketballTeamStats.home }, + { id: awayTeamId, data: basketballTeamStats.away }, + ]; + + for (const t of teams) { + if (!t.data) continue; + await tx.basketballTeamStats.upsert({ + where: { + matchId_teamId: { matchId, teamId: t.id }, + }, + update: { + points: t.data.points, + rebounds: t.data.rebounds, + assists: t.data.assists, + fgMade: t.data.fgMade, + fgAttempted: t.data.fgAttempted, + threePtMade: t.data.threePtMade, + threePtAttempted: t.data.threePtAttempted, + ftMade: t.data.ftMade, + ftAttempted: t.data.ftAttempted, + steals: t.data.steals, + blocks: t.data.blocks, + turnovers: t.data.turnovers, + fouls: t.data.fouls, + q1Score: t.data.q1, + q2Score: t.data.q2, + q3Score: t.data.q3, + q4Score: t.data.q4, + otScore: t.data.ot, + }, + create: { + matchId, + teamId: t.id, + points: t.data.points, + rebounds: t.data.rebounds, + assists: t.data.assists, + fgMade: t.data.fgMade, + fgAttempted: t.data.fgAttempted, + threePtMade: t.data.threePtMade, + threePtAttempted: t.data.threePtAttempted, + ftMade: t.data.ftMade, + ftAttempted: t.data.ftAttempted, + steals: t.data.steals, + blocks: t.data.blocks, + turnovers: t.data.turnovers, + fouls: t.data.fouls, + q1Score: t.data.q1, + q2Score: t.data.q2, + q3Score: t.data.q3, + q4Score: t.data.q4, + otScore: t.data.ot, + }, + }); + } + } + + // 8c. Save Player Stats (Basketball) + if (basketballPlayerStats.length > 0 && sport === 'basketball') { + await tx.basketballPlayerStats.deleteMany({ where: { matchId } }); + + for (const p of basketballPlayerStats) { + if (!p.id || !p.teamId) continue; + + await tx.basketballPlayerStats.create({ + data: { + matchId, + playerId: p.id, + teamId: p.teamId, + minutes: p.minutes, + points: p.points, + rebounds: p.rebounds, + assists: p.assists, + fgMade: p.fgMade, + fgAttempted: p.fgAttempted, + threePtMade: p.threePtMade, + threePtAttempted: p.threePtAttempted, + ftMade: p.ftMade, + ftAttempted: p.ftAttempted, + steals: p.steals, + blocks: p.blocks, + turnovers: p.turnovers, + fouls: p.fouls, + }, + }); + } + } + + // 9. Save Odds (USING HELPER) + await this.saveOddsInTransaction(tx, matchId, oddsArray); + + // 10. Save Officials + if (sport === 'football' && officialsData.length > 0) { + await tx.matchOfficial.deleteMany({ where: { matchId } }); + const processedOfficials = new Set(); + + for (const o of officialsData) { + const roleName = o.role || 'Referee'; + const uniqueKey = `${o.name}_${roleName}`; + + if (processedOfficials.has(uniqueKey)) continue; + processedOfficials.add(uniqueKey); + + const role = await tx.officialRole.upsert({ + where: { name: roleName }, + update: {}, + create: { name: roleName }, + }); + + await tx.matchOfficial.create({ + data: { + matchId, + name: o.name, + roleId: role.id, + }, + }); + } + } + }, + { maxWait: 40000, timeout: 40000 }, + ); + + // WAIT FOR IMAGES AFTER TRANSACTION + await Promise.allSettled(imageDownloads); + + this.logger.log(`✅ SAVED: [${matchId}] ${matchSummary.matchName}`); + return true; + } catch (error: any) { + this.logger.error(`❌ SAVE FAILED [${matchId}]: ${error.message}`); + return false; + } + } + + // ============================================ + // SELECTIVE UPDATE: LINEUPS ONLY + // ============================================ + async saveLineups( + matchId: string, + playersMap: Map, + participationData: MatchParticipation[], + homeTeamId: string, + awayTeamId: string, + ): Promise { + try { + await this.prisma.$transaction( + async (tx) => { + const matchInMainDb = await tx.match.findUnique({ + where: { id: matchId }, + select: { id: true }, + }); + + if (matchInMainDb) { + const playersArray = Array.from(playersMap.values()); + if (playersArray.length > 0) { + const existingPlayers = await tx.player.findMany({ + where: { + id: { in: playersArray.map((p) => p.id) }, + }, + select: { id: true }, + }); + + const existingPlayerIds = new Set( + existingPlayers.map((p) => p.id), + ); + const playersToCreate = playersArray.filter( + (p) => !existingPlayerIds.has(p.id), + ); + + if (playersToCreate.length > 0) { + await tx.player.createMany({ + data: playersToCreate.map((p) => ({ + id: p.id, + name: p.name, + slug: p.slug, + })), + skipDuplicates: true, + }); + } + } + + if (participationData.length > 0) { + await tx.matchPlayerParticipation.deleteMany({ + where: { matchId: matchId }, + }); + + await tx.matchPlayerParticipation.createMany({ + data: participationData.map((p) => ({ + matchId: p.matchId, + playerId: p.playerId, + teamId: p.teamId, + position: this.mapPositionToEnum(p.position), + shirtNumber: p.shirtNumber, + isStarting: p.isStarting, + })), + skipDuplicates: true, + }); + } + } + }, + { maxWait: 15000, timeout: 15000 }, + ); + + this.logger.log(`✅ LINEUPS REFRESHED & SYNCED: [${matchId}]`); + return true; + } catch (error: any) { + this.logger.error(`❌ LINEUP SAVE FAILED [${matchId}]: ${error.message}`); + return false; + } + } + + // ============================================ + // SELECTIVE UPDATE: ODDS ONLY (HISTORY-AWARE) + // ============================================ + async saveOdds( + matchId: string, + oddsArray: DbMarketPayload[], + ): Promise { + try { + await this.prisma.$transaction( + async (tx) => { + // 1. MAIN DB LOGIC + const matchInMainDb = await tx.match.findUnique({ + where: { id: matchId }, + select: { id: true }, + }); + + if (matchInMainDb && oddsArray.length > 0) { + await this.saveOddsInTransaction(tx, matchId, oddsArray); + } + + // 2. LIVE MATCH DB LOGIC + const liveMatch = await tx.liveMatch.findUnique({ + where: { id: matchId }, + select: { id: true }, + }); + + if (liveMatch && oddsArray.length > 0) { + const oddsJson: Record> = {}; + for (const m of oddsArray) { + oddsJson[m.name] = {}; + for (const s of m.selectionCollection) { + const val = parseFloat(s.odd); + if (!isNaN(val)) oddsJson[m.name][s.name] = val; + } + } + + await tx.liveMatch.update({ + where: { id: matchId }, + data: { + odds: oddsJson as any, + oddsUpdatedAt: new Date(), + }, + }); + } + }, + { maxWait: 15000, timeout: 15000 }, + ); + + this.logger.log(`✅ ODDS REFRESHED: [${matchId}]`); + return true; + } catch (error: any) { + this.logger.error(`❌ ODDS SAVE FAILED [${matchId}]: ${error.message}`); + return false; + } + } + + // ============================================ + // FULL DATA FETCH FOR AI + // ============================================ + async getMatchFullDetails(matchId: string) { + const match = await this.prisma.match.findUnique({ + where: { id: matchId }, + include: { + homeTeam: true, + awayTeam: true, + league: true, + oddCategories: { + include: { selections: true }, + }, + playerParticipations: { + select: { playerId: true, teamId: true, isStarting: true }, + }, + }, + }); + + if (!match) return null; + + const homeLineup = match.playerParticipations + .filter((p) => p.teamId === match.homeTeamId) + .map((p) => p.playerId); + const awayLineup = match.playerParticipations + .filter((p) => p.teamId === match.awayTeamId) + .map((p) => p.playerId); + + const getForm = async (teamId: string) => { + const history = await this.prisma.match.findMany({ + where: { + OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }], + status: 'FT', + mstUtc: { lt: match.mstUtc }, + }, + orderBy: { mstUtc: 'desc' }, + take: 5, + }); + + if (history.length === 0) return { avg_gf: 1.2, avg_ga: 1.2 }; + + let totalGF = 0; + let totalGA = 0; + for (const m of history) { + if (m.homeTeamId === teamId) { + totalGF += m.scoreHome ?? 0; + totalGA += m.scoreAway ?? 0; + } else { + totalGF += m.scoreAway ?? 0; + totalGA += m.scoreHome ?? 0; + } + } + return { + avg_gf: totalGF / history.length, + avg_ga: totalGA / history.length, + }; + }; + + const homeForm = await getForm(match.homeTeamId!); + const awayForm = await getForm(match.awayTeamId!); + + const odds: any[] = []; + for (const cat of match.oddCategories) { + for (const sel of cat.selections) { + odds.push({ + category: cat.name, + selection: sel.name, + odd_value: this.safeFloat(sel.oddValue), + }); + } + } + + return { + match_id: match.id, + home_team: match.homeTeam?.name || 'Unknown', + away_team: match.awayTeam?.name || 'Unknown', + home_team_id: match.homeTeamId, + away_team_id: match.awayTeamId, + league_id: match.leagueId, + league_name: match.league?.name, + date: match.mstUtc.toString(), + score_home: match.scoreHome, + score_away: match.scoreAway, + status: match.status, + odds: odds, + home_form: homeForm, + away_form: awayForm, + home_lineup: homeLineup, + away_lineup: awayLineup, + }; + } + + // ============================================ + // CHECKERS + // ============================================ + async matchExists(matchId: string): Promise { + const match = await this.prisma.match.findUnique({ + where: { id: matchId }, + select: { id: true }, + }); + return !!match; + } + + async getExistingMatchIds(matchIds: string[]): Promise { + // Only consider matches "existing" if they have ALL key data points + // This allows re-fetching matches that exist but have missing data + const matches = await this.prisma.match.findMany({ + where: { + id: { in: matchIds }, + AND: [ + { oddCategories: { some: {} } }, + { playerEvents: { some: {} } }, + { officials: { some: {} } }, + { + OR: [ + { footballTeamStats: { some: {} } }, + { basketballTeamStats: { some: {} } }, + ], + }, + ], + }, + select: { id: true }, + }); + return matches.map((m) => m.id); + } + + async hasOdds(matchId: string): Promise { + const category = await this.prisma.oddCategory.findFirst({ + where: { matchId }, + }); + if (category) return true; + + const live = await this.prisma.liveMatch.findUnique({ + where: { id: matchId }, + select: { odds: true }, + }); + return !!(live?.odds && Object.keys(live.odds as any).length > 0); + } + + async getMatch(matchId: string): Promise { + const match = await this.prisma.match.findUnique({ + where: { id: matchId }, + include: { + homeTeam: true, + awayTeam: true, + }, + }); + + if (match) return match; + + const liveMatch = await this.prisma.liveMatch.findUnique({ + where: { id: matchId }, + include: { + homeTeam: true, + awayTeam: true, + league: true, + }, + }); + + if (liveMatch) { + return { + ...liveMatch, + leagueId: liveMatch.leagueId, + homeTeamId: liveMatch.homeTeamId, + awayTeamId: liveMatch.awayTeamId, + scoreHome: liveMatch.scoreHome, + scoreAway: liveMatch.scoreAway, + mstUtc: liveMatch.mstUtc, + sport: liveMatch.sport || 'football', + }; + } + + return null; + } + + async getPlayerCount(matchId: string): Promise { + const relationalCount = await this.prisma.matchPlayerParticipation.count({ + where: { matchId }, + }); + + if (relationalCount > 0) return relationalCount; + + const liveMatch = await this.prisma.liveMatch.findUnique({ + where: { id: matchId }, + select: { lineups: true }, + }); + + if (liveMatch?.lineups) { + try { + const lineups = liveMatch.lineups as any; + const homeXi = lineups.home?.xi?.length || 0; + const awayXi = lineups.away?.xi?.length || 0; + return homeXi + awayXi; + } catch (e) { + return 0; + } + } + + return 0; + } + + // ============================================ + // STATE MANAGEMENT + // ============================================ + async getState(key: string): Promise { + const setting = await this.prisma.appSetting.findUnique({ + where: { key }, + }); + return setting?.value || null; + } + + async setState(key: string, value: string): Promise { + await this.prisma.appSetting.upsert({ + where: { key }, + update: { value, updatedAt: new Date() }, + create: { key, value }, + }); + } +} diff --git a/src/modules/feeder/feeder-scraper.service.ts b/src/modules/feeder/feeder-scraper.service.ts new file mode 100755 index 0000000..a72ba5f --- /dev/null +++ b/src/modules/feeder/feeder-scraper.service.ts @@ -0,0 +1,746 @@ +/** + * Feeder Scraper Service - Senior Level Implementation + * HTTP requests with exact headers from working curl commands + */ + +import { Injectable, Logger } from '@nestjs/common'; +import axios, { AxiosInstance } from 'axios'; +import * as cheerio from 'cheerio'; +import { + Sport, + SPORTS_CONFIG, + DEFAULT_HEADERS, + DEFAULT_TIMEOUT, + KeyEventsResponse, + MatchStatsResponse, + GameStatsResponse, + ManagerResponse, + IddaaMarketsHtmlResponse, + BasketballBoxScoreResponse, + ParsedMatchHeader, + ParsedMarket, + ParsedSelection, + BasketballPlayerStats, + LivescoresApiResponse, + SidelinedResponse, + SidelinedTeamData, + SidelinedPlayer, +} from './feeder.types'; + +@Injectable() +export class FeederScraperService { + private readonly logger = new Logger(FeederScraperService.name); + private readonly axios: AxiosInstance; + + constructor() { + // Create axios instance with default config + this.axios = axios.create({ + headers: DEFAULT_HEADERS, + timeout: DEFAULT_TIMEOUT, + }); + + // Add response interceptor for logging + this.axios.interceptors.response.use( + (response) => { + this.logger.debug( + `✅ [${response.config.url?.split('?')[0]}] Status: ${response.status}`, + ); + return response; + }, + (error) => { + const status = error.response?.status || 'N/A'; + const url = error.config?.url?.split('?')[0] || 'Unknown'; + this.logger.error(`❌ [${url}] Status: ${status} - ${error.message}`); + throw error; + }, + ); + } + + // ============================================ + // Historical source endpoint (match list) + // ============================================ + async fetchLivescores( + dateString: string, + sport: Sport, + ): Promise { + const { sportParam } = SPORTS_CONFIG[sport]; + const url = `https://www.mackolik.com/perform/p0/ajax/components/competition/livescores/json`; + + this.logger.log( + `📡 [${sport}] Fetching historical source snapshot for ${dateString}`, + ); + + const response = await this.axios.get(url, { + params: { + 'sports[]': sportParam, + matchDate: dateString, + }, + }); + + const payload = response.data as unknown; + if ( + !payload || + typeof payload !== 'object' || + !('status' in payload) || + !('data' in payload) + ) { + throw new Error('Historical source payload has invalid shape'); + } + + return payload as LivescoresApiResponse; + } + + // ============================================ + // MATCH HEADER (Score, Status, HT Score) + // ============================================ + async fetchMatchHeader(matchId: string): Promise { + const url = `https://www.mackolik.com/perform/p0/ajax/components/match/matchHeader`; + + this.logger.debug(`📡 [${matchId}] Fetching match header`); + + const response = await this.axios.get(url, { + params: { + matchId, + sdapiLanguageCode: 'tr-mk', + ajaxViewName: 'match-details', + ajaxPartialViewName: 'match-details-status', + displayMode: 'all', + }, + }); + + return this.parseMatchHeader(response.data.data?.html || ''); + } + + private parseMatchHeader(html: string): ParsedMatchHeader { + const $ = cheerio.load(html); + + // Extract match-status from data attribute + const matchStatus = + ($('[data-match-status]').attr('data-match-status') as any) || 'postGame'; + + // Extract scores + const scoreHome = this.safeInt($('[data-slot="score-home"]').text().trim()); + const scoreAway = this.safeInt($('[data-slot="score-away"]').text().trim()); + + // Extract HT score from detailed score (İY X - X) + let htScoreHome: number | null = null; + let htScoreAway: number | null = null; + + const detailedScore = $('.p0c-soccer-match-details-header__detailed-score') + .text() + .trim(); + const htMatch = detailedScore.match(/\(İY\s*(\d+)\s*-\s*(\d+)\)/); + if (htMatch) { + htScoreHome = parseInt(htMatch[1], 10); + htScoreAway = parseInt(htMatch[2], 10); + } + + return { matchStatus, scoreHome, scoreAway, htScoreHome, htScoreAway }; + } + + // ============================================ + // KEY EVENTS (Goals, Cards, Substitutes) + // ============================================ + async fetchKeyEvents( + matchId: string, + ): Promise { + const url = `https://www.mackolik.com/ajax/football/key-events`; + + this.logger.debug(`📡 [${matchId}] Fetching key events`); + + try { + const response = await this.axios.get(url, { + params: { + ajaxViewName: 'events', + matchId, + seasonId: matchId, // Same as matchId + }, + }); + + return response.data.data; + } catch (error: any) { + if (error.response?.status === 404) { + this.logger.warn(`[${matchId}] Key events not found (404)`); + return null; + } + throw error; + } + } + + // ============================================ + // MATCH STATS - STARTING FORMATION (İlk 11) + // ============================================ + async fetchStartingFormation( + matchId: string, + ): Promise { + const url = `https://www.mackolik.com/ajax/football/match-stats`; + + this.logger.debug(`📡 [${matchId}] Fetching starting formation`); + + try { + const response = await this.axios.get(url, { + params: { + ajaxViewName: 'starting-formation', + matchId, + seasonId: matchId, + }, + }); + + return response.data.data; + } catch (error: any) { + if (error.response?.status === 404) { + this.logger.warn(`[${matchId}] Starting formation not found (404)`); + return null; + } + throw error; + } + } + + // ============================================ + // MATCH STATS - SUBSTITUTIONS (Yedekler) + // ============================================ + async fetchSubstitutions( + matchId: string, + ): Promise { + const url = `https://www.mackolik.com/ajax/football/match-stats`; + + this.logger.debug(`📡 [${matchId}] Fetching substitutions`); + + try { + const response = await this.axios.get(url, { + params: { + ajaxViewName: 'substitutions', + matchId, + seasonId: matchId, + }, + }); + + return response.data.data; + } catch (error: any) { + if (error.response?.status === 404) { + this.logger.warn(`[${matchId}] Substitutions not found (404)`); + return null; + } + throw error; + } + } + + // ============================================ + // GAME STATS (Possession, Shots, Passes) + // ============================================ + async fetchGameStats( + matchId: string, + ): Promise { + const url = `https://www.mackolik.com/ajax/soccer/match/gameStats`; + + this.logger.debug(`📡 [${matchId}] Fetching game stats`); + + try { + const response = await this.axios.get(url, { + params: { matchId }, + }); + + return response.data.data; + } catch (error: any) { + if (error.response?.status === 404) { + this.logger.warn(`[${matchId}] Game stats not found (404)`); + return null; + } + throw error; + } + } + + // ============================================ + // MANAGER + // ============================================ + async fetchManager(matchId: string): Promise { + const url = `https://www.mackolik.com/ajax/football/match-stats`; + + this.logger.debug(`📡 [${matchId}] Fetching manager`); + + try { + const response = await this.axios.get(url, { + params: { + ajaxViewName: 'manager', + matchId, + seasonId: matchId, + }, + }); + + return response.data.data; + } catch (error: any) { + if (error.response?.status === 404) { + this.logger.warn(`[${matchId}] Manager not found (404)`); + return null; + } + throw error; + } + } + + // ============================================ + // IDDAA MARKETS (HTML with odds + names) + // ============================================ + async fetchIddaaMarkets(matchId: string): Promise { + const url = `https://www.mackolik.com/ajax/iddaa/markets/soccer/all/${matchId}`; + + this.logger.debug(`📡 [${matchId}] Fetching iddaa markets`); + + try { + const response = await this.axios.get(url, { + params: { template: 'all' }, + }); + + return this.parseIddaaMarketsHtml(response.data.data?.html || ''); + } catch (error: any) { + if (error.response?.status === 404) { + this.logger.warn(`[${matchId}] Iddaa markets not found (404)`); + return []; + } + throw error; + } + } + + private parseIddaaMarketsHtml(html: string): ParsedMarket[] { + if (!html) return []; + + const $ = cheerio.load(html); + const markets: ParsedMarket[] = []; + + $('.widget-iddaa-markets__market-item').each((_, marketEl) => { + const $market = $(marketEl); + + const marketId = $market.attr('data-market') || ''; + const marketName = $market + .find('.widget-iddaa-markets__header-text') + .text() + .trim(); + const iddaaCode = $market + .find('.widget-iddaa-markets__iddaa-code') + .text() + .trim(); + const mbc = $market.find('.widget-iddaa-markets__mbc').text().trim(); + + const selections: ParsedSelection[] = []; + + $market.find('.widget-iddaa-markets__option').each((_, optionEl) => { + const $option = $(optionEl); + + selections.push({ + shortcode: $option.attr('data-shortcode') || '', + outcomeNo: $option.attr('data-outcome-no') || '', + label: $option.find('.widget-iddaa-markets__label').text().trim(), + value: $option.find('.widget-iddaa-markets__value').text().trim(), + }); + }); + + if (marketId && marketName) { + markets.push({ marketId, marketName, iddaaCode, mbc, selections }); + } + }); + + this.logger.debug(`Parsed ${markets.length} iddaa markets`); + return markets; + } + + // ============================================ + // BASKETBALL BOX SCORE + // ============================================ + async fetchBasketballBoxScore( + matchId: string, + ): Promise { + // Updated URL based on user request + const url = `https://www.mackolik.com/ajax/basketball/match/box-score`; + + this.logger.debug(`📡 [${matchId}] Fetching basketball box score`); + + try { + const response = await this.axios.get(url, { + params: { matchId }, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'User-Agent': DEFAULT_HEADERS['User-Agent'], + }, + }); + + return response.data.data; + } catch (error: any) { + if (error.response?.status === 404) { + this.logger.warn(`[${matchId}] Basketball box score not found (404)`); + return null; + } + throw error; + } + } + + parseBasketballBoxScore(html: string): { + teamTotals: any; + players: Partial[]; + } { + if (!html) return { teamTotals: {}, players: [] }; + + const $ = cheerio.load(html); + const players: Partial[] = []; + + // Parse individual players from widget rows + $('.widget-basketball-match-box-score__row').each((_, elem) => { + const row = $(elem); + // Skip if no player name found + const nameElem = row.find('.widget-basketball-match-box-score__player'); + if (!nameElem.length) return; + + const name = nameElem.text().trim(); + // Indices based on User HTML: + // 0: Name, 1: Min, 2: Pts, 3: Reb, 4: Ast, 5: 2FG, 6: 3FG, 7: FT, 8: Fouls, 9: Blk, 10: Stl, 11: TO + const values = row.find('td'); + + // Check if it's a valid player row (should have enough columns) + if (values.length < 10) return; + + // Extract ID from link if possible + let playerId = ''; + const link = nameElem.find('a').attr('href'); + if (link) { + playerId = this.extractPlayerIdFromUrl(link) || ''; + } + + players.push({ + id: playerId, // Will be generated if empty later + name, + minutes: values.eq(1).text().trim(), + points: this.safeInt(values.eq(2).text().trim()) || 0, + rebounds: this.safeInt(values.eq(3).text().trim()) || 0, + assists: this.safeInt(values.eq(4).text().trim()) || 0, + fgMade: this.safeInt(values.eq(5).text().trim().split('/')[0]) || 0, + fgAttempted: + this.safeInt(values.eq(5).text().trim().split('/')[1]) || 0, + threePtMade: + this.safeInt(values.eq(6).text().trim().split('/')[0]) || 0, + threePtAttempted: + this.safeInt(values.eq(6).text().trim().split('/')[1]) || 0, + ftMade: this.safeInt(values.eq(7).text().trim().split('/')[0]) || 0, + ftAttempted: + this.safeInt(values.eq(7).text().trim().split('/')[1]) || 0, + fouls: this.safeInt(values.eq(8).text().trim()) || 0, + blocks: this.safeInt(values.eq(9).text().trim()) || 0, + steals: this.safeInt(values.eq(10).text().trim()) || 0, + turnovers: this.safeInt(values.eq(11).text().trim()) || 0, + }); + }); + + // Parse Team Totals from Footer + const footerRow = $('.widget-basketball-match-box-score__footer td'); + let teamTotals: any = {}; + + if (footerRow.length > 5) { + // Indices shift because first cells might be empty matchers + // usually index 2 matches Points column + teamTotals = { + points: this.safeInt(footerRow.eq(2).text().trim()) || 0, + rebounds: this.safeInt(footerRow.eq(3).text().trim()) || 0, + assists: this.safeInt(footerRow.eq(4).text().trim()) || 0, + fgMade: this.safeInt(footerRow.eq(5).text().trim().split('/')[0]) || 0, + fgAttempted: + this.safeInt(footerRow.eq(5).text().trim().split('/')[1]) || 0, + threePtMade: + this.safeInt(footerRow.eq(6).text().trim().split('/')[0]) || 0, + threePtAttempted: + this.safeInt(footerRow.eq(6).text().trim().split('/')[1]) || 0, + ftMade: this.safeInt(footerRow.eq(7).text().trim().split('/')[0]) || 0, + ftAttempted: + this.safeInt(footerRow.eq(7).text().trim().split('/')[1]) || 0, + fouls: this.safeInt(footerRow.eq(8).text().trim()) || 0, + blocks: this.safeInt(footerRow.eq(9).text().trim()) || 0, + steals: this.safeInt(footerRow.eq(10).text().trim()) || 0, + turnovers: this.safeInt(footerRow.eq(11).text().trim()) || 0, + }; + } + + return { teamTotals, players }; + } + + // ============================================ + // MATCH PAGE (Main page for officials parsing) + // ============================================ + async fetchMatchPage( + matchId: string, + matchSlug: string, + sport: Sport, + ): Promise { + const { iddaaUrlPath } = SPORTS_CONFIG[sport]; + const url = `https://www.mackolik.com/${iddaaUrlPath}/${matchSlug}/${matchId}`; + + this.logger.debug(`📡 [${matchId}] Fetching match page`); + + // For HTML pages, we DON'T send X-Requested-With header + const response = await this.axios.get(url, { + headers: { + 'User-Agent': DEFAULT_HEADERS['User-Agent'], + Referer: DEFAULT_HEADERS['Referer'], + 'Accept-Language': DEFAULT_HEADERS['Accept-Language'], + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + // NO X-Requested-With for HTML pages! + }, + }); + + return response.data; + } + + // ============================================ + // HELPER FUNCTIONS + // ============================================ + private safeInt(value: string | undefined): number | null { + if (!value) return null; + const num = parseInt(value, 10); + return isNaN(num) ? null : num; + } + + // ============================================ + // BASKETBALL DETAILS HEADER (Quarter Scores) + // ============================================ + async fetchBasketballDetailsHeader(matchId: string): Promise { + const url = `https://www.mackolik.com/ajax/basketball/match/details-header`; + + this.logger.debug(`📡 [${matchId}] Fetching basketball details header`); + + try { + const response = await this.axios.get(url, { + params: { matchId }, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'User-Agent': DEFAULT_HEADERS['User-Agent'], + }, + }); + + if (response.data?.data?.views?.scoreDetails?.html) { + return this.parseBasketballDetailsHeader( + response.data.data.views.scoreDetails.html, + ); + } + return null; + } catch (error: any) { + // 404 is acceptable + if (error.response?.status === 404) return null; + throw error; + } + } + + private parseBasketballDetailsHeader( + html: string, + ): { home: any; away: any } | null { + if (!html) return null; + const $ = cheerio.load(html); + + const rows = $( + '.widget-basketball-match-details-header__score-details tbody tr', + ); + if (rows.length < 2) return null; + + const parseRow = (row: any) => { + const cols = $(row).find('td'); + // Format: TeamName, Q1, Q2, Q3, Q4, Final + // Values are inside .widget-basketball-match-details-header__score-part (just the quarter score) + // or direct text if simple table. + // User HTML shows: 33 + const getScore = (index: number) => { + const cell = cols.eq(index); + const part = cell.find( + '.widget-basketball-match-details-header__score-part', + ); + const val = part.length ? part.text() : cell.text(); + return this.safeInt(val.trim()); + }; + + return { + q1: getScore(1), + q2: getScore(2), + q3: getScore(3), + q4: getScore(4), + // If there's OT, it would be column 5, and Final column 6? + // Standard 4 quarters: Col 1,2,3,4. Col 5 is Final. + // If 5 cols (+name), logic holds. + // Let's assume standard for now. + }; + }; + + return { + home: parseRow(rows[0]), + away: parseRow(rows[1]), + }; + } + + // ============================================ + // BASKETBALL MARKETS (Odds) + // ============================================ + async fetchBasketballMarkets(matchId: string): Promise { + // User provided URL structure: /ajax/iddaa/markets/basketball/all/{matchId}?template=all + const url = `https://www.mackolik.com/ajax/iddaa/markets/basketball/all/${matchId}`; + + this.logger.debug(`📡 [${matchId}] Fetching basketball markets`); + + try { + const response = await this.axios.get(url, { + params: { template: 'all' }, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'User-Agent': DEFAULT_HEADERS['User-Agent'], + }, + }); + + if (response.data?.data?.html) { + return this.parseIddaaMarketsHtml(response.data.data.html); + } + return []; + } catch (error: any) { + if (error.response?.status === 404) { + this.logger.warn(`[${matchId}] Basketball markets not found (404)`); + return []; + } + throw error; + } + } + + extractPlayerIdFromUrl(url: string | undefined): string | null { + if (!url) return null; + const parts = url.split('/'); + return parts[parts.length - 1] || null; + } + + // ============================================ + // SIDELINED PLAYERS (Injuries & Suspensions) + // ============================================ + async fetchSidelinedPlayers( + matchId: string, + matchSlug: string, + ): Promise { + const url = `https://www.mackolik.com/mac/${matchSlug}/${matchId}`; + + this.logger.debug(`📡 [${matchId}] Fetching sidelined players`); + + try { + const response = await this.axios.get(url, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7', + Referer: 'https://www.mackolik.com', + }, + timeout: 10000, + }); + + const $ = cheerio.load(response.data); + + return { + homeTeam: this._parseSidelinedSection($, 0), + awayTeam: this._parseSidelinedSection($, 1), + }; + } catch (error: any) { + if (error.response?.status === 404) { + this.logger.warn(`[${matchId}] Match page not found (404)`); + return null; + } + this.logger.warn( + `[${matchId}] Sidelined fetch warning: ${error.message}`, + ); + return null; + } + } + + private _parseSidelinedSection( + $: cheerio.CheerioAPI, + teamIndex: number, + ): SidelinedTeamData { + const sidelinedWidgets = $('.widget-sidelined-players'); + + if (sidelinedWidgets.length <= teamIndex) { + return { teamName: '', teamId: '', totalSidelined: 0, players: [] }; + } + + const widget = sidelinedWidgets.eq(teamIndex); + + const teamCrest = widget.find('.widget-sidelined-players__header-crest'); + const teamCrestSrc = teamCrest.attr('src') || ''; + const teamId = teamCrestSrc.split('/').pop() || ''; + const teamName = widget + .find('.widget-sidelined-players__header-text') + .text() + .trim(); + + const players: SidelinedPlayer[] = []; + widget.find('.widget-sidelined-players__item').each((_, element) => { + const playerData = this._parsePlayerItem($, $(element)); + if (playerData) { + players.push(playerData); + } + }); + + return { + teamName, + teamId, + totalSidelined: players.length, + players, + }; + } + + private _parsePlayerItem( + $: cheerio.CheerioAPI, + $item: cheerio.Cheerio, + ): SidelinedPlayer | null { + try { + const nameElem = $item.find('.widget-sidelined-players__name'); + const playerName = nameElem.text().trim(); + const playerUrl = nameElem.attr('href') || ''; + const playerId = playerUrl.split('/').pop() || ''; + + const positionElem = $item.find('.widget-sidelined-players__position'); + const position = positionElem.attr('title') || ''; + const positionShort = positionElem.text().trim(); + + const reasonImg = $item.find('.widget-sidelined-players__reason img'); + const reasonIcon = reasonImg.attr('src') || ''; + + const numbers = $item.find('.widget-sidelined-players__number'); + // Use parseInt EXACTLY as in JS script (ignoring potential NaN for now, will handle via helper if needed but safer to stick to script logic first) + const matchesMissedText = + numbers.length > 0 ? numbers.eq(0).text().trim() : ''; + const matchesMissed = matchesMissedText + ? parseInt(matchesMissedText, 10) + : null; + + const averageText = numbers.length > 1 ? numbers.eq(1).text().trim() : ''; + const average = averageText ? parseInt(averageText, 10) : null; + + const description = $item + .find('.widget-sidelined-players__value') + .text() + .trim(); + + const type = reasonIcon.includes('shortage_1.png') + ? 'injury' + : reasonIcon.includes('suspension') + ? 'suspension' + : 'other'; + + return { + playerId, + playerName, + playerUrl: playerUrl.startsWith('http') + ? playerUrl + : `https://www.mackolik.com${playerUrl}`, + position, + positionShort, + type, + description, + matchesMissed: isNaN(matchesMissed as number) ? null : matchesMissed, + average: isNaN(average as number) ? null : average, + reasonIcon: reasonIcon.startsWith('http') + ? reasonIcon + : `https://www.mackolik.com${reasonIcon}`, // Keep safer URL construction but stick closer to logic + }; + } catch { + return null; + } + } +} diff --git a/src/modules/feeder/feeder-transformer.service.ts b/src/modules/feeder/feeder-transformer.service.ts new file mode 100755 index 0000000..864368b --- /dev/null +++ b/src/modules/feeder/feeder-transformer.service.ts @@ -0,0 +1,359 @@ +/** + * Feeder Transformer Service - Senior Level Implementation + * Transforms raw API data into database-ready formats + */ + +import { Injectable, Logger } from '@nestjs/common'; +import * as cheerio from 'cheerio'; +import { + RawKeyEvent, + TransformedEvent, + RawPlayerStats, + TransformedPlayer, + MatchParticipation, + TransformedMatchStats, + ParsedMarket, + MatchOfficial, + MatchState, + GameStatsResponse, + DbEventPayload, + DbMarketPayload, +} from './feeder.types'; + +@Injectable() +export class FeederTransformerService { + private readonly logger = new Logger(FeederTransformerService.name); + + // ============================================ + // HELPER FUNCTIONS + // ============================================ + private safeString(value: any): string | null { + return value === null || value === undefined || value === '' + ? null + : String(value); + } + + private safeInt(value: any): number | null { + const num = parseInt(String(value), 10); + return isNaN(num) ? null : num; + } + + private safeFloat(value: any): number | null { + const num = parseFloat(String(value)); + return isNaN(num) ? null : num; + } + + private extractPlayerIdFromUrl(url: string | undefined): string | null { + if (!url) return null; + const parts = url.split('/'); + return parts[parts.length - 1] || null; + } + + // ============================================ + // KEY EVENTS TRANSFORMER + // ============================================ + transformKeyEvents( + rawEvents: RawKeyEvent[], + homeTeamId: string, + awayTeamId: string, + matchId: string, + ): TransformedEvent[] { + return rawEvents.map((e) => { + const playerId = this.extractPlayerIdFromUrl(e.playerUrl) || ''; + const assistPlayerId = e.assistPlayerUrl + ? this.extractPlayerIdFromUrl(e.assistPlayerUrl) + : null; + const playerOutId = e.playerOutUrl + ? this.extractPlayerIdFromUrl(e.playerOutUrl) + : null; + + // Determine event type + let eventType: 'goal' | 'card' | 'substitute' | 'other' = 'other'; + if (e.type === 'goal') eventType = 'goal'; + else if (e.type === 'card') eventType = 'card'; + else if (e.type === 'substitute') eventType = 'substitute'; + + return { + matchId, + playerId, + playerName: e.playerName, + teamId: e.position === 'home' ? homeTeamId : awayTeamId, + eventType, + eventSubtype: e.subType || null, + timeMinute: e.timeMin, + timeSeconds: e.seconds, + periodId: e.periodId, + assistPlayerId, + assistPlayerName: e.assistPlayerName || null, + scoreAfter: e.score || null, + playerOutId, + playerOutName: e.playerOutName || null, + position: e.position, + }; + }); + } + + // ============================================ + // LINEUP PROCESSOR + // ============================================ + processLineup( + players: RawPlayerStats[], + teamId: string, + isStarting: boolean, + matchId: string, + playersMap: Map, + participationData: MatchParticipation[], + ): void { + if (!players || !Array.isArray(players)) return; + + players.forEach((p) => { + const playerId = this.safeString(p.personId); + const playerName = this.safeString(p.matchName); + + if (playerId && playerName) { + // Add to players map (for players table insert) + playersMap.set(playerId, { + id: playerId, + name: playerName, + slug: playerId, + teamId, + }); + + // Add participation record + participationData.push({ + matchId, + playerId, + teamId, + position: this.safeString(p.position), + shirtNumber: this.safeInt(p.shirtNumber), + isStarting, + }); + } + }); + } + + // ============================================ + // GAME STATS TRANSFORMER + // ============================================ + transformGameStats( + data: GameStatsResponse['data'] | null, + ): TransformedMatchStats | null { + if (!data || !data.home) return null; + + // Away possession can be calculated if not provided + const awayPossession: number | undefined = + data.away.possesionPercentage ?? + (data.home.possesionPercentage + ? 100 - data.home.possesionPercentage + : undefined); + + return { + home: { + possesionPercentage: data.home.possesionPercentage, + shotsOnTarget: data.home.shotsOnTarget, + shotsOffTarget: data.home.shotsOffTarget, + totalPasses: data.home.totalPasses, + corners: data.home.corners, + fouls: data.home.fouls, + offsides: data.home.offsides, + }, + away: { + possesionPercentage: awayPossession, + shotsOnTarget: data.away.shotsOnTarget, + shotsOffTarget: data.away.shotsOffTarget, + totalPasses: data.away.totalPasses, + corners: data.away.corners, + fouls: data.away.fouls, + offsides: data.away.offsides, + }, + }; + } + + // ============================================ + // MATCH STATE TO STATUS MAPPER + // ============================================ + mapMatchStateToStatus(state: MatchState | undefined): string { + if (!state) return 'NS'; + + switch (state) { + case 'postGame': + case 'post': + return 'FT'; + case 'preGame': + case 'pre': + return 'NS'; + case 'live': + case 'liveGame': + return 'LIVE'; + default: + return 'NS'; + } + } + + // ============================================ + // OFFICIALS PARSER (from match page HTML) + // ============================================ + parseOfficials(html: string): MatchOfficial[] { + if (!html) return []; + + const $ = cheerio.load(html); + const officials: MatchOfficial[] = []; + + // Try standard officials component + $('.p0c-match-officials__official-list-item').each((_, elem) => { + const name = $(elem) + .find('.p0c-match-officials__official-name') + .text() + .trim(); + const role = $(elem) + .find('.p0c-match-officials__official-group-title') + .text() + .trim(); + + if (name) { + officials.push({ name, role: role || 'Referee' }); + } + }); + + // Fallback: look for referee info in match info section + if (officials.length === 0) { + // Try alternative selectors + $('.widget-match-info__referee-name, .referee-name').each((_, elem) => { + const name = $(elem).text().trim(); + if (name) { + officials.push({ name, role: 'Referee' }); + } + }); + } + + return officials; + } + + // ============================================ + // IDDAA MARKETS TRANSFORMER + // For converting ParsedMarket[] to database format + // ============================================ + transformIddaaMarkets(markets: ParsedMarket[]): DbMarketPayload[] { + return markets.map((market) => ({ + id: market.marketId, + name: market.marketName, + iddaaCode: market.iddaaCode, + mbc: market.mbc, + selectionCollection: market.selections.map((s) => ({ + shortcode: s.shortcode, + name: s.label, + odd: s.value, + position: s.outcomeNo, + })), + })); + } + + /** + * Helper to convert ParsedMarket[] to LiveMatch.odds structure + * Useful for quick JSON storage + */ + transformToOddsJson( + markets: DbMarketPayload[], + ): Record> { + const odds: Record> = {}; + for (const market of markets) { + if (!market.name || !market.selectionCollection) continue; + + const marketName = market.name; + odds[marketName] = {}; + + for (const sel of market.selectionCollection) { + const val = parseFloat(sel.odd); + if (sel.name && !isNaN(val)) { + odds[marketName][sel.name] = val; + } + } + } + return odds; + } + + // ============================================ + // EXTRACT PLAYERS FROM EVENTS + // (for adding to players map) + // ============================================ + extractPlayersFromEvents( + events: TransformedEvent[], + playersMap: Map, + ): void { + events.forEach((event) => { + // Main player + if ( + event.playerId && + event.playerName && + !playersMap.has(event.playerId) + ) { + playersMap.set(event.playerId, { + id: event.playerId, + name: event.playerName, + slug: event.playerId, + }); + } + + // Assist player + if ( + event.assistPlayerId && + event.assistPlayerName && + !playersMap.has(event.assistPlayerId) + ) { + playersMap.set(event.assistPlayerId, { + id: event.assistPlayerId, + name: event.assistPlayerName, + slug: event.assistPlayerId, + }); + } + + // Player out (substitution) + if ( + event.playerOutId && + event.playerOutName && + !playersMap.has(event.playerOutId) + ) { + playersMap.set(event.playerOutId, { + id: event.playerOutId, + name: event.playerOutName, + slug: event.playerOutId, + }); + } + }); + } + + // ============================================ + // PREPARE EVENT DATA FOR DATABASE + // ============================================ + prepareEventDataForDb(events: TransformedEvent[]): DbEventPayload[] { + return events + .filter( + ( + e, + ): e is TransformedEvent & { + eventType: 'goal' | 'card' | 'substitute'; + } => e.eventType !== 'other' && !!e.playerId, + ) + .map((e) => ({ + match_id: e.matchId, + player_id: e.playerId, + team_id: e.teamId, + event_type: e.eventType, + event_subtype: e.eventSubtype, + time_minute: e.timeMinute, + time_seconds: e.timeSeconds, + period_id: e.periodId, + assist_player_id: e.assistPlayerId, + score_after: e.scoreAfter, + player_out_id: e.playerOutId, + position: e.position, + })); + } + + // ============================================ + // BASKETBALL PLAYER ID GENERATOR + // ============================================ + generateBasketballPlayerId(teamId: string, playerName: string): string { + return `${teamId}-${playerName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`; + } +} diff --git a/src/modules/feeder/feeder.module.ts b/src/modules/feeder/feeder.module.ts new file mode 100755 index 0000000..466df67 --- /dev/null +++ b/src/modules/feeder/feeder.module.ts @@ -0,0 +1,22 @@ +/** + * Feeder Module - Senior Level Implementation + */ + +import { Module } from '@nestjs/common'; +import { FeederService } from './feeder.service'; +import { FeederScraperService } from './feeder-scraper.service'; +import { FeederTransformerService } from './feeder-transformer.service'; +import { FeederPersistenceService } from './feeder-persistence.service'; +import { DatabaseModule } from '../../database/database.module'; + +@Module({ + imports: [DatabaseModule], + providers: [ + FeederService, + FeederScraperService, + FeederTransformerService, + FeederPersistenceService, + ], + exports: [FeederService, FeederScraperService, FeederPersistenceService], +}) +export class FeederModule {} diff --git a/src/modules/feeder/feeder.service.ts b/src/modules/feeder/feeder.service.ts new file mode 100755 index 0000000..e49c705 --- /dev/null +++ b/src/modules/feeder/feeder.service.ts @@ -0,0 +1,994 @@ +/** + * Feeder Service - Senior Level Implementation + * Main orchestration service for historical data scanning + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { FeederScraperService } from './feeder-scraper.service'; +import { FeederTransformerService } from './feeder-transformer.service'; +import { FeederPersistenceService } from './feeder-persistence.service'; +import { + Sport, + MatchSummary, + Competition, + LivescoresApiResponse, + TransformedPlayer, + MatchParticipation, + ProcessResult, + BasketballPlayerStats, + BasketballTeamStats, + TransformedMatchStats, + MatchOfficial, + ParsedMatchHeader, + ParsedMarket, + DbEventPayload, + DbMarketPayload, +} from './feeder.types'; + +interface ProcessDateOptions { + onlyCompletedMatches?: boolean; + refreshExistingMatches?: boolean; +} + +@Injectable() +export class FeederService { + private readonly logger = new Logger(FeederService.name); + + // Configuration - Adjust these based on rate limiting behavior + private readonly CONCURRENCY_LIMIT = 20; // Increased for maximum speed on EC2 + private readonly REQUEST_DELAY_MS = 50; // Minimal delay to respect basics + private readonly HISTORICAL_START_DATE = '2023-06-01'; // 2 years of data + private readonly SPORTS: Sport[] = ['football', 'basketball']; + private readonly MAX_RETRIES = 50; + private readonly DAILY_SYNC_TIME_ZONE = 'Europe/Istanbul'; + + constructor( + private readonly scraperService: FeederScraperService, + private readonly transformerService: FeederTransformerService, + private readonly persistenceService: FeederPersistenceService, + ) {} + + // ============================================ + // DELAY HELPER + // ============================================ + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private getYesterdayDateString(timeZone: string): string { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + const parts = formatter.formatToParts(new Date()); + const year = Number(parts.find((part) => part.type === 'year')?.value); + const month = Number(parts.find((part) => part.type === 'month')?.value); + const day = Number(parts.find((part) => part.type === 'day')?.value); + + const tzMidnightUtc = new Date(Date.UTC(year, month - 1, day)); + tzMidnightUtc.setUTCDate(tzMidnightUtc.getUTCDate() - 1); + + return tzMidnightUtc.toISOString().split('T')[0]; + } + + private getTimeZoneOffsetMs(date: Date, timeZone: string): number { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + timeZoneName: 'shortOffset', + }); + const offsetLabel = + formatter.formatToParts(date).find((part) => part.type === 'timeZoneName') + ?.value || 'GMT+0'; + + const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/); + if (!match) return 0; + + const sign = match[1] === '-' ? -1 : 1; + const hours = Number(match[2] || '0'); + const minutes = Number(match[3] || '0'); + + return sign * (hours * 60 + minutes) * 60 * 1000; + } + + private getDayBoundsForTimeZone( + dateString: string, + timeZone: string, + ): { startTs: number; endTs: number } { + const [year, month, day] = dateString.split('-').map(Number); + const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0)); + const nextDayGuess = new Date( + Date.UTC(year, month - 1, day + 1, 0, 0, 0), + ); + + const startOffsetMs = this.getTimeZoneOffsetMs(startGuess, timeZone); + const nextDayOffsetMs = this.getTimeZoneOffsetMs(nextDayGuess, timeZone); + + const startMs = + Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs; + const nextDayStartMs = + Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs; + + return { + startTs: Math.floor(startMs / 1000), + endTs: Math.floor((nextDayStartMs - 1) / 1000), + }; + } + + private parseScoreValue(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + private isCompletedMatchSummary(match: MatchSummary): boolean { + if (match.statusBoxContent === 'ERT') return false; + + const normalizedState = String(match.state || '') + .trim() + .toLowerCase(); + const normalizedStatus = String(match.status || '') + .trim() + .toLowerCase(); + const normalizedSubstate = String(match.substate || '') + .trim() + .toLowerCase(); + + if (['postgame', 'post'].includes(normalizedState)) return true; + + if ( + ['played', 'finished', 'ft', 'afterpenalties', 'penalties'].includes( + normalizedStatus, + ) + ) { + return true; + } + + if (['postgame', 'post', 'played', 'finished', 'ft'].includes(normalizedSubstate)) { + return true; + } + + const homeScore = this.parseScoreValue( + match.score?.home ?? match.homeScore, + ); + const awayScore = this.parseScoreValue( + match.score?.away ?? match.awayScore, + ); + + return homeScore !== null && awayScore !== null; + } + + async runPreviousDayCompletedMatchesScan( + sports: Sport[] = this.SPORTS, + targetDateStr: string = this.getYesterdayDateString( + this.DAILY_SYNC_TIME_ZONE, + ), + targetLeagueIds: string[] = [], + ): Promise { + this.logger.log( + `🗓️ STARTING DAILY COMPLETED MATCH SYNC [Date: ${targetDateStr}] [Sports: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`, + ); + + for (const sport of sports) { + await this.processDate(targetDateStr, sport, targetLeagueIds, { + onlyCompletedMatches: true, + refreshExistingMatches: true, + }); + } + + this.logger.log( + `✅ DAILY COMPLETED MATCH SYNC FINISHED [Date: ${targetDateStr}]`, + ); + } + + // ============================================ + // MAIN HISTORICAL SCAN + // ============================================ + async runHistoricalScan( + sports: Sport[] = this.SPORTS, + startDateStr: string = this.HISTORICAL_START_DATE, + targetLeagueIds: string[] = [], // NEW: Optional league filter + ): Promise { + this.logger.log( + `🚀 STARTING HISTORICAL SCAN [Target: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`, + ); + + const startDate = new Date(startDateStr); + const endDate = new Date(); + // Start from 2 days ago to avoid overlap with live_matches table. + // Cron jobs (data-fetcher.task.ts) handle today and yesterday, + // writing to live_matches. Historical scan should only fill matches table. + endDate.setDate(endDate.getDate() - 2); + + const stateKey = `historical_scan_state_${sports.join('_')}${targetLeagueIds.length > 0 ? '_filtered' : ''}_desc`; + let currentDate: Date | null = null; + + // Resume from saved state + try { + const savedState = await this.persistenceService.getState(stateKey); + if (savedState) { + const resumeDate = new Date(savedState); + // Ensure resumeDate is valid for reverse scan (<= endDate and >= startDate) + if (resumeDate <= endDate && resumeDate >= startDate) { + currentDate = new Date(resumeDate); + // For reverse scan, we resume from the *next* day backwards, i.e., resumeDate - 1 day + currentDate.setDate(currentDate.getDate() - 1); + this.logger.log( + `📍 Resuming from: ${currentDate.toISOString().split('T')[0]}`, + ); + } + } + } catch { + this.logger.warn('Could not read state, starting from beginning'); + } + + // Initialize currentDate to endDate if not resuming (or if resume failed) + // Note: If resuming, currentDate is already set above. + // If not resuming, we start from endDate (Today) and go backwards. + if (!currentDate) { + currentDate = new Date(endDate); + } + + this.logger.log( + `📊 Scanning (Reverse): ${currentDate.toISOString().split('T')[0]} ← ${startDate.toISOString().split('T')[0]}`, + ); + + let processedDays = 0; + const scanStartTime = Date.now(); + + // REVERSE LOOP: Iterate while currentDate is greater than or equal to startDate + while (currentDate >= startDate) { + const dateString = currentDate.toISOString().split('T')[0]; + + for (const sport of sports) { + await this.processDate(dateString, sport, targetLeagueIds); + } + + // Save state + await this.persistenceService.setState(stateKey, dateString); + + // --- ETA CALCULATION --- + processedDays++; + const now = Date.now(); + const totalElapsed = now - scanStartTime; + const avgTimePerDay = totalElapsed / processedDays; + + // Calculate remaining days based on current position for REVERSE scan + // Days left = (currentDate - startDate) + const daysLeft = Math.ceil( + (currentDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24), + ); + + const estimatedRemainingMs = avgTimePerDay * daysLeft; + + // Format time helper + const formatDuration = (ms: number) => { + const seconds = Math.floor((ms / 1000) % 60); + const minutes = Math.floor((ms / (1000 * 60)) % 60); + const hours = Math.floor(ms / (1000 * 60 * 60)); + return `${hours}h ${minutes}m ${seconds}s`; + }; + + this.logger.log( + `⏱️ PROGRESS: [${processedDays} days done] | Avg/Day: ${(avgTimePerDay / 1000).toFixed(1)}s | Remaining: ${daysLeft} days | 🏁 ETA: ${formatDuration(estimatedRemainingMs)}`, + ); + + // Decrement date for reverse scan + currentDate.setDate(currentDate.getDate() - 1); + } + + this.logger.log('🎉 HISTORICAL SCAN COMPLETED'); + } + + // ============================================ + // PROCESS SINGLE DATE + // ============================================ + private async processDate( + dateString: string, + sport: Sport, + targetLeagueIds: string[] = [], + options: ProcessDateOptions = {}, + ): Promise { + const { onlyCompletedMatches = false, refreshExistingMatches = false } = + options; + this.logger.log(`[${sport}] 📅 Processing: ${dateString}`); + + try { + // Fetch historical source snapshot for the date with retry. + // The upstream endpoint is named "livescores", but this path is used + // strictly as a historical source and filtered by mstUtc below. + let response: LivescoresApiResponse | null = null; + for (let i = 0; i < 3; i++) { + try { + response = await this.scraperService.fetchLivescores( + dateString, + sport, + ); + break; // Success, exit loop + } catch (e: any) { + const is502 = + e.message?.includes('502') || + e.response?.status === 502 || + e.message?.includes('Bad Gateway'); + + if (is502 && i < 2) { + this.logger.warn( + `[${sport}] [${dateString}] Historical source fetch returned 502. Retrying in 5s...`, + ); + await this.delay(5000); + continue; + } + throw e; // Rethrow if not 502 or retries exhausted + } + } + const data = response?.data; + + if (!data?.matches || !data?.competitions) { + this.logger.warn(`[${sport}] [${dateString}] No data from API`); + return; + } + + // Filter matches with iddaa code and deduplicate + const rawMatches = Object.values( + data.matches, + ) as unknown as MatchSummary[]; + + const allMatches = rawMatches.filter((m) => m.iddaaCode); + + // CRITICAL FIX: Filter matches by actual match date (mstUtc). + // Mackolik's historical source endpoint can still return current live/upcoming matches + // regardless of the matchDate query parameter. We must filter by mstUtc + // to ensure we only process matches that actually belong to the target date. + const { startTs: targetDateStartTs, endTs: targetDateEndTs } = + this.getDayBoundsForTimeZone( + dateString, + this.DAILY_SYNC_TIME_ZONE, + ); + + const dateFilteredMatches = allMatches.filter((m) => { + const matchTs = m.mstUtc; + return matchTs >= targetDateStartTs && matchTs <= targetDateEndTs; + }); + + const apiReturnedCount = allMatches.length; + const afterDateFilterCount = dateFilteredMatches.length; + + if (apiReturnedCount > 0 && afterDateFilterCount === 0) { + this.logger.log( + `[${sport}] [${dateString}] Historical source returned ${apiReturnedCount} matches, but none belong to the target date after mstUtc filtering. Skipping.`, + ); + return; + } + + if (afterDateFilterCount < apiReturnedCount) { + this.logger.log( + `[${sport}] [${dateString}] Filtered out ${apiReturnedCount - afterDateFilterCount} off-date rows from historical source payload before processing.`, + ); + } + + let matchesToProcess = Array.from( + new Map(dateFilteredMatches.map((m) => [m.id, m])).values(), + ); + + if (targetLeagueIds.length > 0) { + matchesToProcess = matchesToProcess.filter((m) => + targetLeagueIds.includes(m.competitionId), + ); + } + + if (onlyCompletedMatches) { + const beforeCompletedFilter = matchesToProcess.length; + matchesToProcess = matchesToProcess.filter((m) => + this.isCompletedMatchSummary(m), + ); + + if ( + beforeCompletedFilter > 0 && + matchesToProcess.length < beforeCompletedFilter + ) { + this.logger.log( + `[${sport}] [${dateString}] Filtered out ${beforeCompletedFilter - matchesToProcess.length} non-completed matches from daily sync payload.`, + ); + } + } + + // 1. Check if any matches came from source + if (matchesToProcess.length === 0) { + this.logger.log( + `[${sport}] [${dateString}] No iddaa matches found in source`, + ); + return; + } + + // 2. Filter out already existing matches to skip processing + const allIds = matchesToProcess.map((m) => m.id); + const existingIds = + await this.persistenceService.getExistingMatchIds(allIds); + const totalCount = matchesToProcess.length; + + if (!refreshExistingMatches && existingIds.length > 0) { + matchesToProcess = matchesToProcess.filter( + (m) => !existingIds.includes(m.id), + ); + } + + if (matchesToProcess.length === 0) { + this.logger.log( + `[${sport}] [${dateString}] All ${totalCount} matches already exist. Skipping...`, + ); + return; + } + + if (refreshExistingMatches) { + this.logger.log( + `[${sport}] [${dateString}] Refreshing ${matchesToProcess.length} completed matches (${existingIds.length} already existed in matches)`, + ); + } else { + this.logger.log( + `[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} matches (Skipped ${existingIds.length} existing)`, + ); + } + + let successCount = 0; + const failedMatches: MatchSummary[] = []; + + // 1. SEQUENTIAL PROCESSING (Robust Mode) + // Processes matches one by one to avoid 502 errors + let sequentialCount = 0; + for (const match of matchesToProcess) { + sequentialCount++; + + // Batch pause: Wait for ~5 matches worth of time every 10 matches + if (sequentialCount > 1 && sequentialCount % 10 === 0) { + this.logger.log( + `[${sport}] ⏸️ Processed 10 matches, pausing for cooldown...`, + ); + await this.delay(4000); // Wait 2s (approx 5 * 400ms) + } + + await this.delay(300); // 300ms delay between individual matches + try { + const result = await this.processSingleMatch( + match, + data.competitions, + sport, + refreshExistingMatches, + ); + + if (result.success) { + this.logger.log( + `[${sport}] ✅ successful for ${match.id} ${match.homeTeam.name} vs ${match.awayTeam.name}`, + ); + successCount++; + } else if (result.retryable) { + this.logger.log( + `[${sport}] ⚠️ retryable for ${match.id} ${match.homeTeam.name} vs ${match.awayTeam.name}`, + ); + failedMatches.push(match); + } + } catch (e: any) { + this.logger.warn( + `[${sport}] Sequential error for ${match.id}: ${e.message}`, + ); + failedMatches.push(match); + } + } + + // 2. SEQUENTIAL RETRY FOR FAILED (502) MATCHES + if (failedMatches.length > 0) { + this.logger.log( + `[${sport}] ⚠️ Retrying ${failedMatches.length} failed matches sequentially...`, + ); + + for (const match of failedMatches) { + await this.delay(2000); // Longer delay for retries + try { + const result = await this.processSingleMatch( + match, + data.competitions, + sport, + refreshExistingMatches, + ); + if (result.success) { + successCount++; + this.logger.log(`[${sport}] ✅ Retry successful for ${match.id}`); + } else { + this.logger.warn(`[${sport}] ❌ Retry failed for ${match.id}`); + } + } catch (e: any) { + this.logger.warn( + `[${sport}] ❌ Retry exception for ${match.id}: ${e.message}`, + ); + } + } + } + this.logger.log( + `[${sport}] [${dateString}] ✓ Saved ${successCount} matches`, + ); + } catch (error: any) { + this.logger.error( + `[${sport}] [${dateString}] ❌ Failed: ${error.message}`, + ); + } + } + + // ============================================ + // REFRESH SINGLE MATCH (On-demand) + // ============================================ + async refreshMatch( + matchId: string, + scope: 'all' | 'lineups' | 'odds' = 'all', + ): Promise { + this.logger.log(`🔄 Refreshing match (${scope}) for ${matchId}`); + + const matchRecord = await this.persistenceService.getMatch(matchId); + if (!matchRecord) { + this.logger.warn(`[${matchId}] Refresh failed: Match not in DB`); + return { success: false, retryable: false, error: 'Match not found' }; + } + + // Construct MatchSummary from DB record + const summary: MatchSummary = { + id: matchId, + matchName: matchRecord.matchName, + matchSlug: matchRecord.matchSlug, + competitionId: matchRecord.leagueId, + mstUtc: Number(matchRecord.mstUtc), + iddaaCode: matchRecord.iddaaCode, + homeTeam: { + id: matchRecord.homeTeamId, + name: matchRecord.homeTeam?.name || '', + slug: matchRecord.homeTeam?.slug || '', + }, + awayTeam: { + id: matchRecord.awayTeamId, + name: matchRecord.awayTeam?.name || '', + slug: matchRecord.awayTeam?.slug || '', + }, + score: { + home: matchRecord.scoreHome, + away: matchRecord.scoreAway, + }, + }; + + const dummyCompetitions: Record = { + [summary.competitionId]: { + id: summary.competitionId, + name: 'Unknown', + competitionSlug: '', + country: { id: '', name: '' }, + }, + }; + + try { + return await this.processSingleMatch( + summary, + dummyCompetitions, + matchRecord.sport as Sport, + true, // FORCE UPDATE + scope, + ); + } catch (error: any) { + this.logger.error(`[${matchId}] Refresh exception: ${error.message}`); + return { success: false, retryable: true, error: error.message }; + } + } + + // ============================================ + // PROCESS SINGLE MATCH + // ============================================ + private async processSingleMatch( + matchSummary: MatchSummary, + competitions: Record, + sport: Sport, + force: boolean = false, + scope: 'all' | 'lineups' | 'odds' = 'all', // Add scope flag + ): Promise { + const matchId = matchSummary.id; + const homeTeamId = matchSummary.homeTeam?.id; + const awayTeamId = matchSummary.awayTeam?.id; + + if (!matchId || !homeTeamId || !awayTeamId) { + this.logger.warn(`[${matchId}] Skipped: Missing IDs`); + return { success: false, retryable: false }; + } + + // Skip postponed matches (ERT = Erteledendi) + if (matchSummary.statusBoxContent === 'ERT') { + this.logger.debug(`[${matchId}] Skipped: Postponed match (ERT)`); + return { success: false, retryable: false }; + } + + // Track critical errors (502) to trigger retry even if save succeeds + let hasCriticalError = false; + + // Helper for resilient fetching with internal retry + const fetchResilient = async ( + label: string, + fn: () => Promise, + retries = 3, + baseDelayMs = 1000, + ): Promise => { + for (let i = 0; i < retries; i++) { + try { + return await fn(); + } catch (e: any) { + const is502 = + e.message?.includes('502') || + e.response?.status === 502 || + e.message?.includes('Bad Gateway'); + + if (i === retries - 1) throw e; // Last attempt failed + + if (is502) { + // Exponential backoff: 1s, 2s, 3s + const waitTime = baseDelayMs * (i + 1); + // this.logger.debug( + // `[${matchId}] ${label} failed (502). Retrying in ${waitTime}ms...`, + // ); + await this.delay(waitTime); + continue; + } + throw e; // Non-502 error, fail immediately + } + } + return null; + }; + + try { + // Check if exists + if (!force) { + // Skip exist check if force is true + const exists = await this.persistenceService.matchExists(matchId); + if (exists) { + return { success: true, retryable: false }; + } + } + + this.logger.debug( + `[${matchId}] Processing (${scope}): ${matchSummary.matchName}`, + ); + + const league = competitions[matchSummary.competitionId]; + const playersMap = new Map(); + const participationData: MatchParticipation[] = []; + let eventData: DbEventPayload[] = []; + let stats: TransformedMatchStats | null = null; + let basketballTeamStats: BasketballTeamStats | null = null; + const basketballPlayerStats: Partial[] = []; + let officialsData: MatchOfficial[] = []; + + // 1. Fetch Match Header (score, status) + let headerData: ParsedMatchHeader | null = null; + if (scope === 'all') { + try { + headerData = await fetchResilient('Header', () => + this.scraperService.fetchMatchHeader(matchId), + ); + } catch (e: any) { + if (e.message?.includes('502')) hasCriticalError = true; + this.logger.warn(`[${matchId}] Header fetch failed: ${e.message}`); + } + } + + // 2. Sport-specific data fetching + if (sport === 'basketball') { + // Basketball: Box Score (Always if all or lineups) + if (scope === 'all' || scope === 'lineups') { + try { + const boxData = await fetchResilient('BoxScore', () => + this.scraperService.fetchBasketballBoxScore(matchId), + ); + if (boxData) { + const homeParsed = this.scraperService.parseBasketballBoxScore( + boxData.views?.home?.html || '', + ); + const awayParsed = this.scraperService.parseBasketballBoxScore( + boxData.views?.away?.html || '', + ); + + basketballTeamStats = + scope === 'all' + ? { + home: homeParsed.teamTotals, + away: awayParsed.teamTotals, + } + : null; + + if (scope === 'all') { + try { + const details = await fetchResilient('QuarterScores', () => + this.scraperService.fetchBasketballDetailsHeader(matchId), + ); + if (details && basketballTeamStats) { + basketballTeamStats.home = { + ...basketballTeamStats.home, + ...details.home, + }; + basketballTeamStats.away = { + ...basketballTeamStats.away, + ...details.away, + }; + } + } catch (e: any) { + if (e.message?.includes('502')) hasCriticalError = true; + this.logger.warn( + `[${matchId}] Quarter scores fetch failed: ${e.message}`, + ); + } + } + + // Process players (always do if lineups or all) + const processPlayers = ( + parsed: typeof homeParsed, + teamId: string, + ) => { + parsed.players.forEach((p) => { + if (p.name) { + // Use extracted ID if available, otherwise generate one + const id = + p.id || + this.transformerService.generateBasketballPlayerId( + teamId, + p.name, + ); + basketballPlayerStats.push({ ...p, id, teamId }); + playersMap.set(id, { + id, + name: p.name, + slug: id, + teamId, + }); + } + }); + }; + + processPlayers(homeParsed, homeTeamId); + processPlayers(awayParsed, awayTeamId); + } + } catch (e: any) { + if (e.message?.includes('502')) hasCriticalError = true; + this.logger.warn(`[${matchId}] Box score failed: ${e.message}`); + } + } + } else { + // Football: Events, Lineups, Stats, Officials + + // Key Events + if (scope === 'all') { + try { + const eventsData = await fetchResilient('Events', () => + this.scraperService.fetchKeyEvents(matchId), + ); + if (eventsData?.keyEvents) { + const transformedEvents = + this.transformerService.transformKeyEvents( + eventsData.keyEvents, + homeTeamId, + awayTeamId, + matchId, + ); + + this.transformerService.extractPlayersFromEvents( + transformedEvents, + playersMap, + ); + + eventData = + this.transformerService.prepareEventDataForDb( + transformedEvents, + ); + } + } catch (e: any) { + if (e.message?.includes('502')) hasCriticalError = true; + this.logger.warn(`[${matchId}] Events failed: ${e.message}`); + } + + await this.delay(300); + } + + // Starting Formation & Substitutes (Always for lineups or all) + // V20 OPTIMIZATION: Disabled to speed up feeder and reduce 502 errors. + // We only use Team Stats for V20 model. + /* + if (scope === 'all' || scope === 'lineups') { + // Starting Formation + try { + const formationData = + await this.scraperService.fetchStartingFormation(matchId); + if (formationData?.stats) { + this.transformerService.processLineup( + formationData.stats.home || [], + homeTeamId, + true, + matchId, + playersMap, + participationData, + ); + this.transformerService.processLineup( + formationData.stats.away || [], + awayTeamId, + true, + matchId, + playersMap, + participationData, + ); + } + } catch (e: any) { + if (e.message?.includes('502')) hasCriticalError = true; + this.logger.warn(`[${matchId}] Formation failed: ${e.message}`); + } + + // Substitutes + try { + const subsData = + await this.scraperService.fetchSubstitutions(matchId); + if (subsData?.stats) { + this.transformerService.processLineup( + subsData.stats.home || [], + homeTeamId, + false, + matchId, + playersMap, + participationData, + ); + this.transformerService.processLineup( + subsData.stats.away || [], + awayTeamId, + false, + matchId, + playersMap, + participationData, + ); + } + } catch (e: any) { + if (e.message?.includes('502')) hasCriticalError = true; + this.logger.warn(`[${matchId}] Subs failed: ${e.message}`); + } + } + */ + + // Game Stats & Officials + if (scope === 'all') { + try { + const gameStats = await fetchResilient('Stats', () => + this.scraperService.fetchGameStats(matchId), + ); + stats = this.transformerService.transformGameStats(gameStats); + } catch (e: any) { + if (e.message?.includes('502')) hasCriticalError = true; + this.logger.warn(`[${matchId}] Stats failed: ${e.message}`); + } + + // Officials (from match page) + try { + const matchPageHtml = await fetchResilient('Officials', () => + this.scraperService.fetchMatchPage( + matchId, + matchSummary.matchSlug, + sport, + ), + ); + if (matchPageHtml) { + officialsData = + this.transformerService.parseOfficials(matchPageHtml); + } + } catch (e: any) { + if (e.message?.includes('502')) hasCriticalError = true; + this.logger.warn(`[${matchId}] Officials failed: ${e.message}`); + } + } + } + + // 3. Fetch Iddaa Odds (Always if all or odds) + let oddsArray: DbMarketPayload[] = []; + if (scope === 'all' || scope === 'odds') { + try { + let markets: ParsedMarket[] = []; + if (sport === 'basketball') { + markets = + ((await fetchResilient('BucketOdds', () => + this.scraperService.fetchBasketballMarkets(matchId), + )) as ParsedMarket[]) || []; + } else { + markets = + ((await fetchResilient('IddaaOdds', () => + this.scraperService.fetchIddaaMarkets(matchId), + )) as ParsedMarket[]) || []; + } + // Logic is same since structure is ParsedMarket[] + oddsArray = this.transformerService.transformIddaaMarkets(markets); + } catch (e: any) { + if (e.message?.includes('502')) hasCriticalError = true; + this.logger.warn(`[${matchId}] Odds failed: ${e.message}`); + } + } + + // 4. Persist to Database + let saved = false; + if (scope === 'lineups') { + saved = await this.persistenceService.saveLineups( + matchId, + playersMap, + participationData, + homeTeamId, + awayTeamId, + ); + } else if (scope === 'odds') { + saved = await this.persistenceService.saveOdds(matchId, oddsArray); + } else { + // Full Update + saved = await this.persistenceService.saveMatch( + sport, + matchId, + matchSummary, + league, + homeTeamId, + awayTeamId, + headerData, + playersMap, + participationData, + eventData, + stats, + basketballTeamStats, + basketballPlayerStats, + oddsArray, + officialsData, + ); + } + + // === AI FEATURE CALCULATION (V17 - DEPRECATED) === + // Bu servis V17 modeli içindi. V20 Modeli tamamen Python (ai-engine) tarafında çalışmaktadır. + // Gereksiz kaynak tüketmemesi için devre dışı bırakıldı. + /* + if (saved) { + try { + // Fire and forget - don't block the feeder + this.aiFeatureStoreService + .calculateAndSaveFeatures(matchId) + .catch((err) => { + this.logger.warn( + `[${matchId}] AI Feature calculation failed: ${err.message}`, + ); + }); + } catch (e) { + // Safety catch + } + } + */ + // ========================================== + + if (saved && hasCriticalError) { + // Collect missing components + const missingParts: string[] = []; + if (!stats) missingParts.push('Stats'); + if (oddsArray.length === 0) missingParts.push('Odds'); + if (officialsData.length === 0) missingParts.push('Officials'); + + this.logger.warn( + `[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(', ')}]. Scheduled for retry.`, + ); + return { success: false, retryable: true }; + } + + return { success: saved, retryable: !saved }; + } catch (error: any) { + const isRetryable = + error.message.includes('502') || + error.message.includes('504') || + error.message.includes('ECONNABORTED') || + error.message.includes('timeout') || + error.message.includes('ETIMEDOUT') || + error.message.includes('Unique constraint'); // Concurrency retry + + if (isRetryable) { + this.logger.warn(`[${matchId}] ${error.message} - Will retry`); + } else { + this.logger.error(`[${matchId}] ${error.message} - Not retryable`); + } + + return { success: false, retryable: isRetryable }; + } + } +} diff --git a/src/modules/feeder/feeder.types.ts b/src/modules/feeder/feeder.types.ts new file mode 100755 index 0000000..086f3ba --- /dev/null +++ b/src/modules/feeder/feeder.types.ts @@ -0,0 +1,533 @@ +/** + * Feeder Types - Senior Level Implementation + * Based on actual Mackolik API responses + */ + +// ============================================ +// SPORT TYPES +// ============================================ +export type Sport = 'football' | 'basketball'; + +export const SPORTS_CONFIG: Record< + Sport, + { sportParam: string; iddaaUrlPath: string } +> = { + football: { sportParam: 'Soccer', iddaaUrlPath: 'mac' }, + basketball: { sportParam: 'Basketball', iddaaUrlPath: 'basketbol/mac' }, +}; + +// ============================================ +// MATCH STATUS TYPES +// ============================================ +export type MatchStatus = 'Cancelled' | 'Played' | 'Playing' | 'Scheduled'; +export type MatchState = + | 'preGame' + | 'postGame' + | 'live' + | 'liveGame' + | 'pre' + | 'post'; + +// ============================================ +// LIVESCORES API RESPONSE +// ============================================ +export interface LivescoresApiResponse { + status: string; + data: { + matches: Record; + competitions: Record; + }; +} + +export interface MatchSummary { + id: string; + matchName: string; + matchSlug: string; + competitionId: string; + mstUtc: number; + iddaaCode: string | number | null; + statusBoxContent?: string | null; // ERT = Erteledendi + substate?: string | null; + homeTeam: { + id: string; + name: string; + slug: string; + }; + awayTeam: { + id: string; + name: string; + slug: string; + }; + score?: { + home: number | string | null; + away: number | string | null; + ht?: { + home: number | string | null; + away: number | string | null; + }; + }; + homeScore?: number | string | null; + awayScore?: number | string | null; + state?: string | null; + status?: string | null; + winner?: string; +} + +export interface Competition { + id: string; + name: string; + competitionSlug: string; + country: { + id: string; + name: string; + }; +} + +// ============================================ +// MATCH HEADER API RESPONSE +// ============================================ +export interface MatchHeaderResponse { + status: string; + data: { + html: string; // Contains score, status, HT score + }; +} + +export interface ParsedMatchHeader { + matchStatus: MatchState; + scoreHome: number | null; + scoreAway: number | null; + htScoreHome: number | null; + htScoreAway: number | null; +} + +// ============================================ +// KEY EVENTS API RESPONSE +// ============================================ +export interface KeyEventsResponse { + status: string; + data: { + keyEvents: RawKeyEvent[]; + matchState: MatchState; + matchStartTime: number; + finishedPeriodIds: number[]; + }; +} + +export interface RawKeyEvent { + type: 'goal' | 'card' | 'substitute' | 'penalty-missed'; + subType: 'goal' | 'penalty-goal' | 'yc' | 'rc' | 'pm' | 'ps' | null; + position: 'home' | 'away'; + periodId: number; // 1 = 1st half, 2 = 2nd half + timeMin: string; + seconds: number | null; + playerName: string; + playerUrl: string; + assistPlayerName?: string | null; + assistPlayerUrl?: string | null; + playerOutName?: string | null; + playerOutUrl?: string | null; + score?: string; // "1-0" format +} + +export interface TransformedEvent { + matchId: string; + playerId: string; + playerName: string; + teamId: string; + eventType: 'goal' | 'card' | 'substitute' | 'other'; + eventSubtype: string | null; + timeMinute: string; + timeSeconds: number | null; + periodId: number; + assistPlayerId: string | null; + assistPlayerName: string | null; + scoreAfter: string | null; + playerOutId: string | null; + playerOutName: string | null; + position: 'home' | 'away'; +} + +// ============================================ +// MATCH STATS (LINEUPS) API RESPONSE +// ============================================ +export interface MatchStatsResponse { + status: string; + data: { + status: MatchState; + stats: { + home: RawPlayerStats[]; + away: RawPlayerStats[]; + homeBench?: RawPlayerStats[]; + awayBench?: RawPlayerStats[]; + homeSubstitutes?: RawPlayerStats[]; + awaySubstitutes?: RawPlayerStats[]; + }; + }; +} + +export interface RawPlayerStats { + personId: string; + matchName: string; + shirtNumber: number | null; + position: 'goalkeeper' | 'defender' | 'midfielder' | 'striker' | 'Coach' | ''; + events: PlayerEvent[] | null; +} + +export interface PlayerEvent { + name: + | 'goal' + | 'yellow-card' + | 'red-card' + | 'sub-off' + | 'sub-on' + | 'penalty-missed'; + timeMin: string; + count: number; +} + +export interface TransformedPlayer { + id: string; + name: string; + slug: string; + teamId?: string; +} + +export interface MatchParticipation { + matchId: string; + playerId: string; + teamId: string; + position: string | null; + shirtNumber: number | null; + isStarting: boolean; +} + +// ============================================ +// GAME STATS API RESPONSE +// ============================================ +export interface GameStatsResponse { + status: string; + data: { + status: MatchStatus; + startTime: number; + home: TeamGameStats; + away: Partial; + }; +} + +export interface TeamGameStats { + possesionPercentage?: number; + shotsOnTarget?: number; + shotsOffTarget?: number; + totalPasses?: number; + corners?: number; + fouls?: number; + offsides?: number; +} + +export interface TransformedMatchStats { + home: TeamGameStats; + away: TeamGameStats; +} + +// ============================================ +// MANAGER API RESPONSE +// ============================================ +export interface ManagerResponse { + status: string; + data: { + status: MatchState; + stats: { + home: RawPlayerStats; + away: RawPlayerStats; + }; + }; +} + +export interface TransformedManager { + id: string; + name: string; + role: string; +} + +// ============================================ +// IDDAA ODDS API RESPONSE (JSON Endpoint) +// ============================================ +export interface IddaaOddsResponse { + status: string; + data: { + matchStatus: MatchStatus; + markets: Record; + }; +} + +export interface IddaaMarket { + outcomes: Record; + code: string; + mbc: string; +} + +export interface IddaaOutcome { + outcome: string; // The odds value (e.g., "1.78") + handicap: string | null; + state: 'active' | 'suspended'; + label: string; // "1", "X", "2", "Alt", "Üst", etc. +} + +// ============================================ +// IDDAA MARKETS HTML RESPONSE +// ============================================ +export interface IddaaMarketsHtmlResponse { + status: string; + data: { + html: string; + matchStatus: MatchStatus; + }; +} + +export interface ParsedMarket { + marketId: string; + marketName: string; + iddaaCode: string; + mbc: string; + selections: ParsedSelection[]; +} + +export interface ParsedSelection { + shortcode: string; + outcomeNo: string; + label: string; + value: string; // The odds value +} + +// ============================================ +// BASKETBALL BOX SCORE +// ============================================ +export interface BasketballBoxScoreResponse { + status: string; + data: { + views: { + home: { html: string }; + away: { html: string }; + }; + }; +} + +export interface BasketballPlayerStats { + id: string; + name: string; + teamId: string; + minutes: string; + points: number; + rebounds: number; + assists: number; + steals: number; + blocks: number; + turnovers: number; + fouls: number; + fgMade: number; + fgAttempted: number; + threePtMade: number; + threePtAttempted: number; + ftMade: number; + ftAttempted: number; +} + +export interface BasketballTeamTotals { + points?: number; + rebounds?: number; + assists?: number; + steals?: number; + blocks?: number; + turnovers?: number; + fouls?: number; + fgMade?: number; + fgAttempted?: number; + threePtMade?: number; + threePtAttempted?: number; + ftMade?: number; + ftAttempted?: number; + q1?: number | null; + q2?: number | null; + q3?: number | null; + q4?: number | null; + ot?: number | null; +} + +export interface BasketballTeamStats { + home: BasketballTeamTotals; + away: BasketballTeamTotals; +} + +// ============================================ +// MATCH OFFICIALS +// ============================================ +export interface MatchOfficial { + name: string; + role: string; +} + +export interface DbEventPayload { + match_id: string; + player_id: string; + team_id: string; + event_type: 'goal' | 'card' | 'substitute'; + event_subtype: string | null; + time_minute: string; + time_seconds: number | null; + period_id: number; + assist_player_id: string | null; + score_after: string | null; + player_out_id: string | null; + position: 'home' | 'away'; +} + +export interface DbMarketSelectionPayload { + shortcode: string; + name: string; + odd: string; + position: string; +} + +export interface DbMarketPayload { + id: string; + name: string; + iddaaCode: string; + mbc: string; + selectionCollection: DbMarketSelectionPayload[]; +} + +// ============================================ +// MARKET MAPPING (Static) +// ============================================ +export const MARKET_MAPPING: Record = { + // Ana Bahisler + '1': 'Maç Sonucu', + '3': 'Çifte Şans', + '6-11': 'Handikaplı MS (0:1)', + '6-22': 'Handikaplı MS (0:2)', + '611': 'Handikaplı MS (1:0)', + '622': 'Handikaplı MS (2:0)', + '14': 'İlk Yarı / Maç Sonucu', + '15': 'Maç Skoru', + + // Gol Alt/Üst + '180.5': '0.5 Alt/Üst', + '181.5': '1.5 Alt/Üst', + '182.5': '2.5 Alt/Üst', + '183.5': '3.5 Alt/Üst', + '184.5': '4.5 Alt/Üst', + '185.5': '5.5 Alt/Üst', + + // Diğer Gol Bahisleri + '11': 'Karşılıklı Gol', + '12': 'Tek / Çift', + '24': 'İlk Golü Kim Atar', + '26': 'Toplam Gol Aralığı', + '32': 'En Çok Gol Olacak Yarı', + + // Yarı Bahisleri + '4': '1. Yarı Sonucu', + '5': '1. Yarı Çifte Şans', + '54': '2. Yarı Sonucu', + '190.5': '1. Yarı 0.5 Alt/Üst', + '191.5': '1. Yarı 1.5 Alt/Üst', + '192.5': '1. Yarı 2.5 Alt/Üst', + '39': '1. Yarı Karşılıklı Gol', + + // Takım Bahisleri + '280.5': 'Ev Sahibi 0.5 Alt/Üst', + '281.5': 'Ev Sahibi 1.5 Alt/Üst', + '282.5': 'Ev Sahibi 2.5 Alt/Üst', + '283.5': 'Ev Sahibi 3.5 Alt/Üst', + '290.5': 'Deplasman 0.5 Alt/Üst', + '291.5': 'Deplasman 1.5 Alt/Üst', + '292.5': 'Deplasman 2.5 Alt/Üst', + '400.5': '1. Yarı Ev Sahibi 0.5 Alt/Üst', + '430.5': '1. Yarı Deplasman 0.5 Alt/Üst', + '37': 'Ev Sahibi Gol Yemeden Kazanır', + '38': 'Deplasman Gol Yemeden Kazanır', + + // Korner & Kart + '47': 'En Çok Korner', + '48': '1. Yarı En Çok Korner', + '49': 'İlk Korner', + '43': 'Toplam Korner Aralığı', + '44': '1. Yarı Korner Aralığı', + '463.5': '1. Yarı 3.5 Korner Alt/Üst', + '464.5': '1. Yarı 4.5 Korner Alt/Üst', + '465.5': '1. Yarı 5.5 Korner Alt/Üst', + '53': 'Kırmızı Kart Olur mu?', + '384.5': '4.5 Kart Puanı Alt/Üst', + '385.5': '5.5 Kart Puanı Alt/Üst', + '386.5': '6.5 Kart Puanı Alt/Üst', + + // Kombine + '301.5': 'MS ve 1.5 Alt/Üst', + '302.5': 'MS ve 2.5 Alt/Üst', + '303.5': 'MS ve 3.5 Alt/Üst', + '304.5': 'MS ve 4.5 Alt/Üst', + + // İki Yarıyı da Kazanır (39 conflicts with 1. Yarı Karşılıklı Gol, keep that one) + '40': 'Deplasman İki Yarıyı da Kazanır', +}; + +// ============================================ +// AXIOS CONFIG +// ============================================ +export interface AxiosRequestConfig { + headers: { + 'User-Agent': string; + Referer: string; + 'X-Requested-With': string; + 'Accept-Language'?: string; + }; + timeout: number; +} + +export const DEFAULT_HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Referer: 'https://www.mackolik.com/', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7', +}; + +export const DEFAULT_TIMEOUT = 30000; + +// ============================================ +// SIDELINED PLAYERS API RESPONSE +// ============================================ +export interface SidelinedResponse { + homeTeam: SidelinedTeamData; + awayTeam: SidelinedTeamData; +} + +export interface SidelinedTeamData { + teamName: string; + teamId: string; + totalSidelined: number; + players: SidelinedPlayer[]; +} + +export interface SidelinedPlayer { + playerId: string; + playerName: string; + playerUrl: string; + position: string; + positionShort: string; + type: 'injury' | 'suspension' | 'other'; + description: string; + matchesMissed: number | null; + average: number | null; + reasonIcon: string; +} + +// ============================================ +// PROCESSING RESULT +// ============================================ +export interface ProcessResult { + success: boolean; + retryable: boolean; + error?: string; +} diff --git a/src/modules/gemini/gemini.config.ts b/src/modules/gemini/gemini.config.ts new file mode 100755 index 0000000..c35e34b --- /dev/null +++ b/src/modules/gemini/gemini.config.ts @@ -0,0 +1,7 @@ +import { registerAs } from '@nestjs/config'; + +export const geminiConfig = registerAs('gemini', () => ({ + enabled: process.env.ENABLE_GEMINI === 'true', + apiKey: process.env.GOOGLE_API_KEY, + defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash', +})); diff --git a/src/modules/gemini/gemini.module.ts b/src/modules/gemini/gemini.module.ts new file mode 100755 index 0000000..65ccd5a --- /dev/null +++ b/src/modules/gemini/gemini.module.ts @@ -0,0 +1,18 @@ +import { Module, Global } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { GeminiService } from './gemini.service'; +import { geminiConfig } from './gemini.config'; + +/** + * Gemini AI Module + * + * Optional module for AI-powered features using Google Gemini API. + * Enable by setting ENABLE_GEMINI=true in your .env file. + */ +@Global() +@Module({ + imports: [ConfigModule.forFeature(geminiConfig)], + providers: [GeminiService], + exports: [GeminiService], +}) +export class GeminiModule {} diff --git a/src/modules/gemini/gemini.service.ts b/src/modules/gemini/gemini.service.ts new file mode 100755 index 0000000..ab53287 --- /dev/null +++ b/src/modules/gemini/gemini.service.ts @@ -0,0 +1,240 @@ +import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { GoogleGenAI } from '@google/genai'; + +export interface GeminiGenerateOptions { + model?: string; + systemPrompt?: string; + temperature?: number; + maxTokens?: number; +} + +export interface GeminiChatMessage { + role: 'user' | 'model'; + content: string; +} + +/** + * Gemini AI Service + * + * Provides AI-powered text generation using Google Gemini API. + * This service is globally available when ENABLE_GEMINI=true. + * + * @example + * ```typescript + * // Simple text generation + * const response = await geminiService.generateText('Write a poem about coding'); + * + * // With options + * const response = await geminiService.generateText('Translate to Turkish', { + * temperature: 0.7, + * systemPrompt: 'You are a professional translator', + * }); + * + * // Chat conversation + * const messages = [ + * { role: 'user', content: 'Hello!' }, + * { role: 'model', content: 'Hi there!' }, + * { role: 'user', content: 'What is 2+2?' }, + * ]; + * const response = await geminiService.chat(messages); + * ``` + */ +@Injectable() +export class GeminiService implements OnModuleInit { + private readonly logger = new Logger(GeminiService.name); + private client: GoogleGenAI | null = null; + private isEnabled = false; + private defaultModel: string; + + constructor(private readonly configService: ConfigService) { + this.isEnabled = this.configService.get('gemini.enabled', false); + this.defaultModel = this.configService.get( + 'gemini.defaultModel', + 'gemini-2.5-flash', + ); + } + + onModuleInit() { + if (!this.isEnabled) { + this.logger.log( + 'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.', + ); + return; + } + + const apiKey = this.configService.get('gemini.apiKey'); + if (!apiKey) { + this.logger.warn( + 'GOOGLE_API_KEY is not set. Gemini features will not work.', + ); + return; + } + + try { + this.client = new GoogleGenAI({ apiKey }); + this.logger.log('✅ Gemini AI initialized successfully'); + } catch (error) { + this.logger.error('Failed to initialize Gemini AI', error); + } + } + + /** + * Check if Gemini is available and properly configured + */ + isAvailable(): boolean { + return this.isEnabled && this.client !== null; + } + + /** + * Generate text content from a prompt + * + * @param prompt - The text prompt to send to the AI + * @param options - Optional configuration for the generation + * @returns Generated text response + */ + async generateText( + prompt: string, + options: GeminiGenerateOptions = {}, + ): Promise<{ text: string; usage?: any }> { + if (!this.isAvailable()) { + throw new Error('Gemini AI is not available. Check your configuration.'); + } + + const model = options.model || this.defaultModel; + + try { + const contents: any[] = []; + + // Add system prompt if provided + if (options.systemPrompt) { + contents.push({ + role: 'user', + parts: [{ text: options.systemPrompt }], + }); + contents.push({ + role: 'model', + parts: [{ text: 'Understood. I will follow these instructions.' }], + }); + } + + contents.push({ + role: 'user', + parts: [{ text: prompt }], + }); + + const response = await this.client!.models.generateContent({ + model, + contents, + config: { + temperature: options.temperature, + maxOutputTokens: options.maxTokens, + }, + }); + + return { + text: (response.text || '').trim(), + usage: response.usageMetadata, + }; + } catch (error) { + this.logger.error('Gemini generation failed', error); + throw error; + } + } + + /** + * Have a multi-turn chat conversation + * + * @param messages - Array of chat messages + * @param options - Optional configuration for the generation + * @returns Generated text response + */ + async chat( + messages: GeminiChatMessage[], + options: GeminiGenerateOptions = {}, + ): Promise<{ text: string; usage?: any }> { + if (!this.isAvailable()) { + throw new Error('Gemini AI is not available. Check your configuration.'); + } + + const model = options.model || this.defaultModel; + + try { + const contents = messages.map((msg) => ({ + role: msg.role, + parts: [{ text: msg.content }], + })); + + // Prepend system prompt if provided + if (options.systemPrompt) { + contents.unshift( + { + role: 'user', + parts: [{ text: options.systemPrompt }], + }, + { + role: 'model', + parts: [{ text: 'Understood. I will follow these instructions.' }], + }, + ); + } + + const response = await this.client!.models.generateContent({ + model, + contents, + config: { + temperature: options.temperature, + maxOutputTokens: options.maxTokens, + }, + }); + + return { + text: (response.text || '').trim(), + usage: response.usageMetadata, + }; + } catch (error) { + this.logger.error('Gemini chat failed', error); + throw error; + } + } + + /** + * Generate structured JSON output + * + * @param prompt - The prompt describing what JSON to generate + * @param schema - JSON schema description for the expected output + * @param options - Optional configuration for the generation + * @returns Parsed JSON object + */ + async generateJSON( + prompt: string, + schema: string, + options: GeminiGenerateOptions = {}, + ): Promise<{ data: T; usage?: any }> { + const fullPrompt = `${prompt} + +Output the result as valid JSON that matches this schema: +${schema} + +IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; + + const response = await this.generateText(fullPrompt, options); + + try { + // Try to extract JSON from the response + let jsonStr = response.text; + + // Remove potential markdown code blocks + const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch) { + jsonStr = jsonMatch[1].trim(); + } + + const data = JSON.parse(jsonStr) as T; + return { data, usage: response.usage }; + } catch (error) { + this.logger.error('Failed to parse JSON response', error); + throw new Error('Failed to parse AI response as JSON'); + } + } +} diff --git a/src/modules/gemini/index.ts b/src/modules/gemini/index.ts new file mode 100755 index 0000000..bb5f856 --- /dev/null +++ b/src/modules/gemini/index.ts @@ -0,0 +1,3 @@ +export * from './gemini.module'; +export * from './gemini.service'; +export * from './gemini.config'; diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts new file mode 100755 index 0000000..a7f43ee --- /dev/null +++ b/src/modules/health/health.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + HealthCheck, + HealthCheckService, + PrismaHealthIndicator, +} from '@nestjs/terminus'; +import { Public } from '../../common/decorators'; +import { PrismaService } from '../../database/prisma.service'; + +@ApiTags('Health') +@Controller('health') +export class HealthController { + constructor( + private health: HealthCheckService, + private prismaHealth: PrismaHealthIndicator, + private prisma: PrismaService, + ) {} + + @Get() + @Public() + @HealthCheck() + @ApiOperation({ summary: 'Basic health check' }) + check() { + return this.health.check([]); + } + + @Get('ready') + @Public() + @HealthCheck() + @ApiOperation({ summary: 'Readiness check (includes database)' }) + readiness() { + return this.health.check([ + () => this.prismaHealth.pingCheck('database', this.prisma), + ]); + } + + @Get('live') + @Public() + @ApiOperation({ summary: 'Liveness check' }) + liveness() { + return { status: 'ok', timestamp: new Date().toISOString() }; + } +} diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts new file mode 100755 index 0000000..64848b0 --- /dev/null +++ b/src/modules/health/health.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { PrismaHealthIndicator } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [TerminusModule], + controllers: [HealthController], + providers: [PrismaHealthIndicator], +}) +export class HealthModule {} diff --git a/src/modules/leagues/leagues.controller.ts b/src/modules/leagues/leagues.controller.ts new file mode 100755 index 0000000..d83dd34 --- /dev/null +++ b/src/modules/leagues/leagues.controller.ts @@ -0,0 +1,152 @@ +import { + Controller, + Get, + Param, + Query, + NotFoundException, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiQuery, + ApiParam, +} from '@nestjs/swagger'; +import { LeaguesService } from './leagues.service'; +import { Sport } from '@prisma/client'; +import { Public } from '../../common/decorators'; + +@ApiTags('Leagues') +@Controller('leagues') +export class LeaguesController { + constructor(private readonly leaguesService: LeaguesService) {} + + /** + * GET /leagues/countries + * Get all countries + */ + @Get('countries') + @Public() + @ApiOperation({ summary: 'Get all countries' }) + @ApiResponse({ status: 200, description: 'List of countries' }) + async getCountries() { + return this.leaguesService.findAllCountries(); + } + + /** + * GET /leagues/countries/:id + * Get country by ID with leagues + */ + @Get('countries/:id') + @Public() + @ApiOperation({ summary: 'Get country by ID with leagues' }) + @ApiParam({ name: 'id', description: 'Country ID' }) + async getCountryById(@Param('id') id: string) { + const country = await this.leaguesService.findCountryById(id); + if (!country) throw new NotFoundException('Country not found'); + return country; + } + + /** + * GET /leagues + * Get all leagues + */ + @Get() + @Public() + @ApiOperation({ summary: 'Get all leagues' }) + @ApiQuery({ + name: 'sport', + required: false, + enum: ['football', 'basketball'], + }) + async getLeagues(@Query('sport') sport?: string) { + return this.leaguesService.findAllLeagues(sport as Sport); + } + + /** + * GET /leagues/teams/h2h + * Get head-to-head matches between two teams + * NOTE: Must come before /teams/:id to avoid route conflict + */ + @Get('teams/h2h') + @Public() + @ApiOperation({ summary: 'Get head-to-head matches between two teams' }) + @ApiQuery({ name: 'team1', required: true }) + @ApiQuery({ name: 'team2', required: true }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getHeadToHead( + @Query('team1') team1: string, + @Query('team2') team2: string, + @Query('limit') limit?: string, + ) { + return this.leaguesService.getHeadToHead( + team1, + team2, + parseInt(limit || '10', 10), + ); + } + + /** + * GET /leagues/teams/search + * Search teams by name + */ + @Get('teams/search') + @Public() + @ApiOperation({ summary: 'Search teams by name' }) + @ApiQuery({ name: 'q', required: true, description: 'Search query' }) + @ApiQuery({ + name: 'sport', + required: false, + enum: ['football', 'basketball'], + }) + async searchTeams(@Query('q') query: string, @Query('sport') sport?: string) { + return this.leaguesService.searchTeams(query, sport as Sport); + } + + /** + * GET /leagues/teams/:id + * Get team by ID + */ + @Get('teams/:id') + @Public() + @ApiOperation({ summary: 'Get team by ID' }) + @ApiParam({ name: 'id', description: 'Team ID' }) + async getTeamById(@Param('id') id: string) { + const team = await this.leaguesService.findTeamById(id); + if (!team) throw new NotFoundException('Team not found'); + return team; + } + + /** + * GET /leagues/teams/:id/matches + * Get team's recent matches + */ + @Get('teams/:id/matches') + @Public() + @ApiOperation({ summary: "Get team's recent matches" }) + @ApiParam({ name: 'id', description: 'Team ID' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getTeamMatches( + @Param('id') id: string, + @Query('limit') limit?: string, + ) { + return this.leaguesService.getTeamRecentMatches( + id, + parseInt(limit || '10', 10), + ); + } + + /** + * GET /leagues/:id + * Get league by ID + */ + @Get(':id') + @Public() + @ApiOperation({ summary: 'Get league by ID' }) + @ApiParam({ name: 'id', description: 'League ID' }) + async getLeagueById(@Param('id') id: string) { + const league = await this.leaguesService.findLeagueById(id); + if (!league) throw new NotFoundException('League not found'); + return league; + } +} diff --git a/src/modules/leagues/leagues.module.ts b/src/modules/leagues/leagues.module.ts new file mode 100755 index 0000000..2b5be9b --- /dev/null +++ b/src/modules/leagues/leagues.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { LeaguesController } from './leagues.controller'; +import { LeaguesService } from './leagues.service'; +import { DatabaseModule } from '../../database/database.module'; + +@Module({ + imports: [DatabaseModule], + controllers: [LeaguesController], + providers: [LeaguesService], + exports: [LeaguesService], +}) +export class LeaguesModule {} diff --git a/src/modules/leagues/leagues.service.ts b/src/modules/leagues/leagues.service.ts new file mode 100755 index 0000000..a484c19 --- /dev/null +++ b/src/modules/leagues/leagues.service.ts @@ -0,0 +1,173 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { Sport } from '@prisma/client'; + +@Injectable() +export class LeaguesService { + private readonly logger = new Logger(LeaguesService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Get all countries + */ + async findAllCountries() { + return this.prisma.country.findMany({ + orderBy: { name: 'asc' }, + }); + } + + /** + * Get country by ID + */ + async findCountryById(id: string) { + return this.prisma.country.findUnique({ + where: { id }, + include: { leagues: true }, + }); + } + + /** + * Get all leagues + */ + async findAllLeagues(sport?: Sport) { + return this.prisma.league.findMany({ + where: sport ? { sport } : undefined, + include: { country: true }, + orderBy: { name: 'asc' }, + }); + } + + /** + * Get league by ID + */ + async findLeagueById(id: string) { + return this.prisma.league.findUnique({ + where: { id }, + include: { country: true }, + }); + } + + /** + * Get leagues by country + */ + async findLeaguesByCountry(countryId: string, sport?: Sport) { + return this.prisma.league.findMany({ + where: { + countryId, + ...(sport ? { sport } : {}), + }, + include: { country: true }, + orderBy: { name: 'asc' }, + }); + } + + /** + * Get all teams + */ + async findAllTeams(sport?: Sport, search?: string) { + return this.prisma.team.findMany({ + where: { + ...(sport ? { sport } : {}), + ...(search ? { name: { contains: search, mode: 'insensitive' } } : {}), + }, + orderBy: { name: 'asc' }, + take: 100, + }); + } + + /** + * Get team by ID + */ + async findTeamById(id: string) { + return this.prisma.team.findUnique({ + where: { id }, + }); + } + + /** + * Search teams by name + */ + async searchTeams(name: string, sport?: Sport) { + return this.prisma.team.findMany({ + where: { + name: { contains: name, mode: 'insensitive' }, + ...(sport ? { sport } : {}), + }, + take: 20, + }); + } + + /** + * Get team's matches (past + upcoming) + */ + async getTeamRecentMatches(teamId: string, limit: number = 50) { + return this.prisma.match.findMany({ + where: { + OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }], + }, + include: { + homeTeam: true, + awayTeam: true, + league: { include: { country: true } }, + }, + orderBy: { mstUtc: 'desc' }, + take: limit, + }); + } + + /** + * Get head-to-head matches between two teams + */ + async getHeadToHead(teamId1: string, teamId2: string, limit: number = 10) { + const matches = await this.prisma.match.findMany({ + where: { + OR: [ + { homeTeamId: teamId1, awayTeamId: teamId2 }, + { homeTeamId: teamId2, awayTeamId: teamId1 }, + ], + state: 'postGame', // Finished matches are stored as "postGame" + }, + include: { + homeTeam: true, + awayTeam: true, + league: true, + }, + orderBy: { mstUtc: 'desc' }, + take: limit, + }); + + // Calculate statistics + let team1Wins = 0; + let team2Wins = 0; + let draws = 0; + + matches.forEach((match) => { + const homeScore = Number(match.scoreHome ?? -1); + const awayScore = Number(match.scoreAway ?? -1); + + // Skip matches without scores + if (homeScore === -1 || awayScore === -1) return; + + const isTeam1Home = match.homeTeamId === teamId1; + + if (homeScore === awayScore) { + draws++; + } else if ( + (isTeam1Home && homeScore > awayScore) || + (!isTeam1Home && awayScore > homeScore) + ) { + team1Wins++; + } else { + team2Wins++; + } + }); + + return { + matches, + team1Wins, + team2Wins, + draws, + }; + } +} diff --git a/src/modules/matches/dto/index.ts b/src/modules/matches/dto/index.ts new file mode 100755 index 0000000..a704c33 --- /dev/null +++ b/src/modules/matches/dto/index.ts @@ -0,0 +1,219 @@ +import { + IsOptional, + IsString, + IsInt, + IsEnum, + IsDateString, + Min, + Max, + IsArray, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum Sport { + FOOTBALL = 'football', + BASKETBALL = 'basketball', +} + +export class OddFilterDto { + @ApiProperty({ example: 'Maç Sonucu' }) + @IsString() + categoryName: string; + + @ApiProperty({ example: '1' }) + @IsString() + selectionName: string; + + @ApiProperty({ example: 1.5 }) + value: number; + + @ApiPropertyOptional({ example: 0.1 }) + @IsOptional() + tolerance?: number = 0.1; +} + +export class TeamFilterDto { + @ApiProperty() + @IsString() + id: string; + + @ApiPropertyOptional({ enum: ['home', 'away', 'any'] }) + @IsOptional() + @IsString() + role?: 'home' | 'away' | 'any'; +} + +export class DateRangeDto { + @ApiProperty() + @IsDateString() + from: string; + + @ApiProperty() + @IsDateString() + to: string; +} + +export class MatchQueryDto { + @ApiProperty({ enum: Sport, default: Sport.FOOTBALL }) + @IsEnum(Sport) + sport: Sport; + + @ApiPropertyOptional({ default: 50 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number = 50; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + leagueId?: string; + + @ApiPropertyOptional({ + description: 'Filter by status: LIVE, Finished, etc.', + }) + @IsOptional() + @IsString() + status?: string; + + @ApiPropertyOptional({ description: 'Single date filter (YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + date?: string; + + @ApiPropertyOptional({ type: TeamFilterDto }) + @IsOptional() + @ValidateNested() + @Type(() => TeamFilterDto) + team?: TeamFilterDto; + + @ApiPropertyOptional({ type: [OddFilterDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OddFilterDto) + odds?: OddFilterDto[]; + + @ApiPropertyOptional({ type: DateRangeDto }) + @IsOptional() + @ValidateNested() + @Type(() => DateRangeDto) + dateRange?: DateRangeDto; +} + +export class MatchResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + matchName: string; + + @ApiPropertyOptional() + matchSlug?: string; + + @ApiProperty() + mstUtc: number; + + @ApiPropertyOptional() + status?: string; + + @ApiPropertyOptional() + state?: string; + + @ApiPropertyOptional() + scoreHome?: number; + + @ApiPropertyOptional() + scoreAway?: number; + + @ApiPropertyOptional() + htScoreHome?: number; + + @ApiPropertyOptional() + htScoreAway?: number; + + @ApiProperty() + homeTeamName: string; + + @ApiPropertyOptional() + homeTeamLogo?: string; + + @ApiProperty() + awayTeamName: string; + + @ApiPropertyOptional() + awayTeamLogo?: string; + + @ApiPropertyOptional() + leagueName?: string; + + @ApiPropertyOptional() + countryName?: string; + + @ApiPropertyOptional({ type: 'array' }) + odds?: any[]; +} + +export class PaginatedMatchesDto { + @ApiProperty({ type: [MatchResponseDto] }) + matches: MatchResponseDto[]; + + @ApiProperty() + total: number; + + @ApiProperty() + page: number; + + @ApiProperty() + totalPages: number; +} + +export class LeagueWithMatchesDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + code?: string; + + @ApiProperty() + country: { + id: string; + name: string; + flagUrl?: string; + }; + + @ApiProperty() + sport: Sport; + + @ApiProperty({ type: [MatchResponseDto] }) + matches: MatchResponseDto[]; +} + +export class ActiveLeagueDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + code?: string; + + @ApiPropertyOptional() + countryName?: string; + + @ApiPropertyOptional() + countryFlag?: string; + + @ApiProperty() + matchCount: number; + + @ApiProperty() + liveCount: number; +} diff --git a/src/modules/matches/matches.controller.ts b/src/modules/matches/matches.controller.ts new file mode 100755 index 0000000..642d2d7 --- /dev/null +++ b/src/modules/matches/matches.controller.ts @@ -0,0 +1,130 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + HttpCode, + HttpStatus, + NotFoundException, + BadRequestException, + UseInterceptors, +} from '@nestjs/common'; +import { Public } from '../../common/decorators'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiQuery, + ApiParam, +} from '@nestjs/swagger'; +import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager'; +import { MatchesService } from './matches.service'; +import { + MatchQueryDto, + Sport, + LeagueWithMatchesDto, + ActiveLeagueDto, +} from './dto'; + +@ApiTags('Matches') +@Controller('matches') +export class MatchesController { + constructor(private readonly matchesService: MatchesService) {} + + /** + * POST /matches/query + * Advanced match query with filters + */ + @Public() + @Post('query') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Advanced match query with filters' }) + @ApiResponse({ status: 200, type: [LeagueWithMatchesDto] }) + async queryMatches( + @Body() queryDto: MatchQueryDto, + ): Promise { + if (!queryDto.sport) { + throw new BadRequestException("'sport' field is required"); + } + + const matchIds = await this.matchesService.findMatches(queryDto); + + if (matchIds.length === 0) { + return []; + } + + return this.matchesService.getMatchesAndStructureByIds( + matchIds, + queryDto.sport, + ); + } + + /** + * GET /matches + * List matches with pagination + */ + @Public() + @Get() + @ApiOperation({ summary: 'List matches with pagination' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'sport', required: false, enum: Sport }) + @ApiResponse({ status: 200, description: 'Paginated list of matches' }) + async listMatches( + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('sport') sport?: Sport, + ) { + const pageNum = parseInt(page || '1', 10); + const limitNum = parseInt(limit || '20', 10); + const sportType = sport || Sport.FOOTBALL; + + return this.matchesService.listMatches(sportType, pageNum, limitNum); + } + + /** + * GET /matches/leagues/active + * Get active leagues with match counts + */ + @Public() + @Get('leagues/active') + @UseInterceptors(CacheInterceptor) + @CacheTTL(60000) // 1 minute cache + @ApiOperation({ summary: 'Get active leagues with upcoming/live matches' }) + @ApiQuery({ name: 'sport', required: false, enum: Sport }) + @ApiResponse({ status: 200, type: [ActiveLeagueDto] }) + async getActiveLeagues( + @Query('sport') sport?: Sport, + ): Promise { + return this.matchesService.getActiveLeagues(sport || Sport.FOOTBALL); + } + + /** + * GET /matches/:id + * Get full match details + */ + @Public() + @Get(':id') + @ApiOperation({ summary: 'Get full match details by ID' }) + @ApiParam({ name: 'id', description: 'Match ID' }) + @ApiResponse({ + status: 200, + description: 'Match details with lineups, stats, odds, events', + }) + @ApiResponse({ status: 404, description: 'Match not found' }) + async getMatchDetails(@Param('id') id: string) { + if (!id) { + throw new BadRequestException('Match ID is required'); + } + + const match = await this.matchesService.getMatchDetailsById(id); + + if (!match) { + throw new NotFoundException('Match not found'); + } + + return match; + } +} diff --git a/src/modules/matches/matches.module.ts b/src/modules/matches/matches.module.ts new file mode 100755 index 0000000..684d625 --- /dev/null +++ b/src/modules/matches/matches.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { MatchesController } from './matches.controller'; +import { MatchesService } from './matches.service'; +import { DatabaseModule } from '../../database/database.module'; + +@Module({ + imports: [DatabaseModule], + controllers: [MatchesController], + providers: [MatchesService], + exports: [MatchesService], +}) +export class MatchesModule {} diff --git a/src/modules/matches/matches.service.ts b/src/modules/matches/matches.service.ts new file mode 100755 index 0000000..56072e3 --- /dev/null +++ b/src/modules/matches/matches.service.ts @@ -0,0 +1,703 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import { PrismaService } from '../../database/prisma.service'; +import { + Sport, + MatchQueryDto, + LeagueWithMatchesDto, + ActiveLeagueDto, +} from './dto'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class MatchesService { + private readonly logger = new Logger(MatchesService.name); + private topLeagueIds: string[] = []; + + constructor(private readonly prisma: PrismaService) { + this.loadTopLeagues(); + } + + private loadTopLeagues() { + try { + const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json'); + if (fs.existsSync(topLeaguesPath)) { + this.topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8')); + this.logger.log( + `Loaded ${this.topLeagueIds.length} top leagues for filtering.`, + ); + } + } catch (e) { + this.logger.warn(`Failed to load top_leagues.json: ${e.message}`); + } + } + + private getLiveFilter(): Prisma.LiveMatchWhereInput { + return { + OR: [ + { + status: { + in: [ + 'LIVE', + '1H', + '2H', + 'HT', + '1Q', + '2Q', + '3Q', + '4Q', + 'Playing', + 'Half Time', + ], + }, + }, + { + state: { + in: ['live', 'firsthalf', 'secondhalf'], + }, + }, + ], + }; + } + + private getFinishedFilter(): Prisma.LiveMatchWhereInput { + return { + OR: [ + { + status: { + in: ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'], + }, + }, + { + state: { + in: ['Finished', 'post', 'FT', 'postGame'], + }, + }, + ], + }; + } + + private getUpcomingFilter( + fromTimestampMs: number, + ): Prisma.LiveMatchWhereInput { + return { + AND: [ + { + mstUtc: { + gte: BigInt(fromTimestampMs), + }, + }, + { + NOT: { + OR: [this.getLiveFilter(), this.getFinishedFilter()], + }, + }, + ], + }; + } + + private getBrowseFilter(fromTimestampMs: number): Prisma.LiveMatchWhereInput { + return { + AND: [ + { + mstUtc: { + gte: BigInt(fromTimestampMs), + }, + }, + { + NOT: this.getFinishedFilter(), + }, + ], + }; + } + + /** + * Find matches by query criteria + */ + async findMatches(options: MatchQueryDto): Promise { + const { + sport, + limit = 50, + leagueId, + status, + date, + team, + dateRange, + } = options; + + // Build where conditions + const where: Prisma.LiveMatchWhereInput = { + sport: sport as any, + }; + const andConditions: Prisma.LiveMatchWhereInput[] = []; + + if (leagueId) { + where.leagueId = leagueId; + } else if (status === 'LIVE' && this.topLeagueIds.length > 0) { + // Filter live matches by top leagues by default if no leagueId is provided + where.leagueId = { in: this.topLeagueIds }; + } + + if (status === 'LIVE') { + andConditions.push(this.getLiveFilter()); + } else if (status === 'UPCOMING' || status === 'NOT_STARTED') { + andConditions.push(this.getUpcomingFilter(Date.now())); + } else if (status === 'FINISHED') { + andConditions.push(this.getFinishedFilter()); + } else if (status) { + where.status = status; + } + + // Date filter + if (date) { + const d = new Date(date); + const startOfDay = new Date(d); + startOfDay.setUTCHours(0, 0, 0, 0); + const endOfDay = new Date(d); + endOfDay.setUTCHours(23, 59, 59, 999); + + where.mstUtc = { + gte: BigInt(startOfDay.getTime()), + lte: BigInt(endOfDay.getTime()), + }; + } else if (dateRange) { + where.mstUtc = { + gte: BigInt(new Date(dateRange.from).getTime()), + lte: BigInt(new Date(dateRange.to).getTime()), + }; + } + + // Team filter + if (team) { + if (team.role === 'home') { + where.homeTeamId = team.id; + } else if (team.role === 'away') { + where.awayTeamId = team.id; + } else { + andConditions.push({ + OR: [{ homeTeamId: team.id }, { awayTeamId: team.id }], + }); + } + } + + // Default date filter: From today onwards if no specific filter + if (!date && !dateRange && !status) { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); // Start of today in UTC + + andConditions.push(this.getBrowseFilter(today.getTime())); + } + + if (andConditions.length > 0) { + where.AND = andConditions; + } + + // Switch to live_matches table + const matches = await this.prisma.liveMatch.findMany({ + where, + select: { id: true }, + orderBy: { mstUtc: 'asc' }, // Sort by nearest match first + take: limit, + }); + + return matches.map((m) => m.id); + } + + /** + * Find upcoming matches from the live matches table + * Used for Coupon Generator when no specific matches are selected + */ + async findUpcomingMatches( + sport: Sport, + limit: number = 50, + ): Promise { + console.log(`[MatchesService] Finding upcoming matches for ${sport}`); + + const matches = await this.prisma.liveMatch.findMany({ + where: { + sport: sport as any, + AND: [this.getUpcomingFilter(Date.now())], + }, + select: { id: true }, + orderBy: { mstUtc: 'asc' }, + take: limit, + }); + console.log( + `[MatchesService] Found ${matches.length} upcoming matches from live_matches`, + ); + + return matches.map((m) => m.id); + } + + async filterUpcomingMatchIds( + matchIds: string[], + sport: Sport, + ): Promise { + const uniqueIds = [...new Set(matchIds.filter((id) => !!id))]; + + if (uniqueIds.length === 0) { + return []; + } + + const matches = await this.prisma.liveMatch.findMany({ + where: { + id: { in: uniqueIds }, + sport: sport as any, + AND: [this.getUpcomingFilter(Date.now())], + }, + select: { id: true }, + }); + + return matches.map((match) => match.id); + } + + /** + * Get matches structured by league (from live_matches table) + */ + async getMatchesAndStructureByIds( + matchIds: string[], + sport: Sport, + ): Promise { + if (!matchIds.length) return []; + + const matches = await this.prisma.liveMatch.findMany({ + where: { id: { in: matchIds } }, + include: { + league: { + include: { + country: true, + }, + }, + homeTeam: true, + awayTeam: true, + }, + }); + + // Sort matches by time (ASC) before grouping to ensure correct order + matches.sort((a, b) => + Number(BigInt(a.mstUtc || 0) - BigInt(b.mstUtc || 0)), + ); + + // Group by league + const leaguesMap = new Map(); + + for (const match of matches) { + const leagueId = match.leagueId || 'unknown'; + + if (!leaguesMap.has(leagueId)) { + leaguesMap.set(leagueId, { + id: leagueId, + name: match.league?.name || 'Unknown League', + code: match.league?.code || undefined, + country: { + id: match.league?.country?.id || '', + name: match.league?.country?.name || '', + flagUrl: match.league?.country?.flagUrl || undefined, + }, + sport: sport, + matches: [], + }); + } + + const league = leaguesMap.get(leagueId)!; + + // Structure odds from JSON + const structuredOdds: any[] = []; + if ( + match.odds && + typeof match.odds === 'object' && + !Array.isArray(match.odds) + ) { + const oddsObj = match.odds as Record>; + for (const [marketName, selections] of Object.entries(oddsObj)) { + const structuredSelections: Record = {}; + if (selections && typeof selections === 'object') { + for (const [selName, selOdd] of Object.entries(selections)) { + structuredSelections[selName] = { odd: String(selOdd) }; + } + structuredOdds.push({ + category_name: marketName, + selections: structuredSelections, + }); + } + } + } + + // Map status for frontend + let displayStatus = match.status || 'NS'; + if (match.state === 'live') { + displayStatus = 'LIVE'; + } else if ( + match.state === 'post' || + match.state === 'FT' || + match.status === 'Finished' + ) { + displayStatus = 'Finished'; + } + + league.matches.push({ + id: match.id, + matchName: + match.matchName || + `${match.homeTeam?.name} vs ${match.awayTeam?.name}`, + matchSlug: match.matchSlug || undefined, + mstUtc: Number(match.mstUtc), + status: displayStatus, + state: match.state || undefined, + scoreHome: match.scoreHome ?? undefined, + scoreAway: match.scoreAway ?? undefined, + htScoreHome: undefined, // LiveMatch table doesn't have HT scores separately usually + htScoreAway: undefined, + homeTeamName: match.homeTeam?.name || 'Unknown', + homeTeamLogo: match.homeTeamId + ? `https://file.mackolikfeeds.com/teams/${match.homeTeamId}` + : undefined, + awayTeamName: match.awayTeam?.name || 'Unknown', + awayTeamLogo: match.awayTeamId + ? `https://file.mackolikfeeds.com/teams/${match.awayTeamId}` + : undefined, + leagueName: match.league?.name, + countryName: match.league?.country?.name, + odds: structuredOdds, + }); + } + + return Array.from(leaguesMap.values()); + } + + /** + * Get active leagues with match counts + */ + async getActiveLeagues(sport: Sport): Promise { + // Use raw query for complex aggregation + const leagues = await this.prisma.$queryRaw` + SELECT + l.id, l.name, l.code, + c.name as country_name, + c.flag_url as country_flag, + COUNT(lm.id)::int as match_count, + COUNT(CASE WHEN lm.status IN ('LIVE', '1H', '2H', 'HT', '1Q', '2Q', '3Q', '4Q', 'Playing', 'Half Time') + OR lm.state IN ('live', 'firsthalf', 'secondhalf') THEN 1 END)::int as live_count + FROM live_matches lm + JOIN leagues l ON lm.league_id = l.id + LEFT JOIN countries c ON l.country_id = c.id + WHERE lm.sport = ${sport} + ${this.topLeagueIds.length > 0 ? Prisma.sql`AND l.id IN (${Prisma.join(this.topLeagueIds)})` : Prisma.empty} + GROUP BY l.id, l.name, l.code, c.name, c.flag_url + ORDER BY l.name ASC + `; + + // Priority sorting (Mackolik style) + const PRIORITY = [ + 'Trendyol Süper Lig', + 'Süper Lig', + 'Trendyol 1. Lig', + '1. Lig', + 'Premier Lig', + 'LaLiga', + 'Serie A', + 'Bundesliga', + 'Ligue 1', + ]; + + return leagues + .sort((a, b) => { + const aIdx = PRIORITY.findIndex((p) => a.name?.includes(p)); + const bIdx = PRIORITY.findIndex((p) => b.name?.includes(p)); + + const aPriority = aIdx === -1 ? 999 : aIdx; + const bPriority = bIdx === -1 ? 999 : bIdx; + + if (aPriority !== bPriority) return aPriority - bPriority; + return (a.name || '').localeCompare(b.name || ''); + }) + .map((l) => ({ + id: l.id, + name: l.name, + code: l.code, + countryName: l.country_name, + countryFlag: l.country_flag, + matchCount: l.match_count, + liveCount: l.live_count, + })); + } + + /** + * List matches with pagination + */ + async listMatches(sport: Sport, page: number = 1, limit: number = 20) { + const skip = (page - 1) * limit; + + const [matches, total] = await Promise.all([ + this.prisma.match.findMany({ + where: { sport: sport as any }, + include: { + homeTeam: true, + awayTeam: true, + league: { + include: { country: true }, + }, + }, + orderBy: { mstUtc: 'desc' }, + skip, + take: limit, + }), + this.prisma.match.count({ where: { sport: sport as any } }), + ]); + + return { + matches: matches.map((m) => ({ + id: m.id, + matchName: m.matchName, + matchSlug: m.matchSlug, + mstUtc: Number(m.mstUtc), + scoreHome: m.scoreHome, + scoreAway: m.scoreAway, + status: m.status, + homeTeamName: m.homeTeam?.name, + homeTeamLogo: m.homeTeamId + ? `https://file.mackolikfeeds.com/teams/${m.homeTeamId}` + : null, + awayTeamName: m.awayTeam?.name, + awayTeamLogo: m.awayTeamId + ? `https://file.mackolikfeeds.com/teams/${m.awayTeamId}` + : null, + leagueName: m.league?.name, + countryName: m.league?.country?.name, + })), + total, + page, + totalPages: Math.ceil(total / limit), + }; + } + + private normalizeTeamStat(stat: any, sport?: string) { + if (!stat) return null; + + const base = { + id: stat.id, + matchId: stat.matchId, + teamId: stat.teamId, + createdAt: stat.createdAt, + }; + + if ((sport || '').toLowerCase() === 'basketball') { + return { + ...base, + points: stat.points, + rebounds: stat.rebounds, + assists: stat.assists, + fgMade: stat.fgMade, + fgAttempted: stat.fgAttempted, + threePtMade: stat.threePtMade, + threePtAttempted: stat.threePtAttempted, + ftMade: stat.ftMade, + ftAttempted: stat.ftAttempted, + steals: stat.steals, + blocks: stat.blocks, + turnovers: stat.turnovers, + q1Score: stat.q1Score, + q2Score: stat.q2Score, + q3Score: stat.q3Score, + q4Score: stat.q4Score, + otScore: stat.otScore, + }; + } + + return { + ...base, + possessionPercentage: stat.possessionPercentage, + shotsOnTarget: stat.shotsOnTarget, + shotsOffTarget: stat.shotsOffTarget, + totalShots: stat.totalShots, + totalPasses: stat.totalPasses, + corners: stat.corners, + fouls: stat.fouls, + offsides: stat.offsides, + }; + } + + /** + * Get full match details by ID + */ + async getMatchDetailsById(matchId: string) { + let match: any = await this.prisma.match.findUnique({ + where: { id: matchId }, + include: { + league: { include: { country: true } }, + homeTeam: true, + awayTeam: true, + footballTeamStats: true, + basketballTeamStats: true, + playerParticipations: { + include: { player: true }, + orderBy: [{ isStarting: 'desc' }, { position: 'asc' }], + }, + playerEvents: { + include: { + player: true, + assistPlayer: true, + substitutedOut: true, + }, + orderBy: [{ periodId: 'asc' }, { timeMinute: 'asc' }], + }, + oddCategories: { + include: { selections: true }, + }, + officials: true, + }, + }); + + if (!match) { + // Try to find in LiveMatch table + const liveMatch = await this.prisma.liveMatch.findUnique({ + where: { id: matchId }, + include: { + league: { include: { country: true } }, + homeTeam: true, + awayTeam: true, + }, + }); + + if (liveMatch) { + // Map liveMatch status + let displayStatus = liveMatch.status || 'NS'; + if (liveMatch.state === 'live') { + displayStatus = 'LIVE'; + } else if ( + liveMatch.state === 'post' || + liveMatch.state === 'FT' || + liveMatch.status === 'Finished' + ) { + displayStatus = 'Finished'; + } + + match = { + ...liveMatch, + matchName: + liveMatch.matchName || + `${liveMatch.homeTeam?.name} vs ${liveMatch.awayTeam?.name}`, + status: displayStatus, + mstUtc: liveMatch.mstUtc, + score: { + home: liveMatch.scoreHome, + away: liveMatch.scoreAway, + }, + date: new Date(Number(liveMatch.mstUtc)), + // Fill missing relations with empty arrays + teamStats: [], + playerParticipations: [], + playerEvents: [], + oddCategories: [], // Will handle odds parsing below + officials: [], + isLiveSource: true, // Flag to indicate source + }; + } + } + + if (!match) return null; + + // Structure odds + const odds: Record< + string, + Record + > = {}; + + if ( + match.isLiveSource && + match.odds && + typeof match.odds === 'object' && + !Array.isArray(match.odds) + ) { + // Parse JSON odds from LiveMatch + const oddsObj = match.odds as Record>; + for (const [marketName, selections] of Object.entries(oddsObj)) { + odds[marketName] = {}; + if (selections && typeof selections === 'object') { + for (const [selName, selOdd] of Object.entries(selections)) { + odds[marketName][selName] = { odd: String(selOdd) }; + } + } + } + } else if (match.oddCategories) { + // Parse relation odds from Match + for (const cat of match.oddCategories) { + if (!cat.name) continue; + odds[cat.name] = {}; + for (const sel of cat.selections) { + if (sel.name) { + odds[cat.name][sel.name] = { + odd: sel.oddValue || '', + sov: sel.sov ?? undefined, + }; + } + } + } + } + + const sportStats = + match.sport === 'basketball' + ? match.basketballTeamStats || [] + : match.footballTeamStats || []; + const normalizedTeamStats = sportStats.map((s: any) => + this.normalizeTeamStat(s, match.sport), + ); + const homeStat = sportStats.find((s: any) => s.teamId === match.homeTeamId); + const awayStat = sportStats.find((s: any) => s.teamId === match.awayTeamId); + + return { + ...match, + teamStats: normalizedTeamStats, + mstUtc: Number(match.mstUtc), + date: match.date || new Date(Number(match.mstUtc)), + // Ensure score is in expected format (nested object for frontend if needed, but frontend seems to use match.score.home in some places and match.scoreHome in others. + // The match-detail-content uses match.score.home. Match entity has scoreHome/scoreAway fields. + // Let's ensure compatibility. + score: match.score || { home: match.scoreHome, away: match.scoreAway }, + stats: { + home: this.normalizeTeamStat(homeStat, match.sport), + away: this.normalizeTeamStat(awayStat, match.sport), + }, + lineups: { + home: match.playerParticipations.filter( + (p: any) => p.teamId === match.homeTeamId, + ), + away: match.playerParticipations.filter( + (p: any) => p.teamId === match.awayTeamId, + ), + }, + events: match.playerEvents, + odds, + }; + } + + /** + * Get team ID by name (for legacy compatibility) + */ + async getTeamIdByName( + teamName: string, + sport: Sport, + ): Promise { + const trimmedName = teamName.trim(); + + // Exact match first + let team = await this.prisma.team.findFirst({ + where: { name: trimmedName, sport: sport as any }, + select: { id: true }, + }); + + if (team) return team.id; + + // Fuzzy search + team = await this.prisma.team.findFirst({ + where: { + name: { contains: trimmedName, mode: 'insensitive' }, + sport: sport as any, + }, + select: { id: true }, + }); + + return team?.id || null; + } +} diff --git a/src/modules/predictions/dto/index.ts b/src/modules/predictions/dto/index.ts new file mode 100755 index 0000000..cebf94d --- /dev/null +++ b/src/modules/predictions/dto/index.ts @@ -0,0 +1,471 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export type SignalTier = + | 'CORE' + | 'VALUE' + | 'LEAN' + | 'LONGSHOT' + | 'PASS'; + +export class MatchInfoDto { + @ApiProperty() + match_id: string; + + @ApiProperty() + match_name: string; + + @ApiProperty() + home_team: string; + + @ApiProperty() + away_team: string; + + @ApiProperty() + league: string; + + @ApiProperty() + match_date_ms: number; + + @ApiProperty({ required: false, nullable: true }) + league_id?: string | null; + + @ApiProperty({ required: false, default: false }) + is_top_league?: boolean; + + @ApiProperty({ + required: false, + enum: ['football', 'basketball'], + }) + sport?: 'football' | 'basketball'; +} + +export class DataQualityDto { + @ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] }) + label: 'HIGH' | 'MEDIUM' | 'LOW'; + + @ApiProperty() + score: number; + + @ApiProperty() + home_lineup_count: number; + + @ApiProperty() + away_lineup_count: number; + + @ApiProperty({ required: false, default: 'none' }) + lineup_source?: string; + + @ApiProperty({ type: [String] }) + flags: string[]; +} + +export class ConfidenceIntervalDto { + @ApiProperty() + lower: number; + + @ApiProperty() + upper: number; + + @ApiProperty() + width: number; + + @ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] }) + band: 'HIGH' | 'MEDIUM' | 'LOW'; + + @ApiProperty() + threshold_met: boolean; +} + +export class RiskDto { + @ApiProperty({ enum: ['LOW', 'MEDIUM', 'HIGH', 'EXTREME'] }) + level: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; + + @ApiProperty() + score: number; + + @ApiProperty() + is_surprise_risk: boolean; + + @ApiProperty({ nullable: true }) + surprise_type: string | null; + + @ApiProperty({ required: false, default: 0 }) + surprise_score?: number; + + @ApiProperty({ required: false, nullable: true }) + surprise_comment?: string | null; + + @ApiProperty({ type: [String], required: false }) + surprise_reasons?: string[]; + + @ApiProperty({ type: [String] }) + warnings: string[]; +} + +export class EngineBreakdownDto { + @ApiProperty() + team: number; + + @ApiProperty() + player: number; + + @ApiProperty() + odds: number; + + @ApiProperty() + referee: number; +} + +export class MatchPickDto { + @ApiProperty() + market: string; + + @ApiProperty() + pick: string; + + @ApiProperty() + probability: number; + + @ApiProperty() + confidence: number; + + @ApiProperty() + odds: number; + + @ApiProperty() + raw_confidence: number; + + @ApiProperty() + calibrated_confidence: number; + + @ApiProperty() + min_required_confidence: number; + + @ApiProperty() + edge: number; + + @ApiProperty({ required: false, default: 0 }) + ev_edge?: number; + + @ApiProperty({ required: false, default: 0 }) + implied_prob?: number; + + @ApiProperty() + play_score: number; + + @ApiProperty() + playable: boolean; + + @ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] }) + bet_grade: 'A' | 'B' | 'C' | 'PASS'; + + @ApiProperty() + stake_units: number; + + @ApiProperty({ type: [String] }) + decision_reasons: string[]; + + @ApiProperty({ type: ConfidenceIntervalDto, required: false }) + confidence_interval?: ConfidenceIntervalDto; + + @ApiProperty({ + required: false, + enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'], + }) + signal_tier?: SignalTier; +} + +export class MatchBetAdviceDto { + @ApiProperty() + playable: boolean; + + @ApiProperty() + suggested_stake_units: number; + + @ApiProperty() + reason: string; + + @ApiProperty({ required: false, enum: ['HIGH', 'MEDIUM', 'LOW'] }) + confidence_band?: 'HIGH' | 'MEDIUM' | 'LOW'; + + @ApiProperty({ required: false }) + min_confidence_for_play?: number; + + @ApiProperty({ + required: false, + enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'], + }) + signal_tier?: SignalTier; +} + +export class MatchBetSummaryItemDto { + @ApiProperty() + market: string; + + @ApiProperty() + pick: string; + + @ApiProperty() + raw_confidence: number; + + @ApiProperty() + calibrated_confidence: number; + + @ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] }) + bet_grade: 'A' | 'B' | 'C' | 'PASS'; + + @ApiProperty() + playable: boolean; + + @ApiProperty() + stake_units: number; + + @ApiProperty() + play_score: number; + + @ApiProperty({ required: false, default: 0 }) + ev_edge?: number; + + @ApiProperty({ required: false, default: 0 }) + implied_prob?: number; + + @ApiProperty({ required: false, default: 0 }) + odds?: number; + + @ApiProperty({ type: [String] }) + reasons: string[]; + + @ApiProperty({ type: ConfidenceIntervalDto, required: false }) + confidence_interval?: ConfidenceIntervalDto; + + @ApiProperty({ + required: false, + enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'], + }) + signal_tier?: SignalTier; +} + +export class HtFtPredictionDto { + @ApiProperty() + '1/1': number; + @ApiProperty() + '1/X': number; + @ApiProperty() + '1/2': number; + @ApiProperty() + 'X/1': number; + @ApiProperty() + 'X/X': number; + @ApiProperty() + 'X/2': number; + @ApiProperty() + '2/1': number; + @ApiProperty() + '2/X': number; + @ApiProperty() + '2/2': number; + @ApiProperty() + pick: string; + @ApiProperty() + confidence: number; +} + +export class AggressivePickDto { + @ApiProperty() + market: string; + + @ApiProperty() + pick: string; + + @ApiProperty() + probability: number; + + @ApiProperty() + confidence: number; + + @ApiProperty() + odds: number; + + @ApiProperty() + raw_confidence: number; + + @ApiProperty() + calibrated_confidence: number; + + @ApiProperty() + min_required_confidence: number; + + @ApiProperty() + edge: number; + + @ApiProperty({ required: false, default: 0 }) + ev_edge?: number; + + @ApiProperty({ required: false, default: 0 }) + implied_prob?: number; + + @ApiProperty() + play_score: number; + + @ApiProperty() + playable: boolean; + + @ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] }) + bet_grade: 'A' | 'B' | 'C' | 'PASS'; + + @ApiProperty() + stake_units: number; + + @ApiProperty({ type: [String] }) + decision_reasons: string[]; + + @ApiProperty({ type: ConfidenceIntervalDto, required: false }) + confidence_interval?: ConfidenceIntervalDto; +} + +export class ScenarioTop5ItemDto { + @ApiProperty() + scenario: string; + + @ApiProperty() + score: number; + + @ApiProperty() + probability: number; +} + +export class ScorePredictionDto { + @ApiProperty() + ft: string; + + @ApiProperty() + ht: string; + + @ApiProperty() + xg_home: number; + + @ApiProperty() + xg_away: number; + + @ApiProperty() + xg_total: number; +} + +export class MatchPredictionDto { + @ApiProperty() + model_version: string; + + @ApiProperty({ type: MatchInfoDto }) + match_info: MatchInfoDto; + + @ApiProperty({ type: DataQualityDto }) + data_quality: DataQualityDto; + + @ApiProperty({ type: RiskDto }) + risk: RiskDto; + + @ApiProperty({ type: EngineBreakdownDto }) + engine_breakdown: EngineBreakdownDto; + + @ApiProperty({ type: MatchPickDto, nullable: true }) + main_pick: MatchPickDto | null; + + @ApiProperty({ type: MatchPickDto, nullable: true }) + value_pick: MatchPickDto | null; + + @ApiProperty({ type: MatchBetAdviceDto }) + bet_advice: MatchBetAdviceDto; + + @ApiProperty({ type: [MatchBetSummaryItemDto] }) + bet_summary: MatchBetSummaryItemDto[]; + + @ApiProperty({ type: [MatchPickDto] }) + supporting_picks: MatchPickDto[]; + + @ApiProperty({ type: AggressivePickDto, nullable: true }) + aggressive_pick: AggressivePickDto | null; + + @ApiProperty({ type: HtFtPredictionDto, required: false }) + htft?: HtFtPredictionDto; + + @ApiProperty({ type: [ScenarioTop5ItemDto] }) + scenario_top5: ScenarioTop5ItemDto[]; + + @ApiProperty({ type: ScorePredictionDto }) + score_prediction: ScorePredictionDto; + + @ApiProperty({ type: Object }) + market_board: Record; + + @ApiProperty({ type: [String] }) + reasoning_factors: string[]; +} + +export class ValueBetDto { + @ApiProperty() + matchId: string; + + @ApiProperty() + matchName: string; + + @ApiProperty() + betType: string; + + @ApiProperty() + prediction: string; + + @ApiProperty() + confidence: number; + + @ApiProperty() + odd: number; + + @ApiProperty() + expectedValue: number; +} + +export class PredictionHistoryStatsDto { + @ApiProperty() + totalPredictions: number; + + @ApiProperty() + totalResolved: number; + + @ApiProperty() + correctPredictions: number; + + @ApiProperty() + accuracyRate: number; +} + +export class PredictionHistoryResponseDto { + @ApiProperty({ type: PredictionHistoryStatsDto }) + stats: PredictionHistoryStatsDto; + + @ApiProperty({ type: [Object] }) + history: Record[]; +} + +export class UpcomingPredictionsDto { + @ApiProperty() + count: number; + + @ApiProperty({ type: [MatchPredictionDto] }) + matches: MatchPredictionDto[]; + + @ApiProperty() + modelVersion: string; +} + +export class AIHealthDto { + @ApiProperty() + status: string; + + @ApiProperty() + modelLoaded: boolean; + + @ApiProperty() + predictionServiceReady: boolean; +} + +export * from './smart-coupon.dto'; diff --git a/src/modules/predictions/dto/predictions-request.dto.ts b/src/modules/predictions/dto/predictions-request.dto.ts new file mode 100644 index 0000000..92abe7b --- /dev/null +++ b/src/modules/predictions/dto/predictions-request.dto.ts @@ -0,0 +1,63 @@ +import { + IsArray, + IsString, + IsOptional, + IsNotEmpty, + IsNumber, + IsEnum, + ArrayMaxSize, + Min, + Max, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class GeneratePredictionDto { + @ApiProperty({ description: 'Match ID to generate prediction for' }) + @IsString() + @IsNotEmpty() + matchId: string; +} + +export enum CouponStrategy { + SAFE = 'SAFE', + BALANCED = 'BALANCED', + AGGRESSIVE = 'AGGRESSIVE', + VALUE = 'VALUE', + MIRACLE = 'MIRACLE', +} + +export class SmartCouponRequestDto { + @ApiProperty({ + description: 'List of match IDs for coupon', + example: ['match-1', 'match-2'], + }) + @IsArray() + @IsString({ each: true }) + @ArrayMaxSize(50) + matchIds: string[]; + + @ApiPropertyOptional({ + enum: CouponStrategy, + default: CouponStrategy.BALANCED, + }) + @IsOptional() + @IsEnum(CouponStrategy) + strategy?: CouponStrategy; + + @ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(20) + maxMatches?: number; + + @ApiPropertyOptional({ + description: 'Minimum confidence threshold (0-100)', + example: 60, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + minConfidence?: number; +} diff --git a/src/modules/predictions/dto/smart-coupon.dto.ts b/src/modules/predictions/dto/smart-coupon.dto.ts new file mode 100755 index 0000000..ddb588f --- /dev/null +++ b/src/modules/predictions/dto/smart-coupon.dto.ts @@ -0,0 +1,64 @@ +/** + * Smart Coupon DTOs aligned with AI Engine V20+ contract. + */ + +export type CouponStrategy = + | 'SAFE' + | 'BALANCED' + | 'AGGRESSIVE' + | 'VALUE' + | 'MIRACLE'; + +export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; +export type DataQualityLabel = 'HIGH' | 'MEDIUM' | 'LOW'; + +export interface SmartCouponRequestDto { + match_ids: string[]; + strategy?: CouponStrategy; + max_matches?: number; + min_confidence?: number; +} + +export interface CouponBetDto { + match_id: string; + match_name: string; + market: string; + pick: string; + probability: number; + confidence: number; + odds: number; + risk_level: RiskLevel; + data_quality: DataQualityLabel; +} + +export interface RejectedMatchDto { + match_id: string; + reason: string; + threshold?: number; +} + +export interface SmartCouponResponseDto { + strategy: CouponStrategy; + generated_at: string; + match_count: number; + bets: CouponBetDto[]; + total_odds: number; + expected_win_rate: number; + rejected_matches: RejectedMatchDto[]; +} + +export interface SmartCouponApiError { + error: string; + detail?: string; + match_ids_failed?: string[]; +} + +export interface StrategyInfo { + name: CouponStrategy; + description: string; + typical_odds: string; +} + +export interface StrategiesResponseDto { + strategies: StrategyInfo[]; +} diff --git a/src/modules/predictions/predictions.controller.ts b/src/modules/predictions/predictions.controller.ts new file mode 100755 index 0000000..b8f207f --- /dev/null +++ b/src/modules/predictions/predictions.controller.ts @@ -0,0 +1,169 @@ +import { + Controller, + Get, + Post, + Body, + Param, + HttpCode, + HttpStatus, + NotFoundException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; +import { PredictionsService } from './predictions.service'; +import { + MatchPredictionDto, + PredictionHistoryResponseDto, + UpcomingPredictionsDto, + ValueBetDto, + AIHealthDto, +} from './dto'; +import { + GeneratePredictionDto, + SmartCouponRequestDto, +} from './dto/predictions-request.dto'; +import { Public } from 'src/common/decorators'; + +@ApiTags('Predictions') +@Controller('predictions') +export class PredictionsController { + constructor(private readonly predictionsService: PredictionsService) {} + + /** + * GET /predictions/health + * Check AI Engine health status + */ + @Get('health') + @ApiOperation({ summary: 'Check AI Engine health status' }) + @ApiResponse({ status: 200, type: AIHealthDto }) + async checkHealth(): Promise { + return this.predictionsService.checkHealth(); + } + + /** + * GET /predictions/upcoming + * Get predictions for upcoming matches + */ + @Get('upcoming') + @ApiOperation({ summary: 'Get predictions for upcoming matches' }) + @ApiResponse({ status: 200, type: UpcomingPredictionsDto }) + async getUpcoming(): Promise { + return this.predictionsService.getUpcomingPredictions(); + } + + /** + * GET /predictions/test/:id + * Refetch match data and get prediction + */ + @Get('test/:id') + @ApiOperation({ summary: 'Refetch match data and get prediction' }) + @ApiParam({ name: 'id', description: 'Match ID' }) + async getTestPrediction(@Param('id') id: string) { + return this.predictionsService.testPrediction(id); + } + + /** + * GET /predictions/value-bets + * Get EV+ betting opportunities + */ + @Get('value-bets') + @ApiOperation({ summary: 'Get value betting opportunities (EV+)' }) + @ApiResponse({ status: 200, type: [ValueBetDto] }) + async getValueBets(): Promise { + return this.predictionsService.getValueBets(); + } + + /** + * GET /predictions/history + * Get prediction history and accuracy stats + */ + @Get('history') + @ApiOperation({ summary: 'Get prediction history and accuracy statistics' }) + @ApiResponse({ status: 200, type: PredictionHistoryResponseDto }) + async getHistory(): Promise { + return this.predictionsService.getPredictionHistory(); + } + + /** + * GET /predictions/:matchId + * Get prediction for a specific match + */ + @Get(':matchId') + @Public() + @ApiOperation({ summary: 'Get prediction for a specific match' }) + @ApiParam({ name: 'matchId', description: 'Match ID' }) + @ApiResponse({ status: 200, type: MatchPredictionDto }) + @ApiResponse({ status: 404, description: 'Match not found' }) + async getPrediction( + @Param('matchId') matchId: string, + ): Promise { + // Check cache first + const cached = await this.predictionsService.getCachedPrediction(matchId); + if (cached) { + return cached; + } + + // Get from AI Engine + const prediction = await this.predictionsService.getPredictionById(matchId); + + if (!prediction) { + throw new NotFoundException(`Match not found: ${matchId}`); + } + + // Cache the result + await this.predictionsService.cachePrediction(matchId, prediction); + + return prediction; + } + + /** + * POST /predictions/generate + * Generate prediction with provided match data + */ + @Post('generate') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Generate prediction with provided match data' }) + @ApiResponse({ status: 200, type: MatchPredictionDto }) + async generatePrediction( + @Body() dto: GeneratePredictionDto, + ): Promise { + const prediction = await this.predictionsService.getPredictionWithData({ + matchId: dto.matchId, + }); + + if (!prediction) { + throw new NotFoundException('Failed to generate prediction'); + } + + return prediction; + } + + /** + * POST /predictions/smart-coupon + * Generate Smart Coupon using AI Engine V20 + */ + @Post('smart-coupon') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Generate Smart Coupon with V20 AI recommendations', + }) + @ApiResponse({ + status: 200, + description: 'Smart coupon generated successfully', + }) + async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise { + const coupon = await this.predictionsService.getSmartCoupon( + dto.matchIds, + dto.strategy || 'BALANCED', + { + maxMatches: dto.maxMatches, + minConfidence: dto.minConfidence, + }, + ); + + if (!coupon) { + throw new NotFoundException('Failed to generate Smart Coupon'); + } + + return coupon; + } +} diff --git a/src/modules/predictions/predictions.module.ts b/src/modules/predictions/predictions.module.ts new file mode 100755 index 0000000..3cfe60b --- /dev/null +++ b/src/modules/predictions/predictions.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { BullModule } from '@nestjs/bullmq'; +import { PredictionsController } from './predictions.controller'; +import { PredictionsService } from './predictions.service'; +import { AiFeatureStoreService } from './services/ai-feature-store.service'; +import { DatabaseModule } from '../../database/database.module'; +import { MatchesModule } from '../matches/matches.module'; +import { PredictionsQueue } from './queues/predictions.queue'; +import { PredictionsProcessor } from './queues/predictions.processor'; +import { PREDICTIONS_QUEUE } from './queues/predictions.types'; +import { FeederModule } from '../feeder/feeder.module'; + +const redisEnabled = process.env.REDIS_ENABLED === 'true'; + +@Module({ + imports: [ + DatabaseModule, + HttpModule.register({ + timeout: 30000, // 30 seconds + maxRedirects: 5, + }), + ...(redisEnabled + ? [BullModule.registerQueue({ name: PREDICTIONS_QUEUE })] + : []), + MatchesModule, + FeederModule, + ], + controllers: [PredictionsController], + providers: [ + PredictionsService, + AiFeatureStoreService, + ...(redisEnabled ? [PredictionsQueue, PredictionsProcessor] : []), + ], + exports: [PredictionsService, AiFeatureStoreService], +}) +export class PredictionsModule {} diff --git a/src/modules/predictions/predictions.service.ts b/src/modules/predictions/predictions.service.ts new file mode 100755 index 0000000..f60a0aa --- /dev/null +++ b/src/modules/predictions/predictions.service.ts @@ -0,0 +1,1166 @@ +import { + Injectable, + Logger, + HttpException, + HttpStatus, + OnModuleDestroy, + OnModuleInit, + Optional, +} from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { ConfigService } from '@nestjs/config'; +import { QueueEvents } from 'bullmq'; +import { PredictionsQueue } from './queues/predictions.queue'; +import { PREDICTIONS_QUEUE } from './queues/predictions.types'; +import { + MatchPredictionDto, + PredictionHistoryResponseDto, + UpcomingPredictionsDto, + ValueBetDto, + AIHealthDto, +} from './dto'; +import axios, { AxiosError } from 'axios'; +import { Prisma } from '@prisma/client'; +import { FeederService } from '../feeder/feeder.service'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +type ConfidenceBand = 'HIGH' | 'MEDIUM' | 'LOW'; + +interface ConfidenceInterval { + lower: number; + upper: number; + width: number; + band: ConfidenceBand; + threshold_met: boolean; +} + +interface MatchContext { + leagueId: string | null; + isTopLeague: boolean; +} + +@Injectable() +export class PredictionsService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PredictionsService.name); + private queueEvents: QueueEvents | null = null; + private readonly aiEngineUrl: string; + private readonly topLeagueIds = new Set(); + private readonly reasonTranslations: Record = { + confidence_below_threshold: 'Güven eşiğin altında', + confidence_interval_too_wide: 'Güven aralığı çok geniş', + confidence_interval_too_wide_for_main_pick: + 'Ana seçim için güven aralığı çok geniş', + confidence_band_low: 'Güven bandı düşük', + playable_edge_found: 'Oynanabilir avantaj bulundu', + market_signal_dominant: 'Piyasa sinyali baskın', + team_form_signal_dominant: 'Takım formuna dayalı sinyaller çok baskın', + lineup_signal_strong: 'İlk on bir sinyali güçlü', + lineup_signal_weak: 'İlk on bir sinyali zayıf', + lineup_probable_xi_used: 'Muhtemel ilk on bir kullanıldı', + lineup_probable_not_confirmed: 'Muhtemel ilk on bir henüz doğrulanmadı', + lineup_unavailable: 'İlk on bir bilgisi mevcut değil', + lineup_incomplete: 'İlk on bir bilgisi eksik', + missing_referee: 'Hakem verisi eksik', + draw_probability_elevated: 'Beraberlik olasılığı yükselmiş görünüyor', + balanced_match_risk: 'Maç dengeli, sürpriz riski yükseliyor', + draw_pressure: 'Beraberlik baskısı yüksek', + upset_risk_detected: 'Sürpriz riski tespit edildi', + limited_data_confidence: 'Veri kısıtlı olduğu için güven sınırlı', + data_quality_issue: 'Veri kalitesi sorunu var', + high_risk_low_data_quality: 'Risk yüksek, veri kalitesi düşük', + insufficient_play_score: 'Oynanabilirlik puanı yetersiz', + no_bet_conditions_met: 'Bahis koşulları oluşmadı', + market_passed_all_gates: 'Market tüm güvenlik kontrollerini geçti', + no_ev_edge_minimum_stake: + 'Beklenen avantaj oluşmadı, minimum bahis önerildi', + player_form_signal_strong: 'Oyuncu formu sinyali güçlü', + player_form_signal_limited: 'Oyuncu formu sinyali sınırlı', + live_state_impossible_market: 'Canlı maç durumu bu marketi geçersiz kılıyor', + live_score_exceeds_under_line: + 'Mevcut skor bu alt seçeneğiyle çelişiyor', + score_model_conflicts_with_under_pick: + 'Skor modeli alt seçeneğiyle çelişiyor', + score_model_conflicts_with_over_pick: + 'Skor modeli üst seçeneğiyle çelişiyor', + market_stack_conflict_over25: + 'Üst 2.5 sinyaliyle çeliştiği için zayıflatıldı', + market_stack_conflict_btts: + 'Karşılıklı gol sinyaliyle çeliştiği için zayıflatıldı', + first_half_result_conflicts_with_goalless_half: + 'İlk yarı sonucu beklentisi golsüz ilk yarıyla çelişiyor', + first_half_htft_conflicts_with_goalless_half: + 'İlk yarı/maç sonu beklentisi golsüz ilk yarıyla çelişiyor', + first_half_draw_conflicts_with_goal_pick: + 'İlk yarı beraberlik baskısı erken gol beklentisiyle çelişiyor', + first_half_goalless_conflicts_with_result_pick: + 'Golsüz ilk yarı beklentisi ilk yarı sonuç seçimiyle çelişiyor', + first_half_goalless_conflicts_with_htft_pick: + 'Golsüz ilk yarı beklentisi ilk yarı/maç sonu seçimiyle çelişiyor', + first_half_goal_pressure_conflicts_with_htft_draw: + 'İlk yarı gol baskısı ilk yarı beraberlik kurgusuyla çelişiyor', + live_total_goals_close_to_line: + 'Canlı toplam gol çizgisine çok yakın', + score_model_conflicts_with_btts_no: + 'Skor modeli KG Yok seçeneğiyle çelişiyor', + score_model_conflicts_with_draw_pick: + 'Skor modeli beraberlik seçeneğiyle çelişiyor', + score_model_conflicts_with_home_pick: + 'Skor modeli ev sahibi seçeneğiyle çelişiyor', + score_model_conflicts_with_away_pick: + 'Skor modeli deplasman seçeneğiyle çelişiyor', + high_total_goal_volatility: 'Toplam gol volatilitesi yüksek', + mutual_goal_pressure: 'İki takımın da gol baskısı yüksek', + late_goal_swing_risk: 'Geç gol kaynaklı kırılma riski var', + live_match_open_state: 'Canlı maç açık oyuna dönmüş durumda', + live_match_active_state: 'Canlı maç aktif ve dalgalı ilerliyor', + }; + + constructor( + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + private readonly feederService: FeederService, + @Optional() private readonly predictionsQueue?: PredictionsQueue, + ) { + this.aiEngineUrl = this.configService.get( + 'AI_ENGINE_URL', + 'http://localhost:8000', + ); + this.topLeagueIds = this.loadTopLeagueIds(); + } + + onModuleInit() { + if (this.predictionsQueue) { + this.queueEvents = new QueueEvents(PREDICTIONS_QUEUE, { + connection: { + host: this.configService.get('redis.host', 'localhost'), + port: this.configService.get('redis.port', 6379), + password: this.configService.get('redis.password'), + }, + }); + this.logger.log('Queue mode enabled for predictions'); + } else { + this.logger.log('Direct HTTP mode enabled for predictions (no Redis)'); + } + } + + async onModuleDestroy() { + if (this.queueEvents) { + await this.queueEvents.close(); + } + } + + checkHealth(): Promise { + return Promise.resolve({ + status: 'healthy', + modelLoaded: true, + predictionServiceReady: true, + }); + } + + async getPredictionById(matchId: string): Promise { + await this.ensurePredictionDataReady(matchId); + const matchContext = await this.getMatchContext(matchId); + + // Queue mode (Redis enabled) + if (this.predictionsQueue && this.queueEvents) { + try { + const job = await this.predictionsQueue.addPredictMatchJob({ matchId }); + const data = await job.waitUntilFinished(this.queueEvents, 30000); + if (!data || data.error) { + return null; + } + return this.enrichPredictionResponse( + data as MatchPredictionDto, + matchContext, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Prediction queue failed for ${matchId}: ${message}`); + this.throwAiError(message); + } + } + + // Direct HTTP mode (no Redis) + try { + const response = await axios.post( + `${this.aiEngineUrl}/v20plus/analyze/${matchId}`, + {}, + { timeout: 60000 }, + ); + return this.enrichPredictionResponse( + response.data as MatchPredictionDto, + matchContext, + ); + } catch (e: unknown) { + const error = e as AxiosError>; + const status = error?.response?.status; + const detail = + error?.response?.data?.detail || + error?.response?.data || + error?.message; + this.logger.error( + `Direct AI Engine call failed for ${matchId}: status=${status}, detail=${JSON.stringify(detail)}`, + ); + + // Forward AI Engine's actual error + if (status === 404) { + throw new HttpException( + `Match not found in AI Engine: ${matchId}`, + HttpStatus.NOT_FOUND, + ); + } + if (status === 422) { + throw new HttpException( + `AI Engine: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + throw new HttpException( + `AI Engine error: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`, + status || HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + async getPredictionWithData(matchDetails: { + matchId: string; + }): Promise { + return this.getPredictionById(matchDetails.matchId); + } + + async testPrediction(matchId: string): Promise { + this.logger.log(`[TEST PREDICTION] Syncing match data for ${matchId}...`); + // refreshMatch triggers the feeder scraper to get all match info, odds, and lineups and write to DB + const refreshResult = await this.feederService.refreshMatch(matchId, 'all'); + + if (!refreshResult.success) { + this.logger.warn( + `Failed to refresh match ${matchId} before test prediction. Proceeding with existing data anyway.`, + ); + } else { + this.logger.log( + `Successfully refreshed match ${matchId}. Calling AI Engine...`, + ); + } + + return this.getPredictionById(matchId); + } + + async getUpcomingPredictions(): Promise { + const upcoming = await this.prisma.prediction.findMany({ + where: { + match: { + status: 'NS', + mstUtc: { gte: Math.floor(Date.now() / 1000) }, + }, + }, + include: { + match: { + include: { homeTeam: true, awayTeam: true, league: true }, + }, + }, + orderBy: { match: { mstUtc: 'asc' } }, + take: 50, + }); + + return { + count: upcoming.length, + modelVersion: 'v25-v30-ensemble', + matches: upcoming.map((p) => { + const out = p.predictionJson as Record; + const matchInfo = (out?.match_info || {}) as Record; + return { + ...out, + match_info: { + ...matchInfo, + match_name: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`, + match_date_ms: Number(p.match.mstUtc) * 1000, + league: p.match.league?.name || '', + league_id: p.match.leagueId, + is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ''), + }, + } as unknown as MatchPredictionDto; + }), + }; + } + + private loadTopLeagueIds(): Set { + try { + const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json'); + if (!fs.existsSync(topLeaguesPath)) { + return new Set(); + } + + const raw = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8')); + if (!Array.isArray(raw)) { + return new Set(); + } + + return new Set( + raw + .map((value) => String(value).trim()) + .filter((value) => value.length > 0), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to load top_leagues.json: ${message}`); + return new Set(); + } + } + + private async getMatchContext(matchId: string): Promise { + const match = await this.prisma.match.findUnique({ + where: { id: matchId }, + select: { leagueId: true }, + }); + + if (match) { + return { + leagueId: match.leagueId ?? null, + isTopLeague: this.topLeagueIds.has(match.leagueId ?? ''), + }; + } + + const liveMatch = await this.prisma.liveMatch.findUnique({ + where: { id: matchId }, + select: { leagueId: true }, + }); + + return { + leagueId: liveMatch?.leagueId ?? null, + isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ''), + }; + } + + private enrichPredictionResponse( + prediction: MatchPredictionDto, + matchContext: MatchContext, + ): MatchPredictionDto { + const response = prediction as MatchPredictionDto & Record; + const dataQuality = this.asRecord(response.data_quality); + const risk = this.asRecord(response.risk); + const marketBoard = this.asRecord(response.market_board); + const matchInfo = { + ...this.asRecord(response.match_info), + league_id: + this.asRecord(response.match_info).league_id ?? matchContext.leagueId, + is_top_league: + this.asRecord(response.match_info).is_top_league ?? matchContext.isTopLeague, + }; + + const mainPick = this.enrichPick( + response.main_pick, + response, + matchContext, + marketBoard, + ); + const valuePick = this.enrichPick( + response.value_pick, + response, + matchContext, + marketBoard, + ); + const aggressivePick = this.enrichPick( + response.aggressive_pick, + response, + matchContext, + marketBoard, + ); + + const supportingPicks = Array.isArray(response.supporting_picks) + ? response.supporting_picks.map((pick) => + this.enrichPick(pick, response, matchContext, marketBoard), + ).filter((pick): pick is NonNullable => pick !== null) + : []; + + const betSummary = Array.isArray(response.bet_summary) + ? response.bet_summary.map((item) => + this.enrichSummary(item, response, matchContext, marketBoard), + ) + : []; + + const mainBand = + this.asRecord(mainPick?.confidence_interval).band ?? 'LOW'; + const minConfidenceForPlay = this.getMinConfidenceForPlay( + this.asRecord(mainPick).market, + matchContext.isTopLeague, + ); + const isMainPlayable = + Boolean(this.asRecord(mainPick).playable) && + Boolean(this.asRecord(mainPick?.confidence_interval).threshold_met); + const mainSignalTier = this.classifySignalTier( + this.asRecord(mainPick), + this.asRecord(mainPick?.confidence_interval), + ); + + const reasoningFactors = Array.isArray(response.reasoning_factors) + ? response.reasoning_factors.map((reason) => + this.translateReason(String(reason)), + ) + : []; + + if (mainPick && !isMainPlayable) { + reasoningFactors.unshift( + this.translateReason('confidence_interval_too_wide_for_main_pick'), + ); + } + + const betAdvice = { + ...this.asRecord(response.bet_advice), + playable: isMainPlayable, + confidence_band: mainBand, + min_confidence_for_play: minConfidenceForPlay, + signal_tier: mainSignalTier, + reason: this.translateReason( + isMainPlayable + ? String( + this.asRecord(response.bet_advice).reason || + 'playable_edge_found', + ) + : 'confidence_below_threshold', + ), + suggested_stake_units: isMainPlayable + ? Number(this.asRecord(response.bet_advice).suggested_stake_units ?? 0) + : 0, + }; + + const enrichedMarketBoard = Object.fromEntries( + Object.entries(marketBoard).map(([market, entry]) => { + const record = this.asRecord(entry); + const pickName = String(record.pick ?? ''); + if (!pickName || !record.probs || typeof record.probs !== 'object') { + return [market, record]; + } + + const syntheticPick = { + market, + pick: pickName, + probability: this.lookupProbability(record.probs as Record, pickName), + confidence: Number(record.confidence ?? 0), + calibrated_confidence: Number(record.confidence ?? 0), + raw_confidence: Number(record.confidence ?? 0), + min_required_confidence: 0, + odds: 0, + edge: 0, + ev_edge: 0, + implied_prob: 0, + play_score: 0, + playable: false, + bet_grade: 'PASS', + stake_units: 0, + decision_reasons: [], + }; + + const enriched = this.enrichPick( + syntheticPick, + response, + matchContext, + marketBoard, + ); + + return [ + market, + { + ...record, + confidence_interval: this.asRecord(enriched?.confidence_interval), + confidence_band: this.asRecord(enriched?.confidence_interval).band ?? 'LOW', + }, + ]; + }), + ); + + return { + ...response, + match_info: matchInfo as MatchPredictionDto['match_info'], + data_quality: { + ...dataQuality, + lineup_source: String(dataQuality.lineup_source ?? 'none'), + } as MatchPredictionDto['data_quality'], + risk: { + ...risk, + surprise_type: this.translateReason(String(risk.surprise_type ?? '')), + surprise_reasons: Array.isArray(risk.surprise_reasons) + ? risk.surprise_reasons.map((reason) => + this.translateReason(String(reason)), + ) + : [], + warnings: Array.isArray(risk.warnings) + ? risk.warnings.map((warning) => + this.translateReason(String(warning)), + ) + : [], + } as MatchPredictionDto['risk'], + main_pick: mainPick, + value_pick: valuePick, + aggressive_pick: aggressivePick, + supporting_picks: supportingPicks, + bet_summary: betSummary, + bet_advice: betAdvice as MatchPredictionDto['bet_advice'], + market_board: enrichedMarketBoard, + reasoning_factors: reasoningFactors, + }; + } + + private enrichPick( + pick: unknown, + prediction: Record, + matchContext: MatchContext, + marketBoard: Record, + ): MatchPredictionDto['main_pick'] { + if (!pick || typeof pick !== 'object') { + return null; + } + + const record = this.asRecord(pick); + const market = String(record.market ?? ''); + const pickName = String(record.pick ?? ''); + const probs = this.resolveMarketProbabilities(marketBoard, market); + const probability = + this.asNumber(record.probability) || + this.lookupProbability(probs, pickName); + const calibratedConfidence = + this.asNumber(record.calibrated_confidence) || + this.asNumber(record.confidence); + const impliedProb = + this.asNumber(record.implied_prob) || + this.impliedProbabilityFromOdds(this.asNumber(record.odds)); + const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge); + const interval = this.estimateConfidenceInterval({ + market, + probability, + calibratedConfidence, + impliedProb, + evEdge, + marketBoardProbs: probs, + dataQualityScore: this.normalizeScore( + this.asRecord(prediction.data_quality).score, + ), + riskScore: this.normalizeScore(this.asRecord(prediction.risk).score), + lineupSource: String( + this.asRecord(prediction.data_quality).lineup_source ?? 'none', + ), + isTopLeague: matchContext.isTopLeague, + }); + + const nextReasons = Array.isArray(record.decision_reasons) + ? [...record.decision_reasons] + : []; + if (!interval.threshold_met) { + nextReasons.push('confidence_interval_too_wide'); + } + if (interval.band === 'LOW') { + nextReasons.push('confidence_band_low'); + } + + const displayOdds = this.normalizeDisplayOdds( + this.asNumber(record.odds), + impliedProb, + ); + + return { + ...(record as MatchPredictionDto['main_pick']), + market, + pick: pickName, + probability, + confidence: calibratedConfidence || this.asNumber(record.confidence), + calibrated_confidence: calibratedConfidence, + raw_confidence: + this.asNumber(record.raw_confidence) || + calibratedConfidence || + this.asNumber(record.confidence), + min_required_confidence: this.asNumber(record.min_required_confidence), + odds: displayOdds, + edge: this.asNumber(record.edge), + play_score: this.asNumber(record.play_score), + bet_grade: String(record.bet_grade || 'PASS') as 'A' | 'B' | 'C' | 'PASS', + implied_prob: impliedProb, + ev_edge: evEdge, + playable: Boolean(record.playable) && interval.threshold_met, + stake_units: + Boolean(record.playable) && interval.threshold_met + ? this.asNumber(record.stake_units) + : 0, + decision_reasons: Array.from(new Set(nextReasons)).map((reason) => + this.translateReason(String(reason)), + ), + confidence_interval: interval, + signal_tier: this.classifySignalTier(record, interval), + }; + } + + private enrichSummary( + item: unknown, + prediction: Record, + matchContext: MatchContext, + marketBoard: Record, + ): MatchPredictionDto['bet_summary'][number] { + const record = this.asRecord(item); + const market = String(record.market ?? ''); + const pickName = String(record.pick ?? ''); + const probs = this.resolveMarketProbabilities(marketBoard, market); + const probability = this.lookupProbability(probs, pickName); + const calibratedConfidence = + this.asNumber(record.calibrated_confidence) || + this.asNumber(record.raw_confidence); + const odds = this.asNumber(record.odds); + const impliedProb = + this.asNumber(record.implied_prob) || + this.impliedProbabilityFromOdds(odds); + const evEdge = this.asNumber(record.ev_edge); + + const interval = this.estimateConfidenceInterval({ + market, + probability, + calibratedConfidence, + impliedProb, + evEdge, + marketBoardProbs: probs, + dataQualityScore: this.normalizeScore( + this.asRecord(prediction.data_quality).score, + ), + riskScore: this.normalizeScore(this.asRecord(prediction.risk).score), + lineupSource: String( + this.asRecord(prediction.data_quality).lineup_source ?? 'none', + ), + isTopLeague: matchContext.isTopLeague, + }); + + return { + ...(record as MatchPredictionDto['bet_summary'][number]), + odds: this.normalizeDisplayOdds(odds, impliedProb), + implied_prob: impliedProb, + ev_edge: evEdge, + reasons: Array.isArray(record.reasons) + ? record.reasons.map((reason) => this.translateReason(String(reason))) + : [], + confidence_interval: interval, + signal_tier: this.classifySignalTier( + { + ...record, + odds, + implied_prob: impliedProb, + ev_edge: evEdge, + calibrated_confidence: calibratedConfidence, + }, + interval, + ), + }; + } + + private normalizeDisplayOdds(odds: number, impliedProb: number): number { + if (odds <= 1.01 && impliedProb <= 0) { + return 0; + } + + return odds; + } + + private translateReason(reason: string): string { + if (!reason) { + return ''; + } + + const normalized = reason.startsWith('risk:') + ? reason.slice(5) + : reason; + + if (this.reasonTranslations[normalized]) { + return this.reasonTranslations[normalized]; + } + + const evMatch = normalized.match(/^ev_edge_([+\-][\d.]+%)_grade_(\w)$/); + if (evMatch) { + return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`; + } + + const negativeEdgeMatch = normalized.match(/^negative_model_edge_([+\-]?[\d.]+)$/); + if (negativeEdgeMatch) { + return `Model avantajı negatif (${negativeEdgeMatch[1]})`; + } + + const edgeThresholdMatch = normalized.match( + /^below_market_edge_threshold_([+\-]?[\d.]+)$/, + ); + if (edgeThresholdMatch) { + return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`; + } + + return normalized; + } + + private classifySignalTier( + record: Record, + interval: { + band?: 'HIGH' | 'MEDIUM' | 'LOW'; + threshold_met?: boolean; + }, + ): 'CORE' | 'VALUE' | 'LEAN' | 'LONGSHOT' | 'PASS' { + const playable = Boolean(record.playable) && Boolean(interval.threshold_met); + const calibratedConfidence = this.asNumber(record.calibrated_confidence); + const odds = this.asNumber(record.odds); + const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge); + const playScore = this.asNumber(record.play_score); + const band = String(interval.band ?? 'LOW').toUpperCase(); + + if ( + playable && + band === 'HIGH' && + calibratedConfidence >= 72 && + evEdge >= 0.02 && + playScore >= 68 + ) { + return 'CORE'; + } + + if ( + calibratedConfidence >= 52 && + odds >= 1.75 && + evEdge >= 0.04 + ) { + return playable ? 'VALUE' : 'LONGSHOT'; + } + + if ( + calibratedConfidence >= 46 && + (band === 'HIGH' || band === 'MEDIUM' || evEdge > 0) + ) { + return 'LEAN'; + } + + if (odds >= 2.2 && calibratedConfidence >= 38) { + return 'LONGSHOT'; + } + + return 'PASS'; + } + + private estimateConfidenceInterval(input: { + market: string; + probability: number; + calibratedConfidence: number; + impliedProb: number; + evEdge: number; + marketBoardProbs: Record; + dataQualityScore: number; + riskScore: number; + lineupSource: string; + isTopLeague: boolean; + }): ConfidenceInterval { + const probability = this.clamp(input.probability, 0.01, 0.99); + const sortedProbs = Object.values(input.marketBoardProbs) + .map((value) => this.asNumber(value)) + .filter((value) => value > 0) + .sort((a, b) => b - a); + const secondProb = sortedProbs[1] ?? 0; + const topProb = sortedProbs[0] ?? probability; + const margin = Math.max(0, topProb - secondProb); + const normalizedConfidence = this.normalizePercent(input.calibratedConfidence); + + const baseWidthByMarket: Record = { + MS: 0.18, + OU25: 0.14, + BTTS: 0.14, + HT_OU15: 0.16, + CARDS: 0.2, + HCAP: 0.22, + }; + const baseWidth = baseWidthByMarket[input.market] ?? 0.19; + const lineupPenalty = + input.lineupSource === 'confirmed_live' + ? -0.015 + : input.lineupSource === 'probable_xi' + ? 0 + : 0.02; + const width = this.clamp( + baseWidth + - margin * 0.22 + - normalizedConfidence * 0.05 + + (1 - input.dataQualityScore) * 0.09 + + input.riskScore * 0.08 + - (input.isTopLeague ? 0.012 : 0) + + lineupPenalty, + 0.08, + 0.34, + ); + + const lower = this.clamp(probability - width / 2, 0.01, 0.99); + const upper = this.clamp(probability + width / 2, 0.01, 0.99); + const minConfidence = this.getMinConfidenceForPlay( + input.market, + input.isTopLeague, + ); + const thresholdMet = + input.calibratedConfidence >= minConfidence && + width <= this.getMaxAllowedWidth(input.market) && + input.dataQualityScore >= 0.58 && + input.evEdge >= this.getMinEdge(input.market) && + (upper - input.impliedProb) >= 0.03; + + let band: ConfidenceBand = 'LOW'; + if (input.calibratedConfidence >= 69 && width <= 0.12 && margin >= 0.07) { + band = 'HIGH'; + } else if ( + input.calibratedConfidence >= 58 && + width <= 0.18 && + margin >= 0.035 + ) { + band = 'MEDIUM'; + } + + return { + lower: Number((lower * 100).toFixed(1)), + upper: Number((upper * 100).toFixed(1)), + width: Number((width * 100).toFixed(1)), + band, + threshold_met: thresholdMet, + }; + } + + private getMinConfidenceForPlay( + market: string, + isTopLeague: boolean, + ): number { + const baseline: Record = { + MS: 62, + OU25: 60, + BTTS: 60, + HT_OU15: 61, + CARDS: 64, + HCAP: 65, + }; + + const marketBaseline = baseline[market] ?? 62; + return isTopLeague ? marketBaseline : marketBaseline + 2; + } + + private getMaxAllowedWidth(market: string): number { + const byMarket: Record = { + MS: 16, + OU25: 14, + BTTS: 14, + HT_OU15: 15, + CARDS: 18, + HCAP: 18, + }; + return byMarket[market] ?? 16; + } + + private getMinEdge(market: string): number { + const byMarket: Record = { + MS: 0.02, + OU25: 0.018, + BTTS: 0.018, + HT_OU15: 0.02, + CARDS: 0.025, + HCAP: 0.025, + }; + return byMarket[market] ?? 0.02; + } + + private resolveMarketProbabilities( + marketBoard: Record, + market: string, + ): Record { + const entry = this.asRecord(marketBoard[market]); + const probs = entry.probs; + return probs && typeof probs === 'object' + ? (probs as Record) + : {}; + } + + private lookupProbability( + probabilities: Record, + pickName: string, + ): number { + if (!pickName) { + return 0; + } + + const normalizedPick = pickName.toUpperCase(); + for (const [key, value] of Object.entries(probabilities)) { + if (key.toUpperCase() === normalizedPick) { + return this.asNumber(value); + } + } + return 0; + } + + private impliedProbabilityFromOdds(odds: number): number { + if (odds <= 1) { + return 0; + } + return Number((1 / odds).toFixed(4)); + } + + private normalizeScore(value: unknown): number { + const numeric = this.asNumber(value); + return numeric > 1 ? this.clamp(numeric / 100, 0, 1) : this.clamp(numeric, 0, 1); + } + + private normalizePercent(value: number): number { + return value > 1 ? this.clamp(value / 100, 0, 1) : this.clamp(value, 0, 1); + } + + private asRecord(value: unknown): Record { + return value && typeof value === 'object' + ? (value as Record) + : {}; + } + + private asNumber(value: unknown): number { + return typeof value === 'number' + ? value + : typeof value === 'string' + ? Number(value) || 0 + : 0; + } + + private clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); + } + + async getValueBets(): Promise { + const predictions = await this.prisma.prediction.findMany({ + where: { match: { status: 'NS' } }, + include: { match: { include: { homeTeam: true, awayTeam: true } } }, + }); + + const valueBets: ValueBetDto[] = []; + for (const p of predictions) { + const out = p.predictionJson as Record; + const valueBetsList = out.value_bets as + | Record[] + | undefined; + if (Array.isArray(valueBetsList)) { + valueBetsList.forEach((vb) => { + valueBets.push({ + matchId: p.matchId, + matchName: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`, + betType: (vb.market || vb.betType || '') as string, + prediction: (vb.pick || vb.prediction || '') as string, + confidence: typeof vb.confidence === 'number' ? vb.confidence : 0, + odd: typeof vb.odd === 'number' ? vb.odd : 0, + expectedValue: + typeof vb.edge === 'number' + ? vb.edge + : typeof vb.expectedValue === 'number' + ? vb.expectedValue + : 0, + }); + }); + } + } + + return valueBets + .sort((a, b) => b.expectedValue - a.expectedValue) + .slice(0, 50); + } + + async getSmartCoupon( + matchIds: string[], + strategy: string = 'BALANCED', + options: { maxMatches?: number; minConfidence?: number } = {}, + ): Promise { + await this.ensureSmartCouponDataReady(matchIds); + + // Queue mode (Redis enabled) + if (this.predictionsQueue && this.queueEvents) { + try { + const job = await this.predictionsQueue.addSmartCouponJob({ + matchIds, + strategy, + options, + }); + return await job.waitUntilFinished(this.queueEvents, 60000); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Smart coupon queue failed: ${message}`); + this.throwAiError(message); + } + } + + // Direct HTTP mode + try { + const response = await axios.post( + `${this.aiEngineUrl}/smart-coupon`, + { match_ids: matchIds, strategy, ...options }, + { timeout: 60000 }, + ); + return response.data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Direct smart coupon call failed: ${message}`); + this.throwAiError(message); + } + } + + private throwAiError(message: string): never { + if ( + message.includes('timed out') || + message.includes('AI_ENGINE_TIMEOUT') || + message.includes('AI_ENGINE_504') + ) { + throw new HttpException( + 'Prediction request timed out', + HttpStatus.GATEWAY_TIMEOUT, + ); + } + if (message.includes('AI_ENGINE_502')) { + throw new HttpException( + 'AI Engine upstream returned 502', + HttpStatus.BAD_GATEWAY, + ); + } + throw new HttpException( + 'Failed to get prediction from AI Engine', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + getPredictionHistory(): Promise { + return Promise.resolve({ + stats: { + totalPredictions: 0, + totalResolved: 0, + correctPredictions: 0, + accuracyRate: 0, + }, + history: [], + }); + } + + async cachePrediction(matchId: string, prediction: MatchPredictionDto) { + const payload = prediction as unknown as Prisma.InputJsonObject; + try { + await this.prisma.prediction.upsert({ + where: { matchId }, + update: { + predictionJson: payload, + updatedAt: new Date(), + }, + create: { + matchId, + predictionJson: payload, + }, + }); + } catch (error) { + this.logger.warn(`Failed to cache prediction for ${matchId}`, error); + } + } + + async getCachedPrediction( + matchId: string, + ): Promise { + const prediction = await this.prisma.prediction.findUnique({ + where: { matchId }, + }); + + if (!prediction) { + return null; + } + + const cacheAge = Date.now() - prediction.updatedAt.getTime(); + if (cacheAge > 6 * 60 * 60 * 1000) { + return null; + } + + const cached = prediction.predictionJson as Record; + const modelVersion = cached['model_version']; + if (typeof modelVersion !== 'string') { + return null; + } + + if (!modelVersion.startsWith('v25')) { + return null; + } + + return prediction.predictionJson as unknown as MatchPredictionDto; + } + + private async ensureSmartCouponDataReady(matchIds: string[]): Promise { + const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))]; + if (uniqueMatchIds.length === 0) { + throw new HttpException( + 'No matchIds provided for smart coupon generation', + HttpStatus.BAD_REQUEST, + ); + } + + await Promise.all( + uniqueMatchIds.map((matchId) => this.ensurePredictionDataReady(matchId)), + ); + } + + private async ensurePredictionDataReady(matchId: string): Promise { + const [liveMatch, persistedMatch, oddCategoryCount] = await Promise.all([ + this.prisma.liveMatch.findUnique({ + where: { id: matchId }, + select: { + id: true, + odds: true, + state: true, + status: true, + scoreHome: true, + scoreAway: true, + }, + }), + this.prisma.match.findUnique({ + where: { id: matchId }, + select: { + id: true, + state: true, + status: true, + scoreHome: true, + scoreAway: true, + }, + }), + this.prisma.oddCategory.count({ + where: { matchId }, + }), + ]); + + const hasLiveOdds = + !!liveMatch?.odds && + typeof liveMatch.odds === 'object' && + !Array.isArray(liveMatch.odds) && + Object.keys(liveMatch.odds as Record).length > 0; + const matchExists = !!liveMatch?.id || !!persistedMatch?.id; + + if (!matchExists) { + throw new HttpException( + `Match not found: ${matchId}`, + HttpStatus.NOT_FOUND, + ); + } + + const state = liveMatch?.state || persistedMatch?.state; + const status = liveMatch?.status || persistedMatch?.status; + const scoreHome = liveMatch?.scoreHome ?? persistedMatch?.scoreHome; + const scoreAway = liveMatch?.scoreAway ?? persistedMatch?.scoreAway; + const hasScores = + scoreHome !== null && + scoreHome !== undefined && + scoreAway !== null && + scoreAway !== undefined; + + const isFinished = + hasScores || + state === 'MS' || + state === 'postGame' || + ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'].includes( + status as string, + ); + + const hasOdds = hasLiveOdds || oddCategoryCount > 0; + + if (hasOdds || isFinished) { + return; + } + + throw new HttpException( + `Prediction prerequisites are missing for match ${matchId}: odds`, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } +} diff --git a/src/modules/predictions/queues/predictions.processor.spec.ts b/src/modules/predictions/queues/predictions.processor.spec.ts new file mode 100755 index 0000000..05bdca5 --- /dev/null +++ b/src/modules/predictions/queues/predictions.processor.spec.ts @@ -0,0 +1,81 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import axios from 'axios'; +import { PredictionJobType } from './predictions.types'; +import { PredictionsProcessor } from './predictions.processor'; + +jest.mock('axios'); + +const mockedAxios = axios as jest.Mocked; + +describe('PredictionsProcessor', () => { + let processor: PredictionsProcessor; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.AI_ENGINE_URL = 'http://unit-ai:8000'; + processor = new PredictionsProcessor(); + }); + + afterEach(() => { + delete process.env.AI_ENGINE_URL; + }); + + it('posts to analyze endpoint for predict-match jobs', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any); + + const job = { + id: 'j1', + name: PredictionJobType.PREDICT_MATCH, + data: { matchId: 'match-123' }, + } as any; + + const result = await processor.process(job); + + expect(result).toEqual({ ok: true }); + expect(mockedAxios.post).toHaveBeenCalledWith( + 'http://unit-ai:8000/v20plus/analyze/match-123', + {}, + { timeout: 30000 }, + ); + }); + + it('posts mapped payload to coupon endpoint for smart-coupon jobs', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { bets: [] } } as any); + + const job = { + id: 'j2', + name: PredictionJobType.SMART_COUPON, + data: { + matchIds: ['m1', 'm2'], + strategy: 'BALANCED', + options: { maxMatches: 4, minConfidence: 65 }, + }, + } as any; + + const result = await processor.process(job); + + expect(result).toEqual({ bets: [] }); + expect(mockedAxios.post).toHaveBeenCalledWith( + 'http://unit-ai:8000/v20plus/coupon', + { + match_ids: ['m1', 'm2'], + strategy: 'BALANCED', + max_matches: 4, + min_confidence: 65, + }, + { timeout: 60000 }, + ); + }); + + it('throws for unknown job type', async () => { + const job = { + id: 'j3', + name: 'unknown-job', + data: {}, + } as any; + + await expect(processor.process(job)).rejects.toThrow( + 'Unknown job type: unknown-job', + ); + }); +}); diff --git a/src/modules/predictions/queues/predictions.processor.ts b/src/modules/predictions/queues/predictions.processor.ts new file mode 100755 index 0000000..fe474f0 --- /dev/null +++ b/src/modules/predictions/queues/predictions.processor.ts @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { + PREDICTIONS_QUEUE, + PredictionJobType, + PredictMatchJobData, + SmartCouponJobData, +} from './predictions.types'; +import axios from 'axios'; + +/** + * Predictions Processor + * Handles heavy AI computations in background via HTTP calls to AI Engine + */ +@Processor(PREDICTIONS_QUEUE) +export class PredictionsProcessor extends WorkerHost { + private readonly logger = new Logger(PredictionsProcessor.name); + private readonly aiEngineUrl: string; + + constructor() { + super(); + // Default to container service URL + this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000'; + } + + async process(job: Job): Promise { + this.logger.debug(`Processing job ${job.id}: ${job.name}`); + + switch (job.name) { + case PredictionJobType.PREDICT_MATCH: + return this.handlePredictMatch(job.data as PredictMatchJobData); + + case PredictionJobType.SMART_COUPON: + return this.handleSmartCoupon(job.data as SmartCouponJobData); + + default: + throw new Error(`Unknown job type: ${job.name}`); + } + } + + /** + * Handle Single Match Prediction + * HTTP POST /v20plus/analyze/:id + */ + private async handlePredictMatch(data: PredictMatchJobData): Promise { + const { matchId } = data; + this.logger.log(`🤖 AI Engine: Predicting match ${matchId}...`); + + try { + const response = await axios.post( + `${this.aiEngineUrl}/v20plus/analyze/${matchId}`, + {}, + { timeout: 30000 }, + ); + return response.data; + } catch (error) { + throw this.mapAxiosError(error, matchId, 'predict'); + } + } + + /** + * Handle Smart Coupon Generation + * HTTP POST /v20plus/coupon + */ + private async handleSmartCoupon(data: SmartCouponJobData): Promise { + const { matchIds, strategy } = data; + this.logger.log(`🎫 AI Engine: Generating ${strategy} Coupon...`); + + try { + const response = await axios.post( + `${this.aiEngineUrl}/v20plus/coupon`, + { + match_ids: matchIds, + strategy, + max_matches: data.options?.maxMatches, + min_confidence: data.options?.minConfidence, + }, + { timeout: 60000 }, + ); + return response.data; + } catch (error) { + throw this.mapAxiosError(error, matchIds.join(','), 'smart-coupon'); + } + } + + private mapAxiosError( + error: unknown, + identifier: string, + flow: 'predict' | 'smart-coupon', + ): Error { + if (!axios.isAxiosError(error)) { + return error instanceof Error + ? error + : new Error(`AI_ENGINE_UNKNOWN|${flow}|Unknown error`); + } + + const status = error.response?.status; + const detail = error.response?.data?.detail || error.message; + const code = error.code || ''; + + if (status === 502) { + this.logger.error(`AI Engine 502 (${flow}:${identifier}): ${detail}`); + return new Error(`AI_ENGINE_502|${flow}|${detail}`); + } + + if (status === 504) { + this.logger.error(`AI Engine 504 (${flow}:${identifier}): ${detail}`); + return new Error(`AI_ENGINE_504|${flow}|${detail}`); + } + + if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') { + this.logger.error(`AI Engine timeout (${flow}:${identifier}): ${detail}`); + return new Error(`AI_ENGINE_TIMEOUT|${flow}|${detail}`); + } + + this.logger.error( + `AI Engine error (${flow}:${identifier}) [${status ?? 'N/A'}]: ${detail}`, + ); + return new Error(`AI_ENGINE_ERROR|${flow}|${detail}`); + } +} diff --git a/src/modules/predictions/queues/predictions.queue.ts b/src/modules/predictions/queues/predictions.queue.ts new file mode 100755 index 0000000..1b6d1f0 --- /dev/null +++ b/src/modules/predictions/queues/predictions.queue.ts @@ -0,0 +1,41 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { + PREDICTIONS_QUEUE, + PredictionJobType, + PredictMatchJobData, + SmartCouponJobData, +} from './predictions.types'; + +@Injectable() +export class PredictionsQueue { + private readonly logger = new Logger(PredictionsQueue.name); + + constructor( + @InjectQueue(PREDICTIONS_QUEUE) + public readonly queue: Queue, + ) {} + + /** + * Add a single match prediction job + */ + async addPredictMatchJob(data: PredictMatchJobData) { + this.logger.debug(`Adding prediction job for match: ${data.matchId}`); + return this.queue.add(PredictionJobType.PREDICT_MATCH, data, { + priority: 1, // High priority + }); + } + + /** + * Add a smart coupon generation job + */ + async addSmartCouponJob(data: SmartCouponJobData) { + this.logger.debug( + `Adding smart coupon job: ${data.strategy} (${data.matchIds.length} matches)`, + ); + return this.queue.add(PredictionJobType.SMART_COUPON, data, { + priority: 5, // Lower priority than single predictions + }); + } +} diff --git a/src/modules/predictions/queues/predictions.types.ts b/src/modules/predictions/queues/predictions.types.ts new file mode 100755 index 0000000..4426756 --- /dev/null +++ b/src/modules/predictions/queues/predictions.types.ts @@ -0,0 +1,29 @@ +/** + * Prediction Queue Types + * Senior Level Strict Typing + */ + +export const PREDICTIONS_QUEUE = 'predictions-queue'; + +export enum PredictionJobType { + PREDICT_MATCH = 'predict-match', + SMART_COUPON = 'smart-coupon', +} + +export interface PredictMatchJobData { + matchId: string; + forceUpdate?: boolean; +} + +export interface SmartCouponJobData { + matchIds: string[]; + strategy: string; + options?: { + maxMatches?: number; + minConfidence?: number; + }; +} + +export type PredictionJob = + | { type: PredictionJobType.PREDICT_MATCH; data: PredictMatchJobData } + | { type: PredictionJobType.SMART_COUPON; data: SmartCouponJobData }; diff --git a/src/modules/predictions/services/ai-feature-store.service.ts b/src/modules/predictions/services/ai-feature-store.service.ts new file mode 100755 index 0000000..ef3b32d --- /dev/null +++ b/src/modules/predictions/services/ai-feature-store.service.ts @@ -0,0 +1,114 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../../database/prisma.service'; + +@Injectable() +export class AiFeatureStoreService { + private readonly logger = new Logger(AiFeatureStoreService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Bir maç için AI özelliklerini hesaplar ve 'match_ai_features' tablosuna yazar. + * Bu metod Feeder yeni veri çektiğinde tetiklenmelidir. + */ + async calculateAndSaveFeatures(matchId: string): Promise { + const match = await this.prisma.match.findUnique({ + where: { id: matchId }, + include: { + homeTeam: { + include: { homeMatches: { take: 5, orderBy: { mstUtc: 'desc' } } }, + }, + awayTeam: { + include: { awayMatches: { take: 5, orderBy: { mstUtc: 'desc' } } }, + }, + }, + }); + + if (!match || !match.homeTeam || !match.awayTeam) return; + + // 1. Form Score Calculation (0-100) + // Son 5 maçtaki galibiyet, beraberlik ve atılan gollerin ağırlıklı ortalaması + const homeForm = this.calculateFormScore(match.homeTeam.homeMatches); + const awayForm = this.calculateFormScore(match.awayTeam.awayMatches); + + // 2. ELO — Read from team_elo_ratings table (populated by AI Engine compute_elo.py) + const homeElo = match.homeTeamId + ? await this.getTeamElo(match.homeTeamId) + : 1500.0; + const awayElo = match.awayTeamId + ? await this.getTeamElo(match.awayTeamId) + : 1500.0; + + // 3. Missing Player Impact (Sakat/Cezalı etkisi) + // Feeder'dan gelen lineups verisindeki eksik as oyuncuları analiz etmeliyiz. + // Şimdilik 0.0 (Etkisiz) olarak set ediyoruz, ilerde Lineup analizi buraya eklenecek. + const missingImpact = 0.0; + + // 4. Save to Feature Store + await this.prisma.footballAiFeature.upsert({ + where: { matchId }, + update: { + homeElo, + awayElo, + homeFormScore: homeForm, + awayFormScore: awayForm, + missingPlayersImpact: missingImpact, + updatedAt: new Date(), + }, + create: { + matchId, + homeElo, + awayElo, + homeFormScore: homeForm, + awayFormScore: awayForm, + missingPlayersImpact: missingImpact, + }, + }); + + this.logger.debug( + `Features calculated for match ${matchId} (Home Form: ${homeForm}, Away Form: ${awayForm})`, + ); + } + + /** + * Form Puanı Hesaplama Algoritması (V17 Simplified) + * W=30, D=10, L=0 puan. + Gol başına 5 puan (max 15). + * Toplam skor 0-100 arasına normalize edilir. + */ + private calculateFormScore(matches: any[]): number { + if (!matches || matches.length === 0) return 50; // Nötr form + + let totalPoints = 0; + const maxPoints = matches.length * 45; // Max olası puan (30win + 15goal) + + for (const m of matches) { + // Skor kontrolü (bazı maçlar oynanmamış olabilir) + if (m.scoreHome === null || m.scoreAway === null) continue; + + const isWin = m.scoreHome > m.scoreAway; // Home team context + const isDraw = m.scoreHome === m.scoreAway; + + if (isWin) totalPoints += 30; + else if (isDraw) totalPoints += 10; + + const goals = Math.min(m.scoreHome, 3); // Max 3 gol katkısı + totalPoints += goals * 5; + } + + // Normalize to 0-100 + // Eğer hiç maç oynanmadıysa yine 50 dön. + return matches.length > 0 ? (totalPoints / maxPoints) * 100 : 50; + } + + /** + * team_elo_ratings tablosundan takımın güncel ELO puanını okur. + * Kayıt yoksa varsayılan 1500.0 döner. + */ + private async getTeamElo(teamId: string): Promise { + const row = await this.prisma.teamEloRating.findUnique({ + where: { teamId }, + select: { overallElo: true }, + }); + return row?.overallElo ?? 1500.0; + } +} diff --git a/src/modules/social-poster/caption-generator.service.ts b/src/modules/social-poster/caption-generator.service.ts new file mode 100644 index 0000000..f20a099 --- /dev/null +++ b/src/modules/social-poster/caption-generator.service.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { GeminiService } from '../gemini/gemini.service'; +import { PredictionCardDto } from './dto/prediction-card.dto'; + +const SYSTEM_PROMPT = `Sen profesyonel bir spor analisti ve sosyal medya içerik üreticisisin. +Verilen maç tahmin verisini kullanarak kısa, etkili ve ilgi çekici sosyal medya postları yazıyorsun. + +KURALLAR: +- Türkçe yaz +- Maximum 250 karakter (X/Twitter uyumlu) +- Emoji kullan ama abartma (2-4 emoji yeterli) +- Skor tahminini vurgula +- Güven yüzdesini belirt +- İlgili hashtag'leri ekle (#PremierLeague, #SüperLig vb.) +- KESİNLİKLE "kesin kazanır", "garanti" gibi ifadeler KULLANMA +- "Tahminimiz", "Beklentimiz", "Analizimiz" gibi ifadeler kullan +- Farklı maçlar için farklı tarzda yaz, tekdüze olma +- Son satıra her zaman hashtag'leri koy`; + +@Injectable() +export class CaptionGeneratorService { + private readonly logger = new Logger(CaptionGeneratorService.name); + + constructor(private readonly geminiService: GeminiService) {} + + /** + * Generate a social media caption for a match prediction using Gemini AI. + */ + async generateCaption(card: PredictionCardDto): Promise { + if (!this.geminiService.isAvailable()) { + this.logger.warn('Gemini not available, using template caption'); + return this.generateFallbackCaption(card); + } + + const prompt = this.buildPrompt(card); + + try { + const { text } = await this.geminiService.generateText(prompt, { + systemPrompt: SYSTEM_PROMPT, + temperature: 0.8, + maxTokens: 300, + }); + + // Ensure hashtags are present + const caption = this.ensureHashtags(text, card); + this.logger.log( + `Caption generated for ${card.homeTeam} vs ${card.awayTeam}`, + ); + return caption; + } catch (error) { + this.logger.error('Gemini caption generation failed', error); + return this.generateFallbackCaption(card); + } + } + + private buildPrompt(card: PredictionCardDto): string { + const topPicksText = card.topPicks + .map( + (p, i) => + `${i + 1}. ${p.market} (${p.marketEn}) — ${p.pick} — Güven: %${p.confidence} — Oran: ${p.odds}`, + ) + .join('\n'); + + return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur: + +MAÇ: ${card.homeTeam} vs ${card.awayTeam} +LİG: ${card.leagueName} +TARİH: ${card.matchDate} +İLK YARI SKOR TAHMİNİ: ${card.htScore} +MAÇ SONU SKOR TAHMİNİ: ${card.ftScore} +SKOR GÜVEN: %${card.scoreConfidence} +RİSK SEVİYESİ: ${card.riskLevel} + +EN İYİ TAHMİNLER: +${topPicksText} + +Sadece post metnini yaz, başka hiçbir şey ekleme.`; + } + + private ensureHashtags(text: string, card: PredictionCardDto): string { + // If no hashtags in text, add them + if (!text.includes('#')) { + const leagueTag = card.leagueName + .replace(/\s+/g, '') + .replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, ''); + const homeTag = card.homeTeam.replace(/\s+/g, ''); + const awayTag = card.awayTeam.replace(/\s+/g, ''); + text += `\n\n#${leagueTag} #${homeTag} #${awayTag}`; + } + return text.trim(); + } + + /** + * Fallback caption when Gemini is not available. + */ + private generateFallbackCaption(card: PredictionCardDto): string { + const topPick = card.topPicks[0]; + const leagueTag = card.leagueName + .replace(/\s+/g, '') + .replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, ''); + + return `⚡ ${card.homeTeam} vs ${card.awayTeam} +🎯 Tahminimiz: ${card.ftScore} (İY: ${card.htScore}) +📊 Güven: %${card.scoreConfidence} +${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ''} + +#${leagueTag} #SuggestBet #Bahis`.trim(); + } +} diff --git a/src/modules/social-poster/dto/prediction-card.dto.ts b/src/modules/social-poster/dto/prediction-card.dto.ts new file mode 100644 index 0000000..4fb379d --- /dev/null +++ b/src/modules/social-poster/dto/prediction-card.dto.ts @@ -0,0 +1,60 @@ +/** + * Prediction Card DTO + * + * Typed data structure for rendering match prediction cards + * and generating social media captions. + */ + +export interface TopPick { + /** Market name in Turkish, e.g. "Üst 2.5 Gol" */ + market: string; + /** Market name in English, e.g. "Over 2.5" */ + marketEn: string; + /** Pick label, e.g. "Üst" */ + pick: string; + /** Confidence 0-100 */ + confidence: number; + /** Odds value */ + odds: number; +} + +export interface PredictionCardDto { + // ─── Match Info ─── + matchId: string; + homeTeam: string; + awayTeam: string; + homeLogo: string; + awayLogo: string; + leagueName: string; + leagueLogo?: string; + /** Formatted date, e.g. "01 Mar 2026 - 21:00" */ + matchDate: string; + + // ─── Score Predictions ─── + /** HT score, e.g. "1-0" */ + htScore: string; + /** FT score, e.g. "2-1" */ + ftScore: string; + /** Overall confidence 0-100 */ + scoreConfidence: number; + + // ─── Top 3 Best Bets ─── + topPicks: TopPick[]; + + // ─── Risk ─── + riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; + + // ─── Raw prediction JSON (for Gemini caption) ─── + rawPrediction?: Record; +} + +export interface SocialPostResult { + matchId: string; + imagePath: string; + caption: string; + twitterPostId?: string; + facebookPostId?: string; + instagramPostId?: string; + postedAt: Date; + errors?: string[]; +} diff --git a/src/modules/social-poster/image-renderer.service.ts b/src/modules/social-poster/image-renderer.service.ts new file mode 100644 index 0000000..db5c84e --- /dev/null +++ b/src/modules/social-poster/image-renderer.service.ts @@ -0,0 +1,462 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import axios from 'axios'; +import { createCanvas, loadImage } from 'canvas'; +import { PredictionCardDto } from './dto/prediction-card.dto'; + +@Injectable() +export class ImageRendererService implements OnModuleInit { + private readonly logger = new Logger(ImageRendererService.name); + private readonly outputDir = path.join( + process.cwd(), + 'public', + 'predictions', + ); + + onModuleInit() { + // Ensure output directory exists + if (!fs.existsSync(this.outputDir)) { + fs.mkdirSync(this.outputDir, { recursive: true }); + } + } + + /** + * Render a prediction card to a PNG image using Canvas API. + * Returns the file path of the generated image. + */ + async renderCard(card: PredictionCardDto): Promise { + const fileName = `prediction_${card.matchId}_${Date.now()}.png`; + const filePath = path.join(this.outputDir, fileName); + + try { + this.logger.log( + `🎨 Rendering canvas for ${card.homeTeam} vs ${card.awayTeam}...`, + ); + await this.drawCanvas(card, filePath); + this.logger.log(`✅ Card rendered to ${fileName}`); + return filePath; + } catch (error) { + this.logger.error(`Failed to render canvas card: ${error.message}`); + throw error; + } + } + + /** + * Load a team logo image. Handles: + * 1. Local file path (e.g., /uploads/teams/xxx.png → public/uploads/teams/xxx.png) + * 2. Full HTTP URL (e.g., https://cdn.example.com/logo.png) + * 3. Mackolik CDN fallback using team slug from path + */ + private async downloadImage(url: string) { + if (!url) return null; + + try { + // Case 1: Local relative path → read from public/ directory + if (url.startsWith('/')) { + const localPath = path.join(process.cwd(), 'public', url); + if (fs.existsSync(localPath)) { + this.logger.debug(`Loading logo from local file: ${localPath}`); + return await loadImage(localPath); + } + // Local file not found → try as full URL via APP_BASE_URL + this.logger.debug( + `Local file not found: ${localPath}, trying remote...`, + ); + } + + // Case 2: Full HTTP/HTTPS URL → fetch directly + if (url.startsWith('http')) { + const response = await axios.get(url, { + responseType: 'arraybuffer', + timeout: 5000, + }); + return await loadImage(response.data); + } + + this.logger.warn(`Could not resolve logo path: ${url}`); + return null; + } catch (error) { + this.logger.warn(`Could not load image from ${url}: ${error.message}`); + return null; + } + } + + private fillRoundRect( + ctx: any, + x: number, + y: number, + width: number, + height: number, + radius: number, + ) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + ctx.fill(); + } + + private strokeRoundRect( + ctx: any, + x: number, + y: number, + width: number, + height: number, + radius: number, + ) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + ctx.stroke(); + } + + private async drawCanvas( + data: PredictionCardDto, + outPath: string, + ): Promise { + const width = 1080; + const height = 1920; + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + // Background Gradient + const bgGrad = ctx.createLinearGradient(0, 0, width, height); + bgGrad.addColorStop(0, '#0a0e27'); + bgGrad.addColorStop(0.35, '#1a1040'); + bgGrad.addColorStop(0.7, '#0d1b2a'); + bgGrad.addColorStop(1, '#0a0e27'); + ctx.fillStyle = bgGrad; + ctx.fillRect(0, 0, width, height); + + // Watermark + ctx.save(); + ctx.translate(width / 2, height / 2); + ctx.rotate((-35 * Math.PI) / 180); + ctx.fillStyle = 'rgba(255, 255, 255, 0.05)'; + ctx.font = '900 100px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const wmLine = + 'iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com iddaai.com'; + for (let i = -15; i <= 15; i++) { + ctx.fillText(wmLine, 0, i * 180); + } + ctx.restore(); + + // Settings + const paddingX = 80; + + // Header + ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.font = '600 28px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(data.leagueName.toUpperCase(), paddingX, 120); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'; + ctx.font = '400 22px sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText(data.matchDate, width - paddingX, 120); + + // Teams Section + let currentY = 280; + const [homeImg, awayImg] = await Promise.all([ + this.downloadImage(data.homeLogo), + this.downloadImage(data.awayLogo), + ]); + + if (homeImg) ctx.drawImage(homeImg, width / 4 - 100, currentY, 200, 200); + if (awayImg) + ctx.drawImage(awayImg, (width / 4) * 3 - 100, currentY, 200, 200); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.font = '900 56px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('VS', width / 2, currentY + 110); + + currentY += 250; + ctx.fillStyle = '#ffffff'; + ctx.font = '700 36px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(data.homeTeam, width / 4, currentY); + ctx.fillText(data.awayTeam, (width / 4) * 3, currentY); + + // Divider: Skore Prediction + currentY += 140; + const drawSectionTitle = (y: number, text: string) => { + ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.font = '600 22px sans-serif'; + ctx.fillText(text, width / 2, y + 8); + + const txtWidth = ctx.measureText(text).width; + const grad = ctx.createLinearGradient(paddingX, y, width - paddingX, y); + grad.addColorStop(0, 'rgba(120, 80, 255, 0)'); + grad.addColorStop(0.5, 'rgba(120, 80, 255, 0.6)'); + grad.addColorStop(1, 'rgba(120, 80, 255, 0)'); + + ctx.fillStyle = grad; + ctx.fillRect( + paddingX, + y - 2, + (width - 2 * paddingX - txtWidth - 40) / 2, + 3, + ); + ctx.fillRect( + width / 2 + txtWidth / 2 + 20, + y - 2, + (width - 2 * paddingX - txtWidth - 40) / 2, + 3, + ); + }; + + drawSectionTitle(currentY, 'SKOR TAHMİNİ / SCORE PREDICTION'); + + // Scores + currentY += 80; + const scoreBoxWidth = 380; + const scoreBoxHeight = 220; + const htX = width / 2 - scoreBoxWidth - 24; + const ftX = width / 2 + 24; + + // HT Box + ctx.fillStyle = 'rgba(255, 255, 255, 0.04)'; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + ctx.lineWidth = 2; + this.fillRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20); + this.strokeRoundRect(ctx, htX, currentY, scoreBoxWidth, scoreBoxHeight, 20); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'; + ctx.font = '600 20px sans-serif'; + ctx.fillText('İLK YARI', htX + scoreBoxWidth / 2, currentY + 40); + ctx.fillStyle = 'rgba(255, 255, 255, 0.25)'; + ctx.font = '400 16px sans-serif'; + ctx.fillText('Half Time', htX + scoreBoxWidth / 2, currentY + 65); + ctx.fillStyle = '#ffffff'; + ctx.font = '900 80px sans-serif'; + ctx.fillText(data.htScore, htX + scoreBoxWidth / 2, currentY + 160); + + // FT Box + const ftGrad = ctx.createLinearGradient( + ftX, + currentY, + ftX + scoreBoxWidth, + currentY + scoreBoxHeight, + ); + ftGrad.addColorStop(0, 'rgba(120, 80, 255, 0.15)'); + ftGrad.addColorStop(1, 'rgba(0, 200, 255, 0.1)'); + ctx.fillStyle = ftGrad; + ctx.strokeStyle = 'rgba(120, 80, 255, 0.3)'; + this.fillRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20); + this.strokeRoundRect(ctx, ftX, currentY, scoreBoxWidth, scoreBoxHeight, 20); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'; + ctx.font = '600 20px sans-serif'; + ctx.fillText('MAÇ SONU', ftX + scoreBoxWidth / 2, currentY + 40); + ctx.fillStyle = 'rgba(255, 255, 255, 0.25)'; + ctx.font = '400 16px sans-serif'; + ctx.fillText('Full Time', ftX + scoreBoxWidth / 2, currentY + 65); + + // Score text gradient + const txtGrad = ctx.createLinearGradient( + ftX, + currentY + 100, + ftX, + currentY + 160, + ); + txtGrad.addColorStop(0, '#9b6fff'); + txtGrad.addColorStop(1, '#00c8ff'); + ctx.fillStyle = txtGrad; + ctx.font = '900 80px sans-serif'; + ctx.fillText(data.ftScore, ftX + scoreBoxWidth / 2, currentY + 160); + + // Confidence badge + ctx.fillStyle = '#0a0e27'; + ctx.strokeStyle = 'rgba(120, 80, 255, 0.6)'; + this.fillRoundRect( + ctx, + ftX + scoreBoxWidth / 2 - 80, + currentY + scoreBoxHeight - 20, + 160, + 40, + 20, + ); + this.strokeRoundRect( + ctx, + ftX + scoreBoxWidth / 2 - 80, + currentY + scoreBoxHeight - 20, + 160, + 40, + 20, + ); + ctx.fillStyle = '#b89dff'; + ctx.font = '800 20px sans-serif'; + ctx.fillText( + `🎯 %${data.scoreConfidence}`, + ftX + scoreBoxWidth / 2, + currentY + scoreBoxHeight + 7, + ); + + // Divider: Picks + currentY += scoreBoxHeight + 100; + drawSectionTitle(currentY, 'EN İYİ TAHMİNLER / BEST PICKS'); + + // Picks rendering + currentY += 80; + data.topPicks.forEach((pick, index) => { + ctx.fillStyle = 'rgba(255, 255, 255, 0.03)'; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; + this.fillRoundRect( + ctx, + paddingX, + currentY, + width - 2 * paddingX, + 100, + 16, + ); + this.strokeRoundRect( + ctx, + paddingX, + currentY, + width - 2 * paddingX, + 100, + 16, + ); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.font = '700 28px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(String(index + 1), paddingX + 30, currentY + 58); + + ctx.fillStyle = '#ffffff'; + ctx.font = '600 26px sans-serif'; + ctx.fillText(pick.market, paddingX + 80, currentY + 45); + + const marketWidth = ctx.measureText(pick.market).width; + ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; + ctx.font = '400 18px sans-serif'; + ctx.fillText( + `(${pick.marketEn})`, + paddingX + 80 + marketWidth + 10, + currentY + 43, + ); + + // Pick Bar bg + ctx.fillStyle = 'rgba(255, 255, 255, 0.06)'; + const barMaxWidth = width - 2 * paddingX - 220; + this.fillRoundRect(ctx, paddingX + 80, currentY + 65, barMaxWidth, 12, 6); + + // Pick Bar fill + const fillWidth = (pick.confidence / 100) * barMaxWidth; + const barGrad = ctx.createLinearGradient( + paddingX + 80, + 0, + paddingX + 80 + barMaxWidth, + 0, + ); + barGrad.addColorStop(0, '#7850ff'); + barGrad.addColorStop(1, '#00c8ff'); + ctx.fillStyle = barGrad; + this.fillRoundRect(ctx, paddingX + 80, currentY + 65, fillWidth, 12, 6); + + // Confidence text + ctx.fillStyle = '#b89dff'; + ctx.font = '900 32px sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText(`%${pick.confidence}`, width - paddingX - 30, currentY + 58); + + currentY += 124; + }); + + // Footer + currentY = height - 80; + ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; + ctx.font = '700 26px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('⚡ AI Powered by SuggestBet', paddingX, currentY); + + let riskBg, riskColor, riskBorder; + switch (data.riskLevel) { + case 'LOW': + riskBg = 'rgba(0, 200, 100, 0.15)'; + riskColor = '#4ade80'; + riskBorder = 'rgba(0, 200, 100, 0.3)'; + break; + case 'MEDIUM': + riskBg = 'rgba(255, 200, 0, 0.12)'; + riskColor = '#fbbf24'; + riskBorder = 'rgba(255, 200, 0, 0.25)'; + break; + case 'HIGH': + riskBg = 'rgba(255, 100, 50, 0.12)'; + riskColor = '#f97316'; + riskBorder = 'rgba(255, 100, 50, 0.25)'; + break; + case 'EXTREME': + riskBg = 'rgba(255, 50, 50, 0.15)'; + riskColor = '#ef4444'; + riskBorder = 'rgba(255, 50, 50, 0.3)'; + break; + default: + riskBg = 'rgba(255, 255, 255, 0.1)'; + riskColor = '#ffffff'; + riskBorder = 'rgba(255, 255, 255, 0.3)'; + } + + const riskText = `RISK: ${data.riskLevel}`; + ctx.font = '800 20px sans-serif'; + const riskWidth = ctx.measureText(riskText).width; + ctx.fillStyle = riskBg; + ctx.strokeStyle = riskBorder; + this.fillRoundRect( + ctx, + width - paddingX - riskWidth - 48, + currentY - 26, + riskWidth + 48, + 44, + 22, + ); + this.strokeRoundRect( + ctx, + width - paddingX - riskWidth - 48, + currentY - 26, + riskWidth + 48, + 44, + 22, + ); + + ctx.fillStyle = riskColor; + ctx.textAlign = 'center'; + ctx.fillText(riskText, width - paddingX - riskWidth / 2 - 24, currentY + 3); + + // Save Output directly using the buffer + const buffer = canvas.toBuffer('image/png'); + fs.writeFileSync(outPath, buffer); + } + + /** + * Get the web-accessible URL for a rendered image. + */ + getImageUrl(filePath: string): string { + const relativePath = path.relative( + path.join(process.cwd(), 'public'), + filePath, + ); + return `/${relativePath.replace(/\\/g, '/')}`; + } +} diff --git a/src/modules/social-poster/meta.service.ts b/src/modules/social-poster/meta.service.ts new file mode 100644 index 0000000..2063264 --- /dev/null +++ b/src/modules/social-poster/meta.service.ts @@ -0,0 +1,180 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; + +@Injectable() +export class MetaService { + private readonly logger = new Logger(MetaService.name); + + private readonly pageAccessToken: string; + private readonly pageId: string; + private readonly igUserId: string; + private readonly isEnabled: boolean; + private readonly graphApiBase = 'https://graph.facebook.com/v21.0'; + + constructor(private readonly configService: ConfigService) { + this.pageAccessToken = + this.configService.get('META_PAGE_ACCESS_TOKEN') || ''; + this.pageId = this.configService.get('META_PAGE_ID') || ''; + this.igUserId = this.configService.get('META_IG_USER_ID') || ''; + + this.isEnabled = !!(this.pageAccessToken && this.pageId); + + if (this.isEnabled) { + this.logger.log('✅ Meta API client initialized'); + } else { + this.logger.warn( + '⚠️ Meta API not configured. Set META_PAGE_ACCESS_TOKEN, META_PAGE_ID, META_IG_USER_ID', + ); + } + } + + get facebookAvailable(): boolean { + return this.isEnabled; + } + + get instagramAvailable(): boolean { + return this.isEnabled && !!this.igUserId; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // FACEBOOK + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Post a photo to a Facebook Page. + * + * @param message - Post caption + * @param imageUrl - Publicly accessible image URL + * @returns Facebook post ID + */ + async postToFacebook( + message: string, + imageUrl: string, + ): Promise { + if (!this.facebookAvailable) { + this.logger.warn('Facebook not available, skipping post'); + return null; + } + + try { + const response = await axios.post( + `${this.graphApiBase}/${this.pageId}/photos`, + { + url: imageUrl, + message, + access_token: this.pageAccessToken, + }, + ); + + const postId = response.data?.id; + this.logger.log(`✅ Facebook post published: ${postId}`); + return postId || null; + } catch (error) { + this.logger.error( + `❌ Facebook post failed: ${error.response?.data?.error?.message || error.message}`, + ); + return null; + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INSTAGRAM + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Post a photo to Instagram Business/Creator account. + * + * Two-step process: + * 1. Create media container with image_url + * 2. Publish the container + * + * @param caption - Post caption (max 2200 chars) + * @param imageUrl - Publicly accessible JPEG image URL + * @returns Instagram media ID + */ + async postToInstagram( + caption: string, + imageUrl: string, + ): Promise { + if (!this.instagramAvailable) { + this.logger.warn('Instagram not available, skipping post'); + return null; + } + + try { + // Step 1: Create media container + const containerResponse = await axios.post( + `${this.graphApiBase}/${this.igUserId}/media`, + { + image_url: imageUrl, + caption, + access_token: this.pageAccessToken, + }, + ); + + const containerId = containerResponse.data?.id; + if (!containerId) { + throw new Error('No container ID returned'); + } + + // Wait for container processing (IG needs a few seconds) + await this.waitForContainerReady(containerId); + + // Step 2: Publish + const publishResponse = await axios.post( + `${this.graphApiBase}/${this.igUserId}/media_publish`, + { + creation_id: containerId, + access_token: this.pageAccessToken, + }, + ); + + const mediaId = publishResponse.data?.id; + this.logger.log(`✅ Instagram post published: ${mediaId}`); + return mediaId || null; + } catch (error) { + this.logger.error( + `❌ Instagram post failed: ${error.response?.data?.error?.message || error.message}`, + ); + return null; + } + } + + /** + * Wait for Instagram container to be ready for publishing. + */ + private async waitForContainerReady( + containerId: string, + maxWaitMs = 30000, + ): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitMs) { + try { + const response = await axios.get( + `${this.graphApiBase}/${containerId}`, + { + params: { + fields: 'status_code', + access_token: this.pageAccessToken, + }, + }, + ); + + const status = response.data?.status_code; + if (status === 'FINISHED') return; + if (status === 'ERROR') { + throw new Error('Container processing failed'); + } + } catch (error) { + if (error.message === 'Container processing failed') throw error; + } + + // Wait 2 seconds before checking again + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + this.logger.warn('Container wait timed out, attempting publish anyway'); + } +} diff --git a/src/modules/social-poster/social-poster.controller.ts b/src/modules/social-poster/social-poster.controller.ts new file mode 100644 index 0000000..7f84118 --- /dev/null +++ b/src/modules/social-poster/social-poster.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Post, Param, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; + +import { SocialPosterService } from './social-poster.service'; +import { Roles } from '../../common/decorators'; +import { RolesGuard } from '../auth/guards/auth.guards'; + +@ApiTags('Social Poster') +@ApiBearerAuth() +@UseGuards(RolesGuard) +@Roles('admin') +@Controller('social-poster') +export class SocialPosterController { + constructor(private readonly socialPosterService: SocialPosterService) {} + + @Get('preview/:matchId') + async previewCard(@Param('matchId') matchId: string) { + return this.socialPosterService.renderPreview(matchId); + } + + @Post('post/:matchId') + async postMatch(@Param('matchId') matchId: string) { + return this.socialPosterService.manualPost(matchId); + } +} diff --git a/src/modules/social-poster/social-poster.module.ts b/src/modules/social-poster/social-poster.module.ts new file mode 100644 index 0000000..0489244 --- /dev/null +++ b/src/modules/social-poster/social-poster.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; + +import { SocialPosterService } from './social-poster.service'; +import { ImageRendererService } from './image-renderer.service'; +import { CaptionGeneratorService } from './caption-generator.service'; +import { TwitterService } from './twitter.service'; +import { MetaService } from './meta.service'; + +import { SocialPosterController } from './social-poster.controller'; + +/** + * Social Poster Module + * + * Automates the generation of prediction cards and social media posting + * to X (Twitter), Facebook, and Instagram for upcoming matches. + */ +@Module({ + imports: [ConfigModule, ScheduleModule.forRoot()], + controllers: [SocialPosterController], + providers: [ + SocialPosterService, + ImageRendererService, + CaptionGeneratorService, + TwitterService, + MetaService, + ], + exports: [SocialPosterService], +}) +export class SocialPosterModule {} diff --git a/src/modules/social-poster/social-poster.service.ts b/src/modules/social-poster/social-poster.service.ts new file mode 100644 index 0000000..221c225 --- /dev/null +++ b/src/modules/social-poster/social-poster.service.ts @@ -0,0 +1,395 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../database/prisma.service'; +import axios from 'axios'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { ImageRendererService } from './image-renderer.service'; +import { CaptionGeneratorService } from './caption-generator.service'; +import { TwitterService } from './twitter.service'; +import { MetaService } from './meta.service'; +import { + PredictionCardDto, + TopPick, + SocialPostResult, +} from './dto/prediction-card.dto'; + +// Top leagues loaded once + +const TOP_LEAGUES_PATH = path.join(process.cwd(), 'top_leagues.json'); + +@Injectable() +export class SocialPosterService { + private readonly logger = new Logger(SocialPosterService.name); + private readonly aiEngineUrl: string; + private readonly appBaseUrl: string; + private readonly isEnabled: boolean; + private readonly postedMatchIds = new Set(); + private topLeagueIds: Set = new Set(); + + constructor( + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + private readonly imageRenderer: ImageRendererService, + private readonly captionGenerator: CaptionGeneratorService, + private readonly twitterService: TwitterService, + private readonly metaService: MetaService, + ) { + this.aiEngineUrl = + this.configService.get('AI_ENGINE_URL') || + 'http://localhost:8000'; + this.appBaseUrl = + this.configService.get('APP_BASE_URL') || 'http://localhost:3000'; + this.isEnabled = + this.configService.get('SOCIAL_POSTER_ENABLED') === 'true'; + + this.loadTopLeagues(); + } + + private loadTopLeagues() { + try { + const data = fs.readFileSync(TOP_LEAGUES_PATH, 'utf-8'); + const ids = JSON.parse(data); + this.topLeagueIds = new Set(ids); + this.logger.log(`✅ Loaded ${this.topLeagueIds.size} top league IDs`); + } catch { + this.logger.warn('⚠️ Could not load top_leagues.json'); + } + } + + /** + * Cron: Every 10 minutes, check for upcoming matches. + * Posts predictions 30 minutes before kickoff. + */ + @Cron('*/10 * * * *') + async checkAndPostUpcomingMatches() { + if (!this.isEnabled) return; + + try { + const matches = await this.getUpcomingMatches(25, 40); // 25-40 min window + this.logger.log( + `📅 Found ${matches.length} upcoming matches in the window`, + ); + + for (const match of matches) { + if (this.postedMatchIds.has(match.id)) continue; + + try { + await this.predictAndPost(match); + this.postedMatchIds.add(match.id); + + // Cleanup: remove old IDs (keep last 500) + if (this.postedMatchIds.size > 500) { + const arr = Array.from(this.postedMatchIds); + arr + .slice(0, arr.length - 500) + .forEach((id) => this.postedMatchIds.delete(id)); + } + } catch (error) { + this.logger.error( + `Failed to process match ${match.id}: ${error.message}`, + ); + } + + // Small delay between posts to avoid rate limits + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + } catch (error) { + this.logger.error(`Cron job failed: ${error.message}`); + } + } + + /** + * Get matches starting in [minMinutes, maxMinutes] from now. + * Filtered by top leagues. + */ + private async getUpcomingMatches( + minMinutes: number, + maxMinutes: number, + ): Promise { + const now = Date.now(); + const minTime = now + minMinutes * 60 * 1000; + const maxTime = now + maxMinutes * 60 * 1000; + + const matches = await this.prisma.liveMatch.findMany({ + where: { + sport: 'football', + leagueId: { in: Array.from(this.topLeagueIds) }, + mstUtc: { + gte: minTime, + lte: maxTime, + }, + }, + include: { + homeTeam: true, + awayTeam: true, + league: true, + }, + }); + + return matches; + } + + /** + * Full pipeline: Predict → Render Image → Generate Caption → Post. + */ + async predictAndPost(match: any): Promise { + const matchId = match.id; + this.logger.log( + `🚀 Processing: ${match.homeTeam?.name} vs ${match.awayTeam?.name}`, + ); + + // Step 1: Get prediction from AI Engine + const prediction = await this.getPrediction(matchId); + if (!prediction) { + throw new Error('No prediction returned from AI Engine'); + } + + // Step 2: Build prediction card data + const card = this.buildCardFromPrediction(match, prediction); + + // Step 3: Render image + const imagePath = await this.imageRenderer.renderCard(card); + const imageUrl = `${this.appBaseUrl}${this.imageRenderer.getImageUrl(imagePath)}`; + + // Step 4: Generate caption via Gemini + const caption = await this.captionGenerator.generateCaption(card); + + // Step 5: Post to all platforms + const result: SocialPostResult = { + matchId, + imagePath, + caption, + postedAt: new Date(), + errors: [], + }; + + // Twitter + try { + result.twitterPostId = + (await this.twitterService.postWithImage(caption, imagePath)) || + undefined; + } catch (error) { + result.errors!.push(`Twitter: ${error.message}`); + } + + // Facebook + try { + result.facebookPostId = + (await this.metaService.postToFacebook(caption, imageUrl)) || undefined; + } catch (error) { + result.errors!.push(`Facebook: ${error.message}`); + } + + // Instagram + try { + result.instagramPostId = + (await this.metaService.postToInstagram(caption, imageUrl)) || + undefined; + } catch (error) { + result.errors!.push(`Instagram: ${error.message}`); + } + + this.logger.log( + `✅ Posted: ${match.homeTeam?.name} vs ${match.awayTeam?.name} ` + + `[TW: ${result.twitterPostId ? '✅' : '❌'}, ` + + `FB: ${result.facebookPostId ? '✅' : '❌'}, ` + + `IG: ${result.instagramPostId ? '✅' : '❌'}]`, + ); + + return result; + } + + /** + * Call AI Engine's V20+ prediction endpoint directly. + */ + private async getPrediction(matchId: string): Promise { + try { + const response = await axios.post( + `${this.aiEngineUrl}/v20plus/analyze/${matchId}`, + null, + { timeout: 30000 }, + ); + return response.data; + } catch (error) { + this.logger.error(`AI Engine request failed: ${error.message}`); + return null; + } + } + + /** + * Build a PredictionCardDto from the raw AI prediction + match data. + * Maps the V20+ response structure to our card DTO. + */ + private buildCardFromPrediction( + match: any, + prediction: any, + ): PredictionCardDto { + // V20+ returns score_prediction.ft / .ht + const score = prediction.score_prediction || {}; + const htScore = score.ht || '0-0'; + const ftScore = score.ft || '1-1'; + + // Extract best bets from bet_summary array + const topPicks = this.extractTopPicks(prediction); + + // Match date formatting + const matchDate = this.formatMatchDate(match.mstUtc); + + // Score confidence from main_pick or scenario_top5 + const mainPick = prediction.main_pick || {}; + const scoreConfidence = Math.round( + mainPick.confidence || mainPick.raw_confidence || 50, + ); + + return { + matchId: match.id, + homeTeam: + match.homeTeam?.name || prediction.match_info?.home_team || 'Home', + awayTeam: + match.awayTeam?.name || prediction.match_info?.away_team || 'Away', + homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ''), + awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ''), + leagueName: match.league?.name || prediction.match_info?.league || '', + matchDate, + htScore, + ftScore, + scoreConfidence, + topPicks, + riskLevel: prediction.risk?.level || 'MEDIUM', + rawPrediction: prediction, + }; + } + + /** + * Extract top 3 picks sorted by confidence from the V20+ bet_summary array. + */ + private extractTopPicks(prediction: any): TopPick[] { + const betSummary: any[] = prediction.bet_summary || []; + + // Market code to Turkish/English label mapping + const marketLabels: Record = { + MS: { tr: 'Maç Sonucu', en: 'Match Result' }, + OU15: { tr: 'Üst 1.5 Gol', en: 'Over 1.5' }, + OU25: { tr: 'Üst 2.5 Gol', en: 'Over 2.5' }, + OU35: { tr: 'Üst 3.5 Gol', en: 'Over 3.5' }, + BTTS: { tr: 'Karşılıklı Gol', en: 'Both Teams Score' }, + DC: { tr: 'Çifte Şans', en: 'Double Chance' }, + HT: { tr: 'İlk Yarı Sonucu', en: 'Half Time Result' }, + HT_OU05: { tr: 'İY 0.5 Üst/Alt', en: 'HT Over/Under 0.5' }, + OE: { tr: 'Tek/Çift', en: 'Odd/Even' }, + HTFT: { tr: 'İY/MS', en: 'HT/FT' }, + }; + + const candidates: TopPick[] = betSummary.map((bet) => { + const labels = marketLabels[bet.market] || { + tr: bet.market, + en: bet.market, + }; + return { + market: `${labels.tr}: ${bet.pick}`, + marketEn: `${labels.en}: ${bet.pick}`, + pick: bet.pick, + confidence: Math.round(bet.raw_confidence || bet.confidence || 0), + odds: bet.odds || 0, + }; + }); + + // Sort by confidence and return top 3 + candidates.sort((a, b) => b.confidence - a.confidence); + return candidates.slice(0, 3); + } + + /** + * Convert relative logo paths to full HTTP URLs. + * On the deployed server, logos exist at public/uploads/teams/... + * Locally during dev, we fetch them from the deployed server via APP_BASE_URL. + */ + private resolveLogoUrl(logoUrl: string): string { + if (!logoUrl) return ''; + // Already a full URL + if (logoUrl.startsWith('http')) return logoUrl; + // Relative path → check local first, otherwise make full URL + const localPath = path.join(process.cwd(), 'public', logoUrl); + if (fs.existsSync(localPath)) return logoUrl; // Keep relative, renderer reads local + // Not local → prepend base URL for remote fetch + return `${this.appBaseUrl}${logoUrl}`; + } + + private formatMatchDate(mstUtc: number | bigint): string { + const d = new Date(Number(mstUtc)); + const months = [ + 'Oca', + 'Şub', + 'Mar', + 'Nis', + 'May', + 'Haz', + 'Tem', + 'Ağu', + 'Eyl', + 'Eki', + 'Kas', + 'Ara', + ]; + const day = String(d.getDate()).padStart(2, '0'); + const month = months[d.getMonth()]; + const year = d.getFullYear(); + const hour = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${day} ${month} ${year} - ${hour}:${min}`; + } + + /** + * Manual trigger for testing: predict and post for a specific match. + */ + async manualPost(matchId: string): Promise { + const match = await this.prisma.liveMatch.findUnique({ + where: { id: matchId }, + include: { + homeTeam: true, + awayTeam: true, + league: true, + }, + }); + + if (!match) { + throw new Error(`Match ${matchId} not found`); + } + + return this.predictAndPost(match); + } + + /** + * Manual trigger: render only (no posting) — for preview/testing. + */ + async renderPreview( + matchId: string, + ): Promise<{ imagePath: string; card: PredictionCardDto; caption: string }> { + const match = await this.prisma.liveMatch.findUnique({ + where: { id: matchId }, + include: { + homeTeam: true, + awayTeam: true, + league: true, + }, + }); + + if (!match) { + throw new Error(`Match ${matchId} not found`); + } + + const prediction = await this.getPrediction(matchId); + if (!prediction) { + throw new Error('No prediction returned from AI Engine'); + } + + const card = this.buildCardFromPrediction(match, prediction); + const imagePath = await this.imageRenderer.renderCard(card); + const caption = await this.captionGenerator.generateCaption(card); + + return { imagePath, card, caption }; + } +} diff --git a/src/modules/social-poster/twitter.service.ts b/src/modules/social-poster/twitter.service.ts new file mode 100644 index 0000000..27b3206 --- /dev/null +++ b/src/modules/social-poster/twitter.service.ts @@ -0,0 +1,87 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs'; + +@Injectable() +export class TwitterService { + private readonly logger = new Logger(TwitterService.name); + private client: any = null; + private isEnabled = false; + + constructor(private readonly configService: ConfigService) { + const apiKey = this.configService.get('TWITTER_API_KEY'); + const apiSecret = this.configService.get('TWITTER_API_SECRET'); + const accessToken = this.configService.get('TWITTER_ACCESS_TOKEN'); + const accessSecret = this.configService.get( + 'TWITTER_ACCESS_SECRET', + ); + + if (apiKey && apiSecret && accessToken && accessSecret) { + void this.initClient(apiKey, apiSecret, accessToken, accessSecret); + } else { + this.logger.warn( + '⚠️ Twitter API keys not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET', + ); + } + } + + private async initClient( + apiKey: string, + apiSecret: string, + accessToken: string, + accessSecret: string, + ) { + try { + const { TwitterApi } = await import('twitter-api-v2'); + this.client = new TwitterApi({ + appKey: apiKey, + appSecret: apiSecret, + accessToken, + accessSecret, + }); + this.isEnabled = true; + this.logger.log('✅ Twitter API client initialized'); + } catch (error) { + this.logger.error('Failed to initialize Twitter client', error); + } + } + + get available(): boolean { + return this.isEnabled && this.client !== null; + } + + /** + * Post a tweet with an image. + * + * @param text - Tweet text + * @param imagePath - Absolute path to the image file + * @returns Tweet ID + */ + async postWithImage(text: string, imagePath: string): Promise { + if (!this.available) { + this.logger.warn('Twitter not available, skipping post'); + return null; + } + + try { + // Step 1: Upload media via v1.1 + const mediaData = fs.readFileSync(imagePath); + const mediaId = await this.client.v1.uploadMedia(mediaData, { + mimeType: 'image/png', + }); + + // Step 2: Create tweet via v2 + const tweet = await this.client.v2.tweet({ + text, + media: { media_ids: [mediaId] }, + }); + + const tweetId = tweet.data?.id; + this.logger.log(`✅ Tweet posted: ${tweetId}`); + return tweetId || null; + } catch (error) { + this.logger.error(`❌ Twitter post failed: ${error.message}`); + return null; + } + } +} diff --git a/src/modules/spor-toto/dto/spor-toto.dto.ts b/src/modules/spor-toto/dto/spor-toto.dto.ts new file mode 100644 index 0000000..f2c972d --- /dev/null +++ b/src/modules/spor-toto/dto/spor-toto.dto.ts @@ -0,0 +1,256 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsArray, + IsDateString, + IsEnum, + IsInt, + IsNumber, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +// ─── Bulletin Match Item (used in CreateBulletinDto) ─── + +export class BulletinMatchItemDto { + @ApiProperty({ example: 1, description: 'Sıra numarası (1-15)' }) + @IsInt() + @Min(1) + @Max(15) + matchOrder: number; + + @ApiProperty({ example: 'Blackpool' }) + @IsString() + homeTeamName: string; + + @ApiProperty({ example: 'Burton Albion' }) + @IsString() + awayTeamName: string; + + @ApiPropertyOptional({ example: 'İN1' }) + @IsOptional() + @IsString() + leagueName?: string; + + @ApiPropertyOptional({ example: '2026-03-28T18:00:00' }) + @IsOptional() + @IsDateString() + kickoffTime?: string; + + @ApiPropertyOptional({ description: 'Link to existing match ID' }) + @IsOptional() + @IsString() + matchId?: string; +} + +// ─── Create Bulletin DTO ─── + +export class CreateBulletinDto { + @ApiProperty({ example: 333, description: 'Game cycle number from API' }) + @IsInt() + gameCycleNo: number; + + @ApiPropertyOptional({ example: '27-29 Mart' }) + @IsOptional() + @IsString() + programName?: string; + + @ApiPropertyOptional({ example: '2025-2026' }) + @IsOptional() + @IsString() + season?: string; + + @ApiPropertyOptional({ example: '2026-03-22T10:00:00' }) + @IsOptional() + @IsDateString() + payinBeginDate?: string; + + @ApiPropertyOptional({ example: '2026-03-27T20:55:00' }) + @IsOptional() + @IsDateString() + payinEndDate?: string; + + @ApiProperty({ type: [BulletinMatchItemDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BulletinMatchItemDto) + matches: BulletinMatchItemDto[]; +} + +// ─── Update Results DTO ─── + +export class MatchResultDto { + @ApiProperty({ example: 1, description: 'Match order (1-15)' }) + @IsInt() + @Min(1) + @Max(15) + matchOrder: number; + + @ApiProperty({ enum: ['HOME', 'DRAW', 'AWAY'], example: 'HOME' }) + @IsEnum({ HOME: 'HOME', DRAW: 'DRAW', AWAY: 'AWAY' }) + result: 'HOME' | 'DRAW' | 'AWAY'; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + isCancelled?: boolean; + + @ApiPropertyOptional({ enum: ['HOME', 'DRAW', 'AWAY'] }) + @IsOptional() + @IsEnum({ HOME: 'HOME', DRAW: 'DRAW', AWAY: 'AWAY' }) + drawResult?: 'HOME' | 'DRAW' | 'AWAY'; +} + +export class UpdateResultsDto { + @ApiProperty({ type: [MatchResultDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MatchResultDto) + results: MatchResultDto[]; + + @ApiPropertyOptional({ description: '15 bilen sayısı' }) + @IsOptional() + @IsInt() + winners15?: number; + + @ApiPropertyOptional({ description: '15 bilen ödülü (TL)' }) + @IsOptional() + @IsNumber() + prize15?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + winners14?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + prize14?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + winners13?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + prize13?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + winners12?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + prize12?: number; + + @ApiPropertyOptional({ description: 'Sonraki haftaya devir' }) + @IsOptional() + @IsNumber() + rolloverNext?: number; +} + +// ─── Generate Columns DTO ─── + +export type TotoSelectionType = '1' | 'X' | '2'; + +export class TotoMatchSelection { + @ApiProperty({ example: 1 }) + @IsInt() + @Min(1) + @Max(15) + matchOrder: number; + + @ApiProperty({ + type: [String], + example: ['1', 'X'], + description: 'Seçimler: 1=Ev, X=Beraberlik, 2=Deplasman', + }) + @IsArray() + selections: TotoSelectionType[]; +} + +export class GenerateColumnsDto { + @ApiProperty({ description: 'Bulletin ID' }) + @IsString() + bulletinId: string; + + @ApiProperty({ type: [TotoMatchSelection] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TotoMatchSelection) + matchSelections: TotoMatchSelection[]; + + @ApiPropertyOptional({ + example: 'FULL_SYSTEM', + description: 'FULL_SYSTEM | REDUCED_SYSTEM | MANUAL', + }) + @IsOptional() + @IsString() + strategy?: string; + + @ApiPropertyOptional({ + example: 100, + description: 'Max kolon sayısı (reduced system için)', + }) + @IsOptional() + @IsInt() + maxColumns?: number; +} + +// ─── Generate AI Prediction DTO ─── + +export class GenerateSporTotoPredictionDto { + @ApiProperty({ description: 'Bulletin ID' }) + @IsString() + bulletinId: string; + + @ApiPropertyOptional({ + example: 'BALANCED', + enum: ['CONSERVATIVE', 'BALANCED', 'AGGRESSIVE', 'FORMULA_6PCT'], + description: + 'CONSERVATIVE(100 col), BALANCED(500), AGGRESSIVE(2500), FORMULA_6PCT(%6 sampling)', + }) + @IsOptional() + @IsString() + strategy?: 'CONSERVATIVE' | 'BALANCED' | 'AGGRESSIVE' | 'FORMULA_6PCT'; + + @ApiPropertyOptional({ + example: 500, + description: 'Max bütçe (TL). Kolon sayısı buna göre sınırlanır.', + }) + @IsOptional() + @IsNumber() + maxBudget?: number; + + @ApiPropertyOptional({ + example: 200, + description: 'Max kolon sayısı override', + }) + @IsOptional() + @IsInt() + maxColumns?: number; +} + +// ─── Evaluate Columns DTO ─── + +export class EvaluateColumnsDto { + @ApiProperty({ description: 'Bulletin ID' }) + @IsString() + bulletinId: string; + + @ApiProperty({ + type: [String], + example: ['11X2X1XX21X1121'], + description: 'Array of 15-char column strings', + }) + @IsArray() + @IsString({ each: true }) + columns: string[]; +} diff --git a/src/modules/spor-toto/services/toto-analytics.service.ts b/src/modules/spor-toto/services/toto-analytics.service.ts new file mode 100644 index 0000000..59448cf --- /dev/null +++ b/src/modules/spor-toto/services/toto-analytics.service.ts @@ -0,0 +1,190 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../../database/prisma.service'; + +/** + * Spor Toto Analitik Servisi + * - Havuz dağılım hesabı (%25/%20/%20/%35) + * - Expected Value (EV) hesabı + * - Devir geçmişi ve trend analizi + */ +@Injectable() +export class TotoAnalyticsService { + private readonly logger = new Logger(TotoAnalyticsService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Havuz dağılımını hesapla + * Spor Toto havuz dağılımı: + * %35 → 15 bilen + * %20 → 14 bilen + * %20 → 13 bilen + * %25 → 12 bilen + */ + calculatePoolDistribution(totalPool: number): { + pool15: number; + pool14: number; + pool13: number; + pool12: number; + } { + return { + pool15: totalPool * 0.35, + pool14: totalPool * 0.2, + pool13: totalPool * 0.2, + pool12: totalPool * 0.25, + }; + } + + /** + * Expected Value hesaplama + * EV = (Kazanma Olasılığı × Ödül) - Maliyet + * + * 15 maçın tamamını bilme olasılığı (hepsi tek tahmin): + * P = (1/3)^15 ≈ 1/14,348,907 + */ + calculateEV( + poolTotal: number, + rolloverAmount: number, + columnCost: number, + columnCount: number, + ): { + totalPool: number; + pool15: number; + probWin15: number; + ev15: number; + totalCost: number; + netEV: number; + } { + const effectivePool = poolTotal + rolloverAmount; + const distribution = this.calculatePoolDistribution(effectivePool); + + // Basit olasılık: 1/3^15 (her maç bağımsız, 3 sonuç) + const probSingleColumn = 1 / Math.pow(3, 15); // ~6.97e-8 + const probWin15 = 1 - Math.pow(1 - probSingleColumn, columnCount); + + const totalCost = columnCost * columnCount; + const ev15 = probWin15 * distribution.pool15 - totalCost; + + return { + totalPool: effectivePool, + pool15: distribution.pool15, + probWin15, + ev15, + totalCost, + netEV: ev15, + }; + } + + /** + * Devir geçmişi ve trend analizi + */ + async getRolloverHistory(limit = 10): Promise<{ + history: Array<{ + gameCycleNo: number; + programName: string | null; + poolTotal: number | null; + rolloverAmount: number | null; + winners15: number; + prize15: number | null; + }>; + averageRollover: number; + consecutiveRollovers: number; + }> { + const bulletins = await this.prisma.totoBulletin.findMany({ + where: { status: 'COMPLETED' }, + orderBy: { gameCycleNo: 'desc' }, + take: limit, + include: { result: true }, + }); + + const history = bulletins.map((b) => ({ + gameCycleNo: b.gameCycleNo, + programName: b.programName, + poolTotal: b.poolTotal, + rolloverAmount: b.rolloverAmount, + winners15: b.result?.winners15 ?? 0, + prize15: b.result?.prize15 ?? null, + })); + + // Ortalama devir miktarı + const rollovers = history + .map((h) => h.rolloverAmount ?? 0) + .filter((r) => r > 0); + const averageRollover = + rollovers.length > 0 + ? rollovers.reduce((a, b) => a + b, 0) / rollovers.length + : 0; + + // Ardışık devir sayısı (son kaç haftadır devir var) + let consecutiveRollovers = 0; + for (const h of history) { + if (h.winners15 === 0) { + consecutiveRollovers++; + } else { + break; + } + } + + return { history, averageRollover, consecutiveRollovers }; + } + + /** + * Bülten istatistikleri + */ + async getBulletinStats(bulletinId: string): Promise<{ + poolDistribution: { + pool15: number; + pool14: number; + pool13: number; + pool12: number; + } | null; + ev: { + totalPool: number; + pool15: number; + probWin15: number; + ev15: number; + totalCost: number; + netEV: number; + } | null; + rolloverInfo: { averageRollover: number; consecutiveRollovers: number }; + }> { + const bulletin = await this.prisma.totoBulletin.findUnique({ + where: { id: bulletinId }, + }); + + if (!bulletin) { + return { + poolDistribution: null, + ev: null, + rolloverInfo: { averageRollover: 0, consecutiveRollovers: 0 }, + }; + } + + const poolDistribution = bulletin.poolTotal + ? this.calculatePoolDistribution( + bulletin.poolTotal + (bulletin.rolloverAmount ?? 0), + ) + : null; + + const ev = + bulletin.poolTotal != null + ? this.calculateEV( + bulletin.poolTotal, + bulletin.rolloverAmount ?? 0, + 1, // birim fiyat + 1, // tek kolon bazında + ) + : null; + + const rolloverInfo = await this.getRolloverHistory(20); + + return { + poolDistribution, + ev, + rolloverInfo: { + averageRollover: rolloverInfo.averageRollover, + consecutiveRollovers: rolloverInfo.consecutiveRollovers, + }, + }; + } +} diff --git a/src/modules/spor-toto/services/toto-combinatorics.service.ts b/src/modules/spor-toto/services/toto-combinatorics.service.ts new file mode 100644 index 0000000..a432173 --- /dev/null +++ b/src/modules/spor-toto/services/toto-combinatorics.service.ts @@ -0,0 +1,156 @@ +import { Injectable, Logger } from '@nestjs/common'; + +export interface TotoMatchSelectionInput { + matchOrder: number; + selections: ('1' | 'X' | '2')[]; +} + +export interface GeneratedColumn { + predictions: string; // "1X2102X112X2101" — 15 chars +} + +/** + * Kombinatorik kolon üretim motoru. + * Tam Sistem (Full System): Tüm olası kombinasyonları üretir (2^d × 3^t). + * İndirgenmiş Sistem (Reduced): Belirli bir kapak garantisi ile kolon sayısını azaltır. + */ +@Injectable() +export class TotoCombinatoricsService { + private readonly logger = new Logger(TotoCombinatoricsService.name); + + /** + * Tam Sistemli Kolon Üretimi + * Her maç için seçilen tahminlerin tüm olası kombinasyonlarını üretir. + * + * @param matchSelections 15 maç için seçimler + * @returns Tüm olası kolonlar + */ + generateFullSystem( + matchSelections: TotoMatchSelectionInput[], + ): GeneratedColumn[] { + // 15 maçlık tam liste oluştur (seçim yapılmayan maçlara default '1' ata) + const selectionsMap = new Map(); + matchSelections.forEach((ms) => { + selectionsMap.set(ms.matchOrder, ms.selections); + }); + + const orderedSelections: string[][] = []; + for (let i = 1; i <= 15; i++) { + const sel = selectionsMap.get(i); + if (!sel || sel.length === 0) { + orderedSelections.push(['1']); // Default: ev sahibi + } else { + orderedSelections.push(sel); + } + } + + // Toplam kolon sayısını hesapla + const totalColumns = orderedSelections.reduce( + (acc, sel) => acc * sel.length, + 1, + ); + + this.logger.debug( + `Full system: generating ${totalColumns} columns from selections`, + ); + + // Tüm kombinasyonları üret + const columns: GeneratedColumn[] = []; + this.generateCombinations(orderedSelections, 0, '', columns); + + return columns; + } + + /** + * İndirgenmiş Sistem Kolon Üretimi + * Tam sistemdeki kolonlardan rastgele veya stratejik olarak seçim yapar. + * maxColumns kadar kolon üretir. + */ + generateReducedSystem( + matchSelections: TotoMatchSelectionInput[], + maxColumns: number, + ): GeneratedColumn[] { + const fullColumns = this.generateFullSystem(matchSelections); + + if (fullColumns.length <= maxColumns) { + return fullColumns; + } + + // Fisher-Yates shuffle ile rastgele seçim + const shuffled = [...fullColumns]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + + this.logger.debug( + `Reduced system: ${fullColumns.length} → ${maxColumns} columns`, + ); + return shuffled.slice(0, maxColumns); + } + + /** + * Kolon maliyetini hesapla + * Spor Toto birim fiyat: 1 TL/kolon (2026 itibarıyla) + */ + calculateCost(columnCount: number, unitPrice = 1): number { + return columnCount * unitPrice; + } + + /** + * Kolon sayısını hesapla (sistem üretmeden) + */ + calculateColumnCount(matchSelections: TotoMatchSelectionInput[]): number { + const selectionsMap = new Map(); + matchSelections.forEach((ms) => { + selectionsMap.set(ms.matchOrder, ms.selections); + }); + + let total = 1; + for (let i = 1; i <= 15; i++) { + const sel = selectionsMap.get(i); + total *= sel && sel.length > 0 ? sel.length : 1; + } + return total; + } + + /** + * Kolonları sonuçlarla karşılaştır + * @param columns Kolon tahminleri + * @param results Gerçek sonuçlar (15 karakter: 1/X/2) + * @returns Her kolon için doğru tahmin sayısı + */ + evaluateColumns( + columns: GeneratedColumn[], + results: string, + ): { predictions: string; correctCount: number }[] { + return columns.map((col) => { + let correct = 0; + for (let i = 0; i < 15; i++) { + if (col.predictions[i] === results[i]) { + correct++; + } + } + return { predictions: col.predictions, correctCount: correct }; + }); + } + + /** + * Recursive kombinasyon üretici + */ + private generateCombinations( + selections: string[][], + index: number, + current: string, + result: GeneratedColumn[], + ): void { + if (index === selections.length) { + result.push({ predictions: current }); + return; + } + + for (const sel of selections[index]) { + this.generateCombinations(selections, index + 1, current + sel, result); + } + } +} diff --git a/src/modules/spor-toto/services/toto-fetcher.service.ts b/src/modules/spor-toto/services/toto-fetcher.service.ts new file mode 100644 index 0000000..48902ab --- /dev/null +++ b/src/modules/spor-toto/services/toto-fetcher.service.ts @@ -0,0 +1,124 @@ +import { Injectable, Logger } from '@nestjs/common'; +import axios from 'axios'; + +/** + * Spor Toto API response types + * Source: https://sportotov2.iddaa.com/SporToto + */ +export interface SporTotoApiEvent { + eventNo: number; + eventName: string; // "Blackpool-Burton Albion" + competitionName: string; // "İN1" + eventDate: string; // "2026-03-28T18:00:00" + result: string | null; + winner: string | null; +} + +export interface SporTotoApiDividend { + winnerCount15?: number; + dividend15?: number; + winnerCount14?: number; + dividend14?: number; + winnerCount13?: number; + dividend13?: number; + winnerCount12?: number; + dividend12?: number; +} + +export interface SporTotoApiResponse { + isSuccess: boolean; + data: { + payinBeginDate: string; + payinEndDate: string; + gameCycleNo: number; + dividends: SporTotoApiDividend | null; + events: SporTotoApiEvent[]; + programName: string; + nextDrawExpectedWins: number | null; + } | null; + message: string; + error: string | null; + info: string | null; + dateTime: string | null; +} + +@Injectable() +export class TotoFetcherService { + private readonly logger = new Logger(TotoFetcherService.name); + private readonly apiUrl = 'https://sportotov2.iddaa.com/SporToto'; + + /** + * Fetch current bulletin from Spor Toto API + */ + async fetchCurrentBulletin(): Promise { + try { + this.logger.log('Fetching current Spor Toto bulletin...'); + const response = await axios.get(this.apiUrl, { + timeout: 10000, + headers: { + Accept: 'application/json', + 'User-Agent': 'SuggestBet/1.0', + }, + }); + + if (!response.data?.isSuccess || !response.data?.data) { + this.logger.warn( + 'Spor Toto API returned unsuccessful response', + response.data?.message, + ); + return null; + } + + this.logger.log( + `Fetched bulletin: Cycle ${response.data.data.gameCycleNo} — ${response.data.data.programName} (${response.data.data.events.length} events)`, + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + this.logger.error( + `Spor Toto API error: ${error.message}`, + error.response?.status, + ); + } else { + this.logger.error('Spor Toto fetch failed', error); + } + return null; + } + } + + /** + * Parse "Blackpool-Burton Albion" → { home: "Blackpool", away: "Burton Albion" } + */ + parseEventName(eventName: string): { + homeTeam: string; + awayTeam: string; + } { + const parts = eventName.split('-'); + if (parts.length >= 2) { + return { + homeTeam: parts[0].trim(), + awayTeam: parts.slice(1).join('-').trim(), + }; + } + return { homeTeam: eventName, awayTeam: '' }; + } + + /** + * Map API result/winner to TotoMatchResult enum value + * API returns: "1" (HOME), "0" (DRAW), "2" (AWAY) + */ + mapResultToEnum(winner: string | null): 'HOME' | 'DRAW' | 'AWAY' | null { + if (!winner) return null; + switch (winner) { + case '1': + return 'HOME'; + case '0': + case 'X': + return 'DRAW'; + case '2': + return 'AWAY'; + default: + return null; + } + } +} diff --git a/src/modules/spor-toto/services/toto-prediction.service.ts b/src/modules/spor-toto/services/toto-prediction.service.ts new file mode 100644 index 0000000..6083c69 --- /dev/null +++ b/src/modules/spor-toto/services/toto-prediction.service.ts @@ -0,0 +1,795 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../../database/prisma.service'; +import { firstValueFrom } from 'rxjs'; +import { + TotoCombinatoricsService, + TotoMatchSelectionInput, +} from './toto-combinatorics.service'; +import { TotoAnalyticsService } from './toto-analytics.service'; + +// ═══════════ TYPES ═══════════ + +export type PredictionStrategy = + | 'CONSERVATIVE' + | 'BALANCED' + | 'AGGRESSIVE' + | 'FORMULA_6PCT'; + +export type TotoSelection = '1' | 'X' | '2'; + +export interface MatchPredictionAnalysis { + matchOrder: number; + homeTeam: string; + awayTeam: string; + leagueName: string | null; + /** Linked matchId from DB (null if not found) */ + linkedMatchId: string | null; + /** AI Engine prediction source */ + predictionSource: 'AI_ENGINE' | 'HISTORICAL_FORM' | 'FALLBACK'; + /** Raw AI probabilities for each outcome */ + probabilities: { home: number; draw: number; away: number }; + /** AI confidence (0-100) */ + confidence: number; + /** AI's primary pick */ + aiPick: TotoSelection; + /** Contrarian-adjusted selections for the coupon */ + selections: TotoSelection[]; + /** Why this selection was made */ + reasoning: string; + /** Contrarian score: how "against the public" this pick is (0-100) */ + contrarianScore: number; +} + +export interface PredictionResult { + bulletinId: string; + gameCycleNo: number; + strategy: PredictionStrategy; + /** Per-match analysis */ + matchAnalyses: MatchPredictionAnalysis[]; + /** Generated coupon */ + coupon: { + totalColumns: number; + cost: number; + maxCouponLimit: number; + columns: string[]; + }; + /** EV report */ + evReport: { + poolTotal: number; + rolloverAmount: number; + effectivePool: number; + ev15: number; + evPerColumn: number; + recommendation: 'PLAY' | 'WAIT' | 'HIGH_VALUE'; + recommendationReason: string; + }; + /** System info */ + systemInfo: { + singlePicks: number; + doublePicks: number; + triplePicks: number; + formula: string; + }; +} + +// ═══════════ STRATEGY CONFIGS ═══════════ + +interface StrategyConfig { + maxColumns: number; + /** Confidence threshold for single pick */ + singleThreshold: number; + /** Confidence threshold for double pick (below this → triple) */ + doubleThreshold: number; + /** Use formula reduction */ + useFormulaReduction: boolean; + /** Formula sampling rate (e.g., 0.06 = %6) */ + formulaSamplingRate: number; + /** Favor contrarian picks */ + contrarianBias: number; +} + +const STRATEGY_CONFIGS: Record = { + CONSERVATIVE: { + maxColumns: 100, + singleThreshold: 55, + doubleThreshold: 35, + useFormulaReduction: false, + formulaSamplingRate: 1.0, + contrarianBias: 0.0, + }, + BALANCED: { + maxColumns: 500, + singleThreshold: 60, + doubleThreshold: 40, + useFormulaReduction: false, + formulaSamplingRate: 1.0, + contrarianBias: 0.15, + }, + AGGRESSIVE: { + maxColumns: 2500, + singleThreshold: 70, + doubleThreshold: 50, + useFormulaReduction: false, + formulaSamplingRate: 1.0, + contrarianBias: 0.3, + }, + FORMULA_6PCT: { + maxColumns: 2500, + singleThreshold: 60, + doubleThreshold: 40, + useFormulaReduction: true, + formulaSamplingRate: 0.06, + contrarianBias: 0.2, + }, +}; + +// ═══════════ SERVICE ═══════════ + +@Injectable() +export class TotoPredictionService { + private readonly logger = new Logger(TotoPredictionService.name); + private readonly aiEngineUrl: string; + + constructor( + private readonly prisma: PrismaService, + private readonly httpService: HttpService, + private readonly configService: ConfigService, + private readonly combinatorics: TotoCombinatoricsService, + private readonly analytics: TotoAnalyticsService, + ) { + this.aiEngineUrl = + this.configService.get('AI_ENGINE_URL') || 'http://127.0.0.1:8000'; + } + + /** + * Ana tahmin motoru: Bülten → AI analiz → Contrarian strateji → Sistem kuponu + */ + async generatePrediction( + bulletinId: string, + strategy: PredictionStrategy = 'BALANCED', + maxBudget?: number, + ): Promise { + const config = STRATEGY_CONFIGS[strategy]; + + // 1. Bülteni getir + const bulletin = await this.prisma.totoBulletin.findUnique({ + where: { id: bulletinId }, + include: { matches: { orderBy: { matchOrder: 'asc' } } }, + }); + + if (!bulletin) { + throw new Error(`Bulletin not found: ${bulletinId}`); + } + + this.logger.log( + `Generating prediction for Cycle ${bulletin.gameCycleNo} — Strategy: ${strategy}`, + ); + + // 2. Her maç için AI tahmin al + const matchAnalyses: MatchPredictionAnalysis[] = []; + + for (const match of bulletin.matches) { + const analysis = await this.analyzeMatch( + match.matchOrder, + match.homeTeamName, + match.awayTeamName, + match.leagueName, + match.kickoffTime, + match.matchId, + config, + ); + matchAnalyses.push(analysis); + } + + // 3. Selections'dan kupon üret + const matchSelections: TotoMatchSelectionInput[] = matchAnalyses.map( + (a) => ({ + matchOrder: a.matchOrder, + selections: a.selections, + }), + ); + + const totalColumns = + this.combinatorics.calculateColumnCount(matchSelections); + + // Bütçe kontrolü + let effectiveMaxColumns = config.maxColumns; + if (maxBudget && maxBudget < totalColumns) { + effectiveMaxColumns = Math.min(effectiveMaxColumns, maxBudget); + } + + // Kolon üretimi + let columns; + if (config.useFormulaReduction) { + // Formula %6: Tam sistemden yüzde örnekleme + const sampledCount = Math.max( + 1, + Math.floor(totalColumns * config.formulaSamplingRate), + ); + const targetCount = Math.min(sampledCount, effectiveMaxColumns); + columns = this.combinatorics.generateReducedSystem( + matchSelections, + targetCount, + ); + } else if (totalColumns > effectiveMaxColumns) { + columns = this.combinatorics.generateReducedSystem( + matchSelections, + effectiveMaxColumns, + ); + } else { + columns = this.combinatorics.generateFullSystem(matchSelections); + } + + const cost = this.combinatorics.calculateCost(columns.length); + + // 4. EV raporu + const evReport = await this.calculateEvReport( + bulletin.poolTotal ?? 0, + bulletin.rolloverAmount ?? 0, + columns.length, + cost, + ); + + // 5. Sistem bilgisi + const singles = matchAnalyses.filter( + (a) => a.selections.length === 1, + ).length; + const doubles = matchAnalyses.filter( + (a) => a.selections.length === 2, + ).length; + const triples = matchAnalyses.filter( + (a) => a.selections.length === 3, + ).length; + + return { + bulletinId: bulletin.id, + gameCycleNo: bulletin.gameCycleNo, + strategy, + matchAnalyses, + coupon: { + totalColumns: columns.length, + cost, + maxCouponLimit: 2500, + columns: columns.map((c) => c.predictions), + }, + evReport, + systemInfo: { + singlePicks: singles, + doublePicks: doubles, + triplePicks: triples, + formula: `2^${doubles} × 3^${triples} = ${totalColumns} (full) → ${columns.length} (generated)`, + }, + }; + } + + // ═══════════ MATCH ANALYSIS ═══════════ + + /** + * Tek bir maç için AI tahmin + contrarian analiz + */ + private async analyzeMatch( + matchOrder: number, + homeTeam: string, + awayTeam: string, + leagueName: string | null, + kickoffTime: Date | null, + existingMatchId: string | null, + config: StrategyConfig, + ): Promise { + // 1. Match linking — DB'den matchId bul + const linkedMatchId = + existingMatchId || + (await this.fuzzyMatchLink(homeTeam, awayTeam, kickoffTime)); + + // 2. AI Engine'den tahmin al + let probabilities = { home: 0.33, draw: 0.33, away: 0.34 }; + let confidence = 33; + let aiPick: TotoSelection = '1'; + let predictionSource: MatchPredictionAnalysis['predictionSource'] = + 'FALLBACK'; + let reasoning = 'Eşleşme bulunamadı, eşit dağılım kullanıldı'; + + if (linkedMatchId) { + const aiResult = await this.callAiEngine(linkedMatchId); + + if (aiResult) { + probabilities = aiResult.probabilities; + confidence = aiResult.confidence; + aiPick = aiResult.pick; + predictionSource = 'AI_ENGINE'; + reasoning = aiResult.reasoning; + } else { + // AI Engine erişilemez → tarihsel form analizi + const formResult = await this.analyzeHistoricalForm(homeTeam, awayTeam); + probabilities = formResult.probabilities; + confidence = formResult.confidence; + aiPick = formResult.pick; + predictionSource = 'HISTORICAL_FORM'; + reasoning = formResult.reasoning; + } + } else { + // matchId yok → tarihsel form analizi dene + const formResult = await this.analyzeHistoricalForm(homeTeam, awayTeam); + if (formResult.confidence > 33) { + probabilities = formResult.probabilities; + confidence = formResult.confidence; + aiPick = formResult.pick; + predictionSource = 'HISTORICAL_FORM'; + reasoning = formResult.reasoning; + } + } + + // 3. Contrarian strateji — maç seçimleri belirle + const { selections, contrarianScore, contrarianReasoning } = + this.applyContrarianStrategy(probabilities, confidence, aiPick, config); + + return { + matchOrder, + homeTeam, + awayTeam, + leagueName, + linkedMatchId, + predictionSource, + probabilities, + confidence, + aiPick, + selections, + reasoning: `${reasoning} | ${contrarianReasoning}`, + contrarianScore, + }; + } + + // ═══════════ FUZZY MATCH LINKING ═══════════ + + /** + * Bülten maç adlarını DB'deki live_matches/matches ile eşleştir + * Strateji: takım adlarını normalize et, ILIKE ile ara, tarih filtrele + */ + private async fuzzyMatchLink( + homeTeam: string, + awayTeam: string, + kickoffTime: Date | null, + ): Promise { + const homeNorm = this.normalizeTeamName(homeTeam); + const awayNorm = this.normalizeTeamName(awayTeam); + + // 1. Önce live_matches'te ara (canlı + odds verisi var) + try { + const liveMatch = await this.prisma.$queryRawUnsafe< + Array<{ id: string }> + >( + `SELECT id FROM live_matches + WHERE LOWER(match_name) LIKE $1 AND LOWER(match_name) LIKE $2 + ${kickoffTime ? `AND ABS(CAST(mst_utc AS BIGINT) - $3) < 259200000` : ''} + LIMIT 1`, + `%${homeNorm}%`, + `%${awayNorm}%`, + ...(kickoffTime ? [kickoffTime.getTime()] : []), + ); + + if (liveMatch.length > 0) { + this.logger.debug( + `Fuzzy matched live: ${homeTeam} vs ${awayTeam} → ${liveMatch[0].id}`, + ); + return liveMatch[0].id; + } + } catch (err) { + this.logger.warn(`Live match fuzzy search failed: ${err}`); + } + + // 2. matches tablosunda ara (tarihsel) + try { + const match = await this.prisma.$queryRawUnsafe>( + `SELECT id FROM matches + WHERE LOWER(match_name) LIKE $1 AND LOWER(match_name) LIKE $2 + ${kickoffTime ? `AND ABS(CAST(mst_utc AS BIGINT) - $3) < 259200000` : ''} + ORDER BY mst_utc DESC + LIMIT 1`, + `%${homeNorm}%`, + `%${awayNorm}%`, + ...(kickoffTime ? [kickoffTime.getTime()] : []), + ); + + if (match.length > 0) { + this.logger.debug( + `Fuzzy matched historical: ${homeTeam} vs ${awayTeam} → ${match[0].id}`, + ); + return match[0].id; + } + } catch (err) { + this.logger.warn(`Historical match fuzzy search failed: ${err}`); + } + + this.logger.warn(`No match found for: ${homeTeam} vs ${awayTeam}`); + return null; + } + + /** + * Takım adını normalize et (lowercase, türkçe karakter düzelt, boşlukları trim) + */ + private normalizeTeamName(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/ı/g, 'i') + .replace(/ğ/g, 'g') + .replace(/ü/g, 'u') + .replace(/ş/g, 's') + .replace(/ö/g, 'o') + .replace(/ç/g, 'c') + .replace(/\./g, '') + .replace(/\s+/g, ' '); + } + + // ═══════════ AI ENGINE INTEGRATION ═══════════ + + /** + * AI Engine V20+ ile maç analizi + */ + private async callAiEngine(matchId: string): Promise<{ + probabilities: { home: number; draw: number; away: number }; + confidence: number; + pick: TotoSelection; + reasoning: string; + } | null> { + try { + const response = await firstValueFrom( + this.httpService.post( + `${this.aiEngineUrl}/v20plus/analyze/${matchId}`, + {}, + { timeout: 30000 }, + ), + ); + + const data = response.data; + if (!data || !data.bet_summary) { + return null; + } + + // "Maç Sonucu" market'ını bul + const msPick = ( + data.bet_summary as Array<{ + market: string; + pick: string; + calibrated_confidence?: number; + confidence?: number; + probability?: number; + reasons?: string[]; + }> + ).find( + (b) => + b.market?.toLowerCase().includes('maç sonucu') || + b.market?.toLowerCase().includes('match result') || + b.market === '1X2', + ); + + // Score prediction'dan olasılıklar çıkar + const scorePred = data.score_prediction; + let probabilities = { home: 0.4, draw: 0.3, away: 0.3 }; + + if (scorePred?.xg_home != null && scorePred?.xg_away != null) { + // xG bazlı basit olasılık tahmini + const xgHome = scorePred.xg_home; + const xgAway = scorePred.xg_away; + const total = xgHome + xgAway + 0.001; + const homeStrength = xgHome / total; + const awayStrength = xgAway / total; + + probabilities = { + home: Math.max(0.1, Math.min(0.8, homeStrength + 0.1)), + draw: Math.max( + 0.1, + Math.min(0.4, 1 - Math.abs(homeStrength - awayStrength)), + ), + away: Math.max(0.1, Math.min(0.8, awayStrength)), + }; + + // Normalize + const sum = + probabilities.home + probabilities.draw + probabilities.away; + probabilities.home /= sum; + probabilities.draw /= sum; + probabilities.away /= sum; + } + + // Pick'i Toto formatına çevir + let pick: TotoSelection = '1'; + if (msPick) { + const rawPick = msPick.pick?.toLowerCase(); + if ( + rawPick?.includes('2') || + rawPick?.includes('away') || + rawPick?.includes('deplasman') + ) { + pick = '2'; + } else if ( + rawPick?.includes('x') || + rawPick?.includes('draw') || + rawPick?.includes('beraberlik') + ) { + pick = 'X'; + } else { + pick = '1'; + } + } else { + // No explicit MS pick → use probabilities + if ( + probabilities.away > probabilities.home && + probabilities.away > probabilities.draw + ) { + pick = '2'; + } else if (probabilities.draw > probabilities.home) { + pick = 'X'; + } + } + + const confidence = Math.round( + (msPick?.calibrated_confidence ?? msPick?.confidence ?? 50) * + (typeof (msPick?.calibrated_confidence ?? msPick?.confidence) === + 'number' && + (msPick?.calibrated_confidence ?? msPick?.confidence ?? 0) <= 1 + ? 100 + : 1), + ); + + const reasons = msPick?.reasons ?? []; + + return { + probabilities, + confidence, + pick, + reasoning: + reasons.length > 0 + ? reasons.join(' | ') + : `AI Engine: ${pick} (confidence: ${confidence}%)`, + }; + } catch (error) { + this.logger.warn( + `AI Engine call failed for ${matchId}: ${error instanceof Error ? error.message : error}`, + ); + return null; + } + } + + // ═══════════ HISTORICAL FORM ANALYSIS ═══════════ + + /** + * AI Engine erişilemezse: Tarihsel form bazlı basit olasılık hesabı + * Son 10 maçın ev sahibi/deplasman performansını analiz eder + */ + private async analyzeHistoricalForm( + homeTeam: string, + awayTeam: string, + ): Promise<{ + probabilities: { home: number; draw: number; away: number }; + confidence: number; + pick: TotoSelection; + reasoning: string; + }> { + const homeNorm = this.normalizeTeamName(homeTeam); + const awayNorm = this.normalizeTeamName(awayTeam); + + try { + // Son 10 ev sahibi maçı + const homeMatches = await this.prisma.$queryRawUnsafe< + Array<{ winner: string | null }> + >( + `SELECT winner FROM matches + WHERE LOWER(match_name) LIKE $1 AND winner IS NOT NULL + ORDER BY mst_utc DESC LIMIT 10`, + `%${homeNorm}%`, + ); + + // Son 10 deplasman maçı + const awayMatches = await this.prisma.$queryRawUnsafe< + Array<{ winner: string | null }> + >( + `SELECT winner FROM matches + WHERE LOWER(match_name) LIKE $1 AND winner IS NOT NULL + ORDER BY mst_utc DESC LIMIT 10`, + `%${awayNorm}%`, + ); + + if (homeMatches.length === 0 && awayMatches.length === 0) { + return { + probabilities: { home: 0.33, draw: 0.33, away: 0.34 }, + confidence: 33, + pick: '1', + reasoning: 'Tarihsel veri bulunamadı, eşit dağılım', + }; + } + + // Ev sahibi form analizi + const homeWins = homeMatches.filter((m) => m.winner === 'home').length; + const homeDraws = homeMatches.filter((m) => m.winner === 'draw').length; + const homeLosses = homeMatches.filter((m) => m.winner === 'away').length; + const homeTotal = homeMatches.length || 1; + + // Deplasman form analizi + const awayWins = awayMatches.filter((m) => m.winner === 'away').length; + const awayDraws = awayMatches.filter((m) => m.winner === 'draw').length; + const awayLosses = awayMatches.filter((m) => m.winner === 'home').length; + const awayTotal = awayMatches.length || 1; + + // Basit form bazlı olasılık + const homeProb = + (homeWins / homeTotal) * 0.6 + (awayLosses / awayTotal) * 0.4; + const drawProb = + (homeDraws / homeTotal) * 0.5 + (awayDraws / awayTotal) * 0.5; + const awayProb = + (homeLosses / homeTotal) * 0.4 + (awayWins / awayTotal) * 0.6; + + // Normalize + const sum = homeProb + drawProb + awayProb || 1; + const probabilities = { + home: homeProb / sum, + draw: drawProb / sum, + away: awayProb / sum, + }; + + // En yüksek olasılık + let pick: TotoSelection = '1'; + if ( + probabilities.away > probabilities.home && + probabilities.away > probabilities.draw + ) { + pick = '2'; + } else if (probabilities.draw > probabilities.home) { + pick = 'X'; + } + + const confidence = Math.round( + Math.max(probabilities.home, probabilities.draw, probabilities.away) * + 100, + ); + + return { + probabilities, + confidence, + pick, + reasoning: `Form: ${homeTeam} (${homeWins}W/${homeDraws}D/${homeLosses}L son ${homeTotal}) vs ${awayTeam} (${awayWins}W/${awayDraws}D/${awayLosses}L son ${awayTotal})`, + }; + } catch (err) { + this.logger.warn(`Historical form analysis failed: ${err}`); + return { + probabilities: { home: 0.33, draw: 0.33, away: 0.34 }, + confidence: 33, + pick: '1', + reasoning: 'Form analizi yapılamadı, eşit dağılım', + }; + } + } + + // ═══════════ CONTRARIAN STRATEGY ═══════════ + + /** + * Parimutüel mantık: "Herkesin bildiğini bilmenin değeri yok" + * + * - Confidence yüksek + favori → public de aynı yöne oynar → düşük parimutüel değer + * - Sürpriz potansiyeli olan maçlara çift/üçlü seçim → az kişi bilir → yüksek değer + * + * Contrarian bias: Favorinin çok güçlü olduğu durumda bile sürpriz ihtimalini + * kupon içinde tutarak, varyansı kucaklamak (7. maçta Juventus'un evinde kaybetmesi gibi) + */ + private applyContrarianStrategy( + probabilities: { home: number; draw: number; away: number }, + confidence: number, + aiPick: TotoSelection, + config: StrategyConfig, + ): { + selections: TotoSelection[]; + contrarianScore: number; + contrarianReasoning: string; + } { + // Olasılıkları sırala + const probs: Array<{ pick: TotoSelection; prob: number }> = [ + { pick: '1' as TotoSelection, prob: probabilities.home }, + { pick: 'X' as TotoSelection, prob: probabilities.draw }, + { pick: '2' as TotoSelection, prob: probabilities.away }, + ].sort((a, b) => b.prob - a.prob); + + const topProb = probs[0].prob; + const secondProb = probs[1].prob; + const gap = topProb - secondProb; + + // Contrarian score: Favori ne kadar belirginse, public o kadar yığılır + // → bize ters yönü kapsamamız lazım + const contrarianScore = Math.round( + Math.min(100, (topProb * 100 - 33) * 2 + config.contrarianBias * 30), + ); + + // Karar: confidence ve strateji config'e göre + let selections: TotoSelection[]; + let contrarianReasoning: string; + + if (confidence >= config.singleThreshold && gap > 0.2) { + // Yüksek güven + belirgin fark → tek seçim (ama contrarian bias varsa %X ihtimal) + if ( + config.contrarianBias > 0 && + topProb > 0.55 && + Math.random() < config.contrarianBias + ) { + // Contrarian: Favori + ikinci seçenek + selections = [probs[0].pick, probs[1].pick]; + contrarianReasoning = `Contrarian çift: ${probs[0].pick}(${(probs[0].prob * 100).toFixed(0)}%) + ${probs[1].pick}(${(probs[1].prob * 100).toFixed(0)}%) — Public yığılma riski`; + } else { + selections = [probs[0].pick]; + contrarianReasoning = `Tek: ${probs[0].pick}(${(probs[0].prob * 100).toFixed(0)}%) — Yüksek güven`; + } + } else if (confidence >= config.doubleThreshold) { + // Orta güven → ikili seçim (en olası 2) + selections = [probs[0].pick, probs[1].pick]; + contrarianReasoning = `İkili: ${probs[0].pick} + ${probs[1].pick} — Orta güven, varyans koruması`; + } else { + // Düşük güven → üçlü kapatma + selections = ['1', 'X', '2']; + contrarianReasoning = `Kapatma: 1X2 — Düşük güven (${confidence}%), maç çok belirsiz`; + } + + return { selections, contrarianScore, contrarianReasoning }; + } + + // ═══════════ EV CALCULATION ═══════════ + + /** + * Expected Value raporu — Devir yüksekse oyna + */ + private async calculateEvReport( + poolTotal: number, + rolloverAmount: number, + columnCount: number, + totalCost: number, + ): Promise { + const effectivePool = poolTotal + rolloverAmount; + const distribution = + this.analytics.calculatePoolDistribution(effectivePool); + + // Basit EV: (15 bilme olasılığı × havuz payı) - maliyet + const prob15 = 1 / Math.pow(3, 15); // ~6.97e-8 + const probWinWithColumns = 1 - Math.pow(1 - prob15, columnCount); + const ev15 = probWinWithColumns * distribution.pool15 - totalCost; + const evPerColumn = columnCount > 0 ? ev15 / columnCount : 0; + + // Devir bilgisi + let rolloverData; + try { + rolloverData = await this.analytics.getRolloverHistory(5); + } catch { + rolloverData = { + consecutiveRollovers: 0, + averageRollover: 0, + history: [], + }; + } + + // Karar + let recommendation: PredictionResult['evReport']['recommendation']; + let recommendationReason: string; + + if (rolloverAmount > 50_000_000) { + recommendation = 'HIGH_VALUE'; + recommendationReason = `🔥 ${(rolloverAmount / 1_000_000).toFixed(1)}M TL devir! Tarihi fırsat. Agresif oyna.`; + } else if (rolloverAmount > 20_000_000) { + recommendation = 'PLAY'; + recommendationReason = `✅ ${(rolloverAmount / 1_000_000).toFixed(1)}M TL devir. Oynamaya değer. (Ardışık ${rolloverData.consecutiveRollovers} hafta devir)`; + } else if (rolloverAmount > 5_000_000) { + recommendation = 'PLAY'; + recommendationReason = `✅ Orta düzey devir: ${(rolloverAmount / 1_000_000).toFixed(1)}M TL`; + } else { + recommendation = 'WAIT'; + recommendationReason = `⏳ Devir düşük (${(rolloverAmount / 1_000_000).toFixed(1)}M TL). Havuz büyümesini bekle.`; + } + + return { + poolTotal, + rolloverAmount, + effectivePool, + ev15, + evPerColumn, + recommendation, + recommendationReason, + }; + } +} diff --git a/src/modules/spor-toto/spor-toto.controller.ts b/src/modules/spor-toto/spor-toto.controller.ts new file mode 100644 index 0000000..dc76b9e --- /dev/null +++ b/src/modules/spor-toto/spor-toto.controller.ts @@ -0,0 +1,259 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Query, + HttpCode, + HttpStatus, + Logger, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiQuery, + ApiResponse, + ApiParam, + ApiBody, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { SporTotoService } from './spor-toto.service'; +import { + CreateBulletinDto, + UpdateResultsDto, + GenerateColumnsDto, + GenerateSporTotoPredictionDto, + EvaluateColumnsDto, +} from './dto/spor-toto.dto'; +import { Public, Roles } from '../../common/decorators'; +import { JwtAuthGuard } from '../auth/guards/auth.guards'; +import { TotoBulletinStatus } from '@prisma/client'; + +@ApiTags('Spor Toto') +@Controller('spor-toto') +export class SporTotoController { + private readonly logger = new Logger(SporTotoController.name); + + constructor(private readonly sporTotoService: SporTotoService) {} + + // ═══════════ BULLETINS ═══════════ + + @Post('sync') + @UseGuards(JwtAuthGuard) + @Roles('admin') + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Sync current bulletin from Spor Toto API', + description: + 'Fetches the latest bulletin from sportotov2.iddaa.com and upserts it into the database. Updates match results and dividends if already exists.', + }) + @ApiResponse({ + status: 200, + description: 'Sync result with action (created/updated/unchanged)', + }) + async syncFromApi() { + const result = await this.sporTotoService.syncFromApi(); + return { success: true, data: result }; + } + + @Get('bulletins') + @Public() + @ApiOperation({ + summary: 'List Spor Toto bulletins', + description: + 'Returns a paginated list of bulletins, optionally filtered by status.', + }) + @ApiQuery({ + name: 'status', + required: false, + enum: TotoBulletinStatus, + description: 'Filter by bulletin status', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Max results (default: 10)', + }) + @ApiResponse({ + status: 200, + description: 'Array of bulletins with matches and results', + }) + async listBulletins( + @Query('status') status?: TotoBulletinStatus, + @Query('limit') limit?: string, + ) { + const bulletins = await this.sporTotoService.listBulletins( + status, + Number(limit) || 10, + ); + return { success: true, data: bulletins }; + } + + @Get('bulletins/:id') + @Public() + @ApiOperation({ + summary: 'Get bulletin details', + description: + 'Returns a single bulletin with all 15 matches, results, and dividend info.', + }) + @ApiParam({ name: 'id', description: 'Bulletin UUID' }) + @ApiResponse({ + status: 200, + description: 'Bulletin with matches and results', + }) + @ApiResponse({ status: 404, description: 'Bulletin not found' }) + async getBulletin(@Param('id') id: string) { + const bulletin = await this.sporTotoService.getBulletinById(id); + return { success: true, data: bulletin }; + } + + @Post('bulletins') + @UseGuards(JwtAuthGuard) + @Roles('admin') + @ApiBearerAuth() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Create a bulletin manually', + description: + 'Creates a new bulletin with 15 matches. Fails if gameCycleNo already exists.', + }) + @ApiBody({ type: CreateBulletinDto }) + @ApiResponse({ status: 201, description: 'Created bulletin with matches' }) + @ApiResponse({ + status: 409, + description: 'Bulletin with this gameCycleNo already exists', + }) + async createBulletin(@Body() dto: CreateBulletinDto) { + const bulletin = await this.sporTotoService.createBulletin(dto); + return { success: true, data: bulletin }; + } + + @Patch('bulletins/:id/results') + @UseGuards(JwtAuthGuard) + @Roles('admin') + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Update bulletin match results', + description: + 'Updates individual match results and optionally upserts dividend/prize data. Marks bulletin COMPLETED when all 15 results are entered.', + }) + @ApiParam({ name: 'id', description: 'Bulletin UUID' }) + @ApiBody({ type: UpdateResultsDto }) + @ApiResponse({ status: 200, description: 'Updated bulletin with results' }) + @ApiResponse({ status: 404, description: 'Bulletin not found' }) + async updateResults(@Param('id') id: string, @Body() dto: UpdateResultsDto) { + const bulletin = await this.sporTotoService.updateResults(id, dto); + return { success: true, data: bulletin }; + } + + // ═══════════ STATS & ANALYTICS ═══════════ + + @Get('bulletins/:id/stats') + @Public() + @ApiOperation({ + summary: 'Get bulletin pool & EV statistics', + description: + 'Returns pool distribution (35/20/20/25), expected value calculations, and rollover analysis for a bulletin.', + }) + @ApiParam({ name: 'id', description: 'Bulletin UUID' }) + @ApiResponse({ status: 200, description: 'Pool distribution and EV stats' }) + async getBulletinStats(@Param('id') id: string) { + const stats = await this.sporTotoService.getBulletinStats(id); + return { success: true, data: stats }; + } + + @Get('history') + @Public() + @ApiOperation({ + summary: 'Get rollover history and trends', + description: + 'Returns the last N bulletins with rollover amounts and consecutive rollover streak.', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Number of results (default: 20)', + }) + @ApiResponse({ status: 200, description: 'Rollover history with trend data' }) + async getRolloverHistory(@Query('limit') limit?: string) { + const history = await this.sporTotoService.getRolloverHistory( + Number(limit) || 20, + ); + return { success: true, data: history }; + } + + // ═══════════ COLUMNS ═══════════ + + @Post('columns/generate') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Generate Spor Toto columns (full or reduced system)', + description: + 'Takes match selections (1/X/2 per match) and generates columns via Cartesian product (full) or random sampling (reduced). Returns columns with cost calculation.', + }) + @ApiBody({ type: GenerateColumnsDto }) + @ApiResponse({ + status: 200, + description: 'Generated columns with strategy, cost, and column strings', + }) + async generateColumns(@Body() dto: GenerateColumnsDto) { + const result = await this.sporTotoService.generateColumns(dto); + return { success: true, data: result }; + } + + @Post('columns/evaluate') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Evaluate columns against results', + description: + 'Compares generated column strings against actual match results. Returns correct count per column and summary (15/14/13/12 bilen).', + }) + @ApiBody({ type: EvaluateColumnsDto }) + @ApiResponse({ + status: 200, + description: 'Evaluation results with correct counts per column', + }) + async evaluateColumns(@Body() dto: EvaluateColumnsDto) { + const result = await this.sporTotoService.evaluateColumns( + dto.bulletinId, + dto.columns, + ); + return { success: true, data: result }; + } + + // ═══════════ AI PREDICTION ═══════════ + + @Post('predict') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Generate AI predictions with contrarian strategy', + description: + 'Analyzes bulletin matches via AI Engine V20+, applies contrarian parimutüel strategy, and generates optimized system coupons. Supports 4 strategies: CONSERVATIVE (100 cols), BALANCED (500), AGGRESSIVE (2500), FORMULA_6PCT (6% sampling).', + }) + @ApiBody({ type: GenerateSporTotoPredictionDto }) + @ApiResponse({ + status: 200, + description: + 'Prediction result with per-match analysis, system coupon, and EV report with play recommendation', + }) + async generatePrediction(@Body() dto: GenerateSporTotoPredictionDto) { + this.logger.log( + `Generating prediction for bulletin ${dto.bulletinId} with strategy ${dto.strategy || 'BALANCED'}`, + ); + const result = await this.sporTotoService.generatePrediction(dto); + return { success: true, data: result }; + } +} diff --git a/src/modules/spor-toto/spor-toto.module.ts b/src/modules/spor-toto/spor-toto.module.ts new file mode 100644 index 0000000..8ca030e --- /dev/null +++ b/src/modules/spor-toto/spor-toto.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { SporTotoController } from './spor-toto.controller'; +import { SporTotoService } from './spor-toto.service'; +import { TotoFetcherService } from './services/toto-fetcher.service'; +import { TotoCombinatoricsService } from './services/toto-combinatorics.service'; +import { TotoAnalyticsService } from './services/toto-analytics.service'; +import { TotoPredictionService } from './services/toto-prediction.service'; +import { DatabaseModule } from '../../database/database.module'; + +@Module({ + imports: [DatabaseModule, HttpModule, ConfigModule], + controllers: [SporTotoController], + providers: [ + SporTotoService, + TotoFetcherService, + TotoCombinatoricsService, + TotoAnalyticsService, + TotoPredictionService, + ], + exports: [SporTotoService], +}) +export class SporTotoModule {} diff --git a/src/modules/spor-toto/spor-toto.service.ts b/src/modules/spor-toto/spor-toto.service.ts new file mode 100644 index 0000000..1c38bce --- /dev/null +++ b/src/modules/spor-toto/spor-toto.service.ts @@ -0,0 +1,462 @@ +import { + Injectable, + Logger, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { TotoFetcherService } from './services/toto-fetcher.service'; +import { + TotoCombinatoricsService, + TotoMatchSelectionInput, +} from './services/toto-combinatorics.service'; +import { TotoAnalyticsService } from './services/toto-analytics.service'; +import { + TotoPredictionService, + PredictionStrategy, +} from './services/toto-prediction.service'; +import { + CreateBulletinDto, + UpdateResultsDto, + GenerateColumnsDto, + GenerateSporTotoPredictionDto, +} from './dto/spor-toto.dto'; +import { TotoBulletinStatus, TotoMatchResult, Prisma } from '@prisma/client'; + +@Injectable() +export class SporTotoService { + private readonly logger = new Logger(SporTotoService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly fetcher: TotoFetcherService, + private readonly combinatorics: TotoCombinatoricsService, + private readonly analytics: TotoAnalyticsService, + private readonly prediction: TotoPredictionService, + ) {} + + // ═══════════ BULLETIN CRUD ═══════════ + + /** + * Fetch and sync current bulletin from Spor Toto API + */ + async syncFromApi(): Promise<{ + action: 'created' | 'updated' | 'unchanged'; + gameCycleNo: number; + matchCount: number; + }> { + const apiResponse = await this.fetcher.fetchCurrentBulletin(); + if (!apiResponse?.data) { + throw new NotFoundException('Spor Toto API returned no data'); + } + + const apiData = apiResponse.data; + const existing = await this.prisma.totoBulletin.findUnique({ + where: { gameCycleNo: apiData.gameCycleNo }, + include: { matches: true }, + }); + + if (existing) { + // Update: sonuçları güncelle (eğer varsa) + let hasChanges = false; + + for (const event of apiData.events) { + const resultEnum = this.fetcher.mapResultToEnum(event.winner); + if (resultEnum) { + const match = existing.matches.find( + (m) => m.matchOrder === event.eventNo, + ); + if (match && !match.result) { + await this.prisma.totoBulletinMatch.update({ + where: { id: match.id }, + data: { result: resultEnum as TotoMatchResult }, + }); + hasChanges = true; + } + } + } + + // Update dividends if present + if (apiData.dividends) { + await this.upsertResultsFromDividends(existing.id, apiData.dividends); + hasChanges = true; + } + + // Check if all matches have results → mark COMPLETED + const allHaveResults = apiData.events.every((e) => e.winner !== null); + if (allHaveResults && existing.status !== 'COMPLETED') { + await this.prisma.totoBulletin.update({ + where: { id: existing.id }, + data: { status: TotoBulletinStatus.COMPLETED }, + }); + hasChanges = true; + } + + return { + action: hasChanges ? 'updated' : 'unchanged', + gameCycleNo: apiData.gameCycleNo, + matchCount: apiData.events.length, + }; + } + + // Create new bulletin + const matchData = apiData.events.map((event) => { + const parsed = this.fetcher.parseEventName(event.eventName); + return { + matchOrder: event.eventNo, + homeTeamName: parsed.homeTeam, + awayTeamName: parsed.awayTeam, + leagueName: event.competitionName, + kickoffTime: new Date(event.eventDate), + result: this.fetcher.mapResultToEnum( + event.winner, + ) as TotoMatchResult | null, + }; + }); + + await this.prisma.totoBulletin.create({ + data: { + gameCycleNo: apiData.gameCycleNo, + programName: apiData.programName, + payinBeginDate: new Date(apiData.payinBeginDate), + payinEndDate: new Date(apiData.payinEndDate), + status: TotoBulletinStatus.UPCOMING, + matches: { + createMany: { data: matchData }, + }, + }, + }); + + this.logger.log( + `Created bulletin: Cycle ${apiData.gameCycleNo} with ${matchData.length} matches`, + ); + + return { + action: 'created', + gameCycleNo: apiData.gameCycleNo, + matchCount: matchData.length, + }; + } + + /** + * Create bulletin manually + */ + async createBulletin(dto: CreateBulletinDto) { + const existing = await this.prisma.totoBulletin.findUnique({ + where: { gameCycleNo: dto.gameCycleNo }, + }); + + if (existing) { + throw new ConflictException( + `Bulletin with gameCycleNo ${dto.gameCycleNo} already exists`, + ); + } + + return this.prisma.totoBulletin.create({ + data: { + gameCycleNo: dto.gameCycleNo, + programName: dto.programName, + season: dto.season, + payinBeginDate: dto.payinBeginDate + ? new Date(dto.payinBeginDate) + : undefined, + payinEndDate: dto.payinEndDate ? new Date(dto.payinEndDate) : undefined, + matches: { + createMany: { + data: dto.matches.map((m) => ({ + matchOrder: m.matchOrder, + homeTeamName: m.homeTeamName, + awayTeamName: m.awayTeamName, + leagueName: m.leagueName, + kickoffTime: m.kickoffTime ? new Date(m.kickoffTime) : undefined, + matchId: m.matchId, + })), + }, + }, + }, + include: { matches: true }, + }); + } + + /** + * List bulletins + */ + async listBulletins(status?: TotoBulletinStatus, limit = 10) { + const where: Prisma.TotoBulletinWhereInput = {}; + if (status) where.status = status; + + return this.prisma.totoBulletin.findMany({ + where, + orderBy: { gameCycleNo: 'desc' }, + take: limit, + include: { + matches: { orderBy: { matchOrder: 'asc' } }, + result: true, + }, + }); + } + + /** + * Get single bulletin with details + */ + async getBulletinById(id: string) { + const bulletin = await this.prisma.totoBulletin.findUnique({ + where: { id }, + include: { + matches: { orderBy: { matchOrder: 'asc' } }, + result: true, + }, + }); + + if (!bulletin) { + throw new NotFoundException('Bulletin not found'); + } + + return bulletin; + } + + // ═══════════ RESULTS ═══════════ + + /** + * Update match results manually + */ + async updateResults(bulletinId: string, dto: UpdateResultsDto) { + const bulletin = await this.prisma.totoBulletin.findUnique({ + where: { id: bulletinId }, + include: { matches: true }, + }); + + if (!bulletin) { + throw new NotFoundException('Bulletin not found'); + } + + // Update individual match results + for (const r of dto.results) { + const match = bulletin.matches.find((m) => m.matchOrder === r.matchOrder); + if (match) { + await this.prisma.totoBulletinMatch.update({ + where: { id: match.id }, + data: { + result: r.result as TotoMatchResult, + isCancelled: r.isCancelled ?? false, + drawResult: r.drawResult + ? (r.drawResult as TotoMatchResult) + : undefined, + }, + }); + } + } + + // Upsert TotoResult record + if (dto.winners15 !== undefined || dto.winners14 !== undefined) { + await this.prisma.totoResult.upsert({ + where: { bulletinId }, + create: { + bulletinId, + winners15: dto.winners15 ?? 0, + prize15: dto.prize15, + winners14: dto.winners14 ?? 0, + prize14: dto.prize14, + winners13: dto.winners13 ?? 0, + prize13: dto.prize13, + winners12: dto.winners12 ?? 0, + prize12: dto.prize12, + rolloverNext: dto.rolloverNext, + }, + update: { + winners15: dto.winners15, + prize15: dto.prize15, + winners14: dto.winners14, + prize14: dto.prize14, + winners13: dto.winners13, + prize13: dto.prize13, + winners12: dto.winners12, + prize12: dto.prize12, + rolloverNext: dto.rolloverNext, + }, + }); + } + + // Check if all 15 results entered → mark COMPLETED + const allEntered = dto.results.length === 15; + if (allEntered) { + await this.prisma.totoBulletin.update({ + where: { id: bulletinId }, + data: { status: TotoBulletinStatus.COMPLETED }, + }); + } + + return this.getBulletinById(bulletinId); + } + + private async upsertResultsFromDividends( + bulletinId: string, + dividends: { + winnerCount15?: number; + dividend15?: number; + winnerCount14?: number; + dividend14?: number; + winnerCount13?: number; + dividend13?: number; + winnerCount12?: number; + dividend12?: number; + }, + ) { + await this.prisma.totoResult.upsert({ + where: { bulletinId }, + create: { + bulletinId, + winners15: dividends.winnerCount15 ?? 0, + prize15: dividends.dividend15 ?? null, + winners14: dividends.winnerCount14 ?? 0, + prize14: dividends.dividend14 ?? null, + winners13: dividends.winnerCount13 ?? 0, + prize13: dividends.dividend13 ?? null, + winners12: dividends.winnerCount12 ?? 0, + prize12: dividends.dividend12 ?? null, + }, + update: { + winners15: dividends.winnerCount15 ?? undefined, + prize15: dividends.dividend15 ?? undefined, + winners14: dividends.winnerCount14 ?? undefined, + prize14: dividends.dividend14 ?? undefined, + winners13: dividends.winnerCount13 ?? undefined, + prize13: dividends.dividend13 ?? undefined, + winners12: dividends.winnerCount12 ?? undefined, + prize12: dividends.dividend12 ?? undefined, + }, + }); + } + + // ═══════════ COLUMNS & COUPONS ═══════════ + + /** + * Generate columns (system or reduced) + */ + async generateColumns(dto: GenerateColumnsDto) { + const bulletin = await this.getBulletinById(dto.bulletinId); + + const matchSelections: TotoMatchSelectionInput[] = dto.matchSelections.map( + (ms) => ({ + matchOrder: ms.matchOrder, + selections: ms.selections, + }), + ); + + const totalColumnCount = + this.combinatorics.calculateColumnCount(matchSelections); + + let columns; + const strategy = dto.strategy || 'FULL_SYSTEM'; + + if ( + strategy === 'REDUCED_SYSTEM' && + dto.maxColumns && + totalColumnCount > dto.maxColumns + ) { + columns = this.combinatorics.generateReducedSystem( + matchSelections, + dto.maxColumns, + ); + } else { + columns = this.combinatorics.generateFullSystem(matchSelections); + } + + const cost = this.combinatorics.calculateCost(columns.length); + + return { + bulletinId: bulletin.id, + gameCycleNo: bulletin.gameCycleNo, + strategy, + totalPossibleColumns: totalColumnCount, + generatedColumns: columns.length, + cost, + columns: columns.map((c) => c.predictions), + }; + } + + /** + * Evaluate columns against results + */ + async evaluateColumns(bulletinId: string, columnPredictions: string[]) { + const bulletin = await this.prisma.totoBulletin.findUnique({ + where: { id: bulletinId }, + include: { matches: { orderBy: { matchOrder: 'asc' } } }, + }); + + if (!bulletin) { + throw new NotFoundException('Bulletin not found'); + } + + // Build results string (15 chars) + const resultMap: Record = { + HOME: '1', + DRAW: 'X', + AWAY: '2', + }; + + const resultsString = bulletin.matches + .map((m) => { + if (m.isCancelled && m.drawResult) { + return resultMap[m.drawResult] || '?'; + } + return m.result ? resultMap[m.result] || '?' : '?'; + }) + .join(''); + + if (resultsString.includes('?')) { + return { + complete: false, + message: 'Bazı maçların sonuçları henüz girilmedi', + resultsString, + evaluations: [], + }; + } + + const columns = columnPredictions.map((p) => ({ predictions: p })); + const evaluations = this.combinatorics.evaluateColumns( + columns, + resultsString, + ); + + const summary = { + total: evaluations.length, + correct15: evaluations.filter((e) => e.correctCount === 15).length, + correct14: evaluations.filter((e) => e.correctCount === 14).length, + correct13: evaluations.filter((e) => e.correctCount === 13).length, + correct12: evaluations.filter((e) => e.correctCount === 12).length, + maxCorrect: Math.max(...evaluations.map((e) => e.correctCount)), + }; + + return { + complete: true, + resultsString, + summary, + evaluations: evaluations.sort((a, b) => b.correctCount - a.correctCount), + }; + } + + // ═══════════ ANALYTICS ═══════════ + + async getBulletinStats(bulletinId: string) { + return this.analytics.getBulletinStats(bulletinId); + } + + async getRolloverHistory(limit = 20) { + return this.analytics.getRolloverHistory(limit); + } + + // ═══════════ AI PREDICTION ═══════════ + + /** + * AI Engine ile akıllı sistem kuponu üret + */ + async generatePrediction(dto: GenerateSporTotoPredictionDto) { + const strategy: PredictionStrategy = dto.strategy || 'BALANCED'; + return this.prediction.generatePrediction( + dto.bulletinId, + strategy, + dto.maxBudget, + ); + } +} diff --git a/src/modules/users/dto/user.dto.ts b/src/modules/users/dto/user.dto.ts new file mode 100755 index 0000000..1ffa0ab --- /dev/null +++ b/src/modules/users/dto/user.dto.ts @@ -0,0 +1,103 @@ +import { + IsEmail, + IsString, + IsOptional, + IsBoolean, + MinLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; + +export class CreateUserDto { + @ApiPropertyOptional({ example: 'user@example.com' }) + @IsEmail() + email: string; + + @ApiPropertyOptional({ example: 'password123', minLength: 8 }) + @IsString() + @MinLength(8) + password: string; + + @ApiPropertyOptional({ example: 'John' }) + @IsOptional() + @IsString() + firstName?: string; + + @ApiPropertyOptional({ example: 'Doe' }) + @IsOptional() + @IsString() + lastName?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateUserDto extends PartialType(CreateUserDto) { + @ApiPropertyOptional({ example: 'John' }) + @IsOptional() + @IsString() + firstName?: string; + + @ApiPropertyOptional({ example: 'Doe' }) + @IsOptional() + @IsString() + lastName?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateProfileDto { + @ApiPropertyOptional({ example: 'John' }) + @IsOptional() + @IsString() + firstName?: string; + + @ApiPropertyOptional({ example: 'Doe' }) + @IsOptional() + @IsString() + lastName?: string; +} + +export class ChangePasswordDto { + @ApiProperty({ example: 'oldPassword123' }) + @IsString() + currentPassword: string; + + @ApiProperty({ example: 'newPassword456', minLength: 8 }) + @IsString() + @MinLength(8) + newPassword: string; +} + +import { Exclude, Expose } from 'class-transformer'; + +@Exclude() +export class UserResponseDto { + @Expose() + id: string; + + @Expose() + email: string; + + @Expose() + firstName: string | null; + + @Expose() + lastName: string | null; + + @Expose() + role: string; + + @Expose() + isActive: boolean; + + @Expose() + createdAt: Date; + + @Expose() + updatedAt: Date; +} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts new file mode 100755 index 0000000..0c16a81 --- /dev/null +++ b/src/modules/users/users.controller.ts @@ -0,0 +1,105 @@ +import { Controller, Get, Put, Patch, Body } from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiOkResponse, +} from '@nestjs/swagger'; +import { BaseController } from '../../common/base'; +import { UsersService } from './users.service'; +import { + CreateUserDto, + UpdateUserDto, + UpdateProfileDto, + ChangePasswordDto, +} from './dto/user.dto'; +import { CurrentUser, Roles } from '../../common/decorators'; +import { + ApiResponse, + createSuccessResponse, +} from '../../common/types/api-response.type'; +import { User } from '@prisma/client'; + +import { plainToInstance } from 'class-transformer'; +import { UserResponseDto } from './dto/user.dto'; + +interface AuthenticatedUser { + id: string; + email: string; + role: string; +} + +@ApiTags('Users') +@ApiBearerAuth() +@Controller('users') +export class UsersController extends BaseController< + User, + CreateUserDto, + UpdateUserDto +> { + constructor(private readonly usersService: UsersService) { + super(usersService, 'User'); + } + + @Get('me') + @ApiOperation({ summary: 'Get current authenticated user profile' }) + @ApiOkResponse({ type: UserResponseDto }) + async getMe( + @CurrentUser() user: AuthenticatedUser, + ): Promise> { + const fullUser = await this.usersService.findOneWithDetails(user.id); + return createSuccessResponse( + plainToInstance(UserResponseDto, fullUser), + 'User profile retrieved successfully', + ); + } + + @Put('me') + @ApiOperation({ summary: 'Update current user profile' }) + @ApiOkResponse({ type: UserResponseDto }) + async updateMe( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: UpdateProfileDto, + ): Promise> { + const updatedUser = await this.usersService.updateProfile(user.id, dto); + return createSuccessResponse( + plainToInstance(UserResponseDto, updatedUser), + 'User profile updated successfully', + ); + } + + @Patch('me/password') + @ApiOperation({ summary: 'Change current user password' }) + @ApiOkResponse({ description: 'Password changed successfully' }) + async changePassword( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: ChangePasswordDto, + ): Promise> { + await this.usersService.changePassword( + user.id, + dto.currentPassword, + dto.newPassword, + ); + return createSuccessResponse(null, 'Password changed successfully'); + } + + // Override create to require admin role + @Roles('admin') + async create( + ...args: Parameters< + BaseController['create'] + > + ) { + return super.create(...args); + } + + // Override delete to require admin role + @Roles('admin') + async delete( + ...args: Parameters< + BaseController['delete'] + > + ) { + return super.delete(...args); + } +} diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts new file mode 100755 index 0000000..513776d --- /dev/null +++ b/src/modules/users/users.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts new file mode 100755 index 0000000..67093c3 --- /dev/null +++ b/src/modules/users/users.service.ts @@ -0,0 +1,199 @@ +import { + Injectable, + ConflictException, + UnauthorizedException, +} from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { PrismaService } from '../../database/prisma.service'; +import { BaseService } from '../../common/base'; +import { CreateUserDto, UpdateUserDto, UpdateProfileDto } from './dto/user.dto'; +import { User, UserRole } from '@prisma/client'; + +@Injectable() +export class UsersService extends BaseService< + User, + CreateUserDto, + UpdateUserDto +> { + constructor(prisma: PrismaService) { + super(prisma, 'User'); + } + + /** + * Create a new user with hashed password + */ + async create(dto: CreateUserDto): Promise { + // Check if email already exists + const existingUser = await this.findOneBy({ email: dto.email }); + if (existingUser) { + throw new ConflictException('EMAIL_ALREADY_EXISTS'); + } + + // Hash password + const hashedPassword = await this.hashPassword(dto.password); + + // Map password to passwordHash for Prisma + // Exclude plain password from the data and use hashed version + + const { password: _password, ...rest } = dto; + return this.prisma.user.create({ + data: { + ...rest, + passwordHash: hashedPassword, + role: UserRole.user, + }, + }); + } + + /** + * Update user, hash password if provided + */ + async update(id: string, dto: UpdateUserDto): Promise { + const updateData: any = { ...dto }; + + if (dto.password) { + updateData.passwordHash = await this.hashPassword(dto.password); + delete updateData.password; + } + + return this.prisma.user.update({ + where: { id }, + data: updateData, + }); + } + + /** + * Find user by email + */ + async findByEmail(email: string): Promise { + return this.prisma.user.findUnique({ where: { email } }); + } + + /** + * Get user with subscription and usage info + */ + findOneWithDetails(id: string) { + return this.prisma.user.findUnique({ + where: { id }, + include: { + usageLimit: true, + }, + }); + } + + /** + * Check if user has specific role + */ + hasRole(user: User, role: UserRole): boolean { + return user.role === role; + } + + /** + * Update user role + */ + async updateRole(userId: string, role: UserRole): Promise { + return this.prisma.user.update({ + where: { id: userId }, + data: { role }, + }); + } + + /** + * Get or create usage limit for user + */ + async getOrCreateUsageLimit(userId: string) { + let usageLimit = await this.prisma.usageLimit.findUnique({ + where: { userId }, + }); + + if (!usageLimit) { + usageLimit = await this.prisma.usageLimit.create({ + data: { + userId, + analysisCount: 0, + couponCount: 0, + lastResetDate: new Date(), + }, + }); + } + + return usageLimit; + } + + /** + * Increment analysis count + */ + async incrementAnalysisCount(userId: string): Promise { + await this.prisma.usageLimit.upsert({ + where: { userId }, + update: { analysisCount: { increment: 1 } }, + create: { + userId, + analysisCount: 1, + couponCount: 0, + lastResetDate: new Date(), + }, + }); + } + + /** + * Hash password using bcrypt + */ + private async hashPassword(password: string): Promise { + const saltRounds = 12; + return bcrypt.hash(password, saltRounds); + } + + /** + * Compare password with hash + */ + async comparePassword( + password: string, + hashedPassword: string, + ): Promise { + return bcrypt.compare(password, hashedPassword); + } + + /** + * Update user profile (first name, last name) + */ + async updateProfile(userId: string, dto: UpdateProfileDto): Promise { + return this.prisma.user.update({ + where: { id: userId }, + data: dto, + }); + } + + /** + * Change user password with current password verification + */ + async changePassword( + userId: string, + currentPassword: string, + newPassword: string, + ): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new UnauthorizedException('USER_NOT_FOUND'); + } + + const isCurrentPasswordValid = await this.comparePassword( + currentPassword, + user.passwordHash, + ); + + if (!isCurrentPasswordValid) { + throw new UnauthorizedException('INVALID_CURRENT_PASSWORD'); + } + + const hashedNewPassword = await this.hashPassword(newPassword); + + await this.prisma.user.update({ + where: { id: userId }, + data: { passwordHash: hashedNewPassword }, + }); + } +} diff --git a/src/scripts/backtest-accuracy.ts b/src/scripts/backtest-accuracy.ts new file mode 100644 index 0000000..a8793ac --- /dev/null +++ b/src/scripts/backtest-accuracy.ts @@ -0,0 +1,419 @@ +/** + * =================================================== + * BACKTEST ACCURACY — V30 Prediction System + * =================================================== + * Tests historical predictions against actual outcomes. + * Uses the running AI Engine's /v20plus/analyze/{match_id} + * endpoint which extracts features from DB internally. + * + * Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/backtest-accuracy.ts + */ + +import { PrismaClient } from '@prisma/client'; +import axios from 'axios'; + +const prisma = new PrismaClient(); + +// ═══════════════════════════════════════════════════════ +// Configuration +// ═══════════════════════════════════════════════════════ + +const AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:3005'; +const CONCURRENT_REQUESTS = 5; +const MAX_MATCHES = 1000; + +// ═══════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════ + +interface TestMatch { + id: string; + scoreHome: number; + scoreAway: number; + htScoreHome: number | null; + htScoreAway: number | null; +} + +interface BacktestResult { + matchId: string; + actual: { ms: string; ou25: string; btts: string; htft: string }; + predicted: { ms: string; ou25: string; btts: string }; + probabilities: { + home: number; + draw: number; + away: number; + over: number; + under: number; + bttsYes: number; + bttsNo: number; + }; + mainPickCorrect: boolean; +} + +// ═══════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════ + +function determineActualOutcome( + scoreHome: number, + scoreAway: number, + htScoreHome: number | null, + htScoreAway: number | null, +): { ms: string; ou25: string; btts: string; htft: string } { + const ms = scoreHome > scoreAway ? '1' : scoreHome < scoreAway ? '2' : 'X'; + const ou25 = scoreHome + scoreAway > 2.5 ? 'Over' : 'Under'; + const btts = scoreHome > 0 && scoreAway > 0 ? 'Yes' : 'No'; + + let htft = 'unknown'; + if (htScoreHome !== null && htScoreAway !== null) { + const htResult = + htScoreHome > htScoreAway ? '1' : htScoreHome < htScoreAway ? '2' : 'X'; + htft = `${htResult}/${ms}`; + } + + return { ms, ou25, btts, htft }; +} + +function extractPrediction(response: unknown): { + ms: string; + ou25: string; + btts: string; + probs: BacktestResult['probabilities']; + mainPick: string; + mainMarket: string; +} { + const data = response as Record; + const predictions = data?.predictions as Record | undefined; + + const mainPickObj = data?.main_pick as Record | undefined; + const mainPick = + typeof mainPickObj?.pick === 'string' ? mainPickObj.pick : ''; + const mainMarket = + typeof mainPickObj?.market === 'string' ? mainPickObj.market : ''; + + // Extract MS from probabilities or main pick + const msProbs = (predictions?.ms || data?.ms || {}) as Record< + string, + unknown + >; + const homeProb = + typeof msProbs['1'] === 'number' + ? msProbs['1'] + : typeof msProbs.home_prob === 'number' + ? msProbs.home_prob + : 0; + const drawProb = + typeof msProbs['X'] === 'number' + ? msProbs['X'] + : typeof msProbs.draw_prob === 'number' + ? msProbs.draw_prob + : 0; + const awayProb = + typeof msProbs['2'] === 'number' + ? msProbs['2'] + : typeof msProbs.away_prob === 'number' + ? msProbs.away_prob + : 0; + + let ms = '1'; + if (drawProb > homeProb && drawProb > awayProb) ms = 'X'; + else if (awayProb > homeProb) ms = '2'; + + // Extract OU25 + const ou25Probs = (predictions?.ou25 || data?.ou25 || {}) as Record< + string, + unknown + >; + const overProb = + typeof ou25Probs.Over === 'number' + ? ou25Probs.Over + : typeof ou25Probs.over_prob === 'number' + ? ou25Probs.over_prob + : 0; + const underProb = + typeof ou25Probs.Under === 'number' + ? ou25Probs.Under + : typeof ou25Probs.under_prob === 'number' + ? ou25Probs.under_prob + : 0; + const ou25 = overProb > underProb ? 'Over' : 'Under'; + + // Extract BTTS + const bttsProbs = (predictions?.btts || data?.btts || {}) as Record< + string, + unknown + >; + const bttsYes = + typeof bttsProbs.Yes === 'number' + ? bttsProbs.Yes + : typeof bttsProbs.yes_prob === 'number' + ? bttsProbs.yes_prob + : 0; + const bttsNo = + typeof bttsProbs.No === 'number' + ? bttsProbs.No + : typeof bttsProbs.no_prob === 'number' + ? bttsProbs.no_prob + : 0; + const btts = bttsYes > bttsNo ? 'Yes' : 'No'; + + return { + ms, + ou25, + btts, + probs: { + home: homeProb, + draw: drawProb, + away: awayProb, + over: overProb, + under: underProb, + bttsYes, + bttsNo, + }, + mainPick, + mainMarket, + }; +} + +async function processBatch(batch: TestMatch[]): Promise { + const results: BacktestResult[] = []; + + const promises = batch.map(async (match) => { + try { + const response = await axios.post( + `${AI_ENGINE_URL}/v20plus/analyze/${match.id}`, + {}, + { timeout: 15000 }, + ); + + const actual = determineActualOutcome( + match.scoreHome, + match.scoreAway, + match.htScoreHome, + match.htScoreAway, + ); + + const pred = extractPrediction(response.data); + + // Check main pick + let mainPickCorrect = false; + if (pred.mainMarket === 'MS') { + mainPickCorrect = pred.mainPick === actual.ms; + } else if (pred.mainMarket === 'OU25') { + mainPickCorrect = pred.mainPick === actual.ou25; + } else if (pred.mainMarket === 'BTTS') { + mainPickCorrect = pred.mainPick === actual.btts; + } + + results.push({ + matchId: match.id, + actual, + predicted: { ms: pred.ms, ou25: pred.ou25, btts: pred.btts }, + probabilities: pred.probs, + mainPickCorrect, + }); + } catch { + // Skip failed matches silently + } + }); + + await Promise.all(promises); + return results; +} + +// ═══════════════════════════════════════════════════════ +// Main Backtest +// ═══════════════════════════════════════════════════════ + +async function runBacktest(): Promise { + console.log('🎯 BACKTEST ACCURACY — V30 Betting Engine'); + console.log('════════════════════════════════════════════════════════'); + + // 1. Health check + try { + const health = await axios.get(`${AI_ENGINE_URL}/health`, { + timeout: 5000, + }); + console.log(`✅ AI Engine: ${JSON.stringify(health.data)}`); + } catch { + console.error('❌ AI Engine not reachable at', AI_ENGINE_URL); + process.exit(1); + } + + // 2. Load finished matches with features + console.log('\n📥 Loading test matches...'); + const matches = await prisma.$queryRaw` + SELECT m.id, m.score_home AS "scoreHome", m.score_away AS "scoreAway", + m.ht_score_home AS "htScoreHome", m.ht_score_away AS "htScoreAway" + FROM matches m + JOIN match_ai_features maf ON maf.match_id = m.id + WHERE m.status = 'FT' + AND m.score_home IS NOT NULL + AND m.score_away IS NOT NULL + AND m.sport = 'football' + AND maf.home_elo != 1500 + AND maf.implied_home != 0.33 + ORDER BY m.mst_utc DESC + LIMIT ${MAX_MATCHES} + `; + console.log(` 📊 Test matches: ${matches.length}`); + + // 3. Run predictions in batches + console.log('\n🤖 Running predictions...'); + const allResults: BacktestResult[] = []; + let processed = 0; + + for (let i = 0; i < matches.length; i += CONCURRENT_REQUESTS) { + const batch = matches.slice(i, i + CONCURRENT_REQUESTS); + const batchResults = await processBatch(batch); + allResults.push(...batchResults); + processed += batch.length; + + if (processed % 50 === 0 || processed === matches.length) { + const currentMsAcc = + allResults.length > 0 + ? ( + (allResults.filter((r) => r.predicted.ms === r.actual.ms).length / + allResults.length) * + 100 + ).toFixed(1) + : '0'; + console.log( + ` 📊 ${processed}/${matches.length} — Success: ${allResults.length} — MS Acc: ${currentMsAcc}%`, + ); + } + } + + // 4. Calculate metrics + const total = allResults.length; + if (total === 0) { + console.error('❌ No results to analyze'); + process.exit(1); + } + + const msCorrect = allResults.filter( + (r) => r.predicted.ms === r.actual.ms, + ).length; + const ou25Correct = allResults.filter( + (r) => r.predicted.ou25 === r.actual.ou25, + ).length; + const bttsCorrect = allResults.filter( + (r) => r.predicted.btts === r.actual.btts, + ).length; + const mainPickCorrect = allResults.filter((r) => r.mainPickCorrect).length; + + // Actual distribution + const actHome = allResults.filter((r) => r.actual.ms === '1').length; + const actDraw = allResults.filter((r) => r.actual.ms === 'X').length; + const actAway = allResults.filter((r) => r.actual.ms === '2').length; + + // Predicted distribution + const predHome = allResults.filter((r) => r.predicted.ms === '1').length; + const predDraw = allResults.filter((r) => r.predicted.ms === 'X').length; + const predAway = allResults.filter((r) => r.predicted.ms === '2').length; + + // Confidence calibration (based on max probability) + const buckets: Record = { + '33-40%': { correct: 0, total: 0 }, + '40-50%': { correct: 0, total: 0 }, + '50-60%': { correct: 0, total: 0 }, + '60-70%': { correct: 0, total: 0 }, + '70%+': { correct: 0, total: 0 }, + }; + + for (const r of allResults) { + const maxProb = Math.max( + r.probabilities.home, + r.probabilities.draw, + r.probabilities.away, + ); + const key = + maxProb >= 0.7 + ? '70%+' + : maxProb >= 0.6 + ? '60-70%' + : maxProb >= 0.5 + ? '50-60%' + : maxProb >= 0.4 + ? '40-50%' + : '33-40%'; + buckets[key].total++; + if (r.predicted.ms === r.actual.ms) buckets[key].correct++; + } + + // 5. Print Report + console.log('\n════════════════════════════════════════════════════════'); + console.log('📊 BACKTEST ACCURACY REPORT'); + console.log('════════════════════════════════════════════════════════'); + console.log(` Total Matches Analyzed: ${total}`); + console.log(''); + console.log(' 🎯 Market Accuracy:'); + console.log( + ` ⚽ Match Result (MS): ${((msCorrect / total) * 100).toFixed(2)}% (${msCorrect}/${total})`, + ); + console.log( + ` 📈 Over/Under 2.5: ${((ou25Correct / total) * 100).toFixed(2)}% (${ou25Correct}/${total})`, + ); + console.log( + ` 🤝 Both Teams Score: ${((bttsCorrect / total) * 100).toFixed(2)}% (${bttsCorrect}/${total})`, + ); + console.log( + ` 🏆 Main Pick Success: ${((mainPickCorrect / total) * 100).toFixed(2)}% (${mainPickCorrect}/${total})`, + ); + + console.log('\n 📊 MS Distribution:'); + console.log( + ` Actual: 1: ${actHome} (${((actHome / total) * 100).toFixed(1)}%) | X: ${actDraw} (${((actDraw / total) * 100).toFixed(1)}%) | 2: ${actAway} (${((actAway / total) * 100).toFixed(1)}%)`, + ); + console.log( + ` Predicted: 1: ${predHome} (${((predHome / total) * 100).toFixed(1)}%) | X: ${predDraw} (${((predDraw / total) * 100).toFixed(1)}%) | 2: ${predAway} (${((predAway / total) * 100).toFixed(1)}%)`, + ); + + console.log('\n 📊 Confidence Calibration:'); + for (const [range, bucket] of Object.entries(buckets)) { + if (bucket.total === 0) continue; + const acc = (bucket.correct / bucket.total) * 100; + const bar = '█'.repeat(Math.round(acc / 3)); + console.log( + ` ${range.padEnd(8)} : ${acc.toFixed(1)}% acc (n=${bucket.total}) ${bar}`, + ); + } + + // 6. Per-market deep dive + console.log('\n 📊 OU25 Breakdown:'); + const actOver = allResults.filter((r) => r.actual.ou25 === 'Over').length; + const actUnder = total - actOver; + const predOver = allResults.filter((r) => r.predicted.ou25 === 'Over').length; + const predUnder = total - predOver; + console.log( + ` Actual: Over: ${actOver} (${((actOver / total) * 100).toFixed(1)}%) | Under: ${actUnder} (${((actUnder / total) * 100).toFixed(1)}%)`, + ); + console.log( + ` Predicted: Over: ${predOver} (${((predOver / total) * 100).toFixed(1)}%) | Under: ${predUnder} (${((predUnder / total) * 100).toFixed(1)}%)`, + ); + + console.log('\n 📊 BTTS Breakdown:'); + const actBttsYes = allResults.filter((r) => r.actual.btts === 'Yes').length; + const actBttsNo = total - actBttsYes; + const predBttsYes = allResults.filter( + (r) => r.predicted.btts === 'Yes', + ).length; + const predBttsNo = total - predBttsYes; + console.log( + ` Actual: Yes: ${actBttsYes} (${((actBttsYes / total) * 100).toFixed(1)}%) | No: ${actBttsNo} (${((actBttsNo / total) * 100).toFixed(1)}%)`, + ); + console.log( + ` Predicted: Yes: ${predBttsYes} (${((predBttsYes / total) * 100).toFixed(1)}%) | No: ${predBttsNo} (${((predBttsNo / total) * 100).toFixed(1)}%)`, + ); + + console.log('════════════════════════════════════════════════════════'); + console.log('✅ Backtest complete!'); + + await prisma.$disconnect(); +} + +runBacktest().catch((err: unknown) => { + console.error('❌ Backtest failed:', err); + void prisma.$disconnect(); + process.exit(1); +}); diff --git a/src/scripts/batch-predict.ts b/src/scripts/batch-predict.ts new file mode 100644 index 0000000..47cb4b2 --- /dev/null +++ b/src/scripts/batch-predict.ts @@ -0,0 +1,134 @@ +/** + * =================================================== + * BATCH PREDICTION PIPELINE — V30 + * =================================================== + * Processes all upcoming matches (NS) and generates + * predictions by calling the AI Engine. + * Saves results to the predictions table. + * + * Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/batch-predict.ts + */ + +import { PrismaClient } from '@prisma/client'; +import axios from 'axios'; + +const prisma = new PrismaClient(); + +const AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:3005'; +const BATCH_SIZE = 5; +const MAX_MATCHES_TO_PROCESS = 1000; // Limit for local testing/batch capacity + +async function runBatchPrediction() { + console.log('🗓 BATCH PREDICTION PIPELINE STARTING'); + console.log('════════════════════════════════════════════════════════'); + + // 1. Health check + try { + const health = await axios.get(`${AI_ENGINE_URL}/health`, { + timeout: 5000, + }); + console.log(`✅ AI Engine Health: ${JSON.stringify(health.data)}`); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + console.error('❌ AI Engine not reachable at', AI_ENGINE_URL); + process.exit(1); + } + + // 2. Load upcoming matches (Not Started) + const upcomingMatches = await prisma.match.findMany({ + where: { + status: 'NS', + mstUtc: { + gte: Math.floor(Date.now() / 1000), // Future matches + }, + sport: 'football', + }, + orderBy: { mstUtc: 'asc' }, + take: MAX_MATCHES_TO_PROCESS, + select: { + id: true, + homeTeam: { select: { name: true } }, + awayTeam: { select: { name: true } }, + mstUtc: true, + }, + }); + + console.log( + `\n📥 Found ${upcomingMatches.length} upcoming matches to process.`, + ); + + // 3. Process matches in batches + let processedCount = 0; + let successCount = 0; + + for (let i = 0; i < upcomingMatches.length; i += BATCH_SIZE) { + const batch = upcomingMatches.slice(i, i + BATCH_SIZE); + + console.log( + `\n⏳ Processing batch ${Math.floor(i / BATCH_SIZE) + 1} (${batch.length} matches)...`, + ); + + const promises = batch.map(async (match) => { + try { + const response = await axios.post( + `${AI_ENGINE_URL}/v20plus/analyze/${match.id}`, + {}, + { timeout: 20000 }, + ); + + const data = response.data; + if (!data || !data.predictions) { + return false; + } + + // Use the entire response payload so frontend gets the expected MatchPredictionDto schema + const modelOutput = data; + + // Cache result in predictions table + await prisma.prediction.upsert({ + where: { matchId: match.id }, + create: { + matchId: match.id, + predictionJson: modelOutput, + }, + update: { + predictionJson: modelOutput, + updatedAt: new Date(), + }, + }); + + console.log( + ` ✅ Cached prediction for: ${match.homeTeam?.name} vs ${match.awayTeam?.name} (${match.mstUtc})`, + ); + return true; + } catch (e: unknown) { + const err = e as Error; + console.error( + ` ❌ Failed for match ${match.id}:`, + err?.message || 'Unknown error', + ); + return false; + } + }); + + const results = await Promise.all(promises); + successCount += results.filter(Boolean).length; + processedCount += batch.length; + } + + console.log('\n════════════════════════════════════════════════════════'); + console.log(`🎉 BATCH PROCESS COMPLETE`); + console.log(` Total Processed: ${processedCount}`); + console.log(` Successfully Updated/Created: ${successCount}`); + console.log(` Failed: ${processedCount - successCount}`); + console.log('════════════════════════════════════════════════════════'); + + await prisma.$disconnect(); +} + +runBatchPrediction().catch((e: unknown) => { + const err = e as Error; + console.error(err); + void prisma.$disconnect(); + process.exit(1); +}); diff --git a/src/scripts/check-duplicate-matches.ts b/src/scripts/check-duplicate-matches.ts new file mode 100755 index 0000000..a2eaaa7 --- /dev/null +++ b/src/scripts/check-duplicate-matches.ts @@ -0,0 +1,97 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('🔍 Checking for potential duplicate matches...'); + + // Group by unique match characteristics + // Since we can't easily do GROUP BY with HAVING count > 1 in Prisma standard API without raw query, + // we'll use a raw query for efficiency. + + const duplicates = await prisma.$queryRaw< + { + home_team_id: string; + away_team_id: string; + mst_utc: bigint; + count: bigint; + ids: string[]; + }[] + >` + SELECT + home_team_id, + away_team_id, + mst_utc, + COUNT(*) as count, + array_agg(id) as ids + FROM matches + WHERE home_team_id IS NOT NULL + AND away_team_id IS NOT NULL + GROUP BY home_team_id, away_team_id, mst_utc + HAVING COUNT(*) > 1 + ORDER BY count DESC + LIMIT 50; + `; + + if (duplicates.length === 0) { + console.log( + '✅ No duplicate matches found based on (HomeTeam + AwayTeam + Date).', + ); + return; + } + + console.log(`⚠️ Found ${duplicates.length} sets of duplicate matches:\n`); + + for (const group of duplicates) { + const homeTeam = await prisma.team.findUnique({ + where: { id: group.home_team_id }, + select: { name: true }, + }); + const awayTeam = await prisma.team.findUnique({ + where: { id: group.away_team_id }, + select: { name: true }, + }); + + const date = new Date(Number(group.mst_utc)).toISOString(); + console.log( + `📅 ${date} | ${homeTeam?.name} vs ${awayTeam?.name} (Count: ${group.count})`, + ); + console.log(` IDs: ${group.ids.join(', ')}`); + + // Check details of the duplicates to see if one is complete and one is not + for (const id of group.ids) { + const match = await prisma.match.findUnique({ + where: { id }, + include: { + oddCategories: { select: { dbId: true } }, + footballTeamStats: { select: { id: true } }, + basketballPlayerStats: { select: { id: true } }, + playerEvents: { select: { id: true } }, + officials: { select: { id: true } }, + }, + }); + + if (match) { + const counts = [ + match.oddCategories.length > 0 ? 'Odds' : '', + match.footballTeamStats.length > 0 ? 'Stats' : '', + match.playerEvents.length > 0 ? 'Events' : '', + match.officials.length > 0 ? 'Officials' : '', + ] + .filter(Boolean) + .join(', '); + + console.log( + ` - [${id}] Status: ${match.status} | Score: ${match.scoreHome}-${match.scoreAway} | Data: ${counts || 'None'}`, + ); + } + } + console.log('---------------------------------------------------'); + } +} + +main() + .catch((e) => console.error(e)) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/scripts/cleanup-live-matches.ts b/src/scripts/cleanup-live-matches.ts new file mode 100755 index 0000000..f40931e --- /dev/null +++ b/src/scripts/cleanup-live-matches.ts @@ -0,0 +1,107 @@ +/** + * Live Matches Cleanup Script + * + * Bitmiş maçları live_matches tablosundan siler. + * Kullanım: npx ts-node -r tsconfig-paths/register src/scripts/cleanup-live-matches.ts + */ + +import { PrismaClient } from '@prisma/client'; + +const FINISHED_STATUSES = ['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended']; +const FINISHED_STATES = ['Finished', 'post', 'FT', 'postGame']; +const LIVE_STATUSES = [ + 'LIVE', + '1H', + '2H', + 'HT', + '1Q', + '2Q', + '3Q', + '4Q', + 'Playing', + 'Half Time', +]; +const LIVE_STATES = ['live', 'firsthalf', 'secondhalf']; + +async function cleanupLiveMatches() { + const prisma = new PrismaClient(); + + try { + console.log('🧹 Live matches temizliği başlıyor...'); + + const now = Date.now(); + const finishedGraceMs = 6 * 60 * 60 * 1000; + const staleGraceMs = 24 * 60 * 60 * 1000; + const finishedBefore = BigInt(now - finishedGraceMs); + const staleBefore = BigInt(now - staleGraceMs); + + const totalBefore = await prisma.liveMatch.count(); + const outdatedCount = await prisma.liveMatch.count({ + where: { + mstUtc: { lt: BigInt(now) }, + }, + }); + const finishedPastCount = await prisma.liveMatch.count({ + where: { + mstUtc: { lt: finishedBefore }, + OR: [ + { status: { in: FINISHED_STATUSES } }, + { state: { in: FINISHED_STATES } }, + ], + }, + }); + + console.log('📊 Mevcut durum:'); + console.log(` Toplam live_matches: ${totalBefore}`); + console.log(` Geçmiş zamanlı kayıt: ${outdatedCount}`); + console.log( + ` Bitmiş ve grace süresini aşmış kayıt: ${finishedPastCount}`, + ); + + const deleted = await prisma.liveMatch.deleteMany({ + where: { + OR: [ + { + mstUtc: { lt: finishedBefore }, + OR: [ + { status: { in: FINISHED_STATUSES } }, + { state: { in: FINISHED_STATES } }, + ], + }, + { + mstUtc: { lt: staleBefore }, + NOT: { + OR: [ + { status: { in: LIVE_STATUSES } }, + { state: { in: LIVE_STATES } }, + ], + }, + }, + ], + }, + }); + + const totalAfter = await prisma.liveMatch.count(); + + console.log('\n✅ Temizlik tamamlandı!'); + console.log(` Silinen maç: ${deleted.count}`); + console.log(` Kalan maç: ${totalAfter}`); + + const states = await prisma.$queryRaw` + SELECT state, COUNT(*)::int as count + FROM live_matches + GROUP BY state + `; + + console.log('\n📋 Kalan maçların durumları:'); + (states as any).forEach((s: any) => { + console.log(` ${s.state || 'null'}: ${s.count}`); + }); + } catch (error) { + console.error('❌ Hata:', error); + } finally { + await prisma.$disconnect(); + } +} + +void cleanupLiveMatches(); diff --git a/src/scripts/compute-elo-ratings.ts b/src/scripts/compute-elo-ratings.ts new file mode 100644 index 0000000..d098907 --- /dev/null +++ b/src/scripts/compute-elo-ratings.ts @@ -0,0 +1,318 @@ +/** + * compute-elo-ratings.ts + * ====================== + * Batch ELO rating computation for all finished football matches. + * + * Processes 109K+ matches in chronological order and computes: + * - Overall ELO (general strength) + * - Home ELO (home performance) + * - Away ELO (away performance) + * - Form ELO (recent 10-match weighted performance) + * + * Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/compute-elo-ratings.ts + */ + +import { PrismaClient } from '@prisma/client'; + +// ───────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────── + +interface EloState { + overallElo: number; + homeElo: number; + awayElo: number; + formElo: number; + matchesPlayed: number; + recentResults: string[]; // Last 10 results: W/D/L +} + +interface MatchRecord { + id: string; + homeTeamId: string | null; + awayTeamId: string | null; + scoreHome: number | null; + scoreAway: number | null; + mstUtc: bigint; +} + +// ───────────────────────────────────────────────────────────── +// ELO Algorithm +// ───────────────────────────────────────────────────────────── + +const BASE_ELO = 1500.0; +const K_FACTOR = 32; +const HOME_ADVANTAGE = 50; // Home team gets +50 ELO advantage in expected score calc +const FORM_DECAY = 0.9; // Recent matches weighted more heavily for form ELO +const MAX_RECENT_RESULTS = 10; + +function getExpectedScore(ratingA: number, ratingB: number): number { + return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400)); +} + +function getAdaptiveK(matchesPlayed: number): number { + // New teams get higher K for faster convergence + if (matchesPlayed < 10) return K_FACTOR * 2; + if (matchesPlayed < 30) return K_FACTOR * 1.5; + return K_FACTOR; +} + +function getActualScore( + scoreHome: number, + scoreAway: number, + isHomeTeam: boolean, +): number { + if (scoreHome > scoreAway) return isHomeTeam ? 1.0 : 0.0; + if (scoreHome < scoreAway) return isHomeTeam ? 0.0 : 1.0; + return 0.5; // Draw +} + +function getResultChar( + scoreHome: number, + scoreAway: number, + isHomeTeam: boolean, +): string { + if (scoreHome > scoreAway) return isHomeTeam ? 'W' : 'L'; + if (scoreHome < scoreAway) return isHomeTeam ? 'L' : 'W'; + return 'D'; +} + +function calculateFormElo(recentResults: string[]): number { + if (recentResults.length === 0) return BASE_ELO; + + let formScore = 0; + let totalWeight = 0; + + for (let i = 0; i < recentResults.length; i++) { + const weight = Math.pow(FORM_DECAY, i); // Most recent = highest weight + const result = recentResults[i]; + const score = result === 'W' ? 3 : result === 'D' ? 1 : 0; + formScore += score * weight; + totalWeight += 3 * weight; // Max possible per match + } + + // Normalize to ELO-like scale (1200-1800 range) + const normalizedForm = totalWeight > 0 ? formScore / totalWeight : 0.5; + return 1200 + normalizedForm * 600; +} + +// ───────────────────────────────────────────────────────────── +// Main Computation +// ───────────────────────────────────────────────────────────── + +async function computeEloRatings(): Promise { + const prisma = new PrismaClient(); + const startTime = Date.now(); + + try { + console.log('🏟️ ELO Rating Computation — Starting...'); + console.log('─'.repeat(60)); + + // 1. Fetch all finished football matches in chronological order + const matches: MatchRecord[] = await prisma.match.findMany({ + where: { + sport: 'football', + status: 'FT', + scoreHome: { not: null }, + scoreAway: { not: null }, + homeTeamId: { not: null }, + awayTeamId: { not: null }, + }, + select: { + id: true, + homeTeamId: true, + awayTeamId: true, + scoreHome: true, + scoreAway: true, + mstUtc: true, + }, + orderBy: { mstUtc: 'asc' }, + }); + + console.log( + `📊 Total matches to process: ${matches.length.toLocaleString()}`, + ); + + // 2. Initialize ELO state map + const eloMap = new Map(); + + function getOrCreateElo(teamId: string): EloState { + let state = eloMap.get(teamId); + if (!state) { + state = { + overallElo: BASE_ELO, + homeElo: BASE_ELO, + awayElo: BASE_ELO, + formElo: BASE_ELO, + matchesPlayed: 0, + recentResults: [], + }; + eloMap.set(teamId, state); + } + return state; + } + + // 3. Process each match chronologically + let processed = 0; + const logInterval = 10000; + + for (const match of matches) { + const homeTeamId = match.homeTeamId!; + const awayTeamId = match.awayTeamId!; + const scoreHome = match.scoreHome!; + const scoreAway = match.scoreAway!; + + const homeState = getOrCreateElo(homeTeamId); + const awayState = getOrCreateElo(awayTeamId); + + // Actual scores + const homeActual = getActualScore(scoreHome, scoreAway, true); + const awayActual = getActualScore(scoreHome, scoreAway, false); + + // K-factors (adaptive) + const homeK = getAdaptiveK(homeState.matchesPlayed); + const awayK = getAdaptiveK(awayState.matchesPlayed); + + // --- Overall ELO --- + const expectedHome = getExpectedScore( + homeState.overallElo + HOME_ADVANTAGE, + awayState.overallElo, + ); + const expectedAway = 1 - expectedHome; + + homeState.overallElo += homeK * (homeActual - expectedHome); + awayState.overallElo += awayK * (awayActual - expectedAway); + + // --- Home/Away specific ELO --- + const expectedHomeSpec = getExpectedScore( + homeState.homeElo, + awayState.awayElo, + ); + const expectedAwaySpec = 1 - expectedHomeSpec; + + homeState.homeElo += homeK * (homeActual - expectedHomeSpec); + awayState.awayElo += awayK * (awayActual - expectedAwaySpec); + + // --- Recent results & Form ELO --- + const homeResult = getResultChar(scoreHome, scoreAway, true); + const awayResult = getResultChar(scoreHome, scoreAway, false); + + homeState.recentResults.unshift(homeResult); + awayState.recentResults.unshift(awayResult); + + if (homeState.recentResults.length > MAX_RECENT_RESULTS) { + homeState.recentResults.pop(); + } + if (awayState.recentResults.length > MAX_RECENT_RESULTS) { + awayState.recentResults.pop(); + } + + homeState.formElo = calculateFormElo(homeState.recentResults); + awayState.formElo = calculateFormElo(awayState.recentResults); + + // --- Increment match count --- + homeState.matchesPlayed++; + awayState.matchesPlayed++; + + processed++; + if (processed % logInterval === 0) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log( + ` ⏳ Processed ${processed.toLocaleString()} / ${matches.length.toLocaleString()} matches (${elapsed}s)`, + ); + } + } + + console.log( + `✅ ELO computation complete — ${eloMap.size.toLocaleString()} teams rated`, + ); + + // 4. Bulk upsert to team_elo_ratings + console.log('💾 Writing to team_elo_ratings...'); + + const BATCH_SIZE = 500; + const teams = Array.from(eloMap.entries()); + + for (let i = 0; i < teams.length; i += BATCH_SIZE) { + const batch = teams.slice(i, i + BATCH_SIZE); + + await prisma.$transaction( + batch.map(([teamId, state]) => + prisma.teamEloRating.upsert({ + where: { teamId }, + update: { + overallElo: Math.round(state.overallElo * 10) / 10, + homeElo: Math.round(state.homeElo * 10) / 10, + awayElo: Math.round(state.awayElo * 10) / 10, + formElo: Math.round(state.formElo * 10) / 10, + matchesPlayed: state.matchesPlayed, + recentForm: state.recentResults.join(''), + }, + create: { + teamId, + overallElo: Math.round(state.overallElo * 10) / 10, + homeElo: Math.round(state.homeElo * 10) / 10, + awayElo: Math.round(state.awayElo * 10) / 10, + formElo: Math.round(state.formElo * 10) / 10, + matchesPlayed: state.matchesPlayed, + recentForm: state.recentResults.join(''), + }, + }), + ), + ); + + if ((i + BATCH_SIZE) % 2000 === 0 || i + BATCH_SIZE >= teams.length) { + console.log( + ` 💾 Saved ${Math.min(i + BATCH_SIZE, teams.length).toLocaleString()} / ${teams.length.toLocaleString()} teams`, + ); + } + } + + // 5. Print summary stats + const elapsedTotal = ((Date.now() - startTime) / 1000).toFixed(1); + + const eloValues = Array.from(eloMap.values()); + const overallElos = eloValues + .map((s) => s.overallElo) + .sort((a, b) => b - a); + + console.log('─'.repeat(60)); + console.log('📊 ELO Rating Summary:'); + console.log(` Teams rated: ${eloMap.size.toLocaleString()}`); + console.log(` Matches used: ${processed.toLocaleString()}`); + console.log(` Highest ELO: ${overallElos[0]?.toFixed(1) ?? 'N/A'}`); + console.log( + ` Lowest ELO: ${overallElos[overallElos.length - 1]?.toFixed(1) ?? 'N/A'}`, + ); + console.log( + ` Median ELO: ${overallElos[Math.floor(overallElos.length / 2)]?.toFixed(1) ?? 'N/A'}`, + ); + console.log(` Duration: ${elapsedTotal}s`); + console.log('─'.repeat(60)); + + // Top 20 teams + const topTeams = await prisma.teamEloRating.findMany({ + orderBy: { overallElo: 'desc' }, + take: 20, + include: { team: { select: { name: true } } }, + }); + + console.log('\n🏆 Top 20 Teams by ELO:'); + topTeams.forEach((t, i) => { + const form = t.recentForm.split('').join('-'); + console.log( + ` ${String(i + 1).padStart(2)}. ${t.team.name.padEnd(25)} Overall: ${t.overallElo.toFixed(1).padStart(7)} Home: ${t.homeElo.toFixed(1).padStart(7)} Away: ${t.awayElo.toFixed(1).padStart(7)} Form: ${form}`, + ); + }); + + console.log('\n✅ Done!'); + } catch (error) { + console.error('❌ ELO computation failed:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +// Run +computeEloRatings().catch(console.error); diff --git a/src/scripts/export-postman-collection.ts b/src/scripts/export-postman-collection.ts new file mode 100644 index 0000000..52900ad --- /dev/null +++ b/src/scripts/export-postman-collection.ts @@ -0,0 +1,636 @@ +import 'reflect-metadata'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import * as path from 'node:path'; +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { AppModule } from '../app.module'; + +type JsonRecord = Record; +type SwaggerPaths = Record>; +type SwaggerSchemas = Record; + +interface PostmanResponse { + name: string; + originalRequest: JsonRecord; + status: string; + code: number; + _postman_previewlanguage: 'json'; + header: Array<{ key: string; value: string }>; + body: string; +} + +interface PostmanItem { + name: string; + item?: PostmanItem[]; + request?: JsonRecord; + response?: PostmanResponse[]; +} + +interface AiEndpointDefinition { + name: string; + method: 'GET' | 'POST'; + path: string; + description: string; + query?: Array<{ key: string; value: string; description: string }>; + body?: JsonRecord; + response: JsonRecord; +} + +function refName(ref: string | undefined): string | null { + if (!ref) { + return null; + } + const parts = ref.split('/'); + return parts[parts.length - 1] ?? null; +} + +function resolveSchema( + schema: unknown, + schemas: SwaggerSchemas, +): JsonRecord | null { + if (!schema || typeof schema !== 'object') { + return null; + } + + const schemaObject = schema as JsonRecord; + const schemaRef = typeof schemaObject.$ref === 'string' ? schemaObject.$ref : null; + if (schemaRef) { + const name = refName(schemaRef); + return name ? (schemas[name] ?? null) : null; + } + + return schemaObject; +} + +function examplePrimitive(schema: JsonRecord): unknown { + if (schema.example !== undefined) { + return schema.example; + } + if (schema.default !== undefined) { + return schema.default; + } + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + return schema.enum[0]; + } + + const type = typeof schema.type === 'string' ? schema.type : 'string'; + const format = typeof schema.format === 'string' ? schema.format : ''; + + if (type === 'string') { + if (format === 'email') { + return 'user@example.com'; + } + if (format === 'date-time') { + return '2026-04-14T00:00:00.000Z'; + } + if (format === 'date') { + return '2026-04-14'; + } + if (format === 'uuid') { + return '11111111-1111-1111-1111-111111111111'; + } + return 'string'; + } + if (type === 'integer' || type === 'number') { + return 1; + } + if (type === 'boolean') { + return true; + } + return 'string'; +} + +function buildExampleFromSchema( + schema: unknown, + schemas: SwaggerSchemas, + visited: Set = new Set(), +): unknown { + const resolved = resolveSchema(schema, schemas); + if (!resolved) { + return null; + } + + const schemaRef = typeof resolved.$ref === 'string' ? resolved.$ref : null; + if (schemaRef) { + const name = refName(schemaRef); + if (!name || visited.has(name)) { + return null; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return buildExampleFromSchema(schemas[name], schemas, nextVisited); + } + + if (Array.isArray(resolved.allOf) && resolved.allOf.length > 0) { + return resolved.allOf.reduce((accumulator, part) => { + const partial = buildExampleFromSchema(part, schemas, visited); + if (partial && typeof partial === 'object' && !Array.isArray(partial)) { + return { ...accumulator, ...(partial as JsonRecord) }; + } + return accumulator; + }, {}); + } + + if (Array.isArray(resolved.oneOf) && resolved.oneOf.length > 0) { + return buildExampleFromSchema(resolved.oneOf[0], schemas, visited); + } + + if (Array.isArray(resolved.anyOf) && resolved.anyOf.length > 0) { + return buildExampleFromSchema(resolved.anyOf[0], schemas, visited); + } + + const type = typeof resolved.type === 'string' ? resolved.type : 'object'; + if (type === 'array') { + return [buildExampleFromSchema(resolved.items, schemas, visited)]; + } + + if (type === 'object' || resolved.properties) { + const properties = (resolved.properties ?? {}) as JsonRecord; + const output: JsonRecord = {}; + for (const [key, value] of Object.entries(properties)) { + output[key] = buildExampleFromSchema(value, schemas, visited); + } + if (Object.keys(output).length > 0) { + return output; + } + } + + return examplePrimitive(resolved); +} + +function swaggerSchemaFromContent(content: unknown): unknown { + if (!content || typeof content !== 'object') { + return null; + } + const contentObject = content as JsonRecord; + const jsonContent = contentObject['application/json']; + if (jsonContent && typeof jsonContent === 'object') { + return (jsonContent as JsonRecord).schema ?? null; + } + const firstContent = Object.values(contentObject)[0]; + if (firstContent && typeof firstContent === 'object') { + return (firstContent as JsonRecord).schema ?? null; + } + return null; +} + +function toPostmanPath(pathname: string): string { + return pathname.replace(/\{([^}]+)\}/g, '{{$1}}'); +} + +function buildRequestBody( + operation: JsonRecord, + schemas: SwaggerSchemas, +): string | null { + const requestBody = operation.requestBody as JsonRecord | undefined; + const schema = swaggerSchemaFromContent(requestBody?.content); + if (!schema) { + return null; + } + + const example = buildExampleFromSchema(schema, schemas); + return JSON.stringify(example ?? {}, null, 2); +} + +function buildResponses( + operation: JsonRecord, + method: string, + rawPath: string, + baseUrlVariable: string, + schemas: SwaggerSchemas, + body: string | null, +): PostmanResponse[] { + const responses = (operation.responses ?? {}) as JsonRecord; + const entries = Object.entries(responses); + + return entries.map(([statusCode, responseObject]) => { + const responseRecord = responseObject as JsonRecord; + const schema = swaggerSchemaFromContent(responseRecord.content); + const example = buildExampleFromSchema(schema, schemas); + const numericStatus = Number(statusCode); + + return { + name: `${method.toUpperCase()} ${rawPath} - ${statusCode}`, + originalRequest: { + method: method.toUpperCase(), + header: [{ key: 'Content-Type', value: 'application/json' }], + body: body + ? { + mode: 'raw', + raw: body, + } + : undefined, + url: { + raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`, + host: [`{{${baseUrlVariable}}}`], + path: rawPath.split('/').filter(Boolean), + }, + }, + status: typeof responseRecord.description === 'string' + ? responseRecord.description + : `HTTP ${statusCode}`, + code: Number.isFinite(numericStatus) ? numericStatus : 200, + _postman_previewlanguage: 'json', + header: [{ key: 'Content-Type', value: 'application/json' }], + body: JSON.stringify(example ?? {}, null, 2), + }; + }); +} + +function buildQueryParams(operation: JsonRecord): Array { + const parameters = Array.isArray(operation.parameters) + ? (operation.parameters as JsonRecord[]) + : []; + + return parameters + .filter((parameter) => parameter.in === 'query') + .map((parameter) => ({ + key: String(parameter.name ?? ''), + value: + parameter.schema && typeof parameter.schema === 'object' + ? String(((parameter.schema as JsonRecord).default ?? '')) + : '', + description: String(parameter.description ?? ''), + disabled: parameter.required === true ? false : true, + })); +} + +function buildHeaders(operation: JsonRecord): Array { + const headers: Array = [ + { + key: 'Content-Type', + value: 'application/json', + }, + ]; + + const security = Array.isArray(operation.security) + ? (operation.security as JsonRecord[]) + : []; + if (security.length > 0) { + headers.push({ + key: 'Authorization', + value: 'Bearer {{accessToken}}', + }); + } + + return headers; +} + +function createRequestItem( + name: string, + method: string, + rawPath: string, + baseUrlVariable: string, + operation: JsonRecord, + schemas: SwaggerSchemas, +): PostmanItem { + const body = buildRequestBody(operation, schemas); + const query = buildQueryParams(operation); + const headers = buildHeaders(operation); + + const request: JsonRecord = { + method: method.toUpperCase(), + header: headers, + description: + typeof operation.description === 'string' + ? operation.description + : (typeof operation.summary === 'string' ? operation.summary : ''), + url: { + raw: `{{${baseUrlVariable}}}${toPostmanPath(rawPath)}`, + host: [`{{${baseUrlVariable}}}`], + path: rawPath.split('/').filter(Boolean), + query, + }, + }; + + if (body) { + request.body = { + mode: 'raw', + raw: body, + }; + } + + return { + name, + request, + response: buildResponses( + operation, + method, + rawPath, + baseUrlVariable, + schemas, + body, + ), + }; +} + +function buildNestFolders(document: JsonRecord): PostmanItem[] { + const paths = (document.paths ?? {}) as SwaggerPaths; + const schemas = ((document.components ?? {}) as JsonRecord).schemas as + | SwaggerSchemas + | undefined; + const safeSchemas = schemas ?? {}; + + const folders = new Map(); + + for (const [rawPath, pathItem] of Object.entries(paths)) { + for (const [method, operationObject] of Object.entries(pathItem)) { + if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) { + continue; + } + + const operation = operationObject as JsonRecord; + const tags = Array.isArray(operation.tags) ? operation.tags : []; + const folderName = + typeof tags[0] === 'string' && tags[0].trim().length > 0 + ? tags[0] + : 'Misc'; + const requestName = + typeof operation.summary === 'string' && operation.summary.trim().length > 0 + ? operation.summary + : `${method.toUpperCase()} ${rawPath}`; + + const item = createRequestItem( + requestName, + method, + rawPath, + 'beBaseUrl', + operation, + safeSchemas, + ); + + const existing = folders.get(folderName) ?? []; + existing.push(item); + folders.set(folderName, existing); + } + } + + return [...folders.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([folderName, items]) => ({ + name: folderName, + item: items.sort((a, b) => a.name.localeCompare(b.name)), + })); +} + +function createAiRequest( + endpoint: AiEndpointDefinition, + folderName: string, +): PostmanItem { + const url: JsonRecord = { + raw: `{{aiBaseUrl}}${endpoint.path}`, + host: ['{{aiBaseUrl}}'], + path: endpoint.path.split('/').filter(Boolean), + }; + + if (endpoint.query && endpoint.query.length > 0) { + url.query = endpoint.query.map((queryItem) => ({ + key: queryItem.key, + value: queryItem.value, + description: queryItem.description, + })); + } + + const request: JsonRecord = { + method: endpoint.method, + header: [{ key: 'Content-Type', value: 'application/json' }], + description: endpoint.description, + url, + }; + + if (endpoint.body) { + request.body = { + mode: 'raw', + raw: JSON.stringify(endpoint.body, null, 2), + }; + } + + return { + name: endpoint.name, + request, + response: [ + { + name: `${endpoint.method} ${endpoint.path}`, + originalRequest: request, + status: 'OK', + code: 200, + _postman_previewlanguage: 'json', + header: [{ key: 'Content-Type', value: 'application/json' }], + body: JSON.stringify(endpoint.response, null, 2), + }, + ], + }; +} + +function buildAiFolder(): PostmanItem { + const v20Endpoints: AiEndpointDefinition[] = [ + { + name: 'Root', + method: 'GET', + path: '/', + description: 'AI engine root status endpoint', + response: { + status: 'Suggest-Bet AI Engine v20+', + engine: 'V20 Plus Single Match Orchestrator', + }, + }, + { + name: 'Health', + method: 'GET', + path: '/health', + description: 'AI engine health endpoint', + response: { status: 'healthy', engine: 'v20plus', ready: true }, + }, + { + name: 'Analyze Match', + method: 'POST', + path: '/v20plus/analyze/{{match_id}}', + description: 'Full V20+ single match analysis', + response: { + model_version: 'v30.0', + match_info: { match_id: '{{match_id}}' }, + main_pick: { market: 'OU25', pick: '2.5 Üst' }, + market_board: {}, + }, + }, + { + name: 'Analyze HTMS', + method: 'GET', + path: '/v20plus/analyze-htms/{{match_id}}', + description: 'Half-time result analysis endpoint', + response: { match_id: '{{match_id}}', market: 'HT' }, + }, + { + name: 'Analyze HTFT', + method: 'GET', + path: '/v20plus/analyze-htft/{{match_id}}', + description: 'Half-time/full-time analysis endpoint', + query: [ + { + key: 'timeout_sec', + value: '30', + description: 'Timeout between 3 and 120 seconds', + }, + ], + response: { + engine: 'v20plus.1', + match_info: { match_id: '{{match_id}}' }, + ht_ft_probs: { '1/1': 0.25, 'X/X': 0.18 }, + }, + }, + { + name: 'Generate Coupon', + method: 'POST', + path: '/v20plus/coupon', + description: 'Generate V20+ coupon from selected matches', + body: { + match_ids: ['match-1', 'match-2'], + strategy: 'BALANCED', + max_matches: 4, + min_confidence: 55, + }, + response: { + success: true, + data: { + strategy: 'BALANCED', + bets: [], + }, + }, + }, + { + name: 'Daily Banker', + method: 'GET', + path: '/v20plus/daily-banker', + description: 'Get daily banker picks', + query: [ + { + key: 'count', + value: '3', + description: 'Number of banker picks', + }, + ], + response: { count: 3, bankers: [] }, + }, + { + name: 'Reversal Watchlist', + method: 'GET', + path: '/v20plus/reversal-watchlist', + description: 'Reversal watchlist candidates', + query: [ + { key: 'count', value: '20', description: 'Result size' }, + { key: 'horizon_hours', value: '72', description: 'Future horizon' }, + { key: 'min_score', value: '45', description: 'Minimum score' }, + { + key: 'top_leagues_only', + value: 'false', + description: 'Filter to top leagues', + }, + ], + response: { count: 0, items: [] }, + }, + ]; + + const v2Endpoints: AiEndpointDefinition[] = [ + { + name: 'V2 Health', + method: 'GET', + path: '/v2/health', + description: 'V2 betting engine health', + response: { + status: 'healthy', + engine: 'v2.betting_engine', + models_loaded: true, + }, + }, + { + name: 'V2 Analyze Match', + method: 'POST', + path: '/v2/analyze/{{match_id}}', + description: 'V2 leakage-free match analysis', + response: { + model_version: 'v2.betting_engine', + match_info: { match_id: '{{match_id}}' }, + main_pick: { market: 'MS', pick: '1' }, + market_board: { + MS: { pick: '1', confidence: 58.4 }, + }, + }, + }, + ]; + + return { + name: 'AI Engine', + item: [ + { + name: 'V20+', + item: v20Endpoints.map((endpoint) => createAiRequest(endpoint, 'V20+')), + }, + { + name: 'V2', + item: v2Endpoints.map((endpoint) => createAiRequest(endpoint, 'V2')), + }, + ], + }; +} + +async function run(): Promise { + const projectRoot = process.cwd(); + const outputDir = path.join(projectRoot, 'mds'); + const outputFile = path.join( + outputDir, + 'suggest-bet-platform.postman_collection.json', + ); + + process.env.REDIS_ENABLED = 'true'; + + const app = await NestFactory.create(AppModule, { logger: false }); + app.setGlobalPrefix('api'); + + const swaggerConfig = new DocumentBuilder() + .setTitle('Suggest Bet Backend API') + .setDescription('Postman collection export source') + .setVersion('1.0') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument( + app, + swaggerConfig, + ) as unknown as JsonRecord; + + const collection: JsonRecord = { + info: { + name: 'Suggest-Bet Platform API', + description: + 'Auto-generated Postman collection for Nest backend and AI engine endpoints.', + schema: + 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', + }, + variable: [ + { key: 'beBaseUrl', value: 'http://localhost:3005' }, + { key: 'aiBaseUrl', value: 'http://localhost:8000' }, + { key: 'accessToken', value: '' }, + { key: 'match_id', value: 'sample-match-id' }, + ], + auth: { + type: 'bearer', + bearer: [{ key: 'token', value: '{{accessToken}}', type: 'string' }], + }, + item: [ + { + name: 'Nest API', + item: buildNestFolders(document), + }, + buildAiFolder(), + ], + }; + + mkdirSync(outputDir, { recursive: true }); + writeFileSync(outputFile, JSON.stringify(collection, null, 2), 'utf8'); + + await app.close(); + + console.log(`✅ Postman collection exported: ${outputFile}`); +} + +void run(); diff --git a/src/scripts/export-swagger-endpoints-summary.ts b/src/scripts/export-swagger-endpoints-summary.ts new file mode 100755 index 0000000..70a75d0 --- /dev/null +++ b/src/scripts/export-swagger-endpoints-summary.ts @@ -0,0 +1,687 @@ +import 'reflect-metadata'; +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import * as path from 'node:path'; +import ts from 'typescript'; +import { NestFactory } from '@nestjs/core'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { AppModule } from '../app.module'; + +type HttpMethod = + | 'get' + | 'post' + | 'put' + | 'patch' + | 'delete' + | 'options' + | 'head' + | 'all'; + +interface TsDecoratorMeta { + name: string; + firstArg?: string; +} + +interface TsParameterMeta { + name: string; + type: string | null; + decorators: TsDecoratorMeta[]; +} + +interface TsMethodMeta { + operationId: string; + controller: string; + controllerRoute: string; + methodName: string; + httpMethod: HttpMethod; + routePath: string; + returnType: string | null; + hasPublicDecorator: boolean; + methodDecorators: string[]; + params: TsParameterMeta[]; + filePath: string; +} + +const HTTP_DECORATOR_TO_METHOD: Record = { + Get: 'get', + Post: 'post', + Put: 'put', + Patch: 'patch', + Delete: 'delete', + Options: 'options', + Head: 'head', + All: 'all', +}; + +function getDecorators(node: ts.Node): readonly ts.Decorator[] { + return ts.canHaveDecorators(node) ? (ts.getDecorators(node) ?? []) : []; +} + +function parseDecorator( + decorator: ts.Decorator, + sourceFile: ts.SourceFile, +): TsDecoratorMeta | null { + const expression = decorator.expression; + + if (ts.isIdentifier(expression)) { + return { name: expression.text }; + } + + if (ts.isCallExpression(expression)) { + const called = expression.expression; + if (!ts.isIdentifier(called)) { + return null; + } + + const firstArg = expression.arguments[0]; + let firstArgText: string | undefined; + if (firstArg) { + if ( + ts.isStringLiteral(firstArg) || + ts.isNoSubstitutionTemplateLiteral(firstArg) + ) { + firstArgText = firstArg.text; + } else { + firstArgText = firstArg.getText(sourceFile); + } + } + + return { + name: called.text, + firstArg: firstArgText, + }; + } + + return null; +} + +function collectControllerFiles(dirPath: string): string[] { + const files: string[] = []; + const entries = readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const absolutePath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + files.push(...collectControllerFiles(absolutePath)); + continue; + } + + if (entry.isFile() && entry.name.endsWith('.controller.ts')) { + files.push(absolutePath); + } + } + + return files; +} + +function normalizeRoutePart(value: string | undefined): string { + if (!value || value === "''" || value === '""') { + return ''; + } + return value.trim().replace(/^\/+|\/+$/g, ''); +} + +function buildSwaggerPath( + globalPrefix: string, + controllerRoute: string, + routePath: string, +): string { + const parts = [ + normalizeRoutePart(globalPrefix), + normalizeRoutePart(controllerRoute), + normalizeRoutePart(routePath), + ].filter(Boolean); + + return `/${parts.join('/')}`; +} + +function collectTsEndpointMetadata( + projectRoot: string, +): Map { + const modulesDir = path.join(projectRoot, 'src', 'modules'); + const controllerFiles = collectControllerFiles(modulesDir); + const metadataByOperationId = new Map(); + + for (const filePath of controllerFiles) { + const sourceText = readFileSync(filePath, 'utf8'); + const sourceFile = ts.createSourceFile( + filePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + + ts.forEachChild(sourceFile, (node) => { + if (!ts.isClassDeclaration(node) || !node.name) { + return; + } + + const className = node.name.text; + const classDecorators = getDecorators(node) + .map((decorator) => parseDecorator(decorator, sourceFile)) + .filter((decorator): decorator is TsDecoratorMeta => + Boolean(decorator), + ); + + const controllerDecorator = classDecorators.find( + (decorator) => decorator.name === 'Controller', + ); + if (!controllerDecorator) { + return; + } + + const controllerRoute = normalizeRoutePart(controllerDecorator.firstArg); + + for (const member of node.members) { + if (!ts.isMethodDeclaration(member) || !member.name) { + continue; + } + + const methodDecorators = getDecorators(member) + .map((decorator) => parseDecorator(decorator, sourceFile)) + .filter((decorator): decorator is TsDecoratorMeta => + Boolean(decorator), + ); + + const httpDecorator = methodDecorators.find( + (decorator) => decorator.name in HTTP_DECORATOR_TO_METHOD, + ); + if (!httpDecorator) { + continue; + } + + const methodName = member.name.getText(sourceFile); + const httpMethod = HTTP_DECORATOR_TO_METHOD[httpDecorator.name]; + const routePath = normalizeRoutePart(httpDecorator.firstArg); + const returnType = member.type + ? member.type.getText(sourceFile).trim() + : null; + + const params: TsParameterMeta[] = member.parameters.map((param) => { + const paramDecorators = getDecorators(param) + .map((decorator) => parseDecorator(decorator, sourceFile)) + .filter((decorator): decorator is TsDecoratorMeta => + Boolean(decorator), + ); + + return { + name: param.name.getText(sourceFile), + type: param.type ? param.type.getText(sourceFile).trim() : null, + decorators: paramDecorators, + }; + }); + + const operationId = `${className}_${methodName}`; + metadataByOperationId.set(operationId, { + operationId, + controller: className, + controllerRoute, + methodName, + httpMethod, + routePath, + returnType, + hasPublicDecorator: methodDecorators.some( + (decorator) => decorator.name === 'Public', + ), + methodDecorators: methodDecorators.map((decorator) => decorator.name), + params, + filePath, + }); + } + }); + } + + return metadataByOperationId; +} + +function refName(ref?: string): string | null { + if (!ref || typeof ref !== 'string') { + return null; + } + const parts = ref.split('/'); + return parts[parts.length - 1] ?? null; +} + +function collectSchemaRefs( + value: unknown, + refs = new Set(), +): Set { + if (!value || typeof value !== 'object') { + return refs; + } + + const recordValue = value as Record; + const maybeRef = recordValue.$ref; + if (typeof maybeRef === 'string') { + const name = refName(maybeRef); + if (name) { + refs.add(name); + } + } + + for (const nested of Object.values(recordValue)) { + collectSchemaRefs(nested, refs); + } + + return refs; +} + +function schemaTypeSummary(schema: unknown): string { + if (!schema || typeof schema !== 'object') { + return 'unknown'; + } + + const schemaObj = schema as Record; + if (typeof schemaObj.$ref === 'string') { + return refName(schemaObj.$ref) ?? 'unknown'; + } + + const type = typeof schemaObj.type === 'string' ? schemaObj.type : 'object'; + if (type === 'array') { + return `array<${schemaTypeSummary(schemaObj.items)}>`; + } + + if (Array.isArray(schemaObj.enum) && schemaObj.enum.length > 0) { + return `${type}(${schemaObj.enum.join(' | ')})`; + } + + return type; +} + +function normalizeParameters(parameters: unknown[] = []) { + const parsed = parameters + .map((parameter) => parameter as Record) + .filter(Boolean) + .map((parameter) => { + const schema = (parameter.schema ?? {}) as Record; + return { + name: typeof parameter.name === 'string' ? parameter.name : '', + in: typeof parameter.in === 'string' ? parameter.in : '', + required: Boolean(parameter.required), + description: + typeof parameter.description === 'string' + ? parameter.description + : null, + type: schemaTypeSummary(schema), + enum: Array.isArray(schema.enum) ? schema.enum : [], + default: schema.default ?? null, + format: typeof schema.format === 'string' ? schema.format : null, + }; + }); + + return { + path: parsed.filter((item) => item.in === 'path'), + query: parsed.filter((item) => item.in === 'query'), + header: parsed.filter((item) => item.in === 'header'), + cookie: parsed.filter((item) => item.in === 'cookie'), + }; +} + +function normalizeRequestBody(requestBody: unknown) { + if (!requestBody || typeof requestBody !== 'object') { + return null; + } + + const requestBodyObj = requestBody as Record; + if (typeof requestBodyObj.$ref === 'string') { + return { + required: false, + contentTypes: [], + schemaTypes: [], + schemaRefs: [refName(requestBodyObj.$ref)].filter(Boolean), + raw: requestBodyObj, + }; + } + + const content = (requestBodyObj.content ?? {}) as Record< + string, + Record + >; + const contentTypes = Object.keys(content); + const schemaTypes: string[] = []; + const refs = new Set(); + + for (const mediaType of Object.values(content)) { + const schema = mediaType.schema; + schemaTypes.push(schemaTypeSummary(schema)); + collectSchemaRefs(schema, refs); + } + + return { + required: Boolean(requestBodyObj.required), + contentTypes, + schemaTypes, + schemaRefs: [...refs].sort(), + raw: requestBodyObj, + }; +} + +function normalizeResponses(responses: Record) { + return Object.entries(responses) + .sort((a, b) => Number(a[0]) - Number(b[0])) + .map(([statusCode, response]) => { + const responseObj = response as Record; + const content = (responseObj.content ?? {}) as Record< + string, + Record + >; + const contentTypes = Object.keys(content); + const refs = new Set(); + const schemaTypes: string[] = []; + + for (const mediaType of Object.values(content)) { + const schema = mediaType.schema; + schemaTypes.push(schemaTypeSummary(schema)); + collectSchemaRefs(schema, refs); + } + + return { + status: Number(statusCode), + description: + typeof responseObj.description === 'string' + ? responseObj.description + : '', + contentTypes, + schemaTypes, + schemaRefs: [...refs].sort(), + hasSchema: contentTypes.length > 0, + raw: responseObj, + }; + }); +} + +async function run() { + const projectRoot = process.cwd(); + const outputDir = path.join(projectRoot, 'mds'); + const outputFile = path.join( + outputDir, + 'backend_endpoints_swagger_summary.json', + ); + + // Predictions module is conditionally loaded with REDIS_ENABLED in AppModule. + // Force-enable here to include all backend endpoints in one Swagger export. + process.env.REDIS_ENABLED = 'true'; + + const tsMetadata = collectTsEndpointMetadata(projectRoot); + + const app = await NestFactory.create(AppModule, { logger: false }); + app.setGlobalPrefix('api'); + + const swaggerConfig = new DocumentBuilder() + .setTitle('Suggest Bet Backend API') + .setDescription('Auto-generated endpoint summary from Swagger document') + .setVersion('1.0') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, swaggerConfig); + const paths = document.paths ?? {}; + + const endpoints: Array> = []; + const seenOperationIds = new Set(); + const globalPrefix = 'api'; + + const sortedPaths = Object.keys(paths).sort((a, b) => a.localeCompare(b)); + for (const endpointPath of sortedPaths) { + const pathItem = paths[endpointPath] as Record; + + const methods = Object.keys(pathItem) + .filter((method) => + ['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].includes( + method, + ), + ) + .sort((a, b) => a.localeCompare(b)); + + for (const method of methods) { + const operation = pathItem[method] as Record; + const operationId = + typeof operation.operationId === 'string' ? operation.operationId : ''; + + if (operationId) { + seenOperationIds.add(operationId); + } + + const tsMeta = operationId ? tsMetadata.get(operationId) : undefined; + const tags = Array.isArray(operation.tags) + ? operation.tags.map((tag) => String(tag)) + : []; + + const parameters = normalizeParameters( + Array.isArray(operation.parameters) ? operation.parameters : [], + ); + const requestBody = normalizeRequestBody(operation.requestBody); + const responses = normalizeResponses( + (operation.responses ?? {}) as Record, + ); + const security = Array.isArray(operation.security) + ? operation.security + : []; + const securitySchemes = security.flatMap((rule) => + Object.keys((rule ?? {}) as Record), + ); + + const tsBodyParams = + tsMeta?.params + .filter((param) => + param.decorators.some((decorator) => decorator.name === 'Body'), + ) + .map((param) => ({ + name: param.name, + type: param.type, + bodyKey: + param.decorators.find((decorator) => decorator.name === 'Body') + ?.firstArg ?? null, + })) ?? []; + + endpoints.push({ + inSwagger: true, + operationId, + method: method.toUpperCase(), + path: endpointPath, + tag: tags[0] ?? null, + tags, + summary: + typeof operation.summary === 'string' ? operation.summary : null, + description: + typeof operation.description === 'string' + ? operation.description + : null, + auth: { + swaggerSecurityRequired: security.length > 0, + swaggerSecuritySchemes: [...new Set(securitySchemes)].sort(), + hasPublicDecorator: tsMeta?.hasPublicDecorator ?? false, + }, + request: { + parameters, + body: requestBody, + tsBodyParams, + }, + response: { + tsReturnType: tsMeta?.returnType ?? null, + statuses: responses, + }, + source: tsMeta + ? { + controller: tsMeta.controller, + methodName: tsMeta.methodName, + filePath: path.relative(projectRoot, tsMeta.filePath), + } + : null, + }); + } + } + + // Add controller methods that are not present in Swagger document. + for (const [operationId, tsMeta] of tsMetadata.entries()) { + if (seenOperationIds.has(operationId)) { + continue; + } + + endpoints.push({ + inSwagger: false, + operationId, + method: tsMeta.httpMethod.toUpperCase(), + path: buildSwaggerPath( + globalPrefix, + tsMeta.controllerRoute, + tsMeta.routePath, + ), + tag: tsMeta.controller.replace(/Controller$/, ''), + tags: [tsMeta.controller.replace(/Controller$/, '')], + summary: null, + description: 'Not present in generated Swagger document', + auth: { + swaggerSecurityRequired: null, + swaggerSecuritySchemes: [], + hasPublicDecorator: tsMeta.hasPublicDecorator, + }, + request: { + parameters: { + path: [], + query: [], + header: [], + cookie: [], + }, + body: null, + tsBodyParams: tsMeta.params + .filter((param) => + param.decorators.some((decorator) => decorator.name === 'Body'), + ) + .map((param) => ({ + name: param.name, + type: param.type, + bodyKey: + param.decorators.find((decorator) => decorator.name === 'Body') + ?.firstArg ?? null, + })), + }, + response: { + tsReturnType: tsMeta.returnType, + statuses: [], + }, + source: { + controller: tsMeta.controller, + methodName: tsMeta.methodName, + filePath: path.relative(projectRoot, tsMeta.filePath), + }, + }); + } + + endpoints.sort((a, b) => { + const pathA = typeof a.path === 'string' ? a.path : ''; + const pathB = typeof b.path === 'string' ? b.path : ''; + if (pathA !== pathB) { + return pathA.localeCompare(pathB); + } + return (typeof a.method === 'string' ? a.method : '').localeCompare( + typeof b.method === 'string' ? b.method : '', + ); + }); + + const tagStats = new Map(); + for (const endpoint of endpoints) { + const tag = typeof endpoint.tag === 'string' ? endpoint.tag : 'Unknown'; + tagStats.set(tag, (tagStats.get(tag) ?? 0) + 1); + } + + const referencedSchemas = new Set(); + for (const endpoint of endpoints) { + const requestBody = (endpoint.request as Record) + .body as Record | null; + if (requestBody && Array.isArray(requestBody.schemaRefs)) { + for (const schemaName of requestBody.schemaRefs) { + if (typeof schemaName === 'string') { + referencedSchemas.add(schemaName); + } + } + } + + const statuses = (endpoint.response as Record) + .statuses as Array>; + for (const status of statuses ?? []) { + if (!Array.isArray(status.schemaRefs)) { + continue; + } + for (const schemaName of status.schemaRefs) { + if (typeof schemaName === 'string') { + referencedSchemas.add(schemaName); + } + } + } + } + + const allSchemas = (document.components?.schemas ?? {}) as Record< + string, + unknown + >; + const schemaSnapshots: Record = {}; + for (const schemaName of [...referencedSchemas].sort((a, b) => + a.localeCompare(b), + )) { + if (allSchemas[schemaName]) { + schemaSnapshots[schemaName] = allSchemas[schemaName]; + } + } + + const summary = { + generatedAt: new Date().toISOString(), + generatedBy: 'src/scripts/export-swagger-endpoints-summary.ts', + project: 'Suggest-Bet-BE', + swagger: { + docsPath: '/api/docs', + globalPrefix: '/api', + endpointCountInSwagger: endpoints.filter((item) => item.inSwagger).length, + endpointCountTotal: endpoints.length, + warnings: [ + 'Swagger output reflects loaded modules for current environment.', + 'This export forces REDIS_ENABLED=true to include conditional Prediction endpoints.', + ], + }, + stats: { + byTag: [...tagStats.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([tag, count]) => ({ tag, count })), + endpointsWithoutSummary: endpoints + .filter((endpoint) => !endpoint.summary) + .map((endpoint) => ({ + operationId: endpoint.operationId, + method: endpoint.method, + path: endpoint.path, + })), + endpointsWithoutResponseSchema: endpoints + .filter((endpoint) => + ( + (endpoint.response as Record).statuses as Array< + Record + > + ).some((status) => status.hasSchema === false), + ) + .map((endpoint) => ({ + operationId: endpoint.operationId, + method: endpoint.method, + path: endpoint.path, + })), + }, + endpoints, + referencedSchemas: schemaSnapshots, + }; + + mkdirSync(outputDir, { recursive: true }); + writeFileSync(outputFile, JSON.stringify(summary, null, 2), 'utf8'); + + await app.close(); + + console.log(`✅ Swagger endpoint summary exported: ${outputFile}`); + + console.log( + ` Endpoints in swagger: ${summary.swagger.endpointCountInSwagger}, total (with TS scan): ${summary.swagger.endpointCountTotal}`, + ); +} + +void run().catch((error: unknown) => { + console.error('❌ Failed to export Swagger endpoint summary'); + + console.error(error); + process.exit(1); +}); diff --git a/src/scripts/populate-feature-store.ts b/src/scripts/populate-feature-store.ts new file mode 100644 index 0000000..99b9613 --- /dev/null +++ b/src/scripts/populate-feature-store.ts @@ -0,0 +1,888 @@ +/** + * populate-feature-store.ts + * ========================= + * Batch feature computation for all finished football matches. + * Populates the match_ai_features table with 7-pillar feature vectors. + * + * Pillars computed: + * 1. ELO Ratings (from team_elo_ratings) + * 2. Form (last 5 match rolling averages) + * 3. Odds Implied Probability (from odd_selections) + * 4. Team Stats (possession, shots, corners) + * 5. Head-to-Head (historical matchups) + * 6. Referee (bias, cards, goals) + * 7. League DNA (averages across league-season) + * + * Usage: npx ts-node --transpile-only -r tsconfig-paths/register src/scripts/populate-feature-store.ts + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// ───────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────── + +interface MatchRow { + id: string; + homeTeamId: string; + awayTeamId: string; + leagueId: string | null; + scoreHome: number; + scoreAway: number; + mstUtc: bigint; +} + +interface FeatureVector { + matchId: string; + // ELO + homeElo: number; + awayElo: number; + homeHomeElo: number; + awayAwayElo: number; + homeFormElo: number; + awayFormElo: number; + eloDiff: number; + // Form + homeFormScore: number; + awayFormScore: number; + homeGoalsAvg5: number; + awayGoalsAvg5: number; + homeConcededAvg5: number; + awayConcededAvg5: number; + homeCleanSheetRate: number; + awayCleanSheetRate: number; + homeScoringRate: number; + awayScoringRate: number; + homeWinStreak: number; + awayWinStreak: number; + // Odds + impliedHome: number; + impliedDraw: number; + impliedAway: number; + impliedOver25: number; + impliedBttsYes: number; + oddsOverround: number; + // Team Stats + homeAvgPossession: number; + awayAvgPossession: number; + homeAvgShotsOnTarget: number; + awayAvgShotsOnTarget: number; + homeShotConversion: number; + awayShotConversion: number; + homeAvgCorners: number; + awayAvgCorners: number; + // H2H + h2hTotal: number; + h2hHomeWinRate: number; + h2hAvgGoals: number; + h2hOver25Rate: number; + h2hBttsRate: number; + // Referee + refereeAvgCards: number; + refereeHomeBias: number; + refereeAvgGoals: number; + // League DNA + leagueAvgGoals: number; + leagueHomeWinPct: number; + leagueOver25Pct: number; + // Meta + missingPlayersImpact: number; + calculatorVer: string; +} + +// ───────────────────────────────────────────────────────────── +// Pillar 1: ELO Ratings +// ───────────────────────────────────────────────────────────── + +async function loadEloMap(): Promise< + Map +> { + const ratings = await prisma.teamEloRating.findMany(); + const map = new Map< + string, + { overall: number; home: number; away: number; form: number } + >(); + for (const r of ratings) { + map.set(r.teamId, { + overall: r.overallElo, + home: r.homeElo, + away: r.awayElo, + form: r.formElo, + }); + } + console.log(` ✅ ELO map loaded: ${map.size} teams`); + return map; +} + +// ───────────────────────────────────────────────────────────── +// Pillar 2: Form — Pre-compute per-team rolling stats +// ───────────────────────────────────────────────────────────── + +interface TeamFormState { + goalsScored: number[]; + goalsConceded: number[]; + results: string[]; // W/D/L + matchCount: number; +} + +function buildFormIndex( + matches: MatchRow[], +): Map> { + // matchId -> teamId -> form state AT THAT POINT (before match) + const teamStates = new Map(); + const formIndex = new Map>(); + + function getOrCreateState(teamId: string): TeamFormState { + let state = teamStates.get(teamId); + if (!state) { + state = { + goalsScored: [], + goalsConceded: [], + results: [], + matchCount: 0, + }; + teamStates.set(teamId, state); + } + return state; + } + + function cloneState(state: TeamFormState): TeamFormState { + return { + goalsScored: [...state.goalsScored], + goalsConceded: [...state.goalsConceded], + results: [...state.results], + matchCount: state.matchCount, + }; + } + + for (const match of matches) { + const homeState = getOrCreateState(match.homeTeamId); + const awayState = getOrCreateState(match.awayTeamId); + + // Store form state BEFORE this match + const matchFormMap = new Map(); + matchFormMap.set(match.homeTeamId, cloneState(homeState)); + matchFormMap.set(match.awayTeamId, cloneState(awayState)); + formIndex.set(match.id, matchFormMap); + + // Update states AFTER this match + homeState.goalsScored.unshift(match.scoreHome); + homeState.goalsConceded.unshift(match.scoreAway); + awayState.goalsScored.unshift(match.scoreAway); + awayState.goalsConceded.unshift(match.scoreHome); + + if (homeState.goalsScored.length > 10) homeState.goalsScored.pop(); + if (homeState.goalsConceded.length > 10) homeState.goalsConceded.pop(); + if (awayState.goalsScored.length > 10) awayState.goalsScored.pop(); + if (awayState.goalsConceded.length > 10) awayState.goalsConceded.pop(); + + const homeResult = + match.scoreHome > match.scoreAway + ? 'W' + : match.scoreHome < match.scoreAway + ? 'L' + : 'D'; + const awayResult = + match.scoreAway > match.scoreHome + ? 'W' + : match.scoreAway < match.scoreHome + ? 'L' + : 'D'; + + homeState.results.unshift(homeResult); + awayState.results.unshift(awayResult); + if (homeState.results.length > 10) homeState.results.pop(); + if (awayState.results.length > 10) awayState.results.pop(); + + homeState.matchCount++; + awayState.matchCount++; + } + + return formIndex; +} + +function extractFormFeatures(formState: TeamFormState): { + goalsAvg5: number; + concededAvg5: number; + cleanSheetRate: number; + scoringRate: number; + winStreak: number; + formScore: number; +} { + const last5Goals = formState.goalsScored.slice(0, 5); + const last5Conceded = formState.goalsConceded.slice(0, 5); + const n = last5Goals.length || 1; + + const goalsAvg5 = last5Goals.reduce((a, b) => a + b, 0) / n; + const concededAvg5 = last5Conceded.reduce((a, b) => a + b, 0) / n; + const cleanSheetRate = last5Conceded.filter((g) => g === 0).length / n; + const scoringRate = last5Goals.filter((g) => g > 0).length / n; + + let winStreak = 0; + for (const r of formState.results) { + if (r === 'W') winStreak++; + else break; + } + + // Form score: (W=3, D=1, L=0) over last 5, normalized to 0-100 + const last5Results = formState.results.slice(0, 5); + const points = last5Results.reduce( + (sum, r) => sum + (r === 'W' ? 3 : r === 'D' ? 1 : 0), + 0, + ); + const maxPoints = last5Results.length * 3 || 1; + const formScore = (points / maxPoints) * 100; + + return { + goalsAvg5, + concededAvg5, + cleanSheetRate, + scoringRate, + winStreak, + formScore, + }; +} + +// ───────────────────────────────────────────────────────────── +// Pillar 3: Odds Implied Probability (batch) +// ───────────────────────────────────────────────────────────── + +interface OddsData { + impliedHome: number; + impliedDraw: number; + impliedAway: number; + impliedOver25: number; + impliedBttsYes: number; + overround: number; +} + +async function loadOddsIndex(): Promise> { + const oddsIndex = new Map(); + + // Raw SQL: join odd_categories + odd_selections for MS and OU25 + const rows = await prisma.$queryRaw< + Array<{ + match_id: string; + cat_name: string; + sel_name: string; + odds: number; + }> + >` + SELECT oc.match_id, oc.name AS cat_name, os.name AS sel_name, + COALESCE(os.odd_value::numeric, 0)::float AS odds + FROM odd_categories oc + JOIN odd_selections os ON os.odd_category_db_id = oc.db_id + WHERE oc.name IN ('Maç Sonucu', 'Alt/Üst 2,5', 'Karşılıklı Gol') + AND os.odd_value IS NOT NULL + AND os.odd_value ~ '^[0-9]' + ORDER BY oc.match_id + `; + + // Group by match_id + const byMatch = new Map< + string, + Array<{ cat: string; sel: string; odds: number }> + >(); + for (const row of rows) { + let arr = byMatch.get(row.match_id); + if (!arr) { + arr = []; + byMatch.set(row.match_id, arr); + } + arr.push({ cat: row.cat_name, sel: row.sel_name, odds: row.odds }); + } + + for (const [matchId, selections] of byMatch.entries()) { + let msH = 0, + msD = 0, + msA = 0; + let ou25O = 0; + let bttsY = 0; + + for (const s of selections) { + if (s.cat === 'Maç Sonucu') { + if (s.sel === '1') msH = s.odds; + else if (s.sel === 'X' || s.sel === '0') msD = s.odds; + else if (s.sel === '2') msA = s.odds; + } else if (s.cat === 'Alt/Üst 2,5') { + if ( + s.sel.toLowerCase().includes('üst') || + s.sel.toLowerCase().includes('over') + ) + ou25O = s.odds; + } else if (s.cat === 'Karşılıklı Gol') { + if ( + s.sel.toLowerCase().includes('var') || + s.sel.toLowerCase().includes('yes') + ) + bttsY = s.odds; + } + } + + const impliedHome = msH > 0 ? 1 / msH : 0.33; + const impliedDraw = msD > 0 ? 1 / msD : 0.33; + const impliedAway = msA > 0 ? 1 / msA : 0.33; + const totalImplied = impliedHome + impliedDraw + impliedAway; + const overround = totalImplied > 0 ? (totalImplied - 1) * 100 : 0; + + const impliedOver25 = ou25O > 0 ? 1 / ou25O : 0.5; + const impliedBttsYes = bttsY > 0 ? 1 / bttsY : 0.5; + + oddsIndex.set(matchId, { + impliedHome: totalImplied > 0 ? impliedHome / totalImplied : 0.33, + impliedDraw: totalImplied > 0 ? impliedDraw / totalImplied : 0.33, + impliedAway: totalImplied > 0 ? impliedAway / totalImplied : 0.33, + impliedOver25: Math.min(impliedOver25, 1), + impliedBttsYes: Math.min(impliedBttsYes, 1), + overround, + }); + } + + console.log( + ` ✅ Odds index loaded: ${oddsIndex.size} matches with odds data`, + ); + return oddsIndex; +} + +// ───────────────────────────────────────────────────────────── +// Pillar 5: Head-to-Head (batch precompute) +// ───────────────────────────────────────────────────────────── + +interface H2HState { + totalMatches: number; + homeWins: number; + totalGoals: number; + over25Count: number; + bttsCount: number; +} + +function buildH2HIndex(matches: MatchRow[]): Map { + // Key: "teamA_teamB" sorted alphabetically + const h2hMap = new Map(); + // matchId -> h2h state BEFORE that match + const h2hIndex = new Map(); + + function getKey(t1: string, t2: string): string { + return t1 < t2 ? `${t1}_${t2}` : `${t2}_${t1}`; + } + + for (const match of matches) { + const key = getKey(match.homeTeamId, match.awayTeamId); + let state = h2hMap.get(key); + if (!state) { + state = { + totalMatches: 0, + homeWins: 0, + totalGoals: 0, + over25Count: 0, + bttsCount: 0, + }; + h2hMap.set(key, state); + } + + // Save state BEFORE this match + h2hIndex.set(match.id, { ...state }); + + // Update AFTER + state.totalMatches++; + if (match.scoreHome > match.scoreAway) state.homeWins++; + state.totalGoals += match.scoreHome + match.scoreAway; + if (match.scoreHome + match.scoreAway > 2.5) state.over25Count++; + if (match.scoreHome > 0 && match.scoreAway > 0) state.bttsCount++; + } + + return h2hIndex; +} + +// ───────────────────────────────────────────────────────────── +// Pillar 7: League DNA (batch precompute) +// ───────────────────────────────────────────────────────────── + +interface LeagueStats { + totalMatches: number; + totalGoals: number; + homeWins: number; + over25Count: number; +} + +function buildLeagueIndex(matches: MatchRow[]): Map { + const leagueMap = new Map(); + + for (const match of matches) { + const key = match.leagueId ?? 'unknown'; + let stats = leagueMap.get(key); + if (!stats) { + stats = { totalMatches: 0, totalGoals: 0, homeWins: 0, over25Count: 0 }; + leagueMap.set(key, stats); + } + stats.totalMatches++; + stats.totalGoals += match.scoreHome + match.scoreAway; + if (match.scoreHome > match.scoreAway) stats.homeWins++; + if (match.scoreHome + match.scoreAway > 2.5) stats.over25Count++; + } + + return leagueMap; +} + +// ───────────────────────────────────────────────────────────── +// Pillar 6: Referee — Load from match_officials +// ───────────────────────────────────────────────────────────── + +interface RefereeStats { + totalMatches: number; + totalCards: number; + totalGoals: number; + homeWins: number; +} + +async function loadRefereeIndex( + matches: MatchRow[], +): Promise> { + // Build match scores lookup + const matchScores = new Map(); + for (const m of matches) { + matchScores.set(m.id, { home: m.scoreHome, away: m.scoreAway }); + } + + // Load officials + const officials = await prisma.$queryRaw< + Array<{ match_id: string; official_name: string }> + >` + SELECT match_id, name AS official_name FROM match_officials + WHERE role_id = 1 + LIMIT 500000 + `; + + const refereeIndex = new Map(); + + // Group by referee + const refMatchMap = new Map(); + for (const o of officials) { + let arr = refMatchMap.get(o.official_name); + if (!arr) { + arr = []; + refMatchMap.set(o.official_name, arr); + } + arr.push(o.match_id); + } + + // matchId -> referee name + const matchRefereeMap = new Map(); + for (const o of officials) { + matchRefereeMap.set(o.match_id, o.official_name); + } + + // Calculate referee stats + for (const [refName, matchIds] of refMatchMap.entries()) { + let totalGoals = 0; + let homeWins = 0; + let totalMatches = 0; + + for (const mid of matchIds) { + const score = matchScores.get(mid); + if (score) { + totalGoals += score.home + score.away; + if (score.home > score.away) homeWins++; + totalMatches++; + } + } + + refereeIndex.set(refName, { + totalMatches, + totalCards: 0, // Would require card stats table — use 0 as default + totalGoals, + homeWins, + }); + } + + // Build matchId -> refStats lookup + const matchRefStats = new Map(); + for (const [matchId, refName] of matchRefereeMap.entries()) { + const stats = refereeIndex.get(refName); + if (stats) { + matchRefStats.set(matchId, stats); + } + } + + console.log( + ` ✅ Referee index loaded: ${refereeIndex.size} referees, ${matchRefStats.size} match-referee mappings`, + ); + return matchRefStats; +} + +// ───────────────────────────────────────────────────────────── +// Main Feature Store Population +// ───────────────────────────────────────────────────────────── + +async function populateFeatureStore(): Promise { + const startTime = Date.now(); + + try { + console.log('🧠 Feature Store Population — Starting...'); + console.log('─'.repeat(60)); + + // Load all finished football matches + console.log('📥 Loading matches...'); + const rawMatches = await prisma.match.findMany({ + where: { + sport: 'football', + status: 'FT', + scoreHome: { not: null }, + scoreAway: { not: null }, + homeTeamId: { not: null }, + awayTeamId: { not: null }, + }, + select: { + id: true, + homeTeamId: true, + awayTeamId: true, + leagueId: true, + scoreHome: true, + scoreAway: true, + mstUtc: true, + }, + orderBy: { mstUtc: 'asc' }, + }); + + const matches: MatchRow[] = rawMatches.map((m) => ({ + id: m.id, + homeTeamId: m.homeTeamId!, + awayTeamId: m.awayTeamId!, + leagueId: m.leagueId, + scoreHome: m.scoreHome!, + scoreAway: m.scoreAway!, + mstUtc: m.mstUtc, + })); + + console.log(` 📊 Matches loaded: ${matches.length.toLocaleString()}`); + + // Pre-compute all indexes + console.log('\n📊 Building feature indexes...'); + + console.log(' 🏅 Pillar 1: Loading ELO ratings...'); + const eloMap = await loadEloMap(); + + console.log(' 📈 Pillar 2: Building form index...'); + const formIndex = buildFormIndex(matches); + + console.log(' 💰 Pillar 3: Loading odds data...'); + const oddsIndex = await loadOddsIndex(); + + console.log(' ⚔️ Pillar 5: Building H2H index...'); + const h2hIndex = buildH2HIndex(matches); + + console.log(' 📋 Pillar 6: Loading referee data...'); + const refereeIndex = await loadRefereeIndex(matches); + + console.log(' 🏟️ Pillar 7: Building league DNA...'); + const leagueIndex = buildLeagueIndex(matches); + + console.log('\n✅ All indexes built!'); + console.log('─'.repeat(60)); + + // Build feature vectors and batch upsert + console.log('💾 Writing features to database...'); + + const BATCH_SIZE = 1000; + let processed = 0; + const skipped = 0; + + for (let i = 0; i < matches.length; i += BATCH_SIZE) { + const batch = matches.slice(i, i + BATCH_SIZE); + const featureVectors: FeatureVector[] = []; + + for (const match of batch) { + // Pillar 1: ELO + const homeEloData = eloMap.get(match.homeTeamId); + const awayEloData = eloMap.get(match.awayTeamId); + + const homeElo = homeEloData?.overall ?? 1500; + const awayElo = awayEloData?.overall ?? 1500; + + // Pillar 2: Form + const matchForm = formIndex.get(match.id); + const homeFormState = matchForm?.get(match.homeTeamId); + const awayFormState = matchForm?.get(match.awayTeamId); + + const homeForm = homeFormState + ? extractFormFeatures(homeFormState) + : { + goalsAvg5: 0, + concededAvg5: 0, + cleanSheetRate: 0, + scoringRate: 0, + winStreak: 0, + formScore: 50, + }; + const awayForm = awayFormState + ? extractFormFeatures(awayFormState) + : { + goalsAvg5: 0, + concededAvg5: 0, + cleanSheetRate: 0, + scoringRate: 0, + winStreak: 0, + formScore: 50, + }; + + // Pillar 3: Odds + const odds = oddsIndex.get(match.id) ?? { + impliedHome: 0.33, + impliedDraw: 0.33, + impliedAway: 0.33, + impliedOver25: 0.5, + impliedBttsYes: 0.5, + overround: 0, + }; + + // Pillar 5: H2H + const h2h = h2hIndex.get(match.id) ?? { + totalMatches: 0, + homeWins: 0, + totalGoals: 0, + over25Count: 0, + bttsCount: 0, + }; + + // Pillar 6: Referee + const refStats = refereeIndex.get(match.id); + const refTotal = refStats?.totalMatches ?? 0; + + // Pillar 7: League DNA + const leagueKey = match.leagueId ?? 'unknown'; + const leagueStats = leagueIndex.get(leagueKey) ?? { + totalMatches: 1, + totalGoals: 0, + homeWins: 0, + over25Count: 0, + }; + + featureVectors.push({ + matchId: match.id, + // ELO + homeElo, + awayElo, + homeHomeElo: homeEloData?.home ?? 1500, + awayAwayElo: awayEloData?.away ?? 1500, + homeFormElo: homeEloData?.form ?? 1500, + awayFormElo: awayEloData?.form ?? 1500, + eloDiff: homeElo - awayElo, + // Form + homeFormScore: homeForm.formScore, + awayFormScore: awayForm.formScore, + homeGoalsAvg5: round(homeForm.goalsAvg5), + awayGoalsAvg5: round(awayForm.goalsAvg5), + homeConcededAvg5: round(homeForm.concededAvg5), + awayConcededAvg5: round(awayForm.concededAvg5), + homeCleanSheetRate: round(homeForm.cleanSheetRate), + awayCleanSheetRate: round(awayForm.cleanSheetRate), + homeScoringRate: round(homeForm.scoringRate), + awayScoringRate: round(awayForm.scoringRate), + homeWinStreak: homeForm.winStreak, + awayWinStreak: awayForm.winStreak, + // Odds + impliedHome: round(odds.impliedHome), + impliedDraw: round(odds.impliedDraw), + impliedAway: round(odds.impliedAway), + impliedOver25: round(odds.impliedOver25), + impliedBttsYes: round(odds.impliedBttsYes), + oddsOverround: round(odds.overround), + // Team Stats (placeholder — uses form data as proxy) + homeAvgPossession: 50.0, + awayAvgPossession: 50.0, + homeAvgShotsOnTarget: round(homeForm.goalsAvg5 * 2.5), // Approx proxy + awayAvgShotsOnTarget: round(awayForm.goalsAvg5 * 2.5), + homeShotConversion: + homeForm.goalsAvg5 > 0 ? round(homeForm.scoringRate) : 0, + awayShotConversion: + awayForm.goalsAvg5 > 0 ? round(awayForm.scoringRate) : 0, + homeAvgCorners: 5.0, // Default — no corner data in match table + awayAvgCorners: 4.5, + // H2H + h2hTotal: h2h.totalMatches, + h2hHomeWinRate: + h2h.totalMatches > 0 ? round(h2h.homeWins / h2h.totalMatches) : 0, + h2hAvgGoals: + h2h.totalMatches > 0 ? round(h2h.totalGoals / h2h.totalMatches) : 0, + h2hOver25Rate: + h2h.totalMatches > 0 + ? round(h2h.over25Count / h2h.totalMatches) + : 0, + h2hBttsRate: + h2h.totalMatches > 0 ? round(h2h.bttsCount / h2h.totalMatches) : 0, + // Referee + refereeAvgCards: 0, // No card data column available + refereeHomeBias: + refTotal > 0 ? round(refStats!.homeWins / refTotal - 0.46) : 0, // 0.46 = expected home win rate + refereeAvgGoals: + refTotal > 0 ? round(refStats!.totalGoals / refTotal) : 0, + // League DNA + leagueAvgGoals: round( + leagueStats.totalGoals / leagueStats.totalMatches, + ), + leagueHomeWinPct: round( + leagueStats.homeWins / leagueStats.totalMatches, + ), + leagueOver25Pct: round( + leagueStats.over25Count / leagueStats.totalMatches, + ), + // Meta + missingPlayersImpact: 0, + calculatorVer: 'v2.0', + }); + } + + // Batch upsert using raw SQL for performance + if (featureVectors.length > 0) { + await batchUpsertFeatures(featureVectors); + processed += featureVectors.length; + } + + if ((i + BATCH_SIZE) % 10000 === 0 || i + BATCH_SIZE >= matches.length) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log( + ` 💾 Processed ${Math.min(i + BATCH_SIZE, matches.length).toLocaleString()} / ${matches.length.toLocaleString()} (${elapsed}s)`, + ); + } + } + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log('─'.repeat(60)); + console.log(`✅ Feature Store population complete!`); + console.log(` Features written: ${processed.toLocaleString()}`); + console.log(` Skipped: ${skipped}`); + console.log(` Duration: ${elapsed}s`); + + // Verify + const count = await prisma.footballAiFeature.count(); + console.log(` DB row count: ${count.toLocaleString()}`); + console.log('─'.repeat(60)); + } catch (error) { + console.error('❌ Feature store population failed:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +function round(val: number, decimals = 4): number { + const factor = Math.pow(10, decimals); + return Math.round(val * factor) / factor; +} + +async function batchUpsertFeatures(features: FeatureVector[]): Promise { + // Use Prisma transactions for batch upsert + await prisma.$transaction( + features.map((f) => + prisma.footballAiFeature.upsert({ + where: { matchId: f.matchId }, + update: { + homeElo: f.homeElo, + awayElo: f.awayElo, + homeHomeElo: f.homeHomeElo, + awayAwayElo: f.awayAwayElo, + homeFormElo: f.homeFormElo, + awayFormElo: f.awayFormElo, + eloDiff: f.eloDiff, + homeFormScore: f.homeFormScore, + awayFormScore: f.awayFormScore, + homeGoalsAvg5: f.homeGoalsAvg5, + awayGoalsAvg5: f.awayGoalsAvg5, + homeConcededAvg5: f.homeConcededAvg5, + awayConcededAvg5: f.awayConcededAvg5, + homeCleanSheetRate: f.homeCleanSheetRate, + awayCleanSheetRate: f.awayCleanSheetRate, + homeScoringRate: f.homeScoringRate, + awayScoringRate: f.awayScoringRate, + homeWinStreak: f.homeWinStreak, + awayWinStreak: f.awayWinStreak, + impliedHome: f.impliedHome, + impliedDraw: f.impliedDraw, + impliedAway: f.impliedAway, + impliedOver25: f.impliedOver25, + impliedBttsYes: f.impliedBttsYes, + oddsOverround: f.oddsOverround, + homeAvgPossession: f.homeAvgPossession, + awayAvgPossession: f.awayAvgPossession, + homeAvgShotsOnTarget: f.homeAvgShotsOnTarget, + awayAvgShotsOnTarget: f.awayAvgShotsOnTarget, + homeShotConversion: f.homeShotConversion, + awayShotConversion: f.awayShotConversion, + homeAvgCorners: f.homeAvgCorners, + awayAvgCorners: f.awayAvgCorners, + h2hTotal: f.h2hTotal, + h2hHomeWinRate: f.h2hHomeWinRate, + h2hAvgGoals: f.h2hAvgGoals, + h2hOver25Rate: f.h2hOver25Rate, + h2hBttsRate: f.h2hBttsRate, + refereeAvgCards: f.refereeAvgCards, + refereeHomeBias: f.refereeHomeBias, + refereeAvgGoals: f.refereeAvgGoals, + leagueAvgGoals: f.leagueAvgGoals, + leagueHomeWinPct: f.leagueHomeWinPct, + leagueOver25Pct: f.leagueOver25Pct, + missingPlayersImpact: f.missingPlayersImpact, + calculatorVer: f.calculatorVer, + }, + create: { + matchId: f.matchId, + homeElo: f.homeElo, + awayElo: f.awayElo, + homeHomeElo: f.homeHomeElo, + awayAwayElo: f.awayAwayElo, + homeFormElo: f.homeFormElo, + awayFormElo: f.awayFormElo, + eloDiff: f.eloDiff, + homeFormScore: f.homeFormScore, + awayFormScore: f.awayFormScore, + homeGoalsAvg5: f.homeGoalsAvg5, + awayGoalsAvg5: f.awayGoalsAvg5, + homeConcededAvg5: f.homeConcededAvg5, + awayConcededAvg5: f.awayConcededAvg5, + homeCleanSheetRate: f.homeCleanSheetRate, + awayCleanSheetRate: f.awayCleanSheetRate, + homeScoringRate: f.homeScoringRate, + awayScoringRate: f.awayScoringRate, + homeWinStreak: f.homeWinStreak, + awayWinStreak: f.awayWinStreak, + impliedHome: f.impliedHome, + impliedDraw: f.impliedDraw, + impliedAway: f.impliedAway, + impliedOver25: f.impliedOver25, + impliedBttsYes: f.impliedBttsYes, + oddsOverround: f.oddsOverround, + homeAvgPossession: f.homeAvgPossession, + awayAvgPossession: f.awayAvgPossession, + homeAvgShotsOnTarget: f.homeAvgShotsOnTarget, + awayAvgShotsOnTarget: f.awayAvgShotsOnTarget, + homeShotConversion: f.homeShotConversion, + awayShotConversion: f.awayShotConversion, + homeAvgCorners: f.homeAvgCorners, + awayAvgCorners: f.awayAvgCorners, + h2hTotal: f.h2hTotal, + h2hHomeWinRate: f.h2hHomeWinRate, + h2hAvgGoals: f.h2hAvgGoals, + h2hOver25Rate: f.h2hOver25Rate, + h2hBttsRate: f.h2hBttsRate, + refereeAvgCards: f.refereeAvgCards, + refereeHomeBias: f.refereeHomeBias, + refereeAvgGoals: f.refereeAvgGoals, + leagueAvgGoals: f.leagueAvgGoals, + leagueHomeWinPct: f.leagueHomeWinPct, + leagueOver25Pct: f.leagueOver25Pct, + missingPlayersImpact: f.missingPlayersImpact, + calculatorVer: f.calculatorVer, + }, + }), + ), + ); +} + +// Run +populateFeatureStore().catch(console.error); diff --git a/src/scripts/run-all-fe-compatible.ts b/src/scripts/run-all-fe-compatible.ts new file mode 100644 index 0000000..340fbca --- /dev/null +++ b/src/scripts/run-all-fe-compatible.ts @@ -0,0 +1,5 @@ +process.env.PORT = process.env.PORT || '3005'; +process.env.AI_ENGINE_URL = process.env.AI_ENGINE_URL || 'http://127.0.0.1:8000'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +require('./run-full-stack'); diff --git a/src/scripts/run-feeder-basketball.ts b/src/scripts/run-feeder-basketball.ts new file mode 100755 index 0000000..58cfba7 --- /dev/null +++ b/src/scripts/run-feeder-basketball.ts @@ -0,0 +1,24 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { FeederService } from '../modules/feeder/feeder.service'; + +async function bootstrap() { + console.log('🏀 Bootstrapping Basketball Feeder...'); + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['log', 'error', 'warn', 'debug', 'verbose'], + }); + + const feederService = app.get(FeederService); + + // Run ONLY for basketball + // Adjust start date if needed, otherwise uses default + await feederService.runHistoricalScan(['basketball']); + + console.log('✅ Basketball Feeder finished.'); + await app.close(); +} + +bootstrap().catch((err) => { + console.error('❌ Basketball Feeder failed:', err); + process.exit(1); +}); diff --git a/src/scripts/run-feeder-filtered.ts b/src/scripts/run-feeder-filtered.ts new file mode 100755 index 0000000..a2abc39 --- /dev/null +++ b/src/scripts/run-feeder-filtered.ts @@ -0,0 +1,63 @@ +/** + * Run Targeted Historical Feeder Script + * Fetches matches only for leagues in top_leagues.json for the last ~2.5 seasons. + */ + +process.env.FEEDER_MODE = 'historical'; + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { FeederService } from '../modules/feeder/feeder.service'; +import { Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; + +async function bootstrap() { + const logger = new Logger('FeederFilteredScript'); + logger.log('🚀 Starting Targeted Historical Feeder...'); + + // Read top_leagues.json + const leaguesPath = path.join(process.cwd(), 'top_leagues.json'); + let targetLeagues: string[] = []; + try { + const data = fs.readFileSync(leaguesPath, 'utf8'); + targetLeagues = JSON.parse(data); + // Deduplicate + targetLeagues = [...new Set(targetLeagues)]; + logger.log( + `✅ Loaded ${targetLeagues.length} unique target leagues from top_leagues.json`, + ); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + logger.error(`❌ Failed to load top_leagues.json: ${message}`); + process.exit(1); + } + + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['log', 'error', 'warn'], + }); + + try { + const feederService = app.get(FeederService); + // Start from 2023-07-01 to cover 2023-2024, 2024-2025, and current 2025-2026 seasons + const START_DATE = '2023-07-01'; + logger.log(`📅 Date Range: ${START_DATE} -> Today`); + + await feederService.runHistoricalScan( + ['football'], + START_DATE, + targetLeagues, + ); + logger.log('✅ Targeted Feeder completed successfully!'); + } catch (error: any) { + logger.error(`❌ Feeder failed: ${error.message}`); + logger.error(error.stack); + process.exit(1); + } finally { + await app.close(); + } + + process.exit(0); +} + +void bootstrap(); diff --git a/src/scripts/run-feeder.ts b/src/scripts/run-feeder.ts new file mode 100755 index 0000000..a109936 --- /dev/null +++ b/src/scripts/run-feeder.ts @@ -0,0 +1,39 @@ +/** + * Run Previous-Day Completed Match Sync + * Usage: npm run feeder:historical + */ + +import { NestFactory } from '@nestjs/core'; +import { FeederService } from '../modules/feeder/feeder.service'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + process.env.FEEDER_MODE = 'historical'; + + const logger = new Logger('FeederScript'); + + logger.log('🚀 Starting previous-day completed match sync...'); + + // Load AppModule after FEEDER_MODE is set so cron imports can be disabled. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { AppModule } = require('../app.module'); + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['log', 'error', 'warn'], + }); + + try { + const feederService = app.get(FeederService); + await feederService.runPreviousDayCompletedMatchesScan(); + logger.log('✅ Previous-day completed match sync completed successfully!'); + } catch (error: any) { + logger.error(`❌ Feeder failed: ${error.message}`); + logger.error(error.stack); + process.exit(1); + } finally { + await app.close(); + } + + process.exit(0); +} + +void bootstrap(); diff --git a/src/scripts/run-full-stack.ts b/src/scripts/run-full-stack.ts new file mode 100644 index 0000000..9fbd68a --- /dev/null +++ b/src/scripts/run-full-stack.ts @@ -0,0 +1,362 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { access } from 'node:fs/promises'; +import net from 'node:net'; +import path from 'node:path'; +import process from 'node:process'; + +interface ManagedProcess { + readonly name: string; + readonly child: ChildProcess; +} + +const ROOT_DIR = process.cwd(); +const AI_ENGINE_DIR = path.join(ROOT_DIR, 'ai-engine'); +loadEnvFile(path.join(ROOT_DIR, '.env')); +const DEFAULT_AI_URL = process.env.AI_ENGINE_URL ?? 'http://127.0.0.1:8000'; +const DEFAULT_API_PORT = Number(process.env.PORT ?? '3005'); +const AI_ENGINE_PORT = resolveAiPort(DEFAULT_AI_URL); +const AI_START_TIMEOUT_MS = 120_000; +const NEST_START_TIMEOUT_MS = 90_000; +const HEALTH_POLL_INTERVAL_MS = 1_000; + +let nestProcess: ManagedProcess | null = null; +let aiProcess: ManagedProcess | null = null; +let shuttingDown = false; + +async function main(): Promise { + ensureWindowsOrUnixShellAwareness(); + + const aiHealthUrl = `${DEFAULT_AI_URL}/health`; + const aiHost = resolveHost(DEFAULT_AI_URL); + const aiAlreadyHealthy = await isHealthy(aiHealthUrl); + + if (aiAlreadyHealthy) { + log(`AI engine already running at ${aiHealthUrl}`); + } else { + const aiPortBusy = await isPortInUse(aiHost, AI_ENGINE_PORT); + if (aiPortBusy) { + throw new Error( + `AI engine port ${AI_ENGINE_PORT} is already in use but ${aiHealthUrl} is not healthy`, + ); + } + + const pythonCommand = await resolvePythonCommand(); + aiProcess = { + name: 'ai-engine', + child: spawn( + pythonCommand.command, + [ + ...pythonCommand.args, + '-m', + 'uvicorn', + 'main:app', + '--host', + '0.0.0.0', + '--port', + String(AI_ENGINE_PORT), + ...resolveAiExtraArgs(), + ], + { + cwd: AI_ENGINE_DIR, + stdio: 'inherit', + env: { + ...process.env, + PYTHONUNBUFFERED: '1', + PORT: String(AI_ENGINE_PORT), + }, + }, + ), + }; + + attachExitHandlers(aiProcess); + + log(`Waiting for AI engine health at ${aiHealthUrl}`); + await waitForHealth(aiHealthUrl, AI_START_TIMEOUT_MS); + log('AI engine is ready'); + } + + const nestHealthUrl = `http://127.0.0.1:${DEFAULT_API_PORT}/api/health/live`; + const nestAlreadyHealthy = await isHealthy(nestHealthUrl); + + if (nestAlreadyHealthy) { + log(`NestJS already running at ${nestHealthUrl}`); + } else { + const nestPortBusy = await isPortInUse('127.0.0.1', DEFAULT_API_PORT); + if (nestPortBusy) { + throw new Error( + `NestJS port ${DEFAULT_API_PORT} is already in use but ${nestHealthUrl} is not healthy`, + ); + } + + nestProcess = { + name: 'nest', + child: spawnNestProcess(), + }; + + attachExitHandlers(nestProcess); + + log(`Waiting for NestJS health at ${nestHealthUrl}`); + await waitForHealth(nestHealthUrl, NEST_START_TIMEOUT_MS); + log('NestJS is ready'); + } + + log('Full stack is running'); +} + +function ensureWindowsOrUnixShellAwareness(): void { + process.on('SIGINT', () => { + void shutdown('SIGINT'); + }); + + process.on('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + + process.on('uncaughtException', (error: Error) => { + console.error('[full:run] Uncaught exception:', error); + void shutdown('uncaughtException', 1); + }); + + process.on('unhandledRejection', (reason: unknown) => { + console.error('[full:run] Unhandled rejection:', reason); + void shutdown('unhandledRejection', 1); + }); +} + +function resolveNestStartScript(): string { + return process.env.FULL_RUN_NEST_SCRIPT ?? 'start:dev'; +} + +function resolveAiExtraArgs(): string[] { + return process.env.FULL_RUN_AI_RELOAD === 'true' ? ['--reload'] : []; +} + +function spawnNestProcess(): ChildProcess { + const nestScript = resolveNestStartScript(); + + if (process.platform === 'win32') { + return spawn('cmd.exe', ['/d', '/s', '/c', `npm run ${nestScript}`], { + cwd: ROOT_DIR, + stdio: 'inherit', + env: process.env, + }); + } + + return spawn('npm', ['run', nestScript], { + cwd: ROOT_DIR, + stdio: 'inherit', + env: process.env, + }); +} + +async function resolvePythonCommand(): Promise<{ + command: string; + args: string[]; +}> { + const explicitPython = process.env.AI_ENGINE_PYTHON?.trim(); + if (explicitPython) { + return { command: explicitPython, args: [] }; + } + + const localVenvPython = + process.platform === 'win32' + ? path.join(AI_ENGINE_DIR, 'venv', 'Scripts', 'python.exe') + : path.join(AI_ENGINE_DIR, 'venv', 'bin', 'python'); + + if (await pathExists(localVenvPython)) { + return { command: localVenvPython, args: [] }; + } + + return { command: process.platform === 'win32' ? 'python' : 'python3', args: [] }; +} + +function resolveAiPort(aiUrl: string): number { + const parsedUrl = new URL(aiUrl); + + if (parsedUrl.port) { + return Number(parsedUrl.port); + } + + return parsedUrl.protocol === 'https:' ? 443 : 80; +} + +async function pathExists(targetPath: string): Promise { + try { + await access(targetPath); + return true; + } catch { + return false; + } +} + +function resolveHost(url: string): string { + const parsedUrl = new URL(url); + return parsedUrl.hostname; +} + +function attachExitHandlers(managedProcess: ManagedProcess): void { + managedProcess.child.on('exit', (code: number | null, signal: NodeJS.Signals | null) => { + if (shuttingDown) { + return; + } + + const detail = + signal !== null + ? `signal=${signal}` + : `code=${code ?? 'unknown'}`; + + console.error(`[full:run] ${managedProcess.name} exited unexpectedly (${detail})`); + void shutdown(`${managedProcess.name}-exit`, code ?? 1); + }); + + managedProcess.child.on('error', (error: Error) => { + if (shuttingDown) { + return; + } + + console.error(`[full:run] Failed to start ${managedProcess.name}:`, error); + void shutdown(`${managedProcess.name}-error`, 1); + }); +} + +async function waitForHealth(url: string, timeoutMs: number): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + // Service is still booting. + } + + await delay(HEALTH_POLL_INTERVAL_MS); + } + + throw new Error(`Health check timed out: ${url}`); +} + +async function isHealthy(url: string): Promise { + try { + const response = await fetch(url); + return response.ok; + } catch { + return false; + } +} + +async function isPortInUse(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host, port }); + + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + + socket.once('error', () => { + resolve(false); + }); + }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function shutdown(reason: string, exitCode = 0): Promise { + if (shuttingDown) { + return; + } + + shuttingDown = true; + log(`Shutting down stack (${reason})`); + + await stopProcess(nestProcess); + await stopProcess(aiProcess); + + process.exit(exitCode); +} + +async function stopProcess(managedProcess: ManagedProcess | null): Promise { + if (!managedProcess) { + return; + } + + const { child, name } = managedProcess; + if (child.killed || child.exitCode !== null) { + return; + } + + child.kill('SIGTERM'); + const stopped = await waitForProcessExit(child, 10_000); + if (!stopped) { + console.warn(`[full:run] ${name} did not stop gracefully, forcing termination`); + child.kill('SIGKILL'); + await waitForProcessExit(child, 5_000); + } +} + +function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + cleanup(); + resolve(false); + }, timeoutMs); + + const onExit = () => { + cleanup(); + resolve(true); + }; + + const cleanup = () => { + clearTimeout(timeout); + child.off('exit', onExit); + }; + + child.once('exit', onExit); + }); +} + +function log(message: string): void { + console.log(`[full:run] ${message}`); +} + +function loadEnvFile(envPath: string): void { + try { + const content = readFileSync(envPath, 'utf8'); + const lines = content.split(/\r?\n/u); + + lines.forEach((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + return; + } + + const separatorIndex = trimmed.indexOf('='); + if (separatorIndex === -1) { + return; + } + + const key = trimmed.slice(0, separatorIndex).trim(); + const rawValue = trimmed.slice(separatorIndex + 1).trim(); + const normalizedValue = rawValue.replace(/^['"]|['"]$/gu, ''); + + if (!process.env[key]) { + process.env[key] = normalizedValue; + } + }); + } catch { + // .env is optional for this script. + } +} + +void main().catch((error: Error) => { + console.error('[full:run] Startup failed:', error); + void shutdown('startup-failed', 1); +}); diff --git a/src/scripts/run-live-feeder.ts b/src/scripts/run-live-feeder.ts new file mode 100755 index 0000000..4da1b29 --- /dev/null +++ b/src/scripts/run-live-feeder.ts @@ -0,0 +1,43 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { Logger } from '@nestjs/common'; +import { DataFetcherTask } from '../tasks/data-fetcher.task'; + +async function bootstrap() { + const logger = new Logger('LiveFeederShell'); + console.log('🚀 Starting Manual Live Feeder Update (Console)...'); + logger.log('🚀 Starting Manual Live Feeder Update...'); + + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['log', 'error', 'warn'], + }); + + try { + const dataFetcherTask = app.get(DataFetcherTask); + + // 1. Fetch Soccer Matches (4-day full sync: today + 3 days ahead) + logger.log('⚽ Fetching soccer live matches (4-day window)...'); + await dataFetcherTask.fetchLiveMatchesFull(); + + // 2. Fetch Basketball Matches + logger.log('🏀 Fetching basketball live matches...'); + await dataFetcherTask.fetchBasketballMatches(); + + // 3. Fetch Odds for all live matches + logger.log('📊 Fetching odds for all live matches...'); + await dataFetcherTask.fetchOddsForPreMatches(); + + // 4. Fetch Lineups & Sidelined (NEW) + logger.log('👕 Fetching lineups & sidelined for active matches...'); + await dataFetcherTask.updateLineupsAndSidelined(); + + logger.log('✅ Live Feeder update completed successfully!'); + } catch (error: any) { + logger.error(`❌ Live Feeder failed: ${error.message}`); + console.error(error.stack); + } finally { + await app.close(); + } +} + +void bootstrap(); diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts new file mode 100755 index 0000000..21134f9 --- /dev/null +++ b/src/services/ai.service.ts @@ -0,0 +1,319 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; + +export interface AIPredictionResult { + matchId: string; + matchName: string; + predictions: { + betType: string; + prediction: string; + confidence: number; + probabilities?: Record; + reasoning?: string; + odd?: number; + valueBet?: { isValue: boolean; edge: number }; + }[]; + recommendedBets: string[]; + homeAnalysis?: { + teamName: string; + formText: string; + goalsAvg: number; + formRating: string; + squadStrength?: number; + }; + awayAnalysis?: { + teamName: string; + formText: string; + goalsAvg: number; + formRating: string; + squadStrength?: number; + }; + expertComment: string; + modelVersion: string; + confidenceScore: number; + expectedGoals?: number; +} + +@Injectable() +export class AiService { + private readonly logger = new Logger(AiService.name); + private readonly pythonEngineUrl: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + this.pythonEngineUrl = + this.configService.get('AI_ENGINE_URL') || 'http://127.0.0.1:8000'; + } + + /** + * Call the Python match analysis engine and map the result to stable frontend contract. + */ + async callPythonEngine( + matchDetails: any, + _odds: any[], + _lineups: { home: any[]; away: any[] }, + _substitutes: { home: any[]; away: any[] } | null, + _stats: any, + _eventData: any[], + ): Promise { + try { + const matchId = String(matchDetails?.matchId || '').trim(); + if (!matchId) { + this.logger.warn('Skipping AI call: missing matchId'); + return null; + } + + this.logger.log( + `Calling Python V25 Engine for ${matchDetails.homeTeam} vs ${matchDetails.awayTeam}`, + ); + + const response = await firstValueFrom( + this.httpService.post( + `${this.pythonEngineUrl}/v20plus/analyze/${matchId}`, + {}, + { + timeout: 30000, + }, + ), + ); + + if (response.data) { + return this.mapPythonResponse(response.data, matchDetails); + } + + return null; + } catch (error: any) { + this.logger.warn(`Python Engine error: ${error.message}`); + return null; + } + } + + /** + * Map Python response to our interface + */ + private mapPythonResponse(data: any, matchDetails: any): AIPredictionResult { + const picks = Array.isArray(data?.bet_summary) ? data.bet_summary : []; + const recommendedBets = picks + .filter((p: any) => p?.playable) + .map((p: any) => `${p.market}: ${p.pick}`); + + const mappedPredictions = picks.map((p: any) => ({ + betType: String(p.market || ''), + prediction: String(p.pick || ''), + confidence: Number(p.calibrated_confidence ?? p.confidence ?? 0), + probabilities: {}, + reasoning: Array.isArray(p.reasons) + ? p.reasons.join(' | ') + : Array.isArray(p.decision_reasons) + ? p.decision_reasons.join(' | ') + : '', + odd: typeof p.odds === 'number' ? p.odds : undefined, + valueBet: + typeof p.edge === 'number' + ? { + isValue: p.edge > 0, + edge: p.edge, + } + : undefined, + })); + + const matchInfo = data?.match_info || {}; + const confidenceScore = Number( + data?.main_pick?.calibrated_confidence ?? + data?.main_pick?.confidence ?? + 0, + ); + + return { + matchId: matchDetails.matchId || matchInfo.match_id || data.match_id, + matchName: `${matchDetails.homeTeam} vs ${matchDetails.awayTeam}`, + predictions: mappedPredictions, + recommendedBets: + data.recommended_bets && Array.isArray(data.recommended_bets) + ? data.recommended_bets + : recommendedBets, + homeAnalysis: undefined, + awayAnalysis: undefined, + expertComment: data.ai_commentary || data.expert_comment || '', + modelVersion: data.model_version || 'v25.main', + confidenceScore: + confidenceScore > 1 ? confidenceScore : confidenceScore * 100, + expectedGoals: data?.score_prediction?.xg_total, + }; + } + + /** + * Get mapped prediction response from the AI package. + */ + getPredictionForMatch(analysisData: any): any { + const pyData = analysisData.liveMatchData?.pythonEnginePrediction; + + if (!pyData || !Array.isArray(pyData.bet_summary)) { + return this.getEmptyPrediction(); + } + + const allPredictions = pyData.bet_summary.map((p: any) => ({ + betType: p.market, + prediction: p.pick, + confidence: p.calibrated_confidence ?? p.confidence ?? 0, + probabilities: {}, + reasoning: Array.isArray(p.reasons) ? p.reasons.join(' | ') : '', + odd: p.odds || 0, + valueBet: { + is_value: typeof p.edge === 'number' ? p.edge > 0 : false, + edge: p.edge || 0, + }, + })); + const firstPick = allPredictions[0]; + + return { + predictions: allPredictions, + recommendedBets: pyData.recommended_bets || [], + valueBets: allPredictions.filter((p: any) => p.valueBet?.is_value), + homeAnalysis: null, + awayAnalysis: null, + expertComment: pyData.ai_commentary || '', + winnerPrediction: firstPick?.prediction || 'N/A', + scorePrediction: pyData.score_prediction?.ft || '-', + confidenceScore: + typeof firstPick?.confidence === 'number' ? firstPick.confidence : 0, + modelVersion: pyData.model_version || 'v25.main', + expectedGoals: pyData.score_prediction?.xg_total || 0, + keyInsights: [ + `Model: ${pyData.model_version || 'v25.main'}`, + `Risk: ${pyData.risk?.level || 'N/A'} (${pyData.risk?.score ?? 0})`, + `Data Quality: ${pyData.data_quality?.label || 'N/A'}`, + `xG Beklentisi: ${ + typeof pyData.score_prediction?.xg_total === 'number' + ? pyData.score_prediction.xg_total.toFixed(2) + : 'N/A' + }`, + ], + }; + } + + /** + * Generate analysis strategy (replaces Gemini) + */ + getAnalysisStrategy(matchDNA: any): { analysisTactics: any[] } { + const tactics: any[] = []; + const odds = matchDNA?.odds || []; + + // MS 1 oranını bul + const ms1 = odds.find( + (o: any) => + o.category?.toLowerCase().includes('maç sonucu') && o.selection === '1', + ); + + // KG Var oranını bul + const kgVar = odds.find( + (o: any) => + o.category?.toLowerCase().includes('karşılıklı gol') && + o.selection?.toLowerCase() === 'var', + ); + + // Alt 2.5 oranını bul + const alt25 = odds.find( + (o: any) => + o.category?.toLowerCase().includes('alt/üst') && + o.selection?.toLowerCase() === 'alt', + ); + + // Tactic 1: Benzer MS oranları + if (ms1?.odd_value) { + tactics.push({ + tacticName: 'Benzer Maç Sonucu Oranları', + description: + 'Ev sahibi galibiyeti için benzer oran aralığındaki maçlar', + odds: [ + { + categoryName: 'Maç Sonucu', + selectionName: '1', + value: parseFloat(ms1.odd_value), + tolerance: 0.3, + }, + ], + }); + } + + // Tactic 2: Benzer KG + AU oranları + if (kgVar?.odd_value && alt25?.odd_value) { + tactics.push({ + tacticName: 'Benzer Gol Beklentisi', + description: 'Karşılıklı gol ve toplam gol benzerliği', + odds: [ + { + categoryName: 'Karşılıklı Gol', + selectionName: 'Var', + value: parseFloat(kgVar.odd_value), + tolerance: 0.4, + }, + { + categoryName: '2,5 Alt/Üst', + selectionName: 'Alt', + value: parseFloat(alt25.odd_value), + tolerance: 0.3, + }, + ], + }); + } + + // Tactic 3: Favori analizi + if (ms1?.odd_value && parseFloat(ms1.odd_value) < 1.8) { + tactics.push({ + tacticName: 'Favori Takım Analizi', + description: 'Benzer şekilde favori olan ev sahibi takımların maçları', + odds: [ + { + categoryName: 'Maç Sonucu', + selectionName: '1', + value: parseFloat(ms1.odd_value), + tolerance: 0.2, + }, + ], + }); + } + + return { analysisTactics: tactics }; + } + + /** + * Check Python engine health + */ + async checkHealth(): Promise { + try { + const response = await firstValueFrom( + this.httpService.get(`${this.pythonEngineUrl}/health`, { + timeout: 5000, + }), + ); + return response.data?.status === 'healthy'; + } catch { + return false; + } + } + + /** + * Get empty prediction fallback + */ + private getEmptyPrediction() { + return { + predictions: [], + recommendedBets: [], + valueBets: [], + homeAnalysis: null, + awayAnalysis: null, + expertComment: 'Analiz verisi alınamadı (Python Servis Hatası).', + winnerPrediction: 'N/A', + scorePrediction: '-', + confidenceScore: 0, + modelVersion: 'v25.main', + expectedGoals: 0, + keyInsights: [], + }; + } +} diff --git a/src/services/match-analysis.service.ts b/src/services/match-analysis.service.ts new file mode 100755 index 0000000..86e1201 --- /dev/null +++ b/src/services/match-analysis.service.ts @@ -0,0 +1,318 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { ScraperService, ScrapedData } from './scraper.service'; +import { AiService, AIPredictionResult } from './ai.service'; + +export interface AnalysisResult { + aiAnalysis: AIPredictionResult; + strategyUsed: { analysisTactics: any[] }; + similarMatches: any[]; + matchDetails: any; +} + +@Injectable() +export class MatchAnalysisService { + private readonly logger = new Logger(MatchAnalysisService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly scraperService: ScraperService, + private readonly aiService: AiService, + ) {} + + /** + * Main entry point for match analysis + */ + async analyzeMatch(url: string, userId?: string): Promise { + this.logger.log(`Starting analysis for: ${url}`); + + // Phase 1: Parse URL + const { matchId, matchSlug, sport } = this.parseUrl(url); + if (!matchId) { + throw new BadRequestException('Invalid match URL'); + } + + // Phase 2: Scrape data with retry + let scrapedData: ScrapedData; + const retryDelays = [3000, 5000, 7000]; + + for (let attempt = 0; attempt <= retryDelays.length; attempt++) { + try { + scrapedData = await this.scraperService.scrapeMatchData( + matchId, + matchSlug, + sport, + ); + break; + } catch (error: any) { + if (error.response?.status === 502 && attempt < retryDelays.length) { + this.logger.warn( + `Scraper returned 502. Retrying in ${retryDelays[attempt] / 1000}s...`, + ); + await new Promise((resolve) => + setTimeout(resolve, retryDelays[attempt]), + ); + continue; + } + if (attempt === retryDelays.length) { + throw new BadRequestException( + 'Maç bilgileri çekilemedi. Lütfen sonra tekrar deneyiniz.', + ); + } + throw error; + } + } + + this.logger.log('Phase 1 Complete: Data Scraped'); + + // Phase 3: Call Python AI Engine + let pythonAnalysis: AIPredictionResult | null = null; + try { + const flatOdds = this.scraperService.flattenOdds(scrapedData!.odds); + pythonAnalysis = await this.aiService.callPythonEngine( + { ...scrapedData!.matchDetails, matchId }, + flatOdds, + scrapedData!.lineups, + scrapedData!.substitutes, + scrapedData!.stats, + scrapedData!.eventData, + ); + } catch (err: any) { + this.logger.warn(`Python Engine error: ${err.message}`); + } + + this.logger.log('Phase 2 Complete: Python Engine Consulted'); + + // Phase 4: Generate strategy + const matchDNA = await this.aggregateDataForAI( + scrapedData!, + pythonAnalysis, + ); + const strategy = this.aiService.getAnalysisStrategy(matchDNA); + + this.logger.log( + `Phase 3 Complete: Strategy Formulated (${strategy.analysisTactics.length} tactics)`, + ); + + // Phase 5: Find similar matches + const similarMatches = await this.findSimilarMatches( + strategy.analysisTactics, + matchId, + sport, + ); + + this.logger.log( + `Phase 4 Complete: Found ${similarMatches.length} similar matches`, + ); + + // Phase 6: Get final AI prediction + const aiPayload = { + liveMatchData: { + matchDetails: scrapedData!.matchDetails, + odds: this.scraperService.flattenOdds(scrapedData!.odds), + stats: scrapedData!.stats, + eventData: scrapedData!.eventData, + lineups: scrapedData!.lineups, + pythonEnginePrediction: pythonAnalysis, + }, + historicalEvidence: similarMatches, + strategy, + }; + + const aiAnalysis = await this.aiService.getPredictionForMatch(aiPayload); + + this.logger.log('Phase 5 Complete: Analysis Generated'); + + // Phase 7: Save to DB if user provided + if (userId) { + await this.saveAnalysisResult(userId, matchId, { + aiAnalysis, + strategyUsed: strategy, + similarMatches, + }); + } + + return { + aiAnalysis, + strategyUsed: strategy, + similarMatches, + matchDetails: scrapedData!.matchDetails, + }; + } + + /** + * Parse Mackolik URL + */ + private parseUrl(url: string): { + matchId: string; + matchSlug: string; + sport: 'football' | 'basketball'; + } { + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/').filter((p) => p.length > 0); + let sport: 'football' | 'basketball' = 'football'; + if (pathParts.includes('basketbol')) sport = 'basketball'; + const lastPart = pathParts[pathParts.length - 1]; + const slugPart = pathParts[pathParts.length - 2]; + return { matchId: lastPart, matchSlug: slugPart, sport }; + } catch { + return { matchId: '', matchSlug: '', sport: 'football' }; + } + } + + /** + * Aggregate data for AI + */ + private async aggregateDataForAI( + scrapedData: ScrapedData, + pythonAnalysis: any, + ): Promise { + const flattenedOdds = this.scraperService.flattenOdds(scrapedData.odds); + + // Get comparison data from DB + const comparisonData = await this.getComparisonData( + scrapedData.matchDetails.homeTeamId, + scrapedData.matchDetails.awayTeamId, + ); + + return { + liveMatchData: { + matchDetails: scrapedData.matchDetails, + odds: flattenedOdds, + stats: scrapedData.stats, + eventData: scrapedData.eventData, + lineups: scrapedData.lineups, + comparisonData, + pythonEnginePrediction: pythonAnalysis, + }, + tacticalAnalysis: [], + }; + } + + /** + * Get head-to-head and form data + */ + private async getComparisonData( + homeTeamId: string, + awayTeamId: string, + ): Promise { + try { + // H2H matches + const h2h = await this.prisma.match.findMany({ + where: { + OR: [ + { homeTeamId, awayTeamId }, + { homeTeamId: awayTeamId, awayTeamId: homeTeamId }, + ], + state: 'Finished', + }, + orderBy: { mstUtc: 'desc' }, + take: 10, + select: { + id: true, + matchName: true, + scoreHome: true, + scoreAway: true, + mstUtc: true, + }, + }); + + // Home team recent form + const homeForm = await this.prisma.match.findMany({ + where: { + OR: [{ homeTeamId }, { awayTeamId: homeTeamId }], + state: 'Finished', + }, + orderBy: { mstUtc: 'desc' }, + take: 5, + select: { + id: true, + matchName: true, + scoreHome: true, + scoreAway: true, + homeTeamId: true, + }, + }); + + // Away team recent form + const awayForm = await this.prisma.match.findMany({ + where: { + OR: [{ homeTeamId: awayTeamId }, { awayTeamId }], + state: 'Finished', + }, + orderBy: { mstUtc: 'desc' }, + take: 5, + select: { + id: true, + matchName: true, + scoreHome: true, + scoreAway: true, + homeTeamId: true, + }, + }); + + return { h2h, homeForm, awayForm }; + } catch { + return { h2h: [], homeForm: [], awayForm: [] }; + } + } + + /** + * Find similar matches based on tactics + */ + private async findSimilarMatches( + tactics: any[], + currentMatchId: string, + sport: string, + ): Promise { + if (!tactics.length) return []; + + try { + // Get finished matches with similar odds + const matches = await this.prisma.match.findMany({ + where: { + sport: sport as any, + id: { not: currentMatchId }, + scoreHome: { not: null }, + }, + take: 50, + orderBy: { mstUtc: 'desc' }, + select: { + id: true, + matchName: true, + scoreHome: true, + scoreAway: true, + mstUtc: true, + homeTeam: { select: { name: true } }, + awayTeam: { select: { name: true } }, + }, + }); + + return matches; + } catch { + return []; + } + } + + /** + * Save analysis result + */ + private async saveAnalysisResult( + userId: string, + matchId: string, + result: any, + ): Promise { + try { + await this.prisma.analysis.create({ + data: { + userId, + matchIds: matchId, + analysisResultJson: result, + }, + }); + } catch (err: any) { + this.logger.warn(`Failed to save analysis: ${err.message}`); + } + } +} diff --git a/src/services/scraper.service.ts b/src/services/scraper.service.ts new file mode 100755 index 0000000..ac1c6d6 --- /dev/null +++ b/src/services/scraper.service.ts @@ -0,0 +1,273 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import * as cheerio from 'cheerio'; + +export interface ScrapedMatchDetails { + homeTeam: string; + awayTeam: string; + homeTeamId: string; + awayTeamId: string; + league: string; + scoreHome: number | null; + scoreAway: number | null; + status: string; + date: string; + mstUtc: number; +} + +export interface ScrapedOdds { + id: string; + name: string; + mbc: string; + selectionCollection: any[]; +} + +export interface ScrapedData { + matchDetails: ScrapedMatchDetails; + odds: ScrapedOdds[]; + eventData: any[]; + stats: { home: any; away: any }; + lineups: { home: any[]; away: any[] }; + substitutes: { home: any[]; away: any[] } | null; +} + +@Injectable() +export class ScraperService { + private readonly logger = new Logger(ScraperService.name); + + constructor(private readonly httpService: HttpService) {} + + /** + * Main scrape method - fetches all match data from Mackolik + */ + async scrapeMatchData( + matchId: string, + matchSlug: string, + sport: 'football' | 'basketball', + ): Promise { + const sportPath = sport === 'basketball' ? 'basketbol/mac' : 'mac'; + + const oddsUrl = `https://www.mackolik.com/${sportPath}/${matchSlug}/iddaa/${matchId}`; + const infoUrl = `https://www.mackolik.com/${sportPath}/${matchSlug}/${matchId}`; + + try { + this.logger.log(`Scraping match data for ${matchId}`); + + // Parallel fetch + const [oddsResponse, infoResponse] = await Promise.all([ + firstValueFrom(this.httpService.get(oddsUrl)), + firstValueFrom(this.httpService.get(infoUrl)), + ]); + + // Parse odds page + const oddsSettings = this.extractDataSettings(oddsResponse.data); + const odds = this.transformOdds(oddsSettings); + + // Parse info page + const infoSettings = this.extractDataSettings(infoResponse.data); + const dataLayer = this.extractDataLayer(infoResponse.data); + + // Extract match details + const matchDetails = this.extractMatchDetails(infoSettings, dataLayer); + + // Extract events + const eventData = this.extractKeyEvents(infoSettings); + + // Extract stats + const stats = this.extractGameStats(infoSettings); + + // Extract lineups + const lineups = await this.fetchLineups(matchId); + + // Fetch substitutes + const substitutes = await this.fetchSubstitutes(matchId); + + return { + matchDetails, + odds, + eventData, + stats, + lineups, + substitutes, + }; + } catch (error: any) { + this.logger.error(`Scrape failed for ${matchId}: ${error.message}`); + throw error; + } + } + + /** + * Extract all [data-settings] attributes from HTML + */ + extractDataSettings(html: string): any[] { + const $ = cheerio.load(html); + const settings: any[] = []; + + $('[data-settings]').each((i, elem) => { + const settingsJson = $(elem).attr('data-settings'); + if (settingsJson) { + try { + settings.push(JSON.parse(settingsJson)); + } catch { + // Ignore parse errors + } + } + }); + + return settings; + } + + /** + * Extract window.dataLayer from HTML + */ + extractDataLayer(html: string): any { + const match = html.match(/window\.dataLayer\s*=\s*(\[[\s\S]*?\]);/); + if (match && match[1]) { + try { + return JSON.parse(match[1])[0]; + } catch { + return null; + } + } + return null; + } + + /** + * Transform odds from data-settings + */ + private transformOdds(settings: any[]): ScrapedOdds[] { + const oddsData = settings.find((s) => s.iddaaEventId?.marketCollection); + if (!oddsData) return []; + + const markets = Object.values(oddsData.iddaaEventId.marketCollection); + return markets.map((market: any) => ({ + id: market.id, + name: market.name, + mbc: market.mbc, + selectionCollection: Object.values(market.selectionCollection || {}), + })); + } + + /** + * Extract match details from dataLayer + */ + private extractMatchDetails( + settings: any[], + dataLayer: any, + ): ScrapedMatchDetails { + const matchInfo = settings.find((s) => s.homeTeamName && s.matchId); + const matchHeader = settings.find((s) => s.match?.startTime); + + const prf = dataLayer?.prf || {}; + + return { + homeTeam: matchInfo?.homeTeamName || prf.team1Name || 'Unknown', + awayTeam: matchInfo?.awayTeamName || prf.team2Name || 'Unknown', + homeTeamId: prf.team1Id?.toString() || '0', + awayTeamId: prf.team2Id?.toString() || '0', + league: prf.competitionName || 'Unknown', + scoreHome: prf.team1Score != null ? parseInt(prf.team1Score) : null, + scoreAway: prf.team2Score != null ? parseInt(prf.team2Score) : null, + status: this.extractMatchStatus(settings) || 'NS', + date: matchHeader?.match?.startTime?.utc + ? new Date(matchHeader.match.startTime.utc).toISOString() + : new Date().toISOString(), + mstUtc: matchHeader?.match?.startTime?.utc + ? new Date(matchHeader.match.startTime.utc).getTime() + : Date.now(), + }; + } + + /** + * Extract match status + */ + private extractMatchStatus(settings: any[]): string { + const matchState = settings.find((s) => s.matchState || s.status); + return matchState?.matchState || matchState?.status || 'NS'; + } + + /** + * Extract key events (goals, cards, etc) + */ + private extractKeyEvents(settings: any[]): any[] { + const keyEventsContainer = settings.find( + (s) => s.keyEvents && Array.isArray(s.keyEvents), + ); + return keyEventsContainer?.keyEvents || []; + } + + /** + * Extract game stats + */ + private extractGameStats(settings: any[]): { home: any; away: any } { + const gameStats = settings.find((s) => s.home?.shotsOnTarget !== undefined); + if (gameStats) { + return { home: gameStats.home, away: gameStats.away }; + } + return { home: {}, away: {} }; + } + + /** + * Fetch lineups from AJAX endpoint (İlk 11) + */ + async fetchLineups(matchId: string): Promise<{ home: any[]; away: any[] }> { + try { + const url = `https://www.mackolik.com/ajax/football/match-stats?matchId=${matchId}&ajaxViewName=starting-formation&seasonId=${matchId}`; + const response = await firstValueFrom(this.httpService.get(url)); + + if (response.data?.data?.home && response.data?.data?.away) { + return { + home: response.data.data.home || [], + away: response.data.data.away || [], + }; + } + } catch { + this.logger.warn(`Could not fetch lineups for ${matchId}`); + } + + return { home: [], away: [] }; + } + + /** + * Fetch substitutes + */ + async fetchSubstitutes( + matchId: string, + ): Promise<{ home: any[]; away: any[] } | null> { + try { + const url = `https://www.mackolik.com/ajax/football/match-stats?matchId=${matchId}&ajaxViewName=substitutions`; + const response = await firstValueFrom(this.httpService.get(url)); + + if (response.data?.data?.stats) { + return { + home: response.data.data.stats.home || [], + away: response.data.data.stats.away || [], + }; + } + } catch { + this.logger.warn(`Could not fetch substitutes for ${matchId}`); + } + + return null; + } + + /** + * Flatten odds for Python engine + */ + flattenOdds(odds: ScrapedOdds[]): any[] { + const result: any[] = []; + + for (const market of odds) { + for (const selection of market.selectionCollection) { + result.push({ + category: market.name, + selection: selection.name, + odd_value: selection.odd, + }); + } + } + + return result; + } +} diff --git a/src/services/services.module.ts b/src/services/services.module.ts new file mode 100755 index 0000000..0427299 --- /dev/null +++ b/src/services/services.module.ts @@ -0,0 +1,25 @@ +import { Module, Global } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ScraperService } from './scraper.service'; +import { AiService } from './ai.service'; +import { DatabaseModule } from '../database/database.module'; +import { MatchAnalysisService } from './match-analysis.service'; + +@Global() +@Module({ + imports: [ + DatabaseModule, + HttpModule.register({ + timeout: 35000, + maxRedirects: 5, + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8', + }, + }), + ], + providers: [ScraperService, MatchAnalysisService, AiService], + exports: [ScraperService, MatchAnalysisService, AiService], +}) +export class ServicesModule {} diff --git a/src/tasks/data-fetcher.task.ts b/src/tasks/data-fetcher.task.ts new file mode 100755 index 0000000..9ef2bf9 --- /dev/null +++ b/src/tasks/data-fetcher.task.ts @@ -0,0 +1,1461 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { HttpService } from '@nestjs/axios'; +import { PrismaService } from '../database/prisma.service'; +import { firstValueFrom } from 'rxjs'; +import { FeederScraperService } from '../modules/feeder/feeder-scraper.service'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Prisma } from '@prisma/client'; +import { SidelinedResponse } from '../modules/feeder/feeder.types'; + +interface LiveScoreTeamPayload { + id: string; + name: string; + slug: string | null; +} + +interface LiveScoreCompetitionPayload { + id: string; + name: string; + slug: string | null; + country: { + id: string; + name: string; + } | null; +} + +interface LiveScorePayloadMatch { + id: string; + matchName: string; + matchSlug: string; + competitionId: string | null; + mstUtc: number | null; + state: string | null; + substate: string | null; + status: string | null; + statusBoxContent: string | null; // ERT = Erteledendi + iddaaCode: string | null; + homeTeam: LiveScoreTeamPayload; + awayTeam: LiveScoreTeamPayload; + homeScore: number | null; + awayScore: number | null; + score: { + home: number | null; + away: number | null; + } | null; +} + +type LiveMatchOddsTarget = Prisma.LiveMatchGetPayload<{ + include: { + homeTeam: { select: { name: true } }; + awayTeam: { select: { name: true } }; + }; +}>; + +interface LiveLineupsJson { + home: { xi: unknown[]; subs: unknown[] }; + away: { xi: unknown[]; subs: unknown[] }; +} + +@Injectable() +export class DataFetcherTask { + private readonly logger = new Logger(DataFetcherTask.name); + + constructor( + private readonly httpService: HttpService, + private readonly prisma: PrismaService, + private readonly scraper: FeederScraperService, + ) {} + + private shouldSkipInHistoricalMode(jobName: string): boolean { + if (process.env.FEEDER_MODE === 'historical') { + this.logger.debug(`Skipping ${jobName} in historical feeder mode`); + return true; + } + return false; + } + + private isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); + } + + private asString(value: unknown): string | null { + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) + return String(value); + return null; + } + + private asInt(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = parseInt(value, 10); + return Number.isNaN(parsed) ? null : parsed; + } + return null; + } + + private parseTeam(value: unknown): LiveScoreTeamPayload | null { + if (!this.isRecord(value)) return null; + + const id = this.asString(value.id); + if (!id) return null; + + return { + id, + name: this.asString(value.name) || 'Unknown', + slug: this.asString(value.slug), + }; + } + + private parseCompetition(value: unknown): LiveScoreCompetitionPayload | null { + if (!this.isRecord(value)) return null; + + const id = this.asString(value.id); + const name = this.asString(value.name); + if (!id || !name) return null; + + const rawCountry = this.isRecord(value.country) ? value.country : null; + const countryId = rawCountry ? this.asString(rawCountry.id) : null; + const countryName = rawCountry ? this.asString(rawCountry.name) : null; + + return { + id, + name, + slug: this.asString(value.slug), + country: + countryId && countryName ? { id: countryId, name: countryName } : null, + }; + } + + private parseLiveScoreMatch(value: unknown): LiveScorePayloadMatch | null { + if (!this.isRecord(value)) return null; + + const id = this.asString(value.id); + const homeTeam = this.parseTeam(value.homeTeam); + const awayTeam = this.parseTeam(value.awayTeam); + if (!id || !homeTeam || !awayTeam) return null; + + const score = this.isRecord(value.score) + ? { + home: this.asInt(value.score.home), + away: this.asInt(value.score.away), + } + : null; + + return { + id, + matchName: + this.asString(value.matchName) || `${homeTeam.name} - ${awayTeam.name}`, + matchSlug: this.asString(value.matchSlug) || '', + competitionId: this.asString(value.competitionId), + mstUtc: this.asInt(value.mstUtc), + state: this.asString(value.state), + substate: this.asString(value.substate), + status: this.asString(value.status), + statusBoxContent: this.asString(value.statusBoxContent), + iddaaCode: this.asString(value.iddaaCode), + homeTeam, + awayTeam, + homeScore: this.asInt(value.homeScore), + awayScore: this.asInt(value.awayScore), + score, + }; + } + + private parseLiveScoresPayload(raw: unknown): { + matches: LiveScorePayloadMatch[]; + competitions: Record; + } | null { + if (!this.isRecord(raw)) return null; + if (this.asString(raw.status) !== 'success') return null; + + const data = this.isRecord(raw.data) ? raw.data : null; + if (!data) return null; + + const rawMatches = this.isRecord(data.matches) ? data.matches : {}; + const rawCompetitions = this.isRecord(data.competitions) + ? data.competitions + : {}; + + const matches: LiveScorePayloadMatch[] = []; + for (const rawMatch of Object.values(rawMatches)) { + const parsed = this.parseLiveScoreMatch(rawMatch); + if (parsed) matches.push(parsed); + } + + const competitions: Record = {}; + for (const [key, rawCompetition] of Object.entries(rawCompetitions)) { + const parsed = this.parseCompetition(rawCompetition); + if (parsed) { + competitions[key] = parsed; + } + } + + return { matches, competitions }; + } + + private loadLeagueFilterSet(fileName: string): Set | null { + try { + const filePath = path.join(process.cwd(), fileName); + const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as unknown; + if (!Array.isArray(raw)) { + this.logger.error(`${fileName} is not a JSON array`); + return null; + } + + const ids = raw + .map((value) => this.asString(value)) + .filter((value): value is string => !!value); + return new Set(ids); + } catch (e: any) { + this.logger.error(`Failed to load ${fileName}: ${e.message}`); + return null; + } + } + + private resolveCompetition( + competitions: Record, + leagueId: string | null, + ): LiveScoreCompetitionPayload | null { + if (!leagueId) return null; + if (competitions[leagueId]) return competitions[leagueId]; + for (const comp of Object.values(competitions)) { + if (comp.id === leagueId) return comp; + } + return null; + } + + private async ensureLiveEntitySportIntegrity( + entityType: 'league' | 'team' | 'liveMatch', + id: string, + expectedSport: 'football' | 'basketball', + ): Promise { + if (!id) return true; + + let existingSport: string | null | undefined; + + if (entityType === 'league') { + const existing = await this.prisma.league.findUnique({ + where: { id }, + select: { sport: true }, + }); + existingSport = existing?.sport; + } else if (entityType === 'team') { + const existing = await this.prisma.team.findUnique({ + where: { id }, + select: { sport: true }, + }); + existingSport = existing?.sport; + } else { + const existing = await this.prisma.liveMatch.findUnique({ + where: { id }, + select: { sport: true }, + }); + existingSport = existing?.sport; + } + + if (existingSport && existingSport !== expectedSport) { + this.logger.error( + `Sport integrity violation on ${entityType}:${id}. Existing=${existingSport}, incoming=${expectedSport}. Skipping write to prevent cross-sport overwrite.`, + ); + return false; + } + + return true; + } + + /** + * Full 4-day sync — runs daily at 03:00 + * Fetches today + 3 days ahead of match data + */ + @Cron('0 3 * * *') + async fetchLiveMatchesFull(): Promise { + if (this.shouldSkipInHistoricalMode('fetchLiveMatchesFull')) return; + this.logger.log('Starting full 4-day live matches sync...'); + const dates: string[] = []; + for (let i = 0; i < 2; i++) { + const d = new Date(); + d.setDate(d.getDate() + i); + dates.push(d.toISOString().split('T')[0]); + } + await this.fetchLiveMatches(dates); + } + + /** + * Fetch live matches from Mackolik API every 30 minutes (today only) + * Can be called with custom dates array for multi-day fetch + */ + @Cron('*/30 * * * *') + async fetchLiveMatches(dates?: string[]): Promise { + if (!dates && this.shouldSkipInHistoricalMode('fetchLiveMatches')) return; + const targetDates = dates || [new Date().toISOString().split('T')[0]]; + this.logger.log( + `Starting match fetching job for ${targetDates.length} date(s): ${targetDates.join(', ')}...`, + ); + + // Load top leagues filter (once, shared across all dates) + let topLeagueIds: Set = new Set(); + try { + const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json'); + const ids: string[] = JSON.parse( + fs.readFileSync(topLeaguesPath, 'utf-8'), + ); + topLeagueIds = new Set(ids); + this.logger.log( + `Loaded ${topLeagueIds.size} top leagues from top_leagues.json`, + ); + } catch (e: any) { + this.logger.warn( + `Failed to load top_leagues.json, writing ALL matches: ${e.message}`, + ); + } + + for (const date of targetDates) { + try { + await this.fetchLiveMatchesForDate(date, topLeagueIds); + // Stats are logged inside fetchLiveMatchesForDate + } catch (error: any) { + this.logger.error( + `Match fetch job failed for date ${date}: ${error.message}`, + ); + } + } + + this.logger.log( + `Completed match fetching for ${targetDates.length} date(s)`, + ); + } + + /** + * Fetch live matches for a single date + */ + private async fetchLiveMatchesForDate( + date: string, + topLeagueIds: Set, + ): Promise { + const url = `https://www.mackolik.com/perform/p0/ajax/components/competition/livescores/json?sports[]=Soccer&matchDate=${date}`; + + const response = await firstValueFrom( + this.httpService.get(url, { timeout: 20000 }), + ); + + const payload = this.parseLiveScoresPayload(response.data); + if (!payload) { + this.logger.warn(`No valid data received from Mackolik API for ${date}`); + return; + } + + const allMatches = payload.matches; + const competitions = payload.competitions; + + if (allMatches.length === 0) { + this.logger.log(`No matches found for ${date}`); + return; + } + + this.logger.log( + `Processing ${allMatches.length} matches for ${date} (filter: ${topLeagueIds.size > 0 ? topLeagueIds.size + ' top leagues' : 'NONE'})...`, + ); + + let upsertCount = 0; + let skippedCount = 0; + let totalProcessed = 0; + + // Local caches to avoid N+1 redundant upserts in this run + const processedCountries = new Set(); + const processedLeagues = new Set(); + const processedTeams = new Set(); + const integrityChecked = new Map(); + + for (const match of allMatches) { + totalProcessed++; + try { + // Extract IDs + const homeTeamId = match.homeTeam.id; + const awayTeamId = match.awayTeam.id; + const leagueId = match.competitionId; + + const ensureIntegrity = async ( + entityType: 'league' | 'team' | 'liveMatch', + entityId: string | null, + ) => { + if (!entityId) return true; + const cacheKey = `football:${entityType}:${entityId}`; + if (!integrityChecked.has(cacheKey)) { + integrityChecked.set( + cacheKey, + await this.ensureLiveEntitySportIntegrity( + entityType, + entityId, + 'football', + ), + ); + } + return integrityChecked.get(cacheKey) === true; + }; + + if ( + !(await ensureIntegrity('liveMatch', String(match.id))) || + !(await ensureIntegrity('league', leagueId)) || + !(await ensureIntegrity('team', homeTeamId)) || + !(await ensureIntegrity('team', awayTeamId)) + ) { + skippedCount++; + continue; + } + + // Skip non-top-league matches for live_matches table + const isTopLeague = + topLeagueIds.size === 0 || + (leagueId && topLeagueIds.has(String(leagueId))); + + // Get competition details + const compInfo = leagueId ? competitions[leagueId] : null; + const countryId = compInfo?.country?.id || null; + + // 1. Upsert Country (Cached) + if ( + countryId && + compInfo?.country?.name && + !processedCountries.has(countryId) + ) { + await this.prisma.country + .upsert({ + where: { id: countryId }, + update: { name: compInfo.country.name }, + create: { + id: countryId, + name: compInfo.country.name, + }, + }) + .catch((e) => + this.logger.warn(`Country upsert failed: ${e.message}`), + ); + processedCountries.add(countryId); + } + + // 2. Upsert League (Cached) + if ( + leagueId && + compInfo?.name && + !processedLeagues.has(String(leagueId)) + ) { + await this.prisma.league + .upsert({ + where: { id: leagueId }, + update: { + name: compInfo.name, + countryId: countryId, + }, + create: { + id: leagueId, + name: compInfo.name, + countryId: countryId, + sport: 'football', + competitionSlug: compInfo.slug || null, + }, + }) + .catch((e) => + this.logger.warn(`League upsert failed: ${e.message}`), + ); + processedLeagues.add(String(leagueId)); + } + + // 3. Upsert teams (Cached) + if ( + homeTeamId && + match.homeTeam?.name && + !processedTeams.has(homeTeamId) + ) { + await this.prisma.team + .upsert({ + where: { id: homeTeamId }, + update: { + name: match.homeTeam.name, + slug: match.homeTeam.slug || null, + }, + create: { + id: homeTeamId, + name: match.homeTeam.name, + slug: match.homeTeam.slug || null, + sport: 'football', + }, + }) + .catch((e) => + this.logger.warn(`Home team upsert failed: ${e.message}`), + ); + processedTeams.add(homeTeamId); + } + + if ( + awayTeamId && + match.awayTeam?.name && + !processedTeams.has(awayTeamId) + ) { + await this.prisma.team + .upsert({ + where: { id: awayTeamId }, + update: { + name: match.awayTeam.name, + slug: match.awayTeam.slug || null, + }, + create: { + id: awayTeamId, + name: match.awayTeam.name, + slug: match.awayTeam.slug || null, + sport: 'football', + }, + }) + .catch((e) => + this.logger.warn(`Away team upsert failed: ${e.message}`), + ); + processedTeams.add(awayTeamId); + } + + // Helper for safe score parsing + const sHome = this.asInt(match.homeScore ?? match.score?.home); + const sAway = this.asInt(match.awayScore ?? match.score?.away); + + // Handle postponed matches (ERT = Erteledendi) + if (match.statusBoxContent === 'ERT') { + // Update existing match to POSTPONED status so coupons can be settled + await this.prisma.liveMatch + .updateMany({ + where: { id: String(match.id) }, + data: { + status: 'POSTPONED', + state: 'postponed', + substate: 'postponed', + updatedAt: new Date(), + }, + }) + .catch(() => {}); // Ignore if match doesn't exist + this.logger.debug(`Marked as POSTPONED: ${match.matchName}`); + skippedCount++; + continue; + } + + // 4. Save LiveMatch — only for top leagues + if (!isTopLeague) { + skippedCount++; + } else { + await this.prisma.liveMatch.upsert({ + where: { id: String(match.id) }, + update: { + leagueId: leagueId, + state: match.state || null, + substate: match.substate || null, + status: match.status || match.state || 'NS', + scoreHome: sHome, + scoreAway: sAway, + homeTeamId: homeTeamId, + awayTeamId: awayTeamId, + updatedAt: new Date(), + }, + create: { + id: String(match.id), + matchName: match.matchName, + matchSlug: match.matchSlug, + sport: 'football', + leagueId: leagueId, + state: match.state || null, + substate: match.substate || null, + status: match.status || match.state || 'NS', + mstUtc: BigInt(match.mstUtc || Date.now()), + scoreHome: sHome, + scoreAway: sAway, + homeTeamId: homeTeamId, + awayTeamId: awayTeamId, + }, + }); + upsertCount++; + } + + // Improved Progress Logging (Every 100 total processed instead of upsertCount only) + if ( + totalProcessed % 100 === 0 || + totalProcessed === allMatches.length + ) { + this.logger.log( + `⏳ Progress: ${totalProcessed}/${allMatches.length} (Saved: ${upsertCount}, Skipped: ${skippedCount})`, + ); + } + } catch (err: any) { + this.logger.warn(`Match ${match.id} process failed: ${err.message}`); + } + } + + this.logger.log( + `[${date}] Successfully processed ${totalProcessed} matches: ${upsertCount} saved, ${skippedCount} skipped`, + ); + } + + /** + * Fetch odds for upcoming live matches (NS = Not Started) + * Runs every 30 minutes to keep odds fresh + */ + @Cron('*/15 * * * *') + async fetchOddsForPreMatches() { + if (this.shouldSkipInHistoricalMode('fetchOddsForPreMatches')) return; + this.logger.log('Starting odds pre_matches fetching job...'); + + try { + // Load football top leagues filter + let topLeagueIds: string[] = []; + try { + const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json'); + topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf-8')); + } catch (e) { + this.logger.warn( + `Failed to load top_leagues.json for odds filter: ${e.message}`, + ); + } + + // Load basketball top leagues filter + let basketballTopLeagueIds: string[] = []; + try { + const basketPath = path.join( + process.cwd(), + 'basketball_top_leagues.json', + ); + basketballTopLeagueIds = JSON.parse( + fs.readFileSync(basketPath, 'utf-8'), + ); + } catch (e) { + this.logger.warn( + `Failed to load basketball_top_leagues.json for odds filter: ${e.message}`, + ); + } + + const allowedLeagueIds = Array.from( + new Set([...topLeagueIds, ...basketballTopLeagueIds]), + ); + + // Get matches that need odds (starting from 12 hours ago until today) + const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000); + + const matchesToFetch = await this.prisma.liveMatch.findMany({ + where: { + mstUtc: { gte: BigInt(twelveHoursAgo.getTime()) }, + ...(allowedLeagueIds.length > 0 + ? { leagueId: { in: allowedLeagueIds } } + : {}), + }, + include: { + homeTeam: { select: { name: true } }, + awayTeam: { select: { name: true } }, + }, + orderBy: [{ oddsUpdatedAt: 'asc' }, { mstUtc: 'asc' }], + take: 1000, + }); + + if (matchesToFetch.length === 0) { + this.logger.log('No matches to fetch odds for'); + return; + } + + this.logger.log(`Fetching odds for ${matchesToFetch.length} matches`); + + let successCount = 0; + let errorCount = 0; + const failedMatches: LiveMatchOddsTarget[] = []; + + // 1. Initial Pass + for (const match of matchesToFetch) { + try { + await this.processMatchOdds(match); + successCount++; + await this.delay(500); + } catch (err: any) { + errorCount++; + const isRetryable = + err.response?.status === 502 || + err.code === 'ECONNABORTED' || + err.code === 'ETIMEDOUT'; + if (isRetryable) { + failedMatches.push(match); + } else { + this.logger.warn( + `Match ${match.id} odds fetch failed (Non-retryable): ${err.message}`, + ); + } + } + } + + // 2. Retry Logic for Failed Matches + if (failedMatches.length > 0) { + this.logger.warn( + `⚠️ Retrying ${failedMatches.length} failed matches (502/Timeout)...`, + ); + + for (const match of failedMatches) { + await this.delay(2000); + try { + await this.processMatchOdds(match); + successCount++; + this.logger.log(`✅ Retry successful for match ${match.id}`); + } catch (retryErr: any) { + this.logger.error( + `❌ Retry failed for match ${match.id}: ${retryErr.message}`, + ); + } + } + } + + this.logger.log( + `Odds fetch complete: ${successCount} success, ${errorCount} errors (initially)`, + ); + } catch (error: any) { + this.logger.error(`Odds fetch job failed: ${error.message}`); + } + } + + /** + * Fetch basketball matches every 30 minutes + * Uses basketball_top_leagues.json for filtering + */ + @Cron('*/30 * * * *') + async fetchBasketballMatches() { + if (this.shouldSkipInHistoricalMode('fetchBasketballMatches')) return; + this.logger.log('Fetching basketball matches...'); + + const date = new Date().toISOString().split('T')[0]; + const url = `https://www.mackolik.com/perform/p0/ajax/components/competition/livescores/json?sports[]=Basketball&matchDate=${date}`; + + try { + const response = await firstValueFrom( + this.httpService.get(url, { timeout: 20000 }), + ); + + const payload = this.parseLiveScoresPayload(response.data); + if (!payload) return; + + const allMatches = payload.matches; + const competitions = payload.competitions; + + const topLeagueIds = this.loadLeagueFilterSet( + 'basketball_top_leagues.json', + ); + if (!topLeagueIds || topLeagueIds.size === 0) { + this.logger.error( + 'basketball_top_leagues.json is missing/empty. Basketball ingest skipped to protect data quality.', + ); + return; + } + + const targetMatches = allMatches.filter( + (m) => !!m.competitionId && topLeagueIds.has(String(m.competitionId)), + ); + this.logger.log( + `Basketball filter: ${targetMatches.length}/${allMatches.length} matches in configured leagues`, + ); + + const processedCountries = new Set(); + const processedLeagues = new Set(); + const processedTeams = new Set(); + const integrityChecked = new Map(); + + let count = 0; + for (const match of targetMatches) { + try { + const homeTeamId = match.homeTeam.id; + const awayTeamId = match.awayTeam.id; + const leagueId = match.competitionId; + const compInfo = this.resolveCompetition(competitions, leagueId); + const countryId = compInfo?.country?.id || null; + + const ensureIntegrity = async ( + entityType: 'league' | 'team' | 'liveMatch', + entityId: string | null, + ) => { + if (!entityId) return true; + const cacheKey = `basketball:${entityType}:${entityId}`; + if (!integrityChecked.has(cacheKey)) { + integrityChecked.set( + cacheKey, + await this.ensureLiveEntitySportIntegrity( + entityType, + entityId, + 'basketball', + ), + ); + } + return integrityChecked.get(cacheKey) === true; + }; + + if ( + !(await ensureIntegrity('liveMatch', String(match.id))) || + !(await ensureIntegrity('league', leagueId)) || + !(await ensureIntegrity('team', homeTeamId)) || + !(await ensureIntegrity('team', awayTeamId)) + ) { + this.logger.warn( + `Skipping basketball live match ${match.id} because of sport integrity mismatch.`, + ); + continue; + } + + if ( + countryId && + compInfo?.country?.name && + !processedCountries.has(countryId) + ) { + await this.prisma.country + .upsert({ + where: { id: countryId }, + update: { name: compInfo.country.name }, + create: { + id: countryId, + name: compInfo.country.name, + }, + }) + .catch((e) => + this.logger.warn( + `Basketball country upsert failed: ${e.message}`, + ), + ); + processedCountries.add(countryId); + } + + if (leagueId && compInfo?.name && !processedLeagues.has(leagueId)) { + await this.prisma.league + .upsert({ + where: { id: leagueId }, + update: { + name: compInfo.name, + countryId: countryId, + sport: 'basketball', + }, + create: { + id: leagueId, + name: compInfo.name, + countryId: countryId, + sport: 'basketball', + competitionSlug: compInfo.slug || null, + }, + }) + .catch((e) => + this.logger.warn( + `Basketball league upsert failed: ${e.message}`, + ), + ); + processedLeagues.add(leagueId); + } + + if ( + homeTeamId && + match.homeTeam?.name && + !processedTeams.has(homeTeamId) + ) { + await this.prisma.team + .upsert({ + where: { id: homeTeamId }, + update: { + name: match.homeTeam.name, + slug: match.homeTeam.slug || null, + sport: 'basketball', + }, + create: { + id: homeTeamId, + name: match.homeTeam.name, + slug: match.homeTeam.slug || null, + sport: 'basketball', + }, + }) + .catch((e) => + this.logger.warn( + `Basketball home team upsert failed: ${e.message}`, + ), + ); + processedTeams.add(homeTeamId); + } + + if ( + awayTeamId && + match.awayTeam?.name && + !processedTeams.has(awayTeamId) + ) { + await this.prisma.team + .upsert({ + where: { id: awayTeamId }, + update: { + name: match.awayTeam.name, + slug: match.awayTeam.slug || null, + sport: 'basketball', + }, + create: { + id: awayTeamId, + name: match.awayTeam.name, + slug: match.awayTeam.slug || null, + sport: 'basketball', + }, + }) + .catch((e) => + this.logger.warn( + `Basketball away team upsert failed: ${e.message}`, + ), + ); + processedTeams.add(awayTeamId); + } + + const sHome = this.asInt(match.homeScore ?? match.score?.home); + const sAway = this.asInt(match.awayScore ?? match.score?.away); + + // Handle postponed matches (ERT = Erteledendi) + if (match.statusBoxContent === 'ERT') { + await this.prisma.liveMatch + .updateMany({ + where: { id: String(match.id) }, + data: { + status: 'POSTPONED', + state: 'postponed', + substate: 'postponed', + updatedAt: new Date(), + }, + }) + .catch(() => {}); + this.logger.debug( + `Marked basketball match as POSTPONED: ${match.matchName}`, + ); + count++; + continue; + } + + await this.prisma.liveMatch.upsert({ + where: { id: String(match.id) }, + update: { + leagueId: leagueId, + substate: match.substate || null, + state: match.state, + status: match.status || match.state || 'NS', + scoreHome: sHome, + scoreAway: sAway, + homeTeamId: homeTeamId, + awayTeamId: awayTeamId, + updatedAt: new Date(), + }, + create: { + id: match.id, + matchName: match.matchName, + matchSlug: match.matchSlug, + sport: 'basketball', + leagueId: leagueId, + state: match.state, + substate: match.substate || null, + status: match.status || match.state || 'NS', + mstUtc: BigInt(match.mstUtc || Date.now()), + scoreHome: sHome, + scoreAway: sAway, + homeTeamId: homeTeamId, + awayTeamId: awayTeamId, + }, + }); + count++; + } catch { + // Skip individual match errors + } + } + + await this.prisma.liveMatch.deleteMany({ + where: { + sport: 'basketball', + OR: [ + { leagueId: null }, + { + leagueId: { + notIn: Array.from(topLeagueIds), + }, + }, + ], + }, + }); + + this.logger.log(`Fetched ${count} basketball matches`); + } catch (error: any) { + this.logger.error(`Basketball fetch failed: ${error.message}`); + } + } + + /** + * Fetch odds for upcoming live matches (NS = Not Started) + * Runs every 15 minutes to keep odds fresh + */ + // @Cron('*/15 * * * *') // Her 15 dakikada bir + // async fetchOddsForPreMatches() { + // this.logger.log('Starting odds pre_matches fetching job...'); + + // try { + // // Load top leagues filter + // let topLeagueIds: string[] = []; + // try { + // const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json'); + // topLeagueIds = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf-8')); + // } catch (e) { + // this.logger.warn( + // `Failed to load top_leagues.json for odds filter: ${e.message}`, + // ); + // } + + // // Get matches that need odds (starting from 12 hours ago until today) + // // Order by oddsUpdatedAt (nulls first) to ensure rotation and avoid bottlenecks + // const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000); + + // const matchesToFetch = await this.prisma.liveMatch.findMany({ + // where: { + // mstUtc: { gte: BigInt(twelveHoursAgo.getTime()) }, + // // Only top leagues + // ...(topLeagueIds.length > 0 + // ? { leagueId: { in: topLeagueIds } } + // : {}), + // }, + // orderBy: [ + // { oddsUpdatedAt: 'asc' }, // nulls come first in Prisma asc by default + // { mstUtc: 'asc' }, + // ], + // take: 1000, + // }); + + // if (matchesToFetch.length === 0) { + // this.logger.log('No matches to fetch odds for'); + // return; + // } + + // this.logger.log(`Fetching odds for ${matchesToFetch.length} matches`); + + // let successCount = 0; + // let errorCount = 0; + // const failedMatches: any[] = []; + + // // 1. Initial Pass + // for (const match of matchesToFetch) { + // try { + // await this.processMatchOdds(match); + // successCount++; + // // Rate limiting: 500ms between requests + // await this.delay(500); + // } catch (err: any) { + // errorCount++; + // const isRetryable = + // err.response?.status === 502 || + // err.code === 'ECONNABORTED' || + // err.code === 'ETIMEDOUT'; + // if (isRetryable) { + // failedMatches.push(match); + // } else { + // this.logger.warn( + // `Match ${match.id} odds fetch failed (Non-retryable): ${err.message}`, + // ); + // } + // } + // } + + // // 2. Retry Logic for Failed Matches + // if (failedMatches.length > 0) { + // this.logger.warn( + // `⚠️ Retrying ${failedMatches.length} failed matches (502/Timeout)...`, + // ); + + // for (const match of failedMatches) { + // // Longer delay for retry + // await this.delay(2000); + // try { + // await this.processMatchOdds(match); + // successCount++; + // this.logger.log(`✅ Retry successful for match ${match.id}`); + // } catch (retryErr: any) { + // this.logger.error( + // `❌ Retry failed for match ${match.id}: ${retryErr.message}`, + // ); + // } + // } + // } + + // this.logger.log( + // `Odds fetch complete: ${successCount} success, ${errorCount} errors (initially)`, + // ); + // } catch (error: any) { + // this.logger.error(`Odds fetch job failed: ${error.message}`); + // } + // } + + private async processMatchOdds(match: LiveMatchOddsTarget) { + const matchSlug = match.matchSlug || 'match'; + const sport = String(match.sport || 'football').toLowerCase(); + const sportPath = sport === 'basketball' ? 'basketbol/mac' : 'mac'; + const httpHeaders = { + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }; + + let odds: Record> = {}; + let refereeName: string | null = null; + let lineups: LiveLineupsJson | null = null; + let sidelined: SidelinedResponse | null = null; + + // 1. Fetch Odds from İddaa page + const oddsUrl = `https://www.mackolik.com/${sportPath}/${matchSlug}/iddaa/${match.id}`; + try { + const response = await firstValueFrom( + this.httpService.get(oddsUrl, { + timeout: 10000, + headers: httpHeaders, + maxRedirects: 5, + }), + ); + odds = this.extractOddsFromHtml( + typeof response.data === 'string' ? response.data : '', + ); + } catch (e: any) { + const isRetryable = + e.response?.status === 502 || + e.code === 'ECONNABORTED' || + e.code === 'ETIMEDOUT'; + if (isRetryable) throw e; + this.logger.warn(`Odds fetch failed for ${match.id}: ${e.message}`); + } + + if (sport === 'football') { + // 2. Fetch Referee from main match page (referee data is NOT on İddaa page) + if (!refereeName) { + const mainUrl = `https://www.mackolik.com/mac/${matchSlug}/${match.id}`; + try { + const mainResp = await firstValueFrom( + this.httpService.get(mainUrl, { + timeout: 10000, + headers: httpHeaders, + maxRedirects: 5, + }), + ); + refereeName = this.extractRefereeFromHtml( + typeof mainResp.data === 'string' ? mainResp.data : '', + ); + } catch { + // Non-critical — referee is optional + } + } + + // 3. Fetch Lineups & Sidelined Players for football + const now = Date.now(); + const matchTime = Number(match.mstUtc); + const diffHours = (matchTime - now) / (1000 * 60 * 60); + + // Fetch if between -3 hours (started) and +4 hours (upcoming) + if (diffHours < 4 && diffHours > -3) { + // Lineups + try { + const [startingFormation, substitutions] = await Promise.all([ + this.scraper.fetchStartingFormation(match.id), + this.scraper.fetchSubstitutions(match.id), + ]); + + if (startingFormation || substitutions) { + lineups = { + home: { + xi: startingFormation?.stats?.home || [], + subs: substitutions?.stats?.home || [], + }, + away: { + xi: startingFormation?.stats?.away || [], + subs: substitutions?.stats?.away || [], + }, + }; + this.logger.log(`👥 Lineups found for ${match.matchName}`); + } else { + this.logger.debug(`No lineups (yet) for ${match.matchName}`); + } + } catch (err: any) { + this.logger.warn( + `Lineup fetch failed for ${match.id}: ${err.message}`, + ); + } + + // Sidelined Players (Injuries/Suspensions) + try { + sidelined = await this.scraper.fetchSidelinedPlayers( + match.id, + matchSlug, + ); + if (sidelined) { + if (sidelined.homeTeam) { + sidelined.homeTeam.teamName = match.homeTeam?.name || ''; + } + if (sidelined.awayTeam) { + sidelined.awayTeam.teamName = match.awayTeam?.name || ''; + } + + if ( + sidelined.homeTeam?.totalSidelined > 0 || + sidelined.awayTeam?.totalSidelined > 0 + ) { + this.logger.log( + `🚑 Sidelined: ${sidelined.homeTeam.totalSidelined}(H) - ${sidelined.awayTeam.totalSidelined}(A) for ${match.matchName}`, + ); + } + } + } catch (err: any) { + this.logger.warn( + `Sidelined fetch failed for ${match.id}: ${err.message}`, + ); + } + } + } + + // ALWAYS update oddsUpdatedAt to ensure rotation + await this.prisma.liveMatch.update({ + where: { id: match.id }, + data: { + odds: Object.keys(odds).length > 0 ? odds : undefined, + oddsUpdatedAt: new Date(), + refereeName: refereeName ?? undefined, + lineups: (lineups as unknown as Prisma.InputJsonValue) ?? undefined, + sidelined: (sidelined as unknown as Prisma.InputJsonValue) ?? undefined, + }, + }); + + if ( + Object.keys(odds).length > 0 || + refereeName || + lineups || + (sidelined && + (sidelined.homeTeam.totalSidelined > 0 || + sidelined.awayTeam.totalSidelined > 0)) + ) { + this.logger.log( + `✅ Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || 'N/A'} | Lineups: ${lineups ? 'Yes' : 'No'} | Sidelined: ${sidelined ? 'Yes' : 'No'}`, + ); + } else { + this.logger.debug( + `❕ No detailed data for ${match.matchName}, marked check.`, + ); + } + } + + /** + * Extract odds from Mackolik HTML page + * Returns structured odds object: { "MS": {"1": 2.10, "X": 3.40}, "AU25": {"Alt": 2.05, "Üst": 1.75} } + */ + private extractOddsFromHtml( + html: string, + ): Record> { + const odds: Record> = {}; + if (!html) return odds; + + try { + // Find data-settings with iddaaEventId.marketCollection + const settingsPattern = /data-settings="([^"]+)"/g; + let match; + + while ((match = settingsPattern.exec(html)) !== null) { + try { + // Decode HTML entities + const decoded = match[1] + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + + const parsed = JSON.parse(decoded) as unknown; + if (!this.isRecord(parsed)) continue; + + const iddaaEventId = this.isRecord(parsed.iddaaEventId) + ? parsed.iddaaEventId + : null; + const marketCollection = + iddaaEventId && this.isRecord(iddaaEventId.marketCollection) + ? iddaaEventId.marketCollection + : null; + + if (marketCollection) { + for (const marketValue of Object.values(marketCollection)) { + if (!this.isRecord(marketValue)) continue; + // Use raw market name - save ALL categories + const marketName = this.asString(marketValue.name)?.trim(); + if (!marketName) continue; + + // First-come-first-served: Skip if already populated + if ( + odds[marketName] && + Object.keys(odds[marketName]).length > 0 + ) { + continue; + } + + odds[marketName] = {}; + + const selectionCollection = this.isRecord( + marketValue.selectionCollection, + ) + ? marketValue.selectionCollection + : {}; + for (const selectionValue of Object.values(selectionCollection)) { + if (!this.isRecord(selectionValue)) continue; + + const selName = + this.asString(selectionValue.name) || + this.asString(selectionValue.outcome); + const selOdd = Number.parseFloat( + this.asString(selectionValue.odd) || '', + ); + if (selName && !isNaN(selOdd)) { + odds[marketName][selName] = selOdd; + } + } + } + } + } catch { + // JSON parse error, skip + } + } + } catch { + this.logger.warn(`Failed to extract odds from HTML`); + } + + return odds; + } + + /** + * Normalize odds category names to short codes + */ + private normalizeOddsCategory(name: string): string | null { + if (!name) return null; + const lower = name.toLowerCase(); + + // Specific & Compound names FIRST + if (lower.includes('ilk yarı/maç sonucu')) return 'HTFT'; + if (lower.includes('1. yarı sonucu')) return 'HT'; + if (lower.includes('çifte şans')) return 'CS'; + + // General names LATER + if (lower.includes('maç sonucu') && !lower.includes('handikap')) + return 'MS'; + if (lower.includes('karşılıklı gol')) return 'KG'; + if (lower.includes('2,5 alt/üst') || lower.includes('2.5')) return 'AU25'; + if (lower.includes('1,5 alt/üst') || lower.includes('1.5')) return 'AU15'; + if (lower.includes('3,5 alt/üst') || lower.includes('3.5')) return 'AU35'; + + return null; // Unknown category, skip + } + + /** + * Extract referee name from match page HTML + */ + private extractRefereeFromHtml(html: string): string | null { + try { + // Strategy 1: Mackolik officials section — head referee is in the '--main' list item + // HTML:
  • R. Jones + // Orta Hakem
  • + const mainOfficialPattern = + /official-list-item--main[^>]*>\s*(?:<[^>]*>\s*)*?]*official-name[^>]*>\s*([^<]+)/i; + const mainMatch = mainOfficialPattern.exec(html); + if (mainMatch?.[1]) { + const name = mainMatch[1].trim(); + if (name.length > 2 && name.length < 100) return name; + } + + // Strategy 2: Any official-name followed by "Orta Hakem" (fallback for alternate layouts) + const ortaHakemPattern = + /official-name[^>]*>\s*([^<]+)<[\s\S]*?Orta\s*Hakem/i; + const ortaMatch = ortaHakemPattern.exec(html); + if (ortaMatch?.[1]) { + const name = ortaMatch[1].trim(); + if (name.length > 2 && name.length < 100) return name; + } + + // Strategy 3: Generic fallback patterns + const fallbackPatterns = [ + /Hakem:\s*([^<]+)/i, + /"refereeName":"([^"]+)"/i, + ]; + for (const pattern of fallbackPatterns) { + const match = pattern.exec(html); + if (match?.[1]) { + const name = match[1].trim(); + if (name.length > 2 && name.length < 100) return name; + } + } + } catch { + // Ignore extraction errors + } + return null; + } + + /** + * Fetches Lineups and Sidelined players for live matches. + * Uses FeederScraperService methods directly. + */ + @Cron('*/15 * * * *') + async updateLineupsAndSidelined() { + if (this.shouldSkipInHistoricalMode('updateLineupsAndSidelined')) return; + this.logger.log('👕 Starting lineup & sidelined sync...'); + + try { + // 1. Find active matches without lineups (Include matchSlug) + const matchesToUpdate = await this.prisma.liveMatch.findMany({ + where: { status: { notIn: ['FT', 'post', 'postGame'] } }, + select: { id: true, matchSlug: true, lineups: true, sport: true }, + take: 30, + }); + + // Filter out those that already have lineups or are not football + const toUpdate = matchesToUpdate.filter( + (m) => !m.lineups && m.sport === 'football', + ); + + if (toUpdate.length === 0) { + this.logger.log('✅ Lineups already updated.'); + return; + } + + this.logger.log(`🔄 Fetching details for ${toUpdate.length} matches...`); + + for (const match of toUpdate) { + try { + // 2. Fetch Starting Formation (İlk 11) using Scraper Service + const formation = await this.scraper.fetchStartingFormation(match.id); + + // 3. Fetch Sidelined Players using Scraper Service (Requires matchSlug) + const sidelined = match.matchSlug + ? await this.scraper.fetchSidelinedPlayers( + match.id, + match.matchSlug, + ) + : null; + + // 4. Update DB (Serialize objects to standard JSON to fix Prisma types) + await this.prisma.liveMatch.update({ + where: { id: match.id }, + data: { + lineups: formation + ? JSON.parse(JSON.stringify(formation)) + : Prisma.JsonNull, + sidelined: sidelined + ? JSON.parse(JSON.stringify(sidelined)) + : Prisma.JsonNull, + updatedAt: new Date(), + }, + }); + + this.logger.log(`✅ Lineups updated for match ${match.id}`); + await this.delay(500); // Rate limit + } catch (err: any) { + this.logger.warn( + `Lineup fetch failed for ${match.id}: ${err.message}`, + ); + } + } + } catch (error: any) { + this.logger.error(`Sync error: ${error.message}`); + } + } + + /** + * Delay helper for rate limiting + */ + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/tasks/historical-results-sync.task.spec.ts b/src/tasks/historical-results-sync.task.spec.ts new file mode 100644 index 0000000..c72f614 --- /dev/null +++ b/src/tasks/historical-results-sync.task.spec.ts @@ -0,0 +1,34 @@ +import { FeederService } from '../modules/feeder/feeder.service'; +import { HistoricalResultsSyncTask } from './historical-results-sync.task'; + +describe('HistoricalResultsSyncTask', () => { + const runPreviousDayCompletedMatchesScan = jest.fn(); + let task: HistoricalResultsSyncTask; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.FEEDER_MODE; + + task = new HistoricalResultsSyncTask({ + runPreviousDayCompletedMatchesScan, + } as unknown as FeederService); + }); + + afterEach(() => { + delete process.env.FEEDER_MODE; + }); + + it('calls feeder service in normal mode', async () => { + await task.syncPreviousDayCompletedMatches(); + + expect(runPreviousDayCompletedMatchesScan).toHaveBeenCalledTimes(1); + }); + + it('skips execution in historical feeder mode', async () => { + process.env.FEEDER_MODE = 'historical'; + + await task.syncPreviousDayCompletedMatches(); + + expect(runPreviousDayCompletedMatchesScan).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tasks/historical-results-sync.task.ts b/src/tasks/historical-results-sync.task.ts new file mode 100644 index 0000000..a170cdb --- /dev/null +++ b/src/tasks/historical-results-sync.task.ts @@ -0,0 +1,41 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { FeederService } from '../modules/feeder/feeder.service'; + +@Injectable() +export class HistoricalResultsSyncTask { + private readonly logger = new Logger(HistoricalResultsSyncTask.name); + + constructor(private readonly feederService: FeederService) {} + + private shouldSkipInHistoricalMode(jobName: string): boolean { + if (process.env.FEEDER_MODE === 'historical') { + this.logger.debug(`Skipping ${jobName} in historical feeder mode`); + return true; + } + return false; + } + + /** + * Pull yesterday's completed matches into the permanent matches table. + */ + @Cron('0 8 * * *', { timeZone: 'Europe/Istanbul' }) + async syncPreviousDayCompletedMatches() { + if (this.shouldSkipInHistoricalMode('syncPreviousDayCompletedMatches')) { + return; + } + + this.logger.log( + 'Starting previous-day completed match sync for football and basketball...', + ); + + try { + await this.feederService.runPreviousDayCompletedMatchesScan(); + this.logger.log('Previous-day completed match sync finished'); + } catch (error: any) { + this.logger.error( + `Previous-day completed match sync failed: ${error.message}`, + ); + } + } +} diff --git a/src/tasks/limit-resetter.task.ts b/src/tasks/limit-resetter.task.ts new file mode 100755 index 0000000..c97a680 --- /dev/null +++ b/src/tasks/limit-resetter.task.ts @@ -0,0 +1,122 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { PrismaService } from '../database/prisma.service'; + +@Injectable() +export class LimitResetterTask { + private readonly logger = new Logger(LimitResetterTask.name); + + constructor(private readonly prisma: PrismaService) {} + + private shouldSkipInHistoricalMode(jobName: string): boolean { + if (process.env.FEEDER_MODE === 'historical') { + this.logger.debug(`Skipping ${jobName} in historical feeder mode`); + return true; + } + return false; + } + + /** + * Reset usage limits daily at 03:00 (Europe/Istanbul) + */ + @Cron('0 3 * * *', { timeZone: 'Europe/Istanbul' }) + async resetUsageLimits() { + if (this.shouldSkipInHistoricalMode('resetUsageLimits')) return; + this.logger.log('Starting daily usage limit reset job...'); + + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Reset all limits that were last reset before today + const result = await this.prisma.usageLimit.updateMany({ + where: { + lastResetDate: { lt: today }, + }, + data: { + analysisCount: 0, + couponCount: 0, + lastResetDate: today, + }, + }); + + if (result.count > 0) { + this.logger.log( + `Usage limits for ${result.count} users have been reset`, + ); + } else { + this.logger.log('No user limits needed resetting'); + } + } catch (error: any) { + this.logger.error(`Limit reset job failed: ${error.message}`); + } + } + + /** + * Clean up old predictions (older than 30 days) + */ + @Cron('0 4 * * *', { timeZone: 'Europe/Istanbul' }) + async cleanupOldData() { + if (this.shouldSkipInHistoricalMode('cleanupOldData')) return; + this.logger.log('Starting data cleanup job...'); + + try { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + // Delete old AI prediction logs + const deletedLogs = await this.prisma.aiPredictionsLog.deleteMany({ + where: { + createdAt: { lt: thirtyDaysAgo }, + }, + }); + + // Delete old live matches (finished more than 1 day ago) + // Historical data is already persisted in the 'matches' table + const oneDayAgo = new Date(); + oneDayAgo.setDate(oneDayAgo.getDate() - 1); + + const deletedLiveMatches = await this.prisma.liveMatch.deleteMany({ + where: { + state: 'Finished', + updatedAt: { lt: oneDayAgo }, + }, + }); + + this.logger.log( + `Cleanup complete: ${deletedLogs.count} old logs, ${deletedLiveMatches.count} old live matches`, + ); + } catch (error: any) { + this.logger.error(`Cleanup job failed: ${error.message}`); + } + } + + /** + * Reset subscription status for expired users + */ + @Cron('0 0 * * *', { timeZone: 'Europe/Istanbul' }) + async checkSubscriptions() { + if (this.shouldSkipInHistoricalMode('checkSubscriptions')) return; + this.logger.log('Checking expired subscriptions...'); + + try { + const now = new Date(); + + const result = await this.prisma.user.updateMany({ + where: { + subscriptionStatus: 'active', + subscriptionExpiresAt: { lt: now }, + }, + data: { + subscriptionStatus: 'expired', + }, + }); + + if (result.count > 0) { + this.logger.log(`${result.count} subscriptions marked as expired`); + } + } catch (error: any) { + this.logger.error(`Subscription check failed: ${error.message}`); + } + } +} diff --git a/src/tasks/live-updater.task.ts b/src/tasks/live-updater.task.ts new file mode 100755 index 0000000..7f5e1f9 --- /dev/null +++ b/src/tasks/live-updater.task.ts @@ -0,0 +1,177 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { HttpService } from '@nestjs/axios'; +import { PrismaService } from '../database/prisma.service'; +import { firstValueFrom } from 'rxjs'; + +@Injectable() +export class LiveUpdaterTask { + private readonly logger = new Logger(LiveUpdaterTask.name); + + constructor( + private readonly httpService: HttpService, + private readonly prisma: PrismaService, + ) {} + + private shouldSkipInHistoricalMode(jobName: string): boolean { + if (process.env.FEEDER_MODE === 'historical') { + this.logger.debug(`Skipping ${jobName} in historical feeder mode`); + return true; + } + return false; + } + + /** + * Update live match scores every 5 minutes + */ + @Cron('*/15 * * * *') // Every 15 minutes + async updateLiveScores() { + if (this.shouldSkipInHistoricalMode('updateLiveScores')) return; + this.logger.debug('Updating live scores...'); + + try { + // Get all live matches + const liveMatches = await this.prisma.liveMatch.findMany({ + where: { + state: { + in: ['live', 'firsthalf', 'secondhalf', '1H', '2H', 'HT', 'LIVE'], + }, + }, + select: { id: true, matchSlug: true }, + }); + + if (liveMatches.length === 0) { + this.logger.debug('No live matches to update'); + return; + } + + this.logger.log(`Updating ${liveMatches.length} live matches`); + + // Fetch scores for each live match + for (const match of liveMatches) { + try { + const url = `https://www.mackolik.com/ajax/football/match-info?matchId=${match.id}`; + const response = await firstValueFrom( + this.httpService.get(url, { timeout: 5000 }), + ); + + if (response.data?.data) { + const matchData = response.data.data; + + await this.prisma.liveMatch.update({ + where: { id: match.id }, + data: { + scoreHome: matchData.homeScore ?? null, + scoreAway: matchData.awayScore ?? null, + state: matchData.state || matchData.status, + status: matchData.status, + updatedAt: new Date(), + }, + }); + } + } catch { + // Individual match update failed, continue with others + } + } + + this.logger.log('Live score update complete'); + } catch (error: any) { + this.logger.error(`Live update failed: ${error.message}`); + } + } + + /** + * Update finished match results every 30 minutes + */ + @Cron('*/30 * * * *') + async finalizeFinishedMatches() { + if (this.shouldSkipInHistoricalMode('finalizeFinishedMatches')) return; + this.logger.log('Finalizing finished matches...'); + + try { + // Find recently finished matches that need final data + const finishedMatches = await this.prisma.liveMatch.findMany({ + where: { + state: 'Finished', + updatedAt: { + gte: new Date(Date.now() - 60 * 60 * 1000), // Last hour + }, + }, + select: { + id: true, + matchSlug: true, + homeTeamId: true, + awayTeamId: true, + }, + }); + + if (finishedMatches.length === 0) { + return; + } + + this.logger.log(`Finalizing ${finishedMatches.length} matches`); + + for (const liveMatch of finishedMatches) { + try { + // Check if permanent match record exists + const existingMatch = await this.prisma.match.findUnique({ + where: { id: liveMatch.id }, + }); + + if (!existingMatch) { + // Create permanent match record from live match + const liveData = await this.prisma.liveMatch.findUnique({ + where: { id: liveMatch.id }, + }); + + if (liveData) { + await this.prisma.match.create({ + data: { + id: liveData.id, + matchName: liveData.matchName, + matchSlug: liveData.matchSlug, + sport: (liveData.sport || 'football') as any, + leagueId: liveData.leagueId, + homeTeamId: liveData.homeTeamId, + awayTeamId: liveData.awayTeamId, + mstUtc: liveData.mstUtc ?? BigInt(Date.now()), + scoreHome: liveData.scoreHome, + scoreAway: liveData.scoreAway, + state: 'Finished', + status: 'Finished', + }, + }); + + this.logger.log( + `Migrated match ${liveData.id} to permanent storage`, + ); + } + } else { + // Update existing match with final score + const liveData = await this.prisma.liveMatch.findUnique({ + where: { id: liveMatch.id }, + }); + + if (liveData) { + await this.prisma.match.update({ + where: { id: liveMatch.id }, + data: { + scoreHome: liveData.scoreHome, + scoreAway: liveData.scoreAway, + state: 'Finished', + status: 'Finished', + }, + }); + } + } + } catch (err: any) { + this.logger.warn( + `Failed to finalize match ${liveMatch.id}: ${err.message}`, + ); + } + } + } catch (error: any) { + this.logger.error(`Finalize job failed: ${error.message}`); + } + } +} diff --git a/src/tasks/tasks.module.ts b/src/tasks/tasks.module.ts new file mode 100755 index 0000000..63ff8d6 --- /dev/null +++ b/src/tasks/tasks.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { HttpModule } from '@nestjs/axios'; +import { DataFetcherTask } from './data-fetcher.task'; +import { HistoricalResultsSyncTask } from './historical-results-sync.task'; +import { LimitResetterTask } from './limit-resetter.task'; +import { LiveUpdaterTask } from './live-updater.task'; +import { DatabaseModule } from '../database/database.module'; +import { FeederModule } from '../modules/feeder/feeder.module'; + +@Module({ + imports: [ + ScheduleModule.forRoot(), + HttpModule.register({ + timeout: 30000, + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }), + DatabaseModule, + FeederModule, + ], + providers: [ + DataFetcherTask, + HistoricalResultsSyncTask, + LimitResetterTask, + LiveUpdaterTask, + ], + exports: [ + DataFetcherTask, + HistoricalResultsSyncTask, + LimitResetterTask, + LiveUpdaterTask, + ], +}) +export class TasksModule {}